Merge branch 'main' into nix

This commit is contained in:
Igor Ramazanov 2025-03-12 11:20:13 +00:00
commit 484fba4f6c
26 changed files with 608 additions and 157 deletions

View File

@ -1,8 +1,9 @@
package config package config
import ( import (
_ "github.com/joho/godotenv/autoload"
"os" "os"
_ "github.com/joho/godotenv/autoload"
) )
// Config represents the configuration for the agent. // Config represents the configuration for the agent.
@ -16,12 +17,12 @@ type Config struct {
} }
const ( const (
DebugEnv = "HP_AGENT_DEBUG" DebugEnv = "HEADPLANE_AGENT_DEBUG"
HostnameEnv = "HP_AGENT_HOSTNAME" HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HP_AGENT_TS_SERVER" TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HP_AGENT_TS_AUTHKEY" TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
HPControlURLEnv = "HP_AGENT_HP_SERVER" HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HP_AGENT_HP_AUTHKEY" HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
) )
// Load reads the agent configuration from environment variables. // Load reads the agent configuration from environment variables.

View File

@ -2,11 +2,12 @@ package hpagent
import ( import (
"fmt" "fmt"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
"log" "log"
"net/http" "net/http"
"net/url" "net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
) )
type Socket struct { type Socket struct {
@ -23,7 +24,7 @@ func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*S
} }
headers := http.Header{} 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) auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth) headers.Add("Authorization", auth)

View File

@ -91,8 +91,19 @@ export default function Header(data: Props) {
<Link href="https://github.com/juanfont/headscale" text="Headscale" /> <Link href="https://github.com/juanfont/headscale" text="Headscale" />
{data.user ? ( {data.user ? (
<Menu> <Menu>
<Menu.IconButton label="User"> <Menu.IconButton
<CircleUser /> 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.IconButton>
<Menu.Panel <Menu.Panel
onAction={(key) => { onAction={(key) => {

View File

@ -11,6 +11,9 @@ export default [
route('/oidc/callback', 'routes/auth/oidc-callback.ts'), route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'), route('/oidc/start', 'routes/auth/oidc-start.ts'),
// API
route('/api/agent', 'routes/api/agent.ts'),
// All the main logged-in dashboard routes // All the main logged-in dashboard routes
// Double nested to separate error propagations // Double nested to separate error propagations
layout('layouts/shell.tsx', [ layout('layouts/shell.tsx', [

39
app/routes/api/agent.ts Normal file
View File

@ -0,0 +1,39 @@
import { LoaderFunctionArgs } from 'react-router';
import type { AppContext } from '~server/context/app';
export async function loader({
request,
context,
}: LoaderFunctionArgs<AppContext>) {
if (!context?.agentData) {
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 = context.agentData.toJSON();
const missing = nodeIds.filter((nodeID) => !entries[nodeID]);
if (missing.length > 0) {
await context.hp_agentRequest(missing);
}
return new Response(JSON.stringify(context.agentData), {
headers: {
'Content-Type': 'application/json',
},
});
}

View File

@ -11,6 +11,7 @@ import Input from '~/components/Input';
import type { Key } from '~/types'; import type { Key } from '~/types';
import { pull } from '~/utils/headscale'; import { pull } from '~/utils/headscale';
import { noContext } from '~/utils/log'; import { noContext } from '~/utils/log';
import { oidcEnabled } from '~/utils/oidc';
import { commitSession, getSession } from '~/utils/sessions.server'; import { commitSession, getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app'; import type { AppContext } from '~server/context/app';
@ -33,12 +34,12 @@ export async function loader({
// Only set if OIDC is properly enabled anyways // Only set if OIDC is properly enabled anyways
const ctx = context.context; const ctx = context.context;
if (ctx.oidc?.disable_api_key_login) { if (oidcEnabled() && ctx.oidc?.disable_api_key_login) {
return redirect('/oidc/start'); return redirect('/oidc/start');
} }
return { return {
oidc: ctx.oidc?.issuer, oidc: oidcEnabled(),
apiKey: !ctx.oidc?.disable_api_key_login, apiKey: !ctx.oidc?.disable_api_key_login,
}; };
} }
@ -132,7 +133,7 @@ export default function Page() {
</Button> </Button>
</Form> </Form>
) : undefined} ) : undefined}
{data.oidc ? ( {data.oidc === true ? (
<Form method="POST"> <Form method="POST">
{!data.apiKey ? ( {!data.apiKey ? (
<Card.Text className="mb-6"> <Card.Text className="mb-6">

View File

@ -15,6 +15,7 @@ interface Props {
machine: Machine; machine: Machine;
routes: Route[]; routes: Route[];
users: User[]; users: User[];
isAgent?: boolean;
magic?: string; magic?: string;
stats?: HostInfo; stats?: HostInfo;
} }
@ -22,8 +23,9 @@ interface Props {
export default function MachineRow({ export default function MachineRow({
machine, machine,
routes, routes,
magic,
users, users,
isAgent,
magic,
stats, stats,
}: Props) { }: Props) {
const expired = const expired =
@ -79,6 +81,10 @@ export default function MachineRow({
tags.unshift('Subnets'); tags.unshift('Subnets');
} }
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => { const ipOptions = useMemo(() => {
if (magic) { if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`]; return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
@ -146,24 +152,18 @@ export default function MachineRow({
</Menu> </Menu>
</div> </div>
</td> </td>
{/**
<td className="py-2"> <td className="py-2">
{stats !== undefined ? ( {stats !== undefined ? (
<> <>
<p className="leading-snug"> <p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
{hinfo.getTSVersion(stats)}
</p>
<p className="text-sm opacity-50 max-w-48 truncate"> <p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)} {hinfo.getOSInfo(stats)}
</p> </p>
</> </>
) : ( ) : (
<p className="text-sm opacity-50"> <p className="text-sm opacity-50">Unknown</p>
Unknown )}
</p>
)}
</td> </td>
**/}
<td className="py-2"> <td className="py-2">
<span <span
className={cn( className={cn(

View File

@ -14,11 +14,16 @@ import cn from '~/utils/cn';
import { hs_getConfig } from '~/utils/config/loader'; import { hs_getConfig } from '~/utils/config/loader';
import { pull } from '~/utils/headscale'; import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server'; import { getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { menuAction } from './action'; import { menuAction } from './action';
import MenuOptions from './components/menu'; import MenuOptions from './components/menu';
import Routes from './dialogs/routes'; import Routes from './dialogs/routes';
export async function loader({ request, params }: LoaderFunctionArgs) { export async function loader({
request,
params,
context,
}: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie')); const session = await getSession(request.headers.get('Cookie'));
if (!params.id) { if (!params.id) {
throw new Error('No machine ID provided'); throw new Error('No machine ID provided');
@ -44,6 +49,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
routes: routes.routes.filter((route) => route.node.id === params.id), routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users, users: users.users,
magic, magic,
agent: context?.agents.includes(machine.node.id),
}; };
} }
@ -52,8 +58,10 @@ export async function action({ request }: ActionFunctionArgs) {
} }
export default function Page() { 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 [showRouting, setShowRouting] = useState(false);
console.log(machine.expiry);
const expired = const expired =
machine.expiry === '0001-01-01 00:00:00' || machine.expiry === '0001-01-01 00:00:00' ||
@ -68,6 +76,10 @@ export default function Page() {
tags.unshift('Expired'); tags.unshift('Expired');
} }
if (agent) {
tags.unshift('Headplane Agent');
}
// This is much easier with Object.groupBy but it's too new for us // This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{ const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[]; exit: Route[];
@ -148,16 +160,18 @@ export default function Page() {
{machine.user.name} {machine.user.name}
</div> </div>
</div> </div>
<div className="p-2 pl-4"> {tags.length > 0 ? (
<p className="text-sm text-headplane-600 dark:text-headplane-300"> <div className="p-2 pl-4">
Status <p className="text-sm text-headplane-600 dark:text-headplane-300">
</p> Status
<div className="flex gap-1 mt-1 mb-8"> </p>
{tags.map((tag) => ( <div className="flex gap-1 mt-1 mb-8">
<Chip key={tag} text={tag} /> {tags.map((tag) => (
))} <Chip key={tag} text={tag} />
))}
</div>
</div> </div>
</div> ) : undefined}
</div> </div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2> <h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes <Routes

View File

@ -9,11 +9,11 @@ import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { pull } from '~/utils/headscale'; import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server'; import { getSession } from '~/utils/sessions.server';
import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
import Tooltip from '~/components/Tooltip'; import Tooltip from '~/components/Tooltip';
import { hs_getConfig } from '~/utils/config/loader'; import { hs_getConfig } from '~/utils/config/loader';
import { noContext } from '~/utils/log'; import { noContext } from '~/utils/log';
import useAgent from '~/utils/useAgent';
import { AppContext } from '~server/context/app'; import { AppContext } from '~server/context/app';
import { menuAction } from './action'; import { menuAction } from './action';
import MachineRow from './components/machine'; import MachineRow from './components/machine';
@ -34,12 +34,8 @@ export async function loader({
throw noContext(); throw noContext();
} }
initAgentSocket(context);
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
const ctx = context.context; const ctx = context.context;
const { mode, config } = hs_getConfig(); const { mode, config } = hs_getConfig();
let magic: string | undefined; let magic: string | undefined;
if (mode !== 'no') { if (mode !== 'no') {
@ -53,9 +49,9 @@ export async function loader({
routes: routes.routes, routes: routes.routes,
users: users.users, users: users.users,
magic, magic,
stats,
server: ctx.headscale.url, server: ctx.headscale.url,
publicServer: ctx.headscale.public_url, publicServer: ctx.headscale.public_url,
agents: context.agents,
}; };
} }
@ -65,6 +61,7 @@ export async function action({ request }: ActionFunctionArgs) {
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>(); const data = useLoaderData<typeof loader>();
const { data: stats } = useAgent(data.nodes.map((node) => node.nodeKey));
return ( return (
<> <>
@ -108,7 +105,7 @@ export default function Page() {
) : undefined} ) : undefined}
</div> </div>
</th> </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> <th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr> </tr>
</thead> </thead>
@ -127,7 +124,8 @@ export default function Page() {
)} )}
users={data.users} users={data.users}
magic={data.magic} magic={data.magic}
stats={data.stats?.[machine.nodeKey]} stats={stats?.[machine.nodeKey]}
isAgent={data.agents.includes(machine.id)}
/> />
))} ))}
</tbody> </tbody>

View File

@ -105,8 +105,8 @@ const headscaleConfig = type({
magic_dns: goBool.default(true), magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"', base_domain: 'string = "headscale.net"',
nameservers: type({ nameservers: type({
'global?': 'string[]', global: type('string[]').default(() => []),
'split': type('Record<string, string[]>').default(() => ({})), split: type('Record<string, string[]>').default(() => ({})),
}).default(() => ({ global: [], split: {} })), }).default(() => ({ global: [], split: {} })),
search_domains: type('string[]').default(() => []), search_domains: type('string[]').default(() => []),
extra_records: type({ extra_records: type({

View File

@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client'; import * as client from 'openid-client';
import log from '~/utils/log';
import type { AppContext } from '~server/context/app'; import type { AppContext } from '~server/context/app';
import log from '~server/utils/log';
type OidcConfig = NonNullable<AppContext['context']['oidc']>; type OidcConfig = NonNullable<AppContext['context']['oidc']>;
declare global { declare global {
@ -35,6 +36,57 @@ export function getRedirectUri(req: Request) {
return url.href; 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( function clientAuthMethod(
method: string, method: string,
): (secret: string) => client.ClientAuth { ): (secret: string) => client.ClientAuth {
@ -55,7 +107,7 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
new URL(oidc.issuer), new URL(oidc.issuer),
oidc.client_id, oidc.client_id,
oidc.client_secret, oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret), clientAuthMethod(oidc.token_endpoint_auth_method)(__oidc_context.secret),
); );
const codeVerifier = client.randomPKCECodeVerifier(); const codeVerifier = client.randomPKCECodeVerifier();
@ -97,7 +149,7 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
new URL(oidc.issuer), new URL(oidc.issuer),
oidc.client_id, oidc.client_id,
oidc.client_secret, oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret), clientAuthMethod(oidc.token_endpoint_auth_method)(__oidc_context.secret),
); );
let subject: string; let subject: string;
@ -126,15 +178,41 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
); );
return { return {
subject: claims.sub, subject: user.sub,
name: claims.name ? String(claims.name) : 'Anonymous', name: getName(user, claims),
email: claims.email ? String(claims.email) : undefined, email: user.email ?? claims.email?.toString(),
username: claims.preferred_username username: user.preferred_username ?? claims.preferred_username?.toString(),
? String(claims.preferred_username) picture: user.picture,
: undefined,
}; };
} }
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) { export function formatError(error: unknown) {
if (error instanceof client.ResponseBodyError) { if (error instanceof client.ResponseBodyError) {
return { return {
@ -177,13 +255,27 @@ export function formatError(error: unknown) {
}; };
} }
export function oidcEnabled() {
return __oidc_context.valid;
}
export async function testOidc(oidc: OidcConfig) { 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); log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
const secret = await resolveClientSecret(oidc);
const config = await client.discovery( const config = await client.discovery(
new URL(oidc.issuer), new URL(oidc.issuer),
oidc.client_id, oidc.client_id,
oidc.client_secret, oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidc.client_secret), clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret),
); );
const meta = config.serverMetadata(); const meta = config.serverMetadata();
@ -214,13 +306,9 @@ export async function testOidc(oidc: OidcConfig) {
'OIDC server does not support %s', 'OIDC server does not support %s',
oidc.token_endpoint_auth_method, oidc.token_endpoint_auth_method,
); );
return false; return false;
} }
} else {
log.warn(
'OIDC',
'OIDC server does not advertise token_endpoint_auth_methods_supported',
);
} }
log.debug('OIDC', 'OIDC configuration is valid'); log.debug('OIDC', 'OIDC configuration is valid');

View File

@ -12,6 +12,7 @@ export type SessionData = {
name: string; name: string;
email?: string; email?: string;
username?: string; username?: string;
picture?: string;
}; };
}; };

32
app/utils/useAgent.ts Normal file
View File

@ -0,0 +1,32 @@
import { useEffect, useMemo, useRef } from 'react';
import { useFetcher } from 'react-router';
import { HostInfo } from '~/types';
export default function useAgent(nodeIds: string[], interval = 3000) {
const fetcher = useFetcher<Record<string, HostInfo>>();
const qp = useMemo(
() => new URLSearchParams({ node_ids: nodeIds.join(',') }),
[nodeIds],
);
const idRef = useRef<string[]>([]);
useEffect(() => {
if (idRef.current.join(',') !== nodeIds.join(',')) {
fetcher.load(`/api/agent?${qp.toString()}`);
idRef.current = nodeIds;
}
const intervalID = setInterval(() => {
fetcher.load(`/api/agent?${qp.toString()}`);
}, interval);
return () => {
clearInterval(intervalID);
};
}, [interval, qp]);
return {
data: fetcher.data,
isLoading: fetcher.state === 'loading',
};
}

View File

@ -73,7 +73,15 @@ integration:
oidc: oidc:
issuer: "https://accounts.google.com" issuer: "https://accounts.google.com"
client_id: "your-client-id" 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>" 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 disable_api_key_login: false
token_endpoint_auth_method: "client_secret_post" token_endpoint_auth_method: "client_secret_post"

View File

@ -1,5 +1,8 @@
# Headplane Agent # 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. 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: 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 Docker image is available through the `ghcr.io/tale/headplane-agent` tag.
The agent requires the following environment variables to be set: The agent requires the following environment variables to be set:
- **`HP_AGENT_HOSTNAME`**: A hostname you want to use for the agent. - **`HEADPLANE_AGENT_DEBUG`**: Enable debug logging if `true`.
- **`HP_AGENT_TS_SERVER`**: The URL to your Headscale instance. - **`HEADPLANE_AGENT_HOSTNAME`**: A hostname you want to use for the agent.
- **`HP_AGENT_TS_AUTHKEY`**: An authorization key to authenticate with Headscale (see below). - **`HEADPLANE_AGENT_TS_SERVER`**: The URL to your Headscale instance.
- **`HP_AGENT_HP_SERVER`**: The URL to your Headplane instance. - **`HEADPLANE_AGENT_TS_AUTHKEY`**: An authorization key to authenticate with Headscale (see below).
- **`HP_AGENT_HP_AUTHKEY`**: The generated auth key to authenticate with Headplane. - **`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 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. 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

View File

@ -2,6 +2,7 @@
"name": "headplane", "name": "headplane",
"private": true, "private": true,
"sideEffects": false, "sideEffects": false,
"version": "0.5.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build": "react-router build && vite build -c server/vite.config.ts", "build": "react-router build && vite build -c server/vite.config.ts",

View File

@ -1,12 +1,22 @@
import type { HostInfo } from '~/types';
import { TimedCache } from '~server/ws/cache';
import { hp_agentRequest, hp_getAgentCache } from '~server/ws/data';
import { hp_getAgents } from '~server/ws/socket';
import { hp_getConfig } from './loader'; import { hp_getConfig } from './loader';
import { HeadplaneConfig } from './parser'; import type { HeadplaneConfig } from './parser';
export interface AppContext { export interface AppContext {
context: HeadplaneConfig; context: HeadplaneConfig;
hp_agentRequest: typeof hp_agentRequest;
agents: string[];
agentData?: TimedCache<HostInfo>;
} }
export default function appContext() { export default function appContext(): AppContext {
return { return {
context: hp_getConfig(), context: hp_getConfig(),
hp_agentRequest,
agents: [...hp_getAgents().keys()],
agentData: hp_getAgentCache(),
}; };
} }

21
server/context/globals.ts Normal file
View File

@ -0,0 +1,21 @@
import { HeadplaneConfig } from './parser';
declare global {
const __cookie_context: {
cookie_secret: string;
cookie_secure: boolean;
};
const __hs_context: {
url: string;
config_path?: string;
config_strict?: boolean;
};
const __oidc_context: {
valid: boolean;
secret: string;
};
let __integration_context: HeadplaneConfig['integration'];
}

View File

@ -3,7 +3,7 @@ import { env } from 'node:process';
import { type } from 'arktype'; import { type } from 'arktype';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { parseDocument } from 'yaml'; import { parseDocument } from 'yaml';
import { testOidc } from '~/utils/oidc'; import { getOidcSecret, testOidc } from '~/utils/oidc';
import log, { hpServer_loadLogger } from '~server/utils/log'; import log, { hpServer_loadLogger } from '~server/utils/log';
import mutex from '~server/utils/mutex'; import mutex from '~server/utils/mutex';
import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser'; import { HeadplaneConfig, coalesceConfig, validateConfig } from './parser';
@ -20,6 +20,11 @@ declare namespace globalThis {
config_strict?: boolean; config_strict?: boolean;
}; };
let __oidc_context: {
valid: boolean;
secret: string;
};
let __integration_context: HeadplaneConfig['integration']; let __integration_context: HeadplaneConfig['integration'];
} }
@ -113,8 +118,27 @@ export async function hp_loadConfig() {
process.exit(1); process.exit(1);
} }
if (config.oidc?.strict_validation) { // OIDC Related Checks
testOidc(config.oidc); 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.__oidc_context = {
valid: result,
secret: getOidcSecret() ?? '',
};
}
} }
globalThis.__cookie_context = { globalThis.__cookie_context = {

View File

@ -8,12 +8,24 @@ const serverConfig = type({
port: type('string | number.integer').pipe((v) => Number(v)), port: type('string | number.integer').pipe((v) => Number(v)),
cookie_secret: '32 <= string <= 32', cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool, 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({ const oidcConfig = type({
issuer: 'string.url', issuer: 'string.url',
client_id: 'string', client_id: 'string',
client_secret: 'string', client_secret: 'string?',
client_secret_path: 'string?',
token_endpoint_auth_method: token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"', '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?', redirect_uri: 'string.url?',

View File

@ -1,9 +1,11 @@
// import { initWebsocket } from '~server/ws';
import { constants, access } from 'node:fs/promises'; import { constants, access } from 'node:fs/promises';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { WebSocketServer } from 'ws';
import { hp_getConfig, hp_loadConfig } from '~server/context/loader'; import { hp_getConfig, hp_loadConfig } from '~server/context/loader';
import { listener } from '~server/listener'; import { listener } from '~server/listener';
import log from '~server/utils/log'; import log from '~server/utils/log';
import { hp_loadAgentCache } from '~server/ws/data';
import { initWebsocket } from '~server/ws/socket';
log.info('SRVX', 'Running Node.js %s', process.versions.node); log.info('SRVX', 'Running Node.js %s', process.versions.node);
@ -19,16 +21,16 @@ try {
await hp_loadConfig(); await hp_loadConfig();
const server = createServer(listener); 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(); 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, () => { server.listen(context.server.port, context.server.host, () => {
log.info( log.info(
'SRVX', 'SRVX',

View File

@ -1,60 +0,0 @@
import WebSocket, { WebSocketServer } from 'ws';
import log from '~server/utils/log';
const server = new WebSocketServer({ noServer: true });
export function initWebsocket() {
// TODO: Finish this and make public
return;
const key = process.env.LOCAL_AGENT_AUTHKEY;
if (!key) {
return;
}
log.info('CACH', 'Initializing agent WebSocket');
server.on('connection', (ws, req) => {
// biome-ignore lint: this file is not USED
const auth = req.headers['authorization'];
if (auth !== `Bearer ${key}`) {
log.warn('CACH', 'Invalid agent WebSocket connection');
ws.close(1008, 'ERR_INVALID_AUTH');
return;
}
const nodeID = req.headers['x-headplane-ts-node-id'];
if (!nodeID) {
log.warn('CACH', 'Invalid agent WebSocket connection');
ws.close(1008, 'ERR_INVALID_NODE_ID');
return;
}
const pinger = setInterval(() => {
if (ws.readyState !== WebSocket.OPEN) {
clearInterval(pinger);
return;
}
ws.ping();
}, 30000);
ws.on('close', () => {
clearInterval(pinger);
});
ws.on('error', (error) => {
clearInterval(pinger);
log.error('CACH', 'Closing agent WebSocket connection');
log.error('CACH', 'Agent WebSocket error: %s', error);
ws.close(1011, 'ERR_INTERNAL_ERROR');
});
});
return server;
}
export function appContext() {
return {
ws: server,
wsAuthKey: process.env.LOCAL_AGENT_AUTHKEY,
};
}

126
server/ws/cache.ts Normal file
View File

@ -0,0 +1,126 @@
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { type } from 'arktype';
import log from '~server/utils/log';
import mutex from '~server/utils/mutex';
const diskSchema = type({
key: 'string',
value: 'unknown',
expires: 'number?',
}).array();
// A persistent HashMap with a TTL for each key
export class TimedCache<V> {
private _cache = new Map<string, V>();
private _timings = new Map<string, number>();
// Default TTL is 1 minute
private defaultTTL: number;
private filePath: string;
private writeLock = mutex();
// Last flush ID is essentially a hash of the flush contents
// Prevents unnecessary flushing if nothing has changed
private lastFlushId = '';
constructor(defaultTTL: number, filePath: string) {
this.defaultTTL = defaultTTL;
this.filePath = filePath;
// Load the cache from disk and then queue flushes every 10 seconds
this.load().then(() => {
setInterval(() => this.flush(), 10000);
});
}
set(key: string, value: V, ttl: number = this.defaultTTL) {
this._cache.set(key, value);
this._timings.set(key, Date.now() + ttl);
}
get(key: string) {
const value = this._cache.get(key);
if (!value) {
return;
}
const expires = this._timings.get(key);
if (!expires || expires < Date.now()) {
this._cache.delete(key);
this._timings.delete(key);
return;
}
return value;
}
// Map into a Record without any TTLs
toJSON() {
const result: Record<string, V> = {};
for (const [key, value] of this._cache.entries()) {
result[key] = value;
}
return result;
}
// WARNING: This function expects that this.filePath is NOT ENOENT
private async load() {
const data = await readFile(this.filePath, 'utf-8');
const cache = () => {
try {
return JSON.parse(data);
} catch (e) {
return undefined;
}
};
const diskData = cache();
if (diskData === undefined) {
log.error('CACH', 'Failed to load cache at %s', this.filePath);
return;
}
const cacheData = diskSchema(diskData);
if (cacheData instanceof type.errors) {
log.error('CACH', 'Failed to load cache at %s', this.filePath);
log.debug('CACHE', 'Error details: %s', cacheData.toString());
// Skip loading the cache (it should be overwritten soon)
return;
}
for (const { key, value, expires } of diskData) {
this._cache.set(key, value);
this._timings.set(key, expires);
}
log.info('CACH', 'Loaded cache from %s', this.filePath);
}
private async flush() {
this.writeLock.acquire();
const data = Array.from(this._cache.entries()).map(([key, value]) => {
return { key, value, expires: this._timings.get(key) };
});
if (data.length === 0) {
this.writeLock.release();
return;
}
// Calculate the hash of the data
const dumpData = JSON.stringify(data);
const sha = createHash('sha256').update(dumpData).digest('hex');
if (sha === this.lastFlushId) {
this.writeLock.release();
return;
}
await writeFile(this.filePath, dumpData, 'utf-8');
this.lastFlushId = sha;
this.writeLock.release();
log.debug('CACH', 'Flushed cache to %s', this.filePath);
}
}

61
server/ws/data.ts Normal file
View File

@ -0,0 +1,61 @@
import { open } from 'node:fs/promises';
import type { HostInfo } from '~/types';
import log from '~server/utils/log';
import { TimedCache } from './cache';
import { hp_getAgents } from './socket';
let cache: TimedCache<HostInfo> | undefined;
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;
}
cache = new TimedCache(defaultTTL, filepath);
}
export function hp_getAgentCache() {
return 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_getAgents();
// 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();
});
});
}),
);
}

57
server/ws/socket.ts Normal file
View File

@ -0,0 +1,57 @@
import WebSocket, { WebSocketServer } from 'ws';
import log from '~server/utils/log';
export function initWebsocket(server: WebSocketServer, authKey: string) {
log.info('SRVX', 'Starting a WebSocket server for agent connections');
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;
}
const agents = new Map<string, WebSocket>();
export function hp_getAgents() {
return agents;
}

View File

@ -1,19 +1,21 @@
import { readFile } from 'node:fs/promises';
import { reactRouter } from '@react-router/dev/vite'; import { reactRouter } from '@react-router/dev/vite';
import autoprefixer from 'autoprefixer';
import tailwindcss from 'tailwindcss';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import babel from 'vite-plugin-babel'; import babel from 'vite-plugin-babel';
import tsconfigPaths from 'vite-tsconfig-paths'; import tsconfigPaths from 'vite-tsconfig-paths';
import fs from 'node:fs';
import tailwindcss from 'tailwindcss';
import autoprefixer from 'autoprefixer';
const prefix = process.env.__INTERNAL_PREFIX || '/admin'; const prefix = process.env.__INTERNAL_PREFIX || '/admin';
if (prefix.endsWith('/')) { if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash'); throw new Error('Prefix must not end with a slash');
} }
const version = fs.readFileSync("version", "utf8"); // Load the version via package.json
const pkg = await readFile('package.json', 'utf-8');
const { version } = JSON.parse(pkg);
if (!version) { if (!version) {
throw new Error('Unable to read ./version'); throw new Error('Unable to read version from package.json');
} }
export default defineConfig({ export default defineConfig({