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; -}