Compare commits

...

31 Commits

Author SHA1 Message Date
Aarnav Tale 29424366a8 chore: v0.5.5 2025-03-18 00:46:54 -04:00
Aarnav Tale c47346df52 fix: do not require authkey 2025-03-18 00:46:35 -04:00
Aarnav Tale 92dedf51aa fix: ignore ws_agents if there are no agents connected 2025-03-18 00:43:52 -04:00
Aarnav Tale 8f7a85f47d chore: v0.5.4 2025-03-18 00:34:49 -04:00
Aarnav Tale ed1cf188dc style: format github actions 2025-03-18 00:33:14 -04:00
Igor Ramazanov 03a7e51384 feat: add a nix flake (#132)
* feat: wip `nix`

Add initial code to be used when working with `nix` and `NixOS`.

* a Nix flake
* building a package
* a NixOS module

* feat: build `hp_agent` with `nix`

* feat: add `hp_agent` as a flake output

* feat: nix: start `headplane` after `headscale`

* feat: do not rely on `git` for versioning

It causes lots of pain when building with `nix` for a local `flake.nix`.

Not sure if it's a good general solution:
* now it requires a manual step of updating `./version` on each release.
* we're losing commit hash abbreviation, like `0.5.3-5-gbe5a291`

I guess, this can be fixed by installing git-pre-commit-hook, but even
then it'd be wrong, because the output of `git describe --tags --always`
won't be the same before and after commit.

* feat: include `hp_agent` to the NixOS module

* fix: version error message

* fix: use relative path imports in `nix`

* fix: NixOS module: generate `/etc/headplane/config.yaml` from `services.headplane.settings`

* fix: NixOS module: allow passing `bool` in `services.headplane.settings.*`

* fix: NixOS module: fix `/etc/headplane/config.yaml` generation

* docs: add Nix/NixOS docs

* feat: nix: read version from `package.json`

* fix: nix: fix `agent` env vars configuration

* feat: nix: add `services.headplane.agent.debug` option

* fix: delete unnecessary `version` file

* fix: nix: remove unnecessary `sed` substitutions

A left over from previous versions.
See: https://github.com/tale/headplane/issues/95#issue-2807487849

* feat: nix: do not hardcode `headplane-agent` configuration environment variables

To make the module more flexible and to reduce the dependency on the
config API.

* docs: improve `Nix` documentation

* Reflect recent changes.
* Link `Nix` in README

* feat: nix: setup Nix CI

* feat: nix: CI without depending on flakehub.com

* chore: begin bundling all deps into the server

* fix: loosen headscale config validation

* fix: navigate back to machines page on node deletion

* fix: slice off trailing slash if present on headscale url

* feat: switch to a central singleton handler

This also adds support for Headscale TLS installations

* fix: shamefully-hoist dependencies

* fix: handle localized number fields

* fix: revert dependency bundling for now

* chore: cleanup and remove from readme for now

---------

Co-authored-by: Aarnav Tale <aarnavtale@icloud.com>
2025-03-18 00:31:37 -04:00
Aarnav Tale 483f86c457 fix: revert dependency bundling for now 2025-03-18 00:24:25 -04:00
Aarnav Tale a8a4d2a6f8 fix: handle localized number fields 2025-03-17 22:45:31 -04:00
Aarnav Tale eef5bd0648 fix: shamefully-hoist dependencies 2025-03-17 22:25:41 -04:00
Aarnav Tale 6108de52e7 feat: switch to a central singleton handler
This also adds support for Headscale TLS installations
2025-03-17 22:21:16 -04:00
Aarnav Tale 43e06987ad fix: slice off trailing slash if present on headscale url 2025-03-17 16:47:27 -04:00
Aarnav Tale 7741ab88bf fix: navigate back to machines page on node deletion 2025-03-17 10:46:31 -04:00
Aarnav Tale 3bdbfdc033 fix: loosen headscale config validation 2025-03-17 10:44:13 -04:00
Aarnav Tale e701e16112 chore: begin bundling all deps into the server 2025-03-17 10:39:38 -04:00
Aarnav Tale 79d83537d5 chore: track semver in package.json 2025-03-11 22:44:23 -04:00
Aarnav Tale 251c16ca48 fix: handle ws upgrade smoothly 2025-03-11 22:39:49 -04:00
Aarnav Tale 134b38ceda chore: update agent var scheme 2025-03-11 22:39:05 -04:00
Aarnav Tale b8fb1fa40f chore: switch to x-headplane-tailnet-id 2025-03-11 22:35:52 -04:00
Aarnav Tale 951a96fad6 fix: set id ref to none to fetch agent data on first change 2025-03-11 22:35:02 -04:00
Aarnav Tale 5b9dc7cfc6 feat: ingest data from new agent system 2025-03-11 22:34:30 -04:00
Aarnav Tale e36352b7f3 fix: define globals for context 2025-03-11 21:59:26 -04:00
Aarnav Tale 296e4d489c fix: set a default for global dns nameservers 2025-03-11 21:57:40 -04:00
Aarnav Tale c3ddac42a0 fix: memoize agent query params to prevent infinite refetching 2025-03-11 18:16:47 -04:00
Aarnav Tale 01f432cedc feat: support oidc client_secret_path with env interpolation 2025-03-11 18:15:56 -04:00
Aarnav Tale 9c8a2c0120 feat: support oidc picture if available 2025-03-11 15:35:11 -04:00
Aarnav Tale 5f6460f42f fix: use the userinfo endpoint for oidc 2025-03-11 15:29:45 -04:00
Aarnav Tale d9314887fb feat: keep track of agent ids 2025-03-11 15:29:45 -04:00
Aarnav Tale 5dd4c41291 feat: use a new ws implementation thats encapsulated 2025-03-11 15:29:45 -04:00
bitbronze 45537620a6 fix: stop checking for mkey prefix on registration (#131)
String "mkey:" was being appended to the node registration http request causing it to fail. Removed mkey presence check and changed default hint. Form validation is based on length instead.
2025-03-11 15:24:31 -04:00
Gage Orsburn 21af5c4a4d fix: handle missing split dns config (#129)
Closes #127
2025-03-11 15:12:49 -04:00
Steve Wills 983356611e docs: fix mistake in env docs (#119) 2025-03-03 14:32:05 -05:00
65 changed files with 1620 additions and 617 deletions
+1
View File
@@ -0,0 +1 @@
use_flake
+35 -35
View File
@@ -1,44 +1,44 @@
name: Publish Nightly Docker Image
on:
schedule:
- cron: '0 8 * * *'
workflow_dispatch:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
jobs:
publish:
name: Build and Publish Nightly
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
publish:
name: Build and Publish Nightly
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
+20
View File
@@ -0,0 +1,20 @@
name: Update flake.lock
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 0"
jobs:
update-flake-inputs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
dependencies
automated
- uses: DeterminateSystems/flake-checker-action@main
- run: nix flake check --all-systems
+24
View File
@@ -0,0 +1,24 @@
name: Nix CI
on:
pull_request:
workflow_dispatch:
push:
branches:
- main
tags:
- v?[0-9]+.[0-9]+.[0-9]+*
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
nix-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/flake-checker-action@main
- run: nix flake check --all-systems
+35 -35
View File
@@ -1,44 +1,44 @@
name: 'Build'
name: "Build"
on:
push:
branches:
- 'main'
pull_request:
push:
branches:
- "main"
pull_request:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Build
run: pnpm build
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
shamefully-hoist = true
+17
View File
@@ -1,3 +1,20 @@
### 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
- OIDC auth was not using information from the `user_info` endpoint.
- Support the picture of the user who is logged in via OIDC if available.
- Rewrote the Agent implementation to better utilize disk space and perform better (coming soon).
- Loosened checking for the Headscale configuration as it was too strict and required certain optional fields.
- Deleting a node will now correctly redirect back to the nodes page (fixes [#137](https://github.com/tale/headplane/issues/137))
- Supports connecting to Headscale via TLS and can accept a certificate file (partially fixes [#82](https://github.com/tale/headplane/issues/82))
- Add support for running Headplane through Nix, though currently unsupported (via [#132](https://github.com/tale/headplane/pull/132))
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
### 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))
+8 -7
View File
@@ -1,8 +1,9 @@
package config
import (
_ "github.com/joho/godotenv/autoload"
"os"
_ "github.com/joho/godotenv/autoload"
)
// Config represents the configuration for the agent.
@@ -16,12 +17,12 @@ type Config struct {
}
const (
DebugEnv = "HP_AGENT_DEBUG"
HostnameEnv = "HP_AGENT_HOSTNAME"
TSControlURLEnv = "HP_AGENT_TS_SERVER"
TSAuthKeyEnv = "HP_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HP_AGENT_HP_SERVER"
HPAuthKeyEnv = "HP_AGENT_HP_AUTHKEY"
DebugEnv = "HEADPLANE_AGENT_DEBUG"
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
+4 -3
View File
@@ -2,11 +2,12 @@ package hpagent
import (
"fmt"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
@@ -23,7 +24,7 @@ func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*S
}
headers := http.Header{}
headers.Add("X-Headplane-TS-Node-ID", agent.ID)
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
+13 -2
View File
@@ -91,8 +91,19 @@ export default function Header(data: Props) {
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? (
<Menu>
<Menu.IconButton label="User">
<CircleUser />
<Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
>
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
onAction={(key) => {
+2 -2
View File
@@ -57,12 +57,12 @@ export default function NumberInput(props: InputProps) {
>
<input
{...inputProps}
id={id}
required={props.isRequired}
name={name}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
{...decrementButtonProps}
label="Decrement"
+2 -9
View File
@@ -3,9 +3,7 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { EntryContext, ServerRouter } from 'react-router';
import { hp_loadLogger } from '~/utils/log';
import type { AppContext } from '~server/context/app';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
@@ -13,14 +11,9 @@ export default function handleRequest(
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppContext,
loadContext: AppLoadContext,
) {
const { context } = loadContext;
return new Promise((resolve, reject) => {
// This is a promise but we don't need to wait for it to finish
// before we start rendering the shell since it only loads once.
hp_loadLogger(context.debug);
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
+1 -1
View File
@@ -3,9 +3,9 @@ import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import cn from '~/utils/cn';
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import log from '~/utils/log';
import { destroySession, getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import log from '~server/utils/log';
export async function loader({ request }: LoaderFunctionArgs) {
let healthy = false;
+5 -12
View File
@@ -7,33 +7,26 @@ import {
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import { hs_getConfig } from '~/utils/config/loader';
import { noContext } from '~/utils/log';
import { getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { hp_getConfig } from '~server/context/global';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return redirect('/login');
}
if (!context) {
throw noContext();
}
const ctx = context.context;
const context = hp_getConfig();
const { mode, config } = hs_getConfig();
return {
config,
url: ctx.headscale.public_url ?? ctx.headscale.url,
url: context.headscale.public_url ?? context.headscale.url,
configAvailable: mode !== 'no',
debug: ctx.debug,
debug: context.debug,
user: session.get('user'),
};
}
+3
View File
@@ -11,6 +11,9 @@ export default [
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// API
route('/api/agent', 'routes/api/agent.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
+1 -1
View File
@@ -9,11 +9,11 @@ import Spinner from '~/components/Spinner';
import Tabs from '~/components/Tabs';
import { hs_getConfig } from '~/utils/config/loader';
import { HeadscaleError, pull, put } from '~/utils/headscale';
import log from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import toast from '~/utils/toast';
import type { AppContext } from '~server/context/app';
import log from '~server/utils/log';
import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
+39
View File
@@ -0,0 +1,39 @@
import { LoaderFunctionArgs } from 'react-router';
import { hp_getSingleton, hp_getSingletonUnsafe } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const data = hp_getSingletonUnsafe('ws_agent_data');
if (!data) {
return new Response(JSON.stringify({ error: 'Agent data unavailable' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
const qp = new URLSearchParams(request.url.split('?')[1]);
const nodeIds = qp.get('node_ids')?.split(',');
if (!nodeIds) {
return new Response(JSON.stringify({ error: 'No node IDs provided' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
const entries = data.toJSON();
const missing = nodeIds.filter((nodeID) => !entries[nodeID]);
if (missing.length > 0) {
const requestCall = hp_getSingleton('ws_fetch_data');
requestCall(missing);
}
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
},
});
}
+20 -27
View File
@@ -10,14 +10,10 @@ import Code from '~/components/Code';
import Input from '~/components/Input';
import type { Key } from '~/types';
import { pull } from '~/utils/headscale';
import { noContext } from '~/utils/log';
import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines', {
@@ -27,37 +23,34 @@ export async function loader({
});
}
if (!context) {
throw noContext();
}
const context = hp_getConfig();
const disableApiKeyLogin = context.oidc?.disable_api_key_login;
let oidc = false;
// Only set if OIDC is properly enabled anyways
const ctx = context.context;
if (ctx.oidc?.disable_api_key_login) {
return redirect('/oidc/start');
}
try {
// Only set if OIDC is properly enabled anyways
hp_getSingleton('oidc_client');
oidc = true;
if (disableApiKeyLogin) {
return redirect('/oidc/start');
}
} catch {}
return {
oidc: ctx.oidc?.issuer,
apiKey: !ctx.oidc?.disable_api_key_login,
oidc,
apiKey: !disableApiKeyLogin,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<AppContext>) {
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await getSession(request.headers.get('Cookie'));
if (oidcStart) {
if (!context) {
throw noContext();
}
const ctx = context.context;
if (!ctx.oidc) {
const context = hp_getConfig();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
@@ -132,7 +125,7 @@ export default function Page() {
</Button>
</Form>
) : undefined}
{data.oidc ? (
{data.oidc === true ? (
<Form method="POST">
{!data.apiKey ? (
<Card.Text className="mb-6">
+13 -15
View File
@@ -1,14 +1,21 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { noContext } from '~/utils/log';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const { oidc } = hp_getConfig();
try {
if (!oidc) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
@@ -20,15 +27,6 @@ export async function loader({
return redirect('/machines');
}
if (!context) {
throw noContext();
}
const { oidc } = context.context;
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
}
const codeVerifier = session.get('oidc_code_verif');
const state = session.get('oidc_state');
const nonce = session.get('oidc_nonce');
+11 -12
View File
@@ -1,25 +1,24 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { noContext } from '~/utils/log';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { send } from '~/utils/res';
import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines');
}
if (!context) {
throw noContext();
}
const { oidc } = hp_getConfig();
try {
if (!oidc) {
throw new Error('OIDC is not enabled');
}
const { oidc } = context.context;
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided');
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
}
const redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
+1 -1
View File
@@ -1,8 +1,8 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import log from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import log from '~server/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'));
+14 -14
View File
@@ -15,6 +15,7 @@ interface Props {
machine: Machine;
routes: Route[];
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
}
@@ -22,8 +23,9 @@ interface Props {
export default function MachineRow({
machine,
routes,
magic,
users,
isAgent,
magic,
stats,
}: Props) {
const expired =
@@ -79,6 +81,10 @@ export default function MachineRow({
tags.unshift('Subnets');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
@@ -146,24 +152,18 @@ export default function MachineRow({
</Menu>
</div>
</td>
{/**
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">
{hinfo.getTSVersion(stats)}
</p>
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">
Unknown
</p>
)}
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
**/}
<td className="py-2">
<span
className={cn(
+7 -1
View File
@@ -1,3 +1,4 @@
import { useNavigate } from 'react-router';
import Dialog from '~/components/Dialog';
import type { Machine } from '~/types';
@@ -8,9 +9,14 @@ interface DeleteProps {
}
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
const navigate = useNavigate();
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="destructive">
<Dialog.Panel
variant="destructive"
onSubmit={() => navigate('/machines')}
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
+2 -2
View File
@@ -21,7 +21,7 @@ export default function New(data: NewProps) {
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={!mkey.trim().startsWith('mkey:')}>
<Dialog.Panel isDisabled={mkey.length < 1}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
@@ -36,7 +36,7 @@ export default function New(data: NewProps) {
<Input
isRequired
label="Machine Key"
placeholder="mkey:ff....."
placeholder="AbCd..."
validationBehavior="native"
name="mkey"
onChange={setMkey}
+21 -10
View File
@@ -14,6 +14,7 @@ import cn from '~/utils/cn';
import { hs_getConfig } from '~/utils/config/loader';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hp_getSingleton, hp_getSingletonUnsafe } from '~server/context/global';
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
@@ -44,6 +45,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
machine.node.id,
),
};
}
@@ -52,7 +56,8 @@ export async function action({ request }: ActionFunctionArgs) {
}
export default function Page() {
const { machine, magic, routes, users } = useLoaderData<typeof loader>();
const { machine, magic, routes, users, agent } =
useLoaderData<typeof loader>();
const [showRouting, setShowRouting] = useState(false);
const expired =
@@ -68,6 +73,10 @@ export default function Page() {
tags.unshift('Expired');
}
if (agent) {
tags.unshift('Headplane Agent');
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
@@ -148,16 +157,18 @@ export default function Page() {
{machine.user.name}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
{tags.length > 0 ? (
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
</div>
) : undefined}
</div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes
+11 -21
View File
@@ -9,20 +9,16 @@ import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
import Tooltip from '~/components/Tooltip';
import { hs_getConfig } from '~/utils/config/loader';
import { noContext } from '~/utils/log';
import { AppContext } from '~server/context/app';
import useAgent from '~/utils/useAgent';
import { hp_getConfig, hp_getSingletonUnsafe } from '~server/context/global';
import { menuAction } from './action';
import MachineRow from './components/machine';
import NewMachine from './dialogs/new';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
@@ -30,16 +26,8 @@ export async function loader({
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]);
if (!context) {
throw noContext();
}
initAgentSocket(context);
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
const ctx = context.context;
const context = hp_getConfig();
const { mode, config } = hs_getConfig();
let magic: string | undefined;
if (mode !== 'no') {
@@ -53,9 +41,9 @@ export async function loader({
routes: routes.routes,
users: users.users,
magic,
stats,
server: ctx.headscale.url,
publicServer: ctx.headscale.public_url,
server: context.headscale.url,
publicServer: context.headscale.public_url,
agents: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()],
};
}
@@ -65,6 +53,7 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() {
const data = useLoaderData<typeof loader>();
const { data: stats } = useAgent(data.nodes.map((node) => node.nodeKey));
return (
<>
@@ -108,7 +97,7 @@ export default function Page() {
) : undefined}
</div>
</th>
{/**<th className="uppercase text-xs font-bold pb-2">Version</th>**/}
<th className="uppercase text-xs font-bold pb-2">Version</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
@@ -127,7 +116,8 @@ export default function Page() {
)}
users={data.users}
magic={data.magic}
stats={data.stats?.[machine.nodeKey]}
stats={stats?.[machine.nodeKey]}
isAgent={data.agents.includes(machine.id)}
/>
))}
</tbody>
+28 -36
View File
@@ -7,13 +7,39 @@ import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { PreAuthKey, User } from '~/types';
import { post, pull } from '~/utils/headscale';
import { noContext } from '~/utils/log';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { hp_getConfig } from '~server/context/global';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
);
const context = hp_getConfig();
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.headscale.public_url ?? context.headscale.url,
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
@@ -91,40 +117,6 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
);
if (!context) {
throw noContext();
}
const ctx = context.context;
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: ctx.headscale.public_url ?? ctx.headscale.url,
};
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('__headplane_all');
@@ -1,11 +1,11 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import type { AppContext } from '~server/context/app';
import type { HeadplaneConfig } from '~server/context/parser';
import CreateUser from '../dialogs/create-user';
interface Props {
oidc?: NonNullable<AppContext['context']['oidc']>;
oidc?: NonNullable<HeadplaneConfig['oidc']>;
}
export default function ManageBanner({ oidc }: Props) {
+4 -11
View File
@@ -15,22 +15,15 @@ import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hs_getConfig } from '~/utils/config/loader';
import { noContext } from '~/utils/log';
import type { AppContext } from '~server/context/app';
import { hp_getConfig } from '~server/context/global';
import ManageBanner from './components/manage-banner';
import DeleteUser from './dialogs/delete-user';
import RenameUser from './dialogs/rename-user';
import { userAction } from './user-actions';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
if (!context) {
throw noContext();
}
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
@@ -41,7 +34,7 @@ export async function loader({
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
const ctx = context.context;
const { oidc } = hp_getConfig();
const { mode, config } = hs_getConfig();
let magic: string | undefined;
@@ -52,7 +45,7 @@ export async function loader({
}
return {
oidc: ctx.oidc,
oidc,
magic,
users,
};
+1 -1
View File
@@ -1,5 +1,5 @@
import { healthcheck } from '~/utils/headscale';
import log from '~/utils/log';
import log from '~server/utils/log';
export async function loader() {
let healthy = false;
+7 -3
View File
@@ -1,8 +1,9 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { Document, parseDocument } from 'yaml';
import { hp_getIntegration } from '~/utils/integration/loader';
import log from '~/utils/log';
import mutex from '~/utils/mutex';
import { hp_getConfig } from '~server/context/global';
import log from '~server/utils/log';
import { HeadscaleConfig, validateConfig } from './parser';
let runtimeYaml: Document | undefined = undefined;
@@ -181,8 +182,9 @@ export async function hs_patchConfig(patches: PatchConfig[]) {
}
// Revalidate the configuration
const context = hp_getConfig();
const newRawConfig = runtimeYaml.toJSON() as unknown;
runtimeConfig = __hs_context.config_strict
runtimeConfig = context.headscale.config_strict
? validateConfig(newRawConfig, true)
: (newRawConfig as HeadscaleConfig);
@@ -196,5 +198,7 @@ export async function hs_patchConfig(patches: PatchConfig[]) {
}
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
hs_loadConfig(__hs_context.config_path, __hs_context.config_strict);
// TODO: Replace this into the new singleton system
const context = hp_getConfig();
hs_loadConfig(context.headscale.config_path, context.headscale.config_strict);
hp_getIntegration();
+8 -8
View File
@@ -1,5 +1,5 @@
import { type } from 'arktype';
import log from '~/utils/log';
import log from '~server/utils/log';
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === 'true') return true;
@@ -49,7 +49,7 @@ export type HeadscaleConfig = typeof headscaleConfig.infer;
const headscaleConfig = type({
server_url: 'string',
listen_addr: 'string',
metrics_listen_addr: 'string?',
'metrics_listen_addr?': 'string',
grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false),
noise: {
@@ -83,13 +83,13 @@ const headscaleConfig = type({
database: databaseConfig,
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string | ""',
tls_letsencrypt_hostname: 'string | ""',
acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"',
tls_cert_path: 'string?',
tls_key_path: 'string?',
'tls_cert_path?': 'string',
'tls_key_path?': 'string',
log: type({
format: 'string = "text"',
@@ -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: type('string[]').default(() => []),
split: type('Record<string, string[]>').default(() => ({})),
}).default(() => ({ global: [], split: {} })),
search_domains: type('string[]').default(() => []),
extra_records: type({
+35 -39
View File
@@ -1,4 +1,6 @@
import log, { noContext } from '~/utils/log';
import { request } from 'undici';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
import log from '~server/utils/log';
export class HeadscaleError extends Error {
status: number;
@@ -19,24 +21,18 @@ export class FatalError extends Error {
}
}
interface HeadscaleContext {
url: string;
}
declare const global: typeof globalThis & { __hs_context: HeadscaleContext };
export async function healthcheck() {
const prefix = __hs_context.url;
log.debug('APIC', 'GET /health');
const health = new URL('health', prefix);
const response = await fetch(health.toString(), {
const health = new URL('health', hp_getConfig().headscale.url);
const response = await request(health.toString(), {
dispatcher: hp_getSingleton('api_agent'),
headers: {
Accept: 'application/json',
},
});
// Intentionally not catching
return response.status === 200;
return response.statusCode === 200;
}
export async function pull<T>(url: string, key: string) {
@@ -44,26 +40,26 @@ export async function pull<T>(url: string, key: string) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = __hs_context.url;
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, {
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
headers: {
Authorization: `Bearer ${key}`,
},
});
if (!response.ok) {
if (response.statusCode >= 400) {
log.debug(
'APIC',
'GET %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
response.statusCode,
);
throw new HeadscaleError(await response.text(), response.status);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.json() as Promise<T>;
return response.body.json() as Promise<T>;
}
export async function post<T>(url: string, key: string, body?: unknown) {
@@ -71,10 +67,10 @@ export async function post<T>(url: string, key: string, body?: unknown) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = __hs_context.url;
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, {
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
@@ -82,17 +78,17 @@ export async function post<T>(url: string, key: string, body?: unknown) {
},
});
if (!response.ok) {
if (response.statusCode >= 400) {
log.debug(
'APIC',
'POST %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
response.statusCode,
);
throw new HeadscaleError(await response.text(), response.status);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.json() as Promise<T>;
return response.body.json() as Promise<T>;
}
export async function put<T>(url: string, key: string, body?: unknown) {
@@ -100,10 +96,10 @@ export async function put<T>(url: string, key: string, body?: unknown) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = __hs_context.url;
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, {
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
@@ -111,17 +107,17 @@ export async function put<T>(url: string, key: string, body?: unknown) {
},
});
if (!response.ok) {
if (response.statusCode >= 400) {
log.debug(
'APIC',
'PUT %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
response.statusCode,
);
throw new HeadscaleError(await response.text(), response.status);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.json() as Promise<T>;
return response.body.json() as Promise<T>;
}
export async function del<T>(url: string, key: string) {
@@ -129,25 +125,25 @@ export async function del<T>(url: string, key: string) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = __hs_context.url;
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`);
const response = await fetch(`${prefix}/api/${url}`, {
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (!response.ok) {
if (response.statusCode >= 400) {
log.debug(
'APIC',
'DELETE %s failed with status %d',
`${prefix}/api/${url}`,
response.status,
response.statusCode,
);
throw new HeadscaleError(await response.text(), response.status);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.json() as Promise<T>;
return response.body.json() as Promise<T>;
}
+1 -1
View File
@@ -2,8 +2,8 @@ import { constants, access } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici';
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import log from '~/utils/log';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
+1 -1
View File
@@ -5,8 +5,8 @@ import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { HeadscaleError, healthcheck } from '~/utils/headscale';
import log from '~/utils/log';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node
+5 -2
View File
@@ -1,5 +1,6 @@
import log from '~/utils/log';
import { hp_getConfig } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
@@ -66,4 +67,6 @@ function getIntegration(integration: HeadplaneConfig['integration']) {
}
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
hp_loadIntegration(__integration_context);
// TODO: Switch this to the new singleton system
const context = hp_getConfig();
hp_loadIntegration(context.integration);
+1 -1
View File
@@ -4,8 +4,8 @@ import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { HeadscaleError, healthcheck } from '~/utils/headscale';
import log from '~/utils/log';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
-42
View File
@@ -1,42 +0,0 @@
export function hp_loadLogger(debug: boolean) {
if (debug) {
log.debug = (category: string, message: string, ...args: unknown[]) => {
defaultLog('DEBG', category, message, ...args);
};
}
}
const log = {
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);
},
// Default to a no-op until the logger is initialized
debug: (category: string, message: string, ...args: unknown[]) => {},
};
function defaultLog(
level: string,
category: string,
message: string,
...args: unknown[]
) {
const date = new Date().toISOString();
console.log(`${date} (${level}) [${category}] ${message}`, ...args);
}
export function noContext() {
return new Error(
'Context is not loaded. This is most likely a configuration error with your reverse proxy.',
);
}
export default log;
+103 -32
View File
@@ -1,8 +1,10 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client';
import log from '~/utils/log';
import type { AppContext } from '~server/context/app';
import { hp_getSingleton, hp_setSingleton } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
type OidcConfig = NonNullable<AppContext['context']['oidc']>;
type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
declare global {
const __PREFIX__: string;
}
@@ -35,6 +37,57 @@ export function getRedirectUri(req: Request) {
return url.href;
}
let oidcSecret: string | undefined = undefined;
export function getOidcSecret() {
return oidcSecret;
}
async function resolveClientSecret(oidc: OidcConfig) {
if (!oidc.client_secret && !oidc.client_secret_path) {
return;
}
if (oidc.client_secret_path) {
// We need to interpolate environment variables into the path
// Path formatting can be like ${ENV_NAME}/path/to/secret
let path = oidc.client_secret_path;
const matches = path.match(/\${(.*?)}/g);
if (matches) {
for (const match of matches) {
const env = match.slice(2, -1);
const value = process.env[env];
if (!value) {
log.error('CFGX', 'Environment variable %s is not set', env);
return;
}
log.debug('CFGX', 'Interpolating %s with %s', match, value);
path = path.replace(match, value);
}
}
try {
log.debug('CFGX', 'Reading client secret from %s', path);
const secret = await readFile(path, 'utf-8');
if (secret.trim().length === 0) {
log.error('CFGX', 'Empty OIDC client secret');
return;
}
oidcSecret = secret;
} catch (error) {
log.error('CFGX', 'Failed to read client secret from %s', path);
log.error('CFGX', 'Error: %s', error);
log.debug('CFGX', 'Error details: %o', error);
}
}
if (oidc.client_secret) {
oidcSecret = oidc.client_secret;
}
}
function clientAuthMethod(
method: string,
): (secret: string) => client.ClientAuth {
@@ -51,13 +104,7 @@ function clientAuthMethod(
}
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
const config = await client.discovery(
new URL(oidc.issuer),
oidc.client_id,
oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret),
);
const config = hp_getSingleton('oidc_client');
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
@@ -93,16 +140,7 @@ interface FlowOptions {
}
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
const config = await client.discovery(
new URL(oidc.issuer),
oidc.client_id,
oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret),
);
let subject: string;
let accessToken: string;
const config = hp_getSingleton('oidc_client');
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
@@ -126,15 +164,41 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
);
return {
subject: claims.sub,
name: claims.name ? String(claims.name) : 'Anonymous',
email: claims.email ? String(claims.email) : undefined,
username: claims.preferred_username
? String(claims.preferred_username)
: undefined,
subject: user.sub,
name: getName(user, claims),
email: user.email ?? claims.email?.toString(),
username: user.preferred_username ?? claims.preferred_username?.toString(),
picture: user.picture,
};
}
function getName(user: client.UserInfoResponse, claims: client.IDToken) {
if (user.name) {
return user.name;
}
if (claims.name && typeof claims.name === 'string') {
return claims.name;
}
if (user.given_name && user.family_name) {
return `${user.given_name} ${user.family_name}`;
}
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
return 'Anonymous';
}
export function formatError(error: unknown) {
if (error instanceof client.ResponseBodyError) {
return {
@@ -178,12 +242,22 @@ export function formatError(error: unknown) {
}
export async function testOidc(oidc: OidcConfig) {
await resolveClientSecret(oidc);
if (!oidcSecret) {
log.debug(
'OIDC',
'Cannot validate OIDC configuration without a client secret',
);
return false;
}
log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
const secret = await resolveClientSecret(oidc);
const config = await client.discovery(
new URL(oidc.issuer),
oidc.client_id,
oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret),
clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret),
);
const meta = config.serverMetadata();
@@ -214,15 +288,12 @@ export async function testOidc(oidc: OidcConfig) {
'OIDC server does not support %s',
oidc.token_endpoint_auth_method,
);
return false;
}
} else {
log.warn(
'OIDC',
'OIDC server does not advertise token_endpoint_auth_methods_supported',
);
}
log.debug('OIDC', 'OIDC configuration is valid');
hp_setSingleton('oidc_client', config);
return true;
}
+6 -2
View File
@@ -1,4 +1,5 @@
import { Session, createCookieSessionStorage } from 'react-router';
import { hp_getConfig } from '~server/context/global';
export type SessionData = {
hsApiKey: string;
@@ -12,6 +13,7 @@ export type SessionData = {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
@@ -20,6 +22,8 @@ type SessionFlashData = {
};
// TODO: Domain config in cookies
// TODO: Move this to the singleton system
const context = hp_getConfig();
const sessionStorage = createCookieSessionStorage<
SessionData,
SessionFlashData
@@ -30,8 +34,8 @@ const sessionStorage = createCookieSessionStorage<
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
secrets: [__cookie_context.cookie_secret],
secure: __cookie_context.cookie_secure,
secrets: [context.server.cookie_secret],
secure: context.server.cookie_secure,
},
});
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useMemo, useRef } from 'react';
import { useFetcher } from 'react-router';
import { HostInfo } from '~/types';
export default function useAgent(nodeIds: string[], interval = 3000) {
const fetcher = useFetcher<Record<string, HostInfo>>();
const qp = useMemo(
() => new URLSearchParams({ node_ids: nodeIds.join(',') }),
[nodeIds],
);
const idRef = useRef<string[]>([]);
useEffect(() => {
if (idRef.current.join(',') !== nodeIds.join(',')) {
fetcher.load(`/api/agent?${qp.toString()}`);
idRef.current = nodeIds;
}
const intervalID = setInterval(() => {
fetcher.load(`/api/agent?${qp.toString()}`);
}, interval);
return () => {
clearInterval(intervalID);
};
}, [interval, qp]);
return {
data: fetcher.data,
isLoading: fetcher.state === 'loading',
};
}
+1 -2
View File
@@ -4,7 +4,7 @@ import { setTimeout as pSetTimeout } from 'node:timers/promises';
import type { LoaderFunctionArgs } from 'react-router';
import { WebSocket } from 'ws';
import type { HostInfo } from '~/types';
import log from './log';
import log from '~server/utils/log';
// Essentially a HashMap which invalidates entries after a certain time.
// It also is capable of syncing as a compressed file to disk.
@@ -99,7 +99,6 @@ export function initAgentSocket(context: LoaderFunctionArgs['context']) {
// If we aren't connected to an agent, then debug log and return the cache
export async function queryAgent(nodes: string[]) {
return;
// biome-ignore lint: bruh
if (!cache) {
log.error('CACH', 'Cache not initialized');
return;
+16
View File
@@ -17,8 +17,16 @@ server:
headscale:
# The URL to your Headscale instance
# (All API requests are routed through this URL)
# (THIS IS NOT the gRPC endpoint, but the HTTP endpoint)
#
# IMPORTANT: If you are using TLS this MUST be set to `https://`
url: "http://headscale:5000"
# If you use the TLS configuration in Headscale, and you are not using
# Let's Encrypt for your certificate, pass in the path to the certificate.
# (This has no effect `url` does not start with `https://`)
# tls_cert_path: "/var/lib/headplane/tls.crt"
# Optional, public URL if they differ
# This affects certain parts of the web UI
# public_url: "https://headscale.example.com"
@@ -73,7 +81,15 @@ integration:
oidc:
issuer: "https://accounts.google.com"
client_id: "your-client-id"
# The client secret for the OIDC client
# Either this or `client_secret_path` must be set for OIDC to work
client_secret: "<your-client-secret>"
# You can alternatively set `client_secret_path` to read the secret from disk.
# The path specified can resolve environment variables, making integration
# with systemd's `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
disable_api_key_login: false
token_endpoint_auth_method: "client_secret_post"
+9 -11
View File
@@ -1,5 +1,8 @@
# Headplane Agent
> This is currently not available in Headplane.
> It is incomplete and will land within the next few releases.
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:
@@ -19,17 +22,12 @@ Agent binaries are available on the [releases](https://github.com/tale/headplane
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.
- **`HEADPLANE_AGENT_DEBUG`**: Enable debug logging if `true`.
- **`HEADPLANE_AGENT_HOSTNAME`**: A hostname you want to use for the agent.
- **`HEADPLANE_AGENT_TS_SERVER`**: The URL to your Headscale instance.
- **`HEADPLANE_AGENT_TS_AUTHKEY`**: An authorization key to authenticate with Headscale (see below).
- **`HEADPLANE_AGENT_HP_SERVER`**: The URL to your Headplane instance, including the subpath (eg. `https://headplane.example.com/admin`).
- **`HEADPLANE_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
+1 -1
View File
@@ -152,7 +152,7 @@ spec:
env:
# Set these if the pod name for Headscale is not static
# We will use the downward API to get the pod name instead
- name: HEADPLANE_ENV_LOAD_OVERRIDES
- name: HEADPLANE_LOAD_ENV_OVERRIDES
value: 'true'
- name: 'HEADPLANE_INTEGRATION__KUBERNETES__POD_NAME'
valueFrom:
+127
View File
@@ -0,0 +1,127 @@
# Nix
[flake.nix](../flake.nix) provided:
```
$ nix flake show . --all-systems
git+file:///home/igor/personal/headplane?ref=refs/heads/nix&rev=2d78a95a0648a3778e114fb246ea436e96475d62
├───devShell
│ ├───aarch64-darwin: development environment 'headplane'
│ ├───x86_64-darwin: development environment 'headplane'
│ └───x86_64-linux: development environment 'headplane'
├───formatter
│ ├───aarch64-darwin: package 'alejandra-3.1.0'
│ ├───x86_64-darwin: package 'alejandra-3.1.0'
│ └───x86_64-linux: package 'alejandra-3.1.0'
├───nixosModules
│ └───headplane: NixOS module
├───overlays
│ └───default: Nixpkgs overlay
└───packages
├───aarch64-darwin
│ ├───headplane: package 'headplane-0.5.3-SNAPSHOT'
│ └───headplane-agent: package 'hp_agent-0.5.3-SNAPSHOT'
├───x86_64-darwin
│ ├───headplane: package 'headplane-0.5.3-SNAPSHOT'
│ └───headplane-agent: package 'hp_agent-0.5.3-SNAPSHOT'
└───x86_64-linux
├───headplane: package 'headplane-0.5.3-SNAPSHOT'
└───headplane-agent: package 'hp_agent-0.5.3-SNAPSHOT'
```
## NixOS module options
Defined as `services.headplane.*`, check the `./nix/` directory for details.
## Usage
1. Add the `github:tale/headplane` flake input.
2. Import a default overlay to add `pkgs.headplane` and `pkgs.headplane-agent`.
3. Import NixOS module for `services.headplane.*`.
```nix
# Your flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
headplane = {
url = "github:igor-ramazanov/headplane/nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
nixpkgs,
headplane,
...
}: {
nixosConfigurations.MY_MACHINE = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
# provides `services.headplane.*` NixOS options.
headplane.nixosModules.headplane
{
# provides `pkgs.headplane` and `pkgs.headplane-agent`.
nixpkgs.overlays = [ headplane.overlays.default ];
}
{
{config, pkgs, ...}:
let
format = pkgs.formats.yaml {};
# A workaround generate a valid Headscale config accepted by Headplane when `config_strict == true`.
settings = lib.recursiveUpdate config.services.headscale.settings {
acme_email = "/dev/null";
tls_cert_path = "/dev/null";
tls_key_path = "/dev/null";
policy.path = "/dev/null";
oidc.client_secret_path = "/dev/null";
};
headscaleConfig = format.generate "headscale.yml" settings;
in {
services.headplane = {
enable = true;
agent = {
# As an example only.
# Headplane Agent hasn't yet been ready at the moment of writing the doc.
enable = true;
settings = {
HEADPLANE_AGENT_DEBUG = true;
HEADPLANE_AGENT_HOSTNAME = "localhost";
HEADPLANE_AGENT_TS_SERVER = "https://example.com";
HEADPLANE_AGENT_TS_AUTHKEY = "xxxxxxxxxxxxxx";
HEADPLANE_AGENT_HP_SERVER = "https://example.com/admin/dns";
HEADPLANE_AGENT_HP_AUTHKEY = "xxxxxxxxxxxxxx";
};
};
settings = {
server = {
host = "127.0.0.1";
port = 3000;
cookie_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
cookie_secure = true;
};
headscale = {
url = "https://example.com";
config_path = "${headscaleConfig}";
config_strict = true;
};
integration.proc.enabled = true;
oidc = {
issuer = "https://oidc.example.com";
client_id = "headplane";
client_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
disable_api_key_login = true;
# Might needed when integrating with Authelia.
token_endpoint_auth_method = "client_secret_basic";
headscale_api_key = "xxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
redirect_uri = "https://oidc.example.com/admin/oidc/callback";
};
};
};
}
}
];
};
};
}
```
Generated
+82
View File
@@ -0,0 +1,82 @@
{
"nodes": {
"devshell": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1741473158,
"narHash": "sha256-kWNaq6wQUbUMlPgw8Y+9/9wP0F8SHkjy24/mN3UAppg=",
"owner": "numtide",
"repo": "devshell",
"rev": "7c9e793ebe66bcba8292989a68c0419b737a22a0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "devshell",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1741646908,
"narHash": "sha256-55a1x5k+oFY2QCFjj7Mn5nPa8Do0shVl0m280mOAW/Q=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ab0c5b18dab5e4b5d06ed679f8fd7cdc9970c4be",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devshell": "devshell",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+69
View File
@@ -0,0 +1,69 @@
rec {
description = "headplane";
inputs = {
devshell = {
inputs.nixpkgs.follows = "nixpkgs";
url = "github:numtide/devshell";
};
flake-utils.url = "github:numtide/flake-utils";
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
};
outputs = {
devshell,
flake-utils,
nixpkgs,
...
}:
flake-utils.lib.eachSystem [
"aarch64-darwin"
"x86_64-darwin"
"x86_64-linux"
]
(system: let
pkgs = import nixpkgs {
inherit system;
overlays = [devshell.overlays.default];
};
in rec {
formatter = pkgs.alejandra;
packages = {
headplane = pkgs.callPackage ./nix/package.nix {};
headplane-agent = pkgs.callPackage ./nix/agent.nix {};
};
checks.default = pkgs.symlinkJoin {
name = "headplane-with-agent";
paths = [packages.headplane packages.headplane-agent];
};
devShells.default = pkgs.devshell.mkShell rec {
name = description;
motd = let
providedPackages = pkgs.lib.concatStringsSep "\n" (
pkgs.lib.map
(pkg: "\t* ${pkgs.lib.getName pkg}")
(pkgs.lib.reverseList packages)
);
in ''
Entered '${description}' development environment.
Provided packages:
${providedPackages}
'';
packages = [
pkgs.go
pkgs.nodejs-slim_22
pkgs.pnpm_10
pkgs.typescript-language-server
];
env = [];
};
})
// {
overlays.default = final: prev: {
headplane = final.callPackage ./nix/package.nix {};
headplane-agent = final.callPackage ./nix/agent.nix {};
};
nixosModules.headplane = import ./nix/module.nix;
};
}
+9
View File
@@ -0,0 +1,9 @@
{buildGoModule}:
buildGoModule {
pname = "hp_agent";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = ../.;
vendorHash = "sha256-G0kahv3mPTL/mxU2U+0IytJaFVPXMbMBktbLMfM0BO8=";
ldflags = ["-s" "-w"];
env.CGO_ENABLED = 0;
}
+112
View File
@@ -0,0 +1,112 @@
{
config,
lib,
pkgs,
...
}: let
inherit
(lib)
attrsToList
listToAttrs
map
mkEnableOption
mkIf
mkOption
mkPackageOption
typeOf
types
;
cfg = config.services.headplane;
settingsFormat = pkgs.formats.yaml {};
settingsFile = settingsFormat.generate "headplane-config.yaml" cfg.settings;
agentEnv = listToAttrs (map (n: {
name = n.name;
value =
if ((typeOf n.value) == "bool")
then
(
if (n.value)
then "true"
else "false"
)
else n.value;
}) (attrsToList cfg.agent.settings));
in {
options.services.headplane = {
enable = mkEnableOption "headplane";
package = mkPackageOption pkgs "headplane" {};
settings = mkOption {
type = types.submodule {
freeformType = settingsFormat.type;
};
default = {};
description = "Headplane config, generates a YAML config. See: https://github.com/tale/headplane/blob/main/config.example.yaml.";
};
agent = mkOption {
type = types.submodule {
options = {
enable = mkEnableOption "headplane-agent";
package = mkPackageOption pkgs "headplane-agent" {};
settings = mkOption {
type = types.attrsOf [types.str types.bool];
description = "Headplane agent env vars config. See: https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md";
default = {};
};
};
};
};
};
config = mkIf cfg.enable {
environment = {
systemPackages = [cfg.package];
etc."headplane/config.yaml".source = "${settingsFile}";
};
systemd.services.headplane-agent =
mkIf cfg.agent.enable
{
description = "Headplane side-running agent";
wantedBy = ["multi-user.target"];
after = ["headplane.service"];
requires = ["headplane.service"];
environment = agentEnv;
serviceConfig = {
User = config.services.headscale.user;
Group = config.services.headscale.group;
ExecStart = "${pkgs.headplane-agent}/bin/hp_agent";
Restart = "always";
RestartSec = 5;
# TODO: Harden `systemd` security according to the "The Principle of Least Power".
# See: `$ systemd-analyze security headplane-agent`.
};
};
systemd.services.headplane = {
description = "Headscale Web UI";
wantedBy = ["multi-user.target"];
after = ["headscale.service"];
requires = ["headscale.service"];
serviceConfig = {
User = config.services.headscale.user;
Group = config.services.headscale.group;
ExecStart = "${pkgs.headplane}/bin/headplane";
Restart = "always";
RestartSec = 5;
# TODO: Harden `systemd` security according to the "The Principle of Least Power".
# See: `$ systemd-analyze security headplane`.
};
};
};
}
+48
View File
@@ -0,0 +1,48 @@
{
git,
lib,
makeWrapper,
nodejs_22,
pnpm_10,
stdenv,
...
}:
stdenv.mkDerivation (finalAttrs: {
pname = "headplane";
version = (builtins.fromJSON (builtins.readFile ../package.json)).version;
src = ../.;
nativeBuildInputs = [
makeWrapper
nodejs_22
pnpm_10.configHook
git
];
dontCheckForBrokenSymlinks = true;
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-j+3fcxukK19fXVIlVe+tXenYf28MylHy+/qHy7FpvL0=";
};
buildPhase = ''
runHook preBuild
pnpm build
pnpm prune --prod
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/{bin,share/headplane}
cp -r {build,node_modules} $out/share/headplane/
sed -i "s;$PWD;../..;" $out/share/headplane/build/headplane/server.js
makeWrapper ${lib.getExe nodejs_22} $out/bin/headplane \
--chdir $out/share/headplane \
--set BUILD_PATH $out/share/headplane/build \
--set NODE_ENV production \
--add-flags $out/share/headplane/build/headplane/server.js
runHook postInstall
'';
})
+5 -3
View File
@@ -2,6 +2,7 @@
"name": "headplane",
"private": true,
"sideEffects": false,
"version": "0.5.5",
"type": "module",
"scripts": {
"build": "react-router build && vite build -c server/vite.config.ts",
@@ -42,11 +43,9 @@
"react-stately": "^3.35.0",
"remix-utils": "^8.0.0",
"tailwind-merge": "^2.6.0",
"tailwindcss-react-aria-components": "^1.2.0",
"undici": "^7.2.0",
"usehooks-ts": "^3.1.0",
"vite-node": "^3.0.1",
"ws": "^8.18.0",
"ws": "^8.18.1",
"yaml": "^2.7.0",
"zod": "^3.24.1"
},
@@ -54,6 +53,7 @@
"@babel/preset-typescript": "^7.26.0",
"@biomejs/biome": "^1.9.4",
"@react-router/dev": "^7.0.0",
"@types/websocket": "^1.0.10",
"@types/ws": "^8.5.13",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
@@ -63,8 +63,10 @@
"react-scan": "^0.1.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.0",
"typescript": "^5.7.2",
"vite": "^6.0.6",
"vite-node": "^3.0.8",
"vite-plugin-babel": "^1.3.0",
"vite-tsconfig-paths": "^5.1.4"
},
+110 -44
View File
@@ -30,7 +30,7 @@ importers:
version: 5.1.1
'@kubernetes/client-node':
specifier: ^0.22.3
version: 0.22.3
version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@primer/octicons-react':
specifier: ^19.14.0
version: 19.14.0(react@19.0.0)
@@ -109,21 +109,15 @@ importers:
tailwind-merge:
specifier: ^2.6.0
version: 2.6.0
tailwindcss-react-aria-components:
specifier: ^1.2.0
version: 1.2.0(tailwindcss@3.4.17)
undici:
specifier: ^7.2.0
version: 7.2.0
usehooks-ts:
specifier: ^3.1.0
version: 3.1.0(react@19.0.0)
vite-node:
specifier: ^3.0.1
version: 3.0.1(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
ws:
specifier: ^8.18.0
version: 8.18.0
specifier: ^8.18.1
version: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
yaml:
specifier: ^2.7.0
version: 2.7.0
@@ -139,7 +133,10 @@ importers:
version: 1.9.4
'@react-router/dev':
specifier: ^7.0.0
version: 7.1.1(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(tsx@4.19.2)(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)
version: 7.1.1(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)
'@types/websocket':
specifier: ^1.0.10
version: 1.0.10
'@types/ws':
specifier: ^8.5.13
version: 8.5.13
@@ -167,18 +164,24 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
tailwindcss-react-aria-components:
specifier: ^2.0.0
version: 2.0.0(tailwindcss@3.4.17)
typescript:
specifier: ^5.7.2
version: 5.7.2
vite:
specifier: ^6.0.6
version: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
version: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
vite-node:
specifier: ^3.0.8
version: 3.0.8(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
vite-plugin-babel:
specifier: ^1.3.0
version: 1.3.0(@babel/core@7.26.0)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0))
version: 1.3.0(@babel/core@7.26.0)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0))
version: 5.1.4(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))
packages:
@@ -804,6 +807,9 @@ packages:
resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.6':
resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==}
'@jridgewell/sourcemap-codec@1.4.15':
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
@@ -1602,6 +1608,9 @@ packages:
'@types/react@19.0.2':
resolution: {integrity: sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==}
'@types/websocket@1.0.10':
resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==}
'@types/ws@8.5.13':
resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==}
@@ -1747,6 +1756,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
bufferutil@4.0.9:
resolution: {integrity: sha512-WDtdLmJvAuNNPzByAYpRo2rF1Mmradw6gvWsQKf63476DDXmomT9zUiGypLcG4ibIM67vhAj8jJRdbmEws2Aqw==}
engines: {node: '>=6.14.2'}
byline@5.0.0:
resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==}
engines: {node: '>=0.10.0'}
@@ -1795,6 +1808,9 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -2289,6 +2305,10 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.18:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
@@ -2360,8 +2380,8 @@ packages:
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.1:
resolution: {integrity: sha512-6jpjMpOth5S9ITVu5clZ7NOgHNsv5vRQdheL9ztp2vZmM6fRbLvyua1tiBIL4lk8SAe3ARzeXEly6siXCjDHDw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
peek-stream@1.1.3:
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
@@ -2756,10 +2776,10 @@ packages:
peerDependencies:
tailwindcss: '>=3.0.0 || insiders'
tailwindcss-react-aria-components@1.2.0:
resolution: {integrity: sha512-ynlQIfAdoWAz0Og0dCe/d4QVE+L8Yuo6rtK9apdhehZZMoLC1GdwtnvLZyUyx+wHwnTjRuNtFPdN0DpPsPXJJg==}
tailwindcss-react-aria-components@2.0.0:
resolution: {integrity: sha512-l8YdpXX//qx0Mz2bW3YKLjboTQxR3zLrFhlHniAIn+mTwVM1yvT0Jp7Pn3eFrqi+qtOMmcJzXaF2rsKezSr0SQ==}
peerDependencies:
tailwindcss: '*'
tailwindcss: ^4.0.0
tailwindcss@3.4.17:
resolution: {integrity: sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==}
@@ -2770,6 +2790,11 @@ packages:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
engines: {node: '>=18'}
terser@5.39.0:
resolution: {integrity: sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==}
engines: {node: '>=10'}
hasBin: true
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
@@ -2872,6 +2897,10 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18
utf-8-validate@5.0.10:
resolution: {integrity: sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==}
engines: {node: '>=6.14.2'}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -2904,8 +2933,8 @@ packages:
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
vite-node@3.0.1:
resolution: {integrity: sha512-PoH9mCNsSZQXl3gdymM5IE4WR0k0WbnFd89nAyyDvltF2jVGdFcI8vpB1PBdKTcjAR7kkYiHSlIO68X/UT8Q1A==}
vite-node@3.0.8:
resolution: {integrity: sha512-6PhR4H9VGlcwXZ+KWCdMqbtG649xCPZqfI9j2PsK1FcXgEzro5bGHcVKFCTqPLaNKZES8Evqv4LwvZARsq5qlg==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
@@ -2990,8 +3019,8 @@ packages:
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.18.0:
resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==}
ws@8.18.1:
resolution: {integrity: sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
@@ -3598,6 +3627,12 @@ snapshots:
'@jridgewell/set-array@1.2.1': {}
'@jridgewell/source-map@0.3.6':
dependencies:
'@jridgewell/gen-mapping': 0.3.8
'@jridgewell/trace-mapping': 0.3.25
optional: true
'@jridgewell/sourcemap-codec@1.4.15': {}
'@jridgewell/sourcemap-codec@1.5.0': {}
@@ -3615,10 +3650,10 @@ snapshots:
dependencies:
jsep: 1.4.0
'@kubernetes/client-node@0.22.3':
'@kubernetes/client-node@0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies:
byline: 5.0.0
isomorphic-ws: 5.0.0(ws@8.18.0)
isomorphic-ws: 5.0.0(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10))
js-yaml: 4.1.0
jsonpath-plus: 10.2.0
request: 2.88.2
@@ -3626,7 +3661,7 @@ snapshots:
stream-buffers: 3.0.3
tar: 7.4.3
tslib: 2.8.1
ws: 8.18.0
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
optionalDependencies:
openid-client: 6.1.7
transitivePeerDependencies:
@@ -4311,7 +4346,7 @@ snapshots:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
'@react-router/dev@7.1.1(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(tsx@4.19.2)(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)':
'@react-router/dev@7.1.1(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.1.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0)':
dependencies:
'@babel/core': 7.26.0
'@babel/generator': 7.26.3
@@ -4342,8 +4377,8 @@ snapshots:
semver: 7.6.3
set-cookie-parser: 2.7.1
valibot: 0.41.0(typescript@5.7.2)
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
vite-node: 3.0.0-beta.2(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
vite-node: 3.0.0-beta.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
optionalDependencies:
typescript: 5.7.2
transitivePeerDependencies:
@@ -4842,7 +4877,6 @@ snapshots:
'@types/node@22.10.7':
dependencies:
undici-types: 6.20.0
optional: true
'@types/react-dom@19.0.2(@types/react@19.0.2)':
dependencies:
@@ -4852,6 +4886,10 @@ snapshots:
dependencies:
csstype: 3.1.3
'@types/websocket@1.0.10':
dependencies:
'@types/node': 22.10.7
'@types/ws@8.5.13':
dependencies:
'@types/node': 22.10.1
@@ -5013,6 +5051,11 @@ snapshots:
buffer-from@1.1.2: {}
bufferutil@4.0.9:
dependencies:
node-gyp-build: 4.8.4
optional: true
byline@5.0.0: {}
cac@6.7.14: {}
@@ -5065,6 +5108,9 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
commander@2.20.3:
optional: true
commander@4.1.1: {}
convert-source-map@2.0.0: {}
@@ -5359,9 +5405,9 @@ snapshots:
isexe@2.0.0: {}
isomorphic-ws@5.0.0(ws@8.18.0):
isomorphic-ws@5.0.0(ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)):
dependencies:
ws: 8.18.0
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
isstream@0.1.2: {}
@@ -5521,6 +5567,9 @@ snapshots:
nanoid@3.3.8: {}
node-gyp-build@4.8.4:
optional: true
node-releases@2.0.18: {}
normalize-package-data@5.0.0:
@@ -5586,7 +5635,7 @@ snapshots:
pathe@1.1.2: {}
pathe@2.0.1: {}
pathe@2.0.3: {}
peek-stream@1.1.3:
dependencies:
@@ -6042,7 +6091,7 @@ snapshots:
dependencies:
tailwindcss: 3.4.17
tailwindcss-react-aria-components@1.2.0(tailwindcss@3.4.17):
tailwindcss-react-aria-components@2.0.0(tailwindcss@3.4.17):
dependencies:
tailwindcss: 3.4.17
@@ -6082,6 +6131,14 @@ snapshots:
mkdirp: 3.0.1
yallist: 5.0.0
terser@5.39.0:
dependencies:
'@jridgewell/source-map': 0.3.6
acorn: 8.14.0
commander: 2.20.3
source-map-support: 0.5.21
optional: true
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
@@ -6168,6 +6225,11 @@ snapshots:
lodash.debounce: 4.0.8
react: 19.0.0
utf-8-validate@5.0.10:
dependencies:
node-gyp-build: 4.8.4
optional: true
util-deprecate@1.0.2: {}
uuid@3.4.0: {}
@@ -6189,13 +6251,13 @@ snapshots:
core-util-is: 1.0.2
extsprintf: 1.3.0
vite-node@3.0.0-beta.2(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0):
vite-node@3.0.0-beta.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.5.4
pathe: 1.1.2
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -6210,13 +6272,13 @@ snapshots:
- tsx
- yaml
vite-node@3.0.1(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0):
vite-node@3.0.8(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0):
dependencies:
cac: 6.7.14
debug: 4.4.0
es-module-lexer: 1.6.0
pathe: 2.0.1
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
pathe: 2.0.3
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -6231,23 +6293,23 @@ snapshots:
- tsx
- yaml
vite-plugin-babel@1.3.0(@babel/core@7.26.0)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)):
vite-plugin-babel@1.3.0(@babel/core@7.26.0)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)):
dependencies:
'@babel/core': 7.26.0
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
vite-tsconfig-paths@5.1.4(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)):
vite-tsconfig-paths@5.1.4(typescript@5.7.2)(vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)):
dependencies:
debug: 4.4.0
globrex: 0.1.2
tsconfck: 3.1.4(typescript@5.7.2)
optionalDependencies:
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0)
vite: 6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)
transitivePeerDependencies:
- supports-color
- typescript
vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(tsx@4.19.2)(yaml@2.7.0):
vite@6.0.6(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0):
dependencies:
esbuild: 0.24.2
postcss: 8.4.49
@@ -6256,6 +6318,7 @@ snapshots:
'@types/node': 22.10.7
fsevents: 2.3.3
jiti: 1.21.7
terser: 5.39.0
tsx: 4.19.2
yaml: 2.7.0
@@ -6286,7 +6349,10 @@ snapshots:
wrappy@1.0.2: {}
ws@8.18.0: {}
ws@8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10):
optionalDependencies:
bufferutil: 4.0.9
utf-8-validate: 5.0.10
xtend@4.0.2: {}
+4 -5
View File
@@ -1,12 +1,11 @@
import { hp_getConfig } from './loader';
import { HeadplaneConfig } from './parser';
import { hp_agentRequest } from '~server/ws/data';
export interface AppContext {
context: HeadplaneConfig;
hp_agentRequest: typeof hp_agentRequest;
}
export default function appContext() {
export default function appContext(): AppContext {
return {
context: hp_getConfig(),
hp_agentRequest,
};
}
+85
View File
@@ -0,0 +1,85 @@
import type { Configuration } from 'openid-client';
import type { Agent } from 'undici';
import type { WebSocket } from 'ws';
import type { HostInfo } from '~/types';
import type { HeadplaneConfig } from '~server/context/parser';
import type { Logger } from '~server/utils/log';
import type { TimedCache } from '~server/ws/cache';
// This is a stupid workaround for how the Remix import context works
// Even though they run in the same Node instance, they have different
// contexts which means importing this in the app code will not work
// because it will be a different instance of the module.
//
// Instead we can rely on globalThis to share the module between the
// different contexts and use some helper functions to make it easier.
// As a part of this global module, we also define all our singletons
// here in order to avoid polluting the global scope and instead just using
// the `__headplane_server_context` object.
interface ServerContext {
config: HeadplaneConfig;
singletons: ServerSingletons;
}
interface ServerSingletons {
api_agent: Agent;
logger: Logger;
oidc_client: Configuration;
ws_agents: Map<string, WebSocket>;
ws_agent_data: TimedCache<HostInfo>;
ws_fetch_data: (nodeList: string[]) => Promise<void>;
}
// These declarations are separate to prevent the Remix context
// from modifying the globalThis object and causing issues with
// the server context.
declare namespace globalThis {
let __headplane_server_context: {
[K in keyof ServerContext]: ServerContext[K] | null | object;
};
}
// We need to check if the context is already initialized and set a default
// value. This is fine as a side-effect since it's just setting up a framework
// for the object to get modified later.
if (!globalThis.__headplane_server_context) {
globalThis.__headplane_server_context = {
config: null,
singletons: {},
};
}
declare global {
const __headplane_server_context: ServerContext;
}
export function hp_getConfig(): HeadplaneConfig {
return __headplane_server_context.config;
}
export function hp_setConfig(config: HeadplaneConfig): void {
__headplane_server_context.config = config;
}
export function hp_getSingleton<T extends keyof ServerSingletons>(
key: T,
): ServerSingletons[T] {
if (!__headplane_server_context.singletons[key]) {
throw new Error(`Singleton ${key} not initialized`);
}
return __headplane_server_context.singletons[key];
}
export function hp_getSingletonUnsafe<T extends keyof ServerSingletons>(
key: T,
): ServerSingletons[T] | undefined {
return __headplane_server_context.singletons[key];
}
export function hp_setSingleton<
T extends ServerSingletons[keyof ServerSingletons],
>(key: keyof ServerSingletons, value: T): void {
(__headplane_server_context.singletons[key] as T) = value;
}
+41 -50
View File
@@ -2,27 +2,14 @@ import { constants, access, readFile } from 'node:fs/promises';
import { env } from 'node:process';
import { type } from 'arktype';
import dotenv from 'dotenv';
import { Agent } from 'undici';
import { parseDocument } from 'yaml';
import { testOidc } from '~/utils/oidc';
import log, { hpServer_loadLogger } from '~server/utils/log';
import log, { hp_loadLogger } from '~server/utils/log';
import mutex from '~server/utils/mutex';
import { hp_setConfig, hp_setSingleton } from './global';
import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser';
declare namespace globalThis {
let __cookie_context: {
cookie_secret: string;
cookie_secure: boolean;
};
let __hs_context: {
url: string;
config_path?: string;
config_strict?: boolean;
};
let __integration_context: HeadplaneConfig['integration'];
}
const envBool = type('string | undefined').pipe((v) => {
return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? '');
});
@@ -34,30 +21,12 @@ const rootEnvs = type({
}).onDeepUndeclaredKey('reject');
const HEADPLANE_DEFAULT_CONFIG_PATH = '/etc/headplane/config.yaml';
let runtimeConfig: HeadplaneConfig | undefined = undefined;
const runtimeLock = mutex();
// We need to acquire here to ensure that the configuration is loaded
// properly. We can't request a configuration if its in the process
// of being updated.
export function hp_getConfig() {
runtimeLock.acquire();
if (!runtimeConfig) {
runtimeLock.release();
// This shouldn't be possible, we NEED to have a configuration
throw new Error('Configuration not loaded');
}
const config = runtimeConfig;
runtimeLock.release();
return config;
}
// hp_loadConfig should ONLY be called when we explicitly need to reload
// the configuration. This should be done when the configuration file
// changes and we ignore environment variable changes.
//
// To read the config hp_getConfig should be used.
// TODO: File watching for hp_loadConfig()
export async function hp_loadConfig() {
runtimeLock.acquire();
@@ -79,7 +48,7 @@ export async function hp_loadConfig() {
}
// Load our debug based logger before ANYTHING
hpServer_loadLogger(envs.HEADPLANE_DEBUG_LOG);
await hp_loadLogger(envs.HEADPLANE_DEBUG_LOG);
if (envs.HEADPLANE_CONFIG_PATH) {
path = envs.HEADPLANE_CONFIG_PATH;
}
@@ -113,24 +82,46 @@ export async function hp_loadConfig() {
process.exit(1);
}
if (config.oidc?.strict_validation) {
testOidc(config.oidc);
// OIDC Related Checks
if (config.oidc) {
if (!config.oidc.client_secret && !config.oidc.client_secret_path) {
log.error('CFGX', 'OIDC configuration is missing a secret, disabling');
log.error(
'CFGX',
'Please specify either `oidc.client_secret` or `oidc.client_secret_path`',
);
}
if (config.oidc?.strict_validation) {
const result = await testOidc(config.oidc);
if (!result) {
log.error('CFGX', 'OIDC configuration failed validation, disabling');
}
}
}
globalThis.__cookie_context = {
cookie_secret: config.server.cookie_secret,
cookie_secure: config.server.cookie_secure,
};
if (config.headscale.tls_cert_path) {
log.debug('CFGX', 'Attempting to load supplied Headscale TLS cert');
try {
const data = await readFile(config.headscale.tls_cert_path, 'utf8');
log.info('CFGX', 'Headscale TLS cert loaded successfully');
hp_setSingleton(
'api_agent',
new Agent({
connect: {
ca: data.trim(),
},
}),
);
} catch (error) {
log.error('CFGX', 'Failed to load Headscale TLS cert');
log.debug('CFGX', 'Error Details: %o', error);
}
} else {
hp_setSingleton('api_agent', new Agent());
}
globalThis.__hs_context = {
url: config.headscale.url,
config_path: config.headscale.config_path,
config_strict: config.headscale.config_strict,
};
globalThis.__integration_context = config.integration;
runtimeConfig = config;
hp_setConfig(config);
runtimeLock.release();
}
+15 -2
View File
@@ -8,12 +8,24 @@ const serverConfig = type({
port: type('string | number.integer').pipe((v) => Number(v)),
cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool,
agent: type({
authkey: 'string = ""',
ttl: 'number.integer = 180000', // Default to 3 minutes
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
})
.onDeepUndeclaredKey('reject')
.default(() => ({
authkey: '',
ttl: 180000,
cache_path: '/var/lib/headplane/agent_cache.json',
})),
});
const oidcConfig = type({
issuer: 'string.url',
client_id: 'string',
client_secret: 'string',
client_secret: 'string?',
client_secret_path: 'string?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?',
@@ -23,7 +35,8 @@ const oidcConfig = type({
}).onDeepUndeclaredKey('reject');
const headscaleConfig = type({
url: 'string.url',
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
tls_cert_path: 'string?',
public_url: 'string.url?',
config_path: 'string?',
config_strict: stringToBool,
+15 -12
View File
@@ -1,9 +1,12 @@
// import { initWebsocket } from '~server/ws';
import { constants, access } from 'node:fs/promises';
import { createServer } from 'node:http';
import { hp_getConfig, hp_loadConfig } from '~server/context/loader';
import { WebSocketServer } from 'ws';
import { hp_getConfig } from '~server/context/global';
import { hp_loadConfig } from '~server/context/loader';
import { listener } from '~server/listener';
import log from '~server/utils/log';
import { hp_loadAgentCache } from '~server/ws/data';
import { initWebsocket } from '~server/ws/socket';
import log from './utils/log';
log.info('SRVX', 'Running Node.js %s', process.versions.node);
@@ -19,16 +22,16 @@ try {
await hp_loadConfig();
const server = createServer(listener);
// const ws = initWebsocket();
// if (ws) {
// server.on('upgrade', (req, socket, head) => {
// ws.handleUpgrade(req, socket, head, (ws) => {
// ws.emit('connection', ws, req);
// });
// });
// }
const context = hp_getConfig();
if (context.server.agent.authkey.length > 0) {
const ws = new WebSocketServer({ server });
initWebsocket(ws, context.server.agent.authkey);
await hp_loadAgentCache(
context.server.agent.ttl,
context.server.agent.cache_path,
);
}
server.listen(context.server.port, context.server.host, () => {
log.info(
'SRVX',
+32 -16
View File
@@ -1,19 +1,45 @@
export function hpServer_loadLogger(debug: boolean) {
import {
hp_getSingleton,
hp_getSingletonUnsafe,
hp_setSingleton,
} from '~server/context/global';
export interface Logger {
info: (category: string, message: string, ...args: unknown[]) => void;
warn: (category: string, message: string, ...args: unknown[]) => void;
error: (category: string, message: string, ...args: unknown[]) => void;
debug: (category: string, message: string, ...args: unknown[]) => void;
}
export function hp_loadLogger(debug: boolean) {
const newLog = { ...log };
if (debug) {
log.debug = (category: string, message: string, ...args: unknown[]) => {
newLog.debug = (category: string, message: string, ...args: unknown[]) => {
defaultLog('DEBG', category, message, ...args);
};
log.info('CFGX', 'Debug logging enabled');
log.info(
newLog.info('CFGX', 'Debug logging enabled');
newLog.info(
'CFGX',
'This is very verbose and should only be used for debugging purposes',
);
log.info(
newLog.info(
'CFGX',
'If you run this in production, your storage COULD fill up quickly',
);
}
hp_setSingleton('logger', newLog);
}
function defaultLog(
level: string,
category: string,
message: string,
...args: unknown[]
) {
const date = new Date().toISOString();
console.log(`${date} (${level}) [${category}] ${message}`, ...args);
}
const log = {
@@ -32,14 +58,4 @@ const log = {
debug: (category: string, message: string, ...args: unknown[]) => {},
};
function defaultLog(
level: string,
category: string,
message: string,
...args: unknown[]
) {
const date = new Date().toISOString();
console.log(`${date} (${level}) [${category}] ${message}`, ...args);
}
export default log;
export default hp_getSingletonUnsafe('logger') ?? log;
-60
View File
@@ -1,60 +0,0 @@
import WebSocket, { WebSocketServer } from 'ws';
import log from '~server/utils/log';
const server = new WebSocketServer({ noServer: true });
export function initWebsocket() {
// TODO: Finish this and make public
return;
const key = process.env.LOCAL_AGENT_AUTHKEY;
if (!key) {
return;
}
log.info('CACH', 'Initializing agent WebSocket');
server.on('connection', (ws, req) => {
// biome-ignore lint: this file is not USED
const auth = req.headers['authorization'];
if (auth !== `Bearer ${key}`) {
log.warn('CACH', 'Invalid agent WebSocket connection');
ws.close(1008, 'ERR_INVALID_AUTH');
return;
}
const nodeID = req.headers['x-headplane-ts-node-id'];
if (!nodeID) {
log.warn('CACH', 'Invalid agent WebSocket connection');
ws.close(1008, 'ERR_INVALID_NODE_ID');
return;
}
const pinger = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
clearInterval(pinger);
return;
}
ws.ping();
}, 30000);
ws.on('close', () => {
clearInterval(pinger);
});
ws.on('error', (error) => {
clearInterval(pinger);
log.error('CACH', 'Closing agent WebSocket connection');
log.error('CACH', 'Agent WebSocket error: %s', error);
ws.close(1011, 'ERR_INTERNAL_ERROR');
});
});
return server;
}
export function appContext() {
return {
ws: server,
wsAuthKey: process.env.LOCAL_AGENT_AUTHKEY,
};
}
+41 -14
View File
@@ -1,11 +1,14 @@
import { createRequire } from 'node:module';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { devDependencies } from '../package.json';
const prefix = process.env.__INTERNAL_PREFIX || '/admin';
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash');
}
const require = createRequire(import.meta.url);
export default defineConfig({
define: {
__hp_prefix: JSON.stringify(prefix),
@@ -13,16 +16,32 @@ export default defineConfig({
resolve: {
preserveSymlinks: true,
alias: {
buffer: 'node:buffer',
crypto: 'node:crypto',
events: 'node:events',
fs: 'node:fs',
net: 'node:net',
http: 'node:http',
https: 'node:https',
os: 'node:os',
path: 'node:path',
stream: 'node:stream',
crypto: 'node:crypto'
tls: 'node:tls',
url: 'node:url',
zlib: 'node:zlib',
ws: require.resolve('ws'),
},
},
plugins: [tsconfigPaths()],
build: {
minify: false,
target: 'esnext',
target: 'node18',
// lib: {
// entry: 'server/entry.ts',
// formats: ['es'],
// },
rollupOptions: {
input: './server/entry.ts',
input: 'server/entry.ts',
treeshake: {
moduleSideEffects: false,
},
@@ -31,19 +50,27 @@ export default defineConfig({
dir: 'build/headplane',
banner: '#!/usr/bin/env node\n',
},
// external: (id) => id.startsWith('node:') || id === 'ws',
// We are selecting a list of dependencies we want to include
// We are only including our production dependencies
external: (id) => {
// Resolve happens before side-effects are removed
// ie. vite import because of viteDevServer
if (/node_modules/.test(id)) {
if (id.startsWith('node:')) {
return true;
}
return id.startsWith('node:')
|| id === 'ws'
|| id === 'mime/lite'
|| id === '@react-router/node';
}
if (id === 'ws') {
return true;
}
const match = id.match(/node_modules\/([^/]+)/);
if (match) {
return true;
// const dep = match[1];
// if ((devDependencies as Record<string, string>)[dep]) {
// return true;
// }
}
},
},
}
})
},
});
+126
View File
@@ -0,0 +1,126 @@
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { type } from 'arktype';
import log from '~server/utils/log';
import mutex from '~server/utils/mutex';
const diskSchema = type({
key: 'string',
value: 'unknown',
expires: 'number?',
}).array();
// A persistent HashMap with a TTL for each key
export class TimedCache<V> {
private _cache = new Map<string, V>();
private _timings = new Map<string, number>();
// Default TTL is 1 minute
private defaultTTL: number;
private filePath: string;
private writeLock = mutex();
// Last flush ID is essentially a hash of the flush contents
// Prevents unnecessary flushing if nothing has changed
private lastFlushId = '';
constructor(defaultTTL: number, filePath: string) {
this.defaultTTL = defaultTTL;
this.filePath = filePath;
// Load the cache from disk and then queue flushes every 10 seconds
this.load().then(() => {
setInterval(() => this.flush(), 10000);
});
}
set(key: string, value: V, ttl: number = this.defaultTTL) {
this._cache.set(key, value);
this._timings.set(key, Date.now() + ttl);
}
get(key: string) {
const value = this._cache.get(key);
if (!value) {
return;
}
const expires = this._timings.get(key);
if (!expires || expires < Date.now()) {
this._cache.delete(key);
this._timings.delete(key);
return;
}
return value;
}
// Map into a Record without any TTLs
toJSON() {
const result: Record<string, V> = {};
for (const [key, value] of this._cache.entries()) {
result[key] = value;
}
return result;
}
// WARNING: This function expects that this.filePath is NOT ENOENT
private async load() {
const data = await readFile(this.filePath, 'utf-8');
const cache = () => {
try {
return JSON.parse(data);
} catch (e) {
return undefined;
}
};
const diskData = cache();
if (diskData === undefined) {
log.error('CACH', 'Failed to load cache at %s', this.filePath);
return;
}
const cacheData = diskSchema(diskData);
if (cacheData instanceof type.errors) {
log.error('CACH', 'Failed to load cache at %s', this.filePath);
log.debug('CACHE', 'Error details: %s', cacheData.toString());
// Skip loading the cache (it should be overwritten soon)
return;
}
for (const { key, value, expires } of diskData) {
this._cache.set(key, value);
this._timings.set(key, expires);
}
log.info('CACH', 'Loaded cache from %s', this.filePath);
}
private async flush() {
this.writeLock.acquire();
const data = Array.from(this._cache.entries()).map(([key, value]) => {
return { key, value, expires: this._timings.get(key) };
});
if (data.length === 0) {
this.writeLock.release();
return;
}
// Calculate the hash of the data
const dumpData = JSON.stringify(data);
const sha = createHash('sha256').update(dumpData).digest('hex');
if (sha === this.lastFlushId) {
this.writeLock.release();
return;
}
await writeFile(this.filePath, dumpData, 'utf-8');
this.lastFlushId = sha;
this.writeLock.release();
log.debug('CACH', 'Flushed cache to %s', this.filePath);
}
}
+62
View File
@@ -0,0 +1,62 @@
import { open } from 'node:fs/promises';
import type { HostInfo } from '~/types';
import {
hp_getSingleton,
hp_getSingletonUnsafe,
hp_setSingleton,
} from '~server/context/global';
import log from '~server/utils/log';
import { TimedCache } from './cache';
export async function hp_loadAgentCache(defaultTTL: number, filepath: string) {
log.debug('CACH', `Loading agent cache from ${filepath}`);
try {
const handle = await open(filepath, 'w');
log.info('CACH', `Using agent cache file at ${filepath}`);
await handle.close();
} catch (e) {
log.info('CACH', `Agent cache file not found at ${filepath}`);
return;
}
const cache = new TimedCache<HostInfo>(defaultTTL, filepath);
hp_setSingleton('ws_agent_data', cache);
}
export async function hp_agentRequest(nodeList: string[]) {
// Request to all connected agents (we can have multiple)
// Luckily we can parse all the data at once through message parsing
// and then overlapping cache entries will be overwritten by time
const agents = hp_getSingleton('ws_agents');
const cache = hp_getSingletonUnsafe('ws_agent_data');
// Deduplicate the list of nodes
const NodeIDs = [...new Set(nodeList)];
NodeIDs.map((node) => {
log.debug('CACH', 'Requesting agent data for', node);
});
// Await so that data loads on first request without racing
// Since we do agent.once() we NEED to wait for it to finish
await Promise.allSettled(
[...agents].map(async ([id, agent]) => {
agent.send(JSON.stringify({ NodeIDs }));
await new Promise<void>((resolve) => {
// Just as a safety measure, we set a maximum timeout of 3 seconds
setTimeout(() => resolve(), 3000);
agent.once('message', (data) => {
const parsed = JSON.parse(data.toString());
log.debug('CACH', 'Received agent data from %s', id);
for (const [node, info] of Object.entries<HostInfo>(parsed)) {
cache?.set(node, info);
log.debug('CACH', 'Cached %s', node);
}
resolve();
});
});
}),
);
}
+58
View File
@@ -0,0 +1,58 @@
import WebSocket, { WebSocketServer } from 'ws';
import { hp_setSingleton } from '~server/context/global';
import log from '~server/utils/log';
import { hp_agentRequest } from './data';
export function initWebsocket(server: WebSocketServer, authKey: string) {
log.info('SRVX', 'Starting a WebSocket server for agent connections');
const agents = new Map<string, WebSocket>();
hp_setSingleton('ws_agents', agents);
hp_setSingleton('ws_fetch_data', hp_agentRequest);
server.on('connection', (ws, req) => {
const tailnetID = req.headers['x-headplane-tailnet-id'];
if (!tailnetID || typeof tailnetID !== 'string') {
log.warn(
'SRVX',
'Rejecting an agent WebSocket connection without a tailnet ID',
);
ws.close(1008, 'ERR_INVALID_TAILNET_ID');
return;
}
if (req.headers.authorization !== `Bearer ${authKey}`) {
log.warn('SRVX', 'Rejecting an unauthorized WebSocket connection');
if (req.socket.remoteAddress) {
log.warn('SRVX', 'Agent source IP: %s', req.socket.remoteAddress);
}
ws.close(1008, 'ERR_UNAUTHORIZED');
return;
}
agents.set(tailnetID, ws);
const pinger = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
clearInterval(pinger);
return;
}
ws.ping();
}, 30000);
ws.on('close', () => {
clearInterval(pinger);
agents.delete(tailnetID);
});
ws.on('error', (error) => {
clearInterval(pinger);
log.error('SRVX', 'Agent WebSocket error: %s', error);
log.debug('SRVX', 'Error details: %o', error);
log.error('SRVX', 'Closing agent WebSocket connection');
ws.close(1011, 'ERR_INTERNAL_ERROR');
});
});
return server;
}
+7 -6
View File
@@ -1,20 +1,21 @@
import { readFile } from 'node:fs/promises';
import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel';
import tsconfigPaths from 'vite-tsconfig-paths';
import { execSync } from 'node:child_process';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
const prefix = process.env.__INTERNAL_PREFIX || '/admin';
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash');
}
// Load the version via git tags
const version = execSync('git describe --tags --always').toString().trim();
// Load the version via package.json
const pkg = await readFile('package.json', 'utf-8');
const { version } = JSON.parse(pkg);
if (!version) {
throw new Error('Unable to execute git describe');
throw new Error('Unable to read version from package.json');
}
export default defineConfig({