Merge branch 'main' into nix
This commit is contained in:
commit
484fba4f6c
@ -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.
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
39
app/routes/api/agent.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
32
app/utils/useAgent.ts
Normal 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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
21
server/context/globals.ts
Normal 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'];
|
||||||
|
}
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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?',
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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
126
server/ws/cache.ts
Normal 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
61
server/ws/data.ts
Normal 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
57
server/ws/socket.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user