diff --git a/app/routes/machines/components/machine.tsx b/app/routes/machines/components/machine.tsx
index c317771..c40e44d 100644
--- a/app/routes/machines/components/machine.tsx
+++ b/app/routes/machines/components/machine.tsx
@@ -4,19 +4,21 @@ import { useMemo } from 'react';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import { toast } from '~/components/Toaster';
-import type { Machine, Route, User } from '~/types';
+import type { Machine, Route, User, HostInfo } from '~/types';
import { cn } from '~/utils/cn';
+import * as hinfo from '~/utils/host-info';
import MenuOptions from './menu';
interface Props {
- readonly machine: Machine;
- readonly routes: Route[];
- readonly users: User[];
- readonly magic?: string;
+ machine: Machine;
+ routes: Route[];
+ users: User[];
+ magic?: string;
+ stats?: HostInfo;
}
-export default function MachineRow({ machine, routes, magic, users }: Props) {
+export default function MachineRow({ machine, routes, magic, users, stats }: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
@@ -151,6 +153,22 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
+
+ {stats !== undefined ? (
+ <>
+
+ {hinfo.getTSVersion(stats)}
+
+
+ {hinfo.getOSInfo(stats)}
+
+ >
+ ) : (
+
+ Unknown
+
+ )}
+ |
('v1/node', session.get('hsApiKey')!),
@@ -25,6 +26,9 @@ export async function loader({ request }: LoaderFunctionArgs) {
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
]);
+ initAgentSocket(lC);
+
+ const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
const context = await loadContext();
let magic: string | undefined;
@@ -44,6 +48,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
routes: routes.routes,
users: users.users,
magic,
+ stats,
server: context.headscaleUrl,
publicServer: context.headscalePublicUrl,
};
@@ -107,6 +112,7 @@ export default function Page() {
) : undefined}
+ | Version |
Last Seen |
@@ -125,6 +131,7 @@ export default function Page() {
)}
users={data.users}
magic={data.magic}
+ stats={data.stats?.[machine.nodeKey]}
/>
))}
diff --git a/app/types/HostInfo.ts b/app/types/HostInfo.ts
new file mode 100644
index 0000000..4cd10b7
--- /dev/null
+++ b/app/types/HostInfo.ts
@@ -0,0 +1,206 @@
+// Roughly follows the HostInfo we get from the headplane agent
+// Should it drift too much we may begin to get errors, but in go its stable
+// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
+
+export interface HostInfo {
+ /** Version of this code (in version.Long format) */
+ IPNVersion?: string;
+
+ /** Logtail ID of frontend instance */
+ FrontendLogID?: string;
+
+ /** Logtail ID of backend instance */
+ BackendLogID?: string;
+
+ /** Operating system the client runs on (a version.OS value) */
+ OS?: string;
+
+ /**
+ * Version of the OS, if available.
+ *
+ * - Android: "10", "11", "12", etc.
+ * - iOS/macOS: "15.6.1", "12.4.0", etc.
+ * - Windows: "10.0.19044.1889", etc.
+ * - FreeBSD: "12.3-STABLE", etc.
+ * - Linux (pre-1.32): "Debian 10.4; kernel=xxx; container; env=kn"
+ * - Linux (1.32+): Kernel version, e.g., "5.10.0-17-amd64".
+ */
+ OSVersion?: string;
+
+ /** Whether the client is running in a container (best-effort detection) */
+ Container?: boolean;
+
+ /** Host environment type as a string */
+ Env?: string;
+
+ /** Distribution name (e.g., "debian", "ubuntu", "nixos") */
+ Distro?: string;
+
+ /** Distribution version (e.g., "20.04") */
+ DistroVersion?: string;
+
+ /** Distribution code name (e.g., "jammy", "bullseye") */
+ DistroCodeName?: string;
+
+ /** Used to disambiguate Tailscale clients that run using tsnet */
+ App?: string;
+
+ /** Whether a desktop was detected on Linux */
+ Desktop?: boolean;
+
+ /** Tailscale package identifier ("choco", "appstore", etc.; empty if unknown) */
+ Package?: string;
+
+ /** Mobile phone model (e.g., "Pixel 3a", "iPhone12,3") */
+ DeviceModel?: string;
+
+ /** macOS/iOS APNs device token for notifications (future support for Android) */
+ PushDeviceToken?: string;
+
+ /** Name of the host the client runs on */
+ Hostname?: string;
+
+ /** Indicates whether the host is blocking incoming connections */
+ ShieldsUp?: boolean;
+
+ /** Indicates this node exists in netmap because it's owned by a shared-to user */
+ ShareeNode?: boolean;
+
+ /** Indicates user has opted out of sending logs and support */
+ NoLogsNoSupport?: boolean;
+
+ /** Indicates the node wants the option to receive ingress connections */
+ WireIngress?: boolean;
+
+ /** Indicates node has opted-in to admin-console-driven remote updates */
+ AllowsUpdate?: boolean;
+
+ /** Current host's machine type (e.g., uname -m) */
+ Machine?: string;
+
+ /** `GOARCH` value of the built binary */
+ GoArch?: string;
+
+ /** Architecture variant (e.g., GOARM, GOAMD64) of the built binary */
+ GoArchVar?: string;
+
+ /** Go version the binary was built with */
+ GoVersion?: string;
+
+ /** Set of IP ranges this client can route */
+ RoutableIPs?: string[];
+
+ /** Set of ACL tags this node wants to claim */
+ RequestTags?: string[];
+
+ /** MAC addresses to send Wake-on-LAN packets to wake this node */
+ WoLMACs?: string[];
+
+ /** Services advertised by this machine */
+ Services?: Service[];
+
+ /** Networking information about the node */
+ NetInfo?: NetInfo;
+
+ /** SSH host keys if advertised */
+ sshHostKeys?: string[];
+
+ /** Cloud provider information (if any) */
+ Cloud?: string;
+
+ /** Indicates if the client is running in userspace (netstack) mode */
+ Userspace?: boolean;
+
+ /** Indicates if the client's subnet router is running in userspace (netstack) mode */
+ UserspaceRouter?: boolean;
+
+ /** Indicates if the client is running the app-connector service */
+ AppConnector?: boolean;
+
+ /** Opaque hash of the most recent list of tailnet services (indicates config updates) */
+ ServicesHash?: string;
+
+ /** Geographical location data about the Tailscale host (optional) */
+ Location?: Location;
+}
+
+/** Represents a network service advertised by a node */
+interface Service {
+ /** Protocol type (e.g., "tcp", "udp", "peerapi4") */
+ Proto: string;
+
+ /** Port number */
+ Port: number;
+
+ /** Textual description of the service (usually the process name) */
+ Description?: string;
+}
+
+/** Networking information for a Tailscale node */
+interface NetInfo {
+ /** Indicates if NAT mappings vary based on destination IP */
+ MappingVariesByDestIP?: boolean;
+
+ /** Indicates if the router supports hairpinning */
+ HairPinning?: boolean;
+
+ /** Indicates if the host has IPv6 internet connectivity */
+ WorkingIPv6?: boolean;
+
+ /** Indicates if the OS supports IPv6 */
+ OSHasIPv6?: boolean;
+
+ /** Indicates if the host has UDP internet connectivity */
+ WorkingUDP?: boolean;
+
+ /** Indicates if ICMPv4 works (empty if not checked) */
+ WorkingICMPv4?: boolean;
+
+ /** Indicates if there is an existing portmap open (UPnP, PMP, PCP) */
+ HavePortMap?: boolean;
+
+ /** Indicates if UPnP appears present on the LAN (empty if not checked) */
+ UPnP?: boolean;
+
+ /** Indicates if NAT-PMP appears present on the LAN (empty if not checked) */
+ PMP?: boolean;
+
+ /** Indicates if PCP appears present on the LAN (empty if not checked) */
+ PCP?: boolean;
+
+ /** Preferred DERP region ID */
+ PreferredDERP?: number;
+
+ /** Current link type ("wired", "wifi", "mobile") */
+ LinkType?: string;
+
+ /** Fastest recent time to reach various DERP STUN servers (seconds) */
+ DERPLatency?: Record;
+
+ /** Firewall mode on Linux-specific configurations */
+ FirewallMode?: string;
+}
+
+/** Represents the geographical location of a Tailscale host */
+interface Location {
+ /** Country name (user-friendly, properly capitalized) */
+ Country?: string;
+
+ /** ISO 3166-1 alpha-2 country code (upper case) */
+ CountryCode?: string;
+
+ /** City name (user-friendly, properly capitalized) */
+ City?: string;
+
+ /** City code to disambiguate between cities (e.g., IATA, ICAO, ISO 3166-2) */
+ CityCode?: string;
+
+ /** Latitude of the node (in degrees, optional) */
+ Latitude?: number;
+
+ /** Longitude of the node (in degrees, optional) */
+ Longitude?: number;
+
+ /** Priority for exit node selection (0 means no priority, negative not allowed) */
+ Priority?: number;
+}
diff --git a/app/types/index.ts b/app/types/index.ts
index c2b3405..f0f89f7 100644
--- a/app/types/index.ts
+++ b/app/types/index.ts
@@ -3,3 +3,4 @@ export * from './Machine';
export * from './Route';
export * from './User';
export * from './PreAuthKey';
+export * from './HostInfo';
diff --git a/app/utils/config/headplane.ts b/app/utils/config/headplane.ts
index 9779b35..2c96585 100644
--- a/app/utils/config/headplane.ts
+++ b/app/utils/config/headplane.ts
@@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale';
import { testOidc } from '~/utils/oidc';
import log from '~/utils/log';
import { initSessionManager } from '~/utils/sessions.server';
+import { initAgentCache } from '~/utils/ws-agent';
export interface HeadplaneContext {
debug: boolean;
@@ -21,6 +22,12 @@ export interface HeadplaneContext {
cookieSecret: string;
integration: IntegrationFactory | undefined;
+ cache: {
+ enabled: boolean;
+ path: string;
+ defaultTTL: number;
+ }
+
config: {
read: boolean;
write: boolean;
@@ -98,6 +105,18 @@ export async function loadContext(): Promise {
// Initialize Session Management
initSessionManager();
+ const cacheEnabled = process.env.AGENT_CACHE_DISABLED !== 'true';
+ const cachePath = process.env.AGENT_CACHE_PATH ?? '/etc/headplane/agent.cache';
+ const cacheTTL = 300 * 1000; // 5 minutes
+
+ // Load agent cache
+ if (cacheEnabled) {
+ log.info('CTXT', 'Initializing Agent Cache');
+ log.debug('CTXT', 'Cache Path: %s', cachePath);
+ log.debug('CTXT', 'Cache TTL: %d', cacheTTL);
+ await initAgentCache(cacheTTL, cachePath);
+ }
+
context = {
debug,
headscaleUrl,
@@ -105,6 +124,11 @@ export async function loadContext(): Promise {
cookieSecret,
integration: await loadIntegration(),
config: contextData,
+ cache: {
+ enabled: cacheEnabled,
+ path: cachePath,
+ defaultTTL: cacheTTL,
+ },
oidc: await checkOidc(config),
};
diff --git a/app/utils/host-info.ts b/app/utils/host-info.ts
new file mode 100644
index 0000000..f3dad7a
--- /dev/null
+++ b/app/utils/host-info.ts
@@ -0,0 +1,36 @@
+import type { HostInfo } from '~/utils/types';
+
+export function getTSVersion(host: HostInfo) {
+ const { IPNVersion } = host;
+ if (!IPNVersion) {
+ return 'Unknown';
+ }
+
+ // IPNVersion is --
+ return IPNVersion.split('-')[0];
+}
+
+export function getOSInfo(host: HostInfo) {
+ const { OS, OSVersion } = host;
+ // OS follows runtime.GOOS but uses iOS and macOS instead of darwin
+ const formattedOS = formatOS(OS);
+
+ // Trim in case OSVersion is empty
+ return `${formattedOS} ${OSVersion}`.trim();
+}
+
+function formatOS(os?: string) {
+ switch (os) {
+ case 'macOS':
+ case 'iOS':
+ return os;
+ case 'windows':
+ return 'Windows';
+ case 'linux':
+ return 'Linux';
+ case undefined:
+ return 'Unknown';
+ default:
+ return os;
+ }
+}
diff --git a/app/utils/oidc.ts b/app/utils/oidc.ts
index ebb6d26..1b6419d 100644
--- a/app/utils/oidc.ts
+++ b/app/utils/oidc.ts
@@ -1,4 +1,5 @@
import { redirect } from 'react-router';
+import * as client from 'openid-client';
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge,
@@ -21,8 +22,130 @@ import { commitSession, getSession } from '~/utils/sessions.server';
import log from '~/utils/log';
import type { HeadplaneContext } from './config/headplane';
+import { z } from 'zod';
-type OidcConfig = NonNullable;
+const oidcConfigSchema = z.object({
+ issuer: z.string(),
+ clientId: z.string(),
+ clientSecret: z.string(),
+ tokenEndpointAuthMethod: z
+ .enum(['client_secret_post', 'client_secret_basic'])
+ .default('client_secret_basic'),
+ idTokenSigningAlg: z
+ .enum([
+ 'RS256',
+ 'RS384',
+ 'RS512',
+ 'ES256',
+ 'ES384',
+ 'ES512',
+ 'PS256',
+ 'PS384',
+ 'PS512',
+ ])
+ .default('RS256'),
+ idTokenEncryptionAlg: z
+ .enum(['RSA1_5', 'RSA-OAEP', 'RSA-OAEP-256'])
+ .default('RSA-OAEP'),
+ idTokenEncryptionEnc: z
+ .enum([
+ 'A128CBC-HS256',
+ 'A192CBC-HS384',
+ 'A256CBC-HS512',
+ 'A128GCM',
+ 'A192GCM',
+ 'A256GCM',
+ ])
+ .default('A256GCM'),
+});
+
+declare global {
+ const __PREFIX__: string;
+}
+
+export type OidcConfig = z.infer;
+
+// We try our best to infer the callback URI of our Headplane instance
+// By default it is always //oidc/callback
+export function getRedirectUri(req: Request) {
+ const base = __PREFIX__ ?? '/admin'; // Fallback
+ const url = new URL(`${base}/oidc/callback`, req.url);
+ let host = req.headers.get('Host');
+ if (!host) {
+ host = req.headers.get('X-Forwarded-Host');
+ }
+
+ if (!host) {
+ log.error('OIDC', 'Unable to find a host header');
+ log.error('OIDC', 'Ensure either Host or X-Forwarded-Host is set');
+ throw new Error('Could not determine reverse proxy host');
+ }
+
+ const proto = req.headers.get('X-Forwarded-Proto');
+ if (!proto) {
+ log.warn('OIDC', 'No X-Forwarded-Proto header found');
+ log.warn('OIDC', 'Assuming your Headplane instance runs behind HTTP');
+ }
+
+ url.protocol = proto ?? 'http:';
+ url.host = host;
+ return url.href;
+}
+
+export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
+ const config = await client.discovery(
+ oidc.issuer,
+ oidc.clientId,
+ oidc.clientSecret,
+ );
+
+ let codeVerifier: string, codeChallenge: string;
+ codeVerifier = client.randomPKCECodeVerifier();
+ codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
+
+ let params: Record = {
+ redirect_uri,
+ scope: 'openid profile email',
+ code_challenge: codeChallenge,
+ code_challenge_method: 'S256',
+ }
+
+ // PKCE is backwards compatible with non-PKCE servers
+ // so if we don't support it, just set our nonce
+ if (!config.serverMetadata().supportsPKCE()) {
+ params.nonce = client.randomNonce();
+ }
+
+ const url = client.buildAuthorizationUrl(config, params);
+ return {
+ url: url.href,
+ codeVerifier,
+ nonce: params.nonce,
+ };
+}
+
+interface FlowOptions {
+ redirect_uri: string;
+ codeVerifier: string;
+ nonce?: string;
+}
+
+export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
+ const config = await client.discovery(
+ oidc.issuer,
+ oidc.clientId,
+ oidc.clientSecret,
+ );
+
+ let subject: string, accessToken: string;
+ const tokens = await client.authorizationCodeGrant(config, new URL(options.redirect_uri), {
+ pkceCodeVerifier: options.codeVerifier,
+ expectedNonce: options.nonce,
+ idTokenExpected: true
+ })
+
+ console.log(tokens);
+}
export async function startOidc(oidc: OidcConfig, req: Request) {
const session = await getSession(req.headers.get('Cookie'));
@@ -35,12 +158,13 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
});
}
+
// TODO: Properly validate the method is a valid type
const method = oidc.method as ClientAuthenticationMethod;
const issuerUrl = new URL(oidc.issuer);
const oidcClient = {
client_id: oidc.client,
- token_endpoint_auth_method: method
+ token_endpoint_auth_method: method,
} satisfies Client;
const response = await discoveryRequest(issuerUrl);
diff --git a/app/utils/ws-agent.ts b/app/utils/ws-agent.ts
new file mode 100644
index 0000000..f562613
--- /dev/null
+++ b/app/utils/ws-agent.ts
@@ -0,0 +1,139 @@
+// Handlers for the Local Agent on the server side
+import { readFile, writeFile } from 'node:fs/promises';
+import { setTimeout as pSetTimeout } from 'node:timers/promises';
+import type { LoaderFunctionArgs } from 'react-router';
+import type { HostInfo } from '~/types';
+import { WebSocket } from 'ws';
+import { log } from './log';
+
+// Essentially a HashMap which invalidates entries after a certain time.
+// It also is capable of syncing as a compressed file to disk.
+class TimedCache {
+ private _cache = new Map();
+ private _timeCache = new Map();
+ private defaultTTL: number;
+ private filepath: string;
+
+ constructor(defaultTTL: number, filepath: string) {
+ this.defaultTTL = defaultTTL;
+ this.filepath = filepath;
+ }
+
+ async set(key: K, value: V, ttl: number = this.defaultTTL) {
+ this._cache.set(key, value);
+ this._timeCache.set(key, Date.now() + ttl);
+ await this.syncToFile();
+ }
+
+ async get(key: K) {
+ const entry = this._cache.get(key);
+ if (!entry) {
+ return;
+ }
+
+ const expires = this._timeCache.get(key);
+ if (!expires || expires < Date.now()) {
+ this._cache.delete(key);
+ this._timeCache.delete(key);
+ await this.syncToFile();
+ return;
+ }
+
+ return entry;
+ }
+
+ async loadFromFile() {
+ try {
+ const data = await readFile(this.filepath, 'utf-8');
+ const cache = JSON.parse(data);
+ for (const { key, value, expires } of cache) {
+ this._cache.set(key, value);
+ this._timeCache.set(key, expires);
+ }
+ } catch (e) {
+ if (e['code'] !== 'ENOENT') {
+ // log.error('CACH', 'Failed to load cache from file', e);
+ return;
+ }
+
+ // log.debug('CACH', 'Cache file not found, creating new cache');
+ }
+ }
+
+ private async syncToFile() {
+ const data = Array.from(this._cache.entries()).map(([key, value]) => {
+ return { key, value, expires: this._timeCache.get(key) }
+ });
+
+ await writeFile(this.filepath, JSON.stringify(data), 'utf-8');
+ }
+}
+
+let cache: TimedCache | undefined;
+export async function initAgentCache(defaultTTL: number, filepath: string) {
+ cache = new TimedCache(defaultTTL, filepath);
+ await pSetTimeout(500);
+ await cache.loadFromFile();
+}
+
+let agentSocket: WebSocket | undefined;
+export function initAgentSocket(context: LoaderFunctionArgs['context']) {
+ const client = context.ws.clients.values().next().value;
+ agentSocket = client;
+}
+
+// Check the cache and then attempt the websocket query
+// If we aren't connected to an agent, then debug log and return the cache
+export async function queryAgent(nodes: string[]) {
+ if (!cache) {
+ log.error('CACH', 'Cache not initialized');
+ return;
+ }
+
+ const cached: Record = {};
+ await Promise.all(nodes.map(async node => {
+ const cachedData = await cache.get(node);
+ if (cachedData) {
+ cached[node] = cachedData;
+ }
+ }))
+
+ const uncached = nodes.filter(node => !cached[node]);
+
+ // No need to query the agent if we have all the data cached
+ if (uncached.length === 0) {
+ return cached;
+ }
+
+ // We don't have an agent socket, so we can't query the agent
+ // and we just return the cached values available instead
+ if (!agentSocket) {
+ return cached;
+ }
+
+ agentSocket.send(JSON.stringify({ NodeIDs: uncached }));
+ const returnData = await new Promise | void>((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ agentSocket.removeAllListeners('message');
+ resolve();
+ }, 3000);
+
+ agentSocket.on('message', async (message: string) => {
+ const data = JSON.parse(message.toString());
+ if (Object.keys(data).length === 0) {
+ resolve();
+ }
+
+ agentSocket.removeAllListeners('message');
+ resolve(data);
+ });
+ });
+
+ if (returnData) {
+ for await (const [node, info] of Object.entries(returnData)) {
+ await cache.set(node, info);
+ }
+ }
+
+ return returnData
+}
diff --git a/app/utils/ws.ts b/app/utils/ws.ts
deleted file mode 100644
index 33bed07..0000000
--- a/app/utils/ws.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-// This is a "side-effect" but we want a lifecycle cache map of
-// peer statuses to prevent unnecessary fetches to the agent.
-import type { LoaderFunctionArgs } from 'react-router';
-
-type Context = LoaderFunctionArgs['context'];
-const cache: { [nodeID: string]: unknown } = {};
-
-export async function queryWS(context: Context, nodeIDs: string[]) {
- const ws = context.ws;
- const firstClient = ws.clients.values().next().value;
- if (!firstClient) {
- return cache;
- }
-
- const cached = nodeIDs.map((nodeID) => {
- const cached = cache[nodeID];
- if (cached) {
- return cached;
- }
- });
-
- // We only need to query the nodes that are not cached
- const uncached = nodeIDs.filter((nodeID) => !cached.includes(nodeID));
- if (uncached.length === 0) {
- return cache;
- }
-
- firstClient.send(JSON.stringify({ NodeIDs: uncached }));
- await new Promise((resolve) => {
- const timeout = setTimeout(() => {
- resolve();
- }, 3000);
-
- firstClient.on('message', (message: string) => {
- const data = JSON.parse(message.toString());
- if (Object.keys(data).length === 0) {
- resolve();
- }
-
- for (const [nodeID, status] of Object.entries(data)) {
- cache[nodeID] = status;
- }
- });
- });
-
- return cache;
-}