mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29424366a8 | |||
| c47346df52 | |||
| 92dedf51aa | |||
| 8f7a85f47d | |||
| ed1cf188dc | |||
| 03a7e51384 | |||
| 483f86c457 | |||
| a8a4d2a6f8 | |||
| eef5bd0648 | |||
| 6108de52e7 | |||
| 43e06987ad | |||
| 7741ab88bf | |||
| 3bdbfdc033 | |||
| e701e16112 | |||
| 79d83537d5 | |||
| 251c16ca48 | |||
| 134b38ceda | |||
| b8fb1fa40f | |||
| 951a96fad6 | |||
| 5b9dc7cfc6 | |||
| e36352b7f3 | |||
| 296e4d489c | |||
| c3ddac42a0 | |||
| 01f432cedc | |||
| 9c8a2c0120 | |||
| 5f6460f42f | |||
| d9314887fb | |||
| 5dd4c41291 | |||
| 45537620a6 | |||
| 21af5c4a4d | |||
| 983356611e |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,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'));
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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`.
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
},
|
||||
|
||||
Generated
+110
-44
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
// }
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user