mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: ditch hono
This commit is contained in:
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
// Globals replaced at build time by Vite (`define` in `vite.config.ts`).
|
||||
|
||||
declare const __PREFIX__: string;
|
||||
declare const __VERSION__: string;
|
||||
@@ -1,12 +1,12 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { LoadContext } from "~/server";
|
||||
import type { AppContext } from "~/server/context";
|
||||
|
||||
export async function loader() {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
|
||||
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
|
||||
try {
|
||||
principal = await context.auth.require(request);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLoaderData } from "react-router";
|
||||
import Code from "~/components/code";
|
||||
import Notice from "~/components/notice";
|
||||
import PageError from "~/components/page-error";
|
||||
import type { LoadContext } from "~/server";
|
||||
import type { AppContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import ManageDomains from "./components/manage-domains";
|
||||
@@ -15,7 +15,7 @@ import ToggleMagic from "./components/toggle-magic";
|
||||
import { dnsAction } from "./dns-actions";
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error("No configuration is available");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# `app/server/`
|
||||
|
||||
Server-side application code for Headplane. Everything in this directory
|
||||
runs only on the Node process — never in the browser.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/server/
|
||||
├── app.ts ← The Headplane application (load context, RR listener)
|
||||
├── main.ts ← Production bootstrap (binds an http(s) server)
|
||||
├── context.ts ← createAppContext() — assembles the AppLoadContext
|
||||
├── result.ts ← Result<T, E> helper used across the server modules
|
||||
│
|
||||
├── config/ ← YAML config loading, schema, env-overrides, integrations
|
||||
├── db/ ← Drizzle SQLite client + schema
|
||||
├── headscale/ ← Headscale REST API client + headscale-config loader
|
||||
├── oidc/ ← OIDC provider abstraction
|
||||
├── web/ ← Authentication service, identity, RBAC capabilities
|
||||
└── hp-agent.ts ← Headplane agent process manager
|
||||
```
|
||||
|
||||
## Entry points
|
||||
|
||||
There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
|
||||
|
||||
### `app.ts` — the application module
|
||||
|
||||
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts))
|
||||
→ exports the React Router `RequestListener` as `default`, plus the
|
||||
resolved `config` as a named export.
|
||||
|
||||
This module has no opinions about how the server is hosted. It does not
|
||||
listen on a socket, doesn't compose static-asset serving, and doesn't
|
||||
handle the basename redirect — that's the runtime's job (see
|
||||
[`runtime/`](../../runtime/)).
|
||||
|
||||
Consumed by:
|
||||
|
||||
- [`main.ts`](./main.ts) in production builds
|
||||
- [`runtime/vite-plugin.ts`](../../runtime/vite-plugin.ts) in `react-router dev`
|
||||
|
||||
### `main.ts` — the production bootstrap
|
||||
|
||||
The SSR build input. Rollup bundles this file into
|
||||
`build/server/index.js`. It:
|
||||
|
||||
1. imports the listener + config from [`app.ts`](./app.ts)
|
||||
2. wraps the listener with `composeListener` from
|
||||
[`runtime/http.ts`](../../runtime/http.ts) — adds `/admin → /admin/`
|
||||
redirect and serves `build/client/` as static assets with immutable
|
||||
caching for the `assets/` subdirectory
|
||||
3. binds an http(s) server with `startHttpServer`
|
||||
|
||||
Run with `node /app/build/server/index.js` (this is what the Dockerfile
|
||||
does). TLS is a one-line addition: pass `tls: { key, cert }` to
|
||||
`startHttpServer`.
|
||||
|
||||
## Application context
|
||||
|
||||
[`context.ts`](./context.ts) exposes `createAppContext(config)`, which
|
||||
constructs everything that needs to live for the lifetime of the
|
||||
process:
|
||||
|
||||
- the SQLite client (`db`)
|
||||
- the Headscale REST interface (`hsApi`)
|
||||
- the optional Headplane agent manager (`agents`)
|
||||
- the auth service (`auth`)
|
||||
- the optional OIDC service (`oidc`)
|
||||
- the live store (`hsLive`)
|
||||
- the (best-effort) parsed Headscale config (`hs`)
|
||||
- the integration adapter (`integration`)
|
||||
|
||||
The returned object is the `AppLoadContext` exposed to every React
|
||||
Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }`
|
||||
so route handlers get full type inference on `context`.
|
||||
|
||||
When a route needs the type, import it from `~/server/context`:
|
||||
|
||||
```ts
|
||||
import type { AppContext } from "~/server/context";
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
## Dev vs. prod
|
||||
|
||||
| Concern | Dev (`react-router dev`) | Prod (`node build/server/index.js`) |
|
||||
| ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| HTTP server | Vite owns it (`vite.config.ts` `server.host/port`) | `runtime/http.ts` `startHttpServer` |
|
||||
| Static assets | `./public` (served by `runtime/vite-plugin.ts`) | `build/client/` (served by `runtime/http.ts` static handler) |
|
||||
| Basename redirect (`/admin` → `/admin/`) | `runtime/vite-plugin.ts` via `composeListener` | `main.ts` via `composeListener` |
|
||||
| App load (HMR) | `ssrLoadModule(app.ts)` per request | bundled into `build/server/index.js` |
|
||||
| Entry point | [`app.ts`](./app.ts) | [`main.ts`](./main.ts) |
|
||||
|
||||
There is **no** `if (import.meta.env.PROD)` branch in [`app.ts`](./app.ts)
|
||||
or [`main.ts`](./main.ts) — the dev/prod split is expressed by which
|
||||
file is loaded, not by runtime conditionals.
|
||||
|
||||
## Adding a new server-side module
|
||||
|
||||
1. Create the module under an existing subdirectory (or add a new one
|
||||
that names a coherent concern, e.g. `metrics/`, `ratelimit/`).
|
||||
2. If it owns process-lifetime state (a connection pool, a service
|
||||
client, …), construct it in [`context.ts`](./context.ts) and add it
|
||||
to the returned object — this gives every route automatic access via
|
||||
`context.<name>`.
|
||||
3. If it's purely a helper (pure functions, type definitions), import
|
||||
it directly from the module that needs it.
|
||||
@@ -0,0 +1,50 @@
|
||||
// MARK: Headplane Application
|
||||
//
|
||||
// Loads configuration, builds the per-process app context, and exports
|
||||
// the React Router request listener as the default export.
|
||||
//
|
||||
// This module is consumed in two places:
|
||||
// - `app/server/main.ts` — the production bootstrap; wraps the
|
||||
// listener with static-asset serving and binds an http(s) server.
|
||||
// - `runtime/vite-plugin.ts` — the dev-mode Vite middleware; loads
|
||||
// this module through `ssrLoadModule` and dispatches each request.
|
||||
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createRequestListener } from "@react-router/node";
|
||||
import * as build from "virtual:react-router/server-build";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ConfigError } from "./config/error";
|
||||
import { loadConfig } from "./config/load";
|
||||
import { createAppContext } from "./context";
|
||||
|
||||
log.info("server", "Running Node.js %s", versions.node);
|
||||
|
||||
let config: HeadplaneConfig;
|
||||
try {
|
||||
config = await loadConfig();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
log.error("server", "Unable to load configuration: %s", error.message);
|
||||
} else {
|
||||
log.error("server", "Failed to load configuration: %s", error);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const ctx = await createAppContext(config);
|
||||
ctx.auth.start();
|
||||
|
||||
export { config };
|
||||
|
||||
// TODO: `getLoadContext` is the right place to handle reverse proxy
|
||||
// translation — better than doing it in the OIDC client because it
|
||||
// applies to all requests, not just OIDC ones.
|
||||
export default createRequestListener({
|
||||
build,
|
||||
mode: import.meta.env.MODE,
|
||||
getLoadContext: () => ctx,
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { loadIntegration } from "./config/integration";
|
||||
import { createDbClient } from "./db/client.server";
|
||||
import { createHeadscaleInterface } from "./headscale/api";
|
||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
||||
import { createAgentManager } from "./hp-agent";
|
||||
import { createOidcService } from "./oidc/provider";
|
||||
import { createAuthService } from "./web/auth";
|
||||
|
||||
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends AppContext {}
|
||||
}
|
||||
|
||||
export async function createAppContext(config: HeadplaneConfig) {
|
||||
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
|
||||
const hsApi = await createHeadscaleInterface(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
);
|
||||
|
||||
// Resolve the Headscale API key: headscale.api_key takes precedence,
|
||||
// falling back to the deprecated oidc.headscale_api_key for compatibility.
|
||||
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
|
||||
|
||||
let agents;
|
||||
if (headscaleApiKey) {
|
||||
agents = await createAgentManager(
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
hsApi.getRuntimeClient(headscaleApiKey),
|
||||
hsApi.clientHelpers.isAtleast("0.28.0"),
|
||||
db,
|
||||
);
|
||||
} else if (config.integration?.agent?.enabled) {
|
||||
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
|
||||
}
|
||||
|
||||
const auth = createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
headscaleApiKey,
|
||||
db,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
secure: config.server.cookie_secure,
|
||||
maxAge: config.server.cookie_max_age,
|
||||
domain: config.server.cookie_domain,
|
||||
},
|
||||
});
|
||||
|
||||
const oidc =
|
||||
config.oidc && config.oidc.enabled !== false && headscaleApiKey
|
||||
? {
|
||||
service: createOidcService({
|
||||
issuer: config.oidc.issuer,
|
||||
clientId: config.oidc.client_id,
|
||||
clientSecret: config.oidc.client_secret,
|
||||
baseUrl: config.server.base_url ?? "",
|
||||
authorizationEndpoint: config.oidc.authorization_endpoint,
|
||||
tokenEndpoint: config.oidc.token_endpoint,
|
||||
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
||||
endSessionEndpoint: config.oidc.end_session_endpoint,
|
||||
tokenEndpointAuthMethod:
|
||||
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
||||
? undefined
|
||||
: config.oidc.token_endpoint_auth_method,
|
||||
usePkce: config.oidc.use_pkce,
|
||||
scope: config.oidc.scope,
|
||||
subjectClaims: config.oidc.subject_claims,
|
||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||
extraParams: config.oidc.extra_params,
|
||||
profilePictureSource: config.oidc.profile_picture_source,
|
||||
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||
}),
|
||||
disableApiKeyLogin: config.oidc.disable_api_key_login,
|
||||
useEndSession: config.oidc.use_end_session,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
config,
|
||||
db,
|
||||
hsApi,
|
||||
headscaleApiKey,
|
||||
agents,
|
||||
auth,
|
||||
oidc,
|
||||
hsLive: createLiveStore([nodesResource, usersResource]),
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
integration: await loadIntegration(config.integration),
|
||||
};
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createHonoServer } from "react-router-hono-server/node";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { loadIntegration } from "./config/integration";
|
||||
import { loadConfig } from "./config/load";
|
||||
import { createDbClient } from "./db/client.server";
|
||||
import { createHeadscaleInterface } from "./headscale/api";
|
||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
||||
import { createAgentManager } from "./hp-agent";
|
||||
import { createAuthService } from "./web/auth";
|
||||
|
||||
declare global {
|
||||
const __PREFIX__: string;
|
||||
const __VERSION__: string;
|
||||
}
|
||||
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because everything running here
|
||||
// exists for the lifetime of the process, making it appropriate.
|
||||
log.info("server", "Running Node.js %s", versions.node);
|
||||
let config: HeadplaneConfig;
|
||||
|
||||
try {
|
||||
config = await loadConfig();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
log.error("server", "Unable to load configuration: %s", error.message);
|
||||
}
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
|
||||
const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path);
|
||||
|
||||
// Resolve the Headscale API key: headscale.api_key takes precedence,
|
||||
// falling back to the deprecated oidc.headscale_api_key for compatibility.
|
||||
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
|
||||
|
||||
const agents = headscaleApiKey
|
||||
? await createAgentManager(
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
hsApi.getRuntimeClient(headscaleApiKey),
|
||||
hsApi.clientHelpers.isAtleast("0.28.0"),
|
||||
db,
|
||||
)
|
||||
: (() => {
|
||||
if (config.integration?.agent?.enabled) {
|
||||
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We also use this file to load anything needed by the react router code.
|
||||
// These are usually per-request things that we need access to, like the
|
||||
// helper that can issue and revoke cookies.
|
||||
export type LoadContext = typeof appLoadContext;
|
||||
|
||||
import "react-router";
|
||||
import { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ConfigError } from "./config/error";
|
||||
import { createOidcService } from "./oidc/provider";
|
||||
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
}
|
||||
|
||||
const hsLive = createLiveStore([nodesResource, usersResource]);
|
||||
|
||||
const appLoadContext = {
|
||||
config,
|
||||
hsLive,
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
|
||||
auth: createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
headscaleApiKey,
|
||||
db,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
secure: config.server.cookie_secure,
|
||||
maxAge: config.server.cookie_max_age,
|
||||
domain: config.server.cookie_domain,
|
||||
},
|
||||
}),
|
||||
|
||||
headscaleApiKey,
|
||||
hsApi,
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc:
|
||||
config.oidc && config.oidc.enabled !== false && headscaleApiKey
|
||||
? {
|
||||
service: createOidcService({
|
||||
issuer: config.oidc.issuer,
|
||||
clientId: config.oidc.client_id,
|
||||
clientSecret: config.oidc.client_secret,
|
||||
baseUrl: config.server.base_url ?? "",
|
||||
authorizationEndpoint: config.oidc.authorization_endpoint,
|
||||
tokenEndpoint: config.oidc.token_endpoint,
|
||||
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
||||
endSessionEndpoint: config.oidc.end_session_endpoint,
|
||||
tokenEndpointAuthMethod:
|
||||
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
||||
? undefined
|
||||
: config.oidc.token_endpoint_auth_method,
|
||||
usePkce: config.oidc.use_pkce,
|
||||
scope: config.oidc.scope,
|
||||
subjectClaims: config.oidc.subject_claims,
|
||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||
extraParams: config.oidc.extra_params,
|
||||
profilePictureSource: config.oidc.profile_picture_source,
|
||||
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||
}),
|
||||
disableApiKeyLogin: config.oidc.disable_api_key_login,
|
||||
useEndSession: config.oidc.use_end_session,
|
||||
}
|
||||
: undefined,
|
||||
db,
|
||||
};
|
||||
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
}
|
||||
|
||||
export default createHonoServer({
|
||||
overrideGlobalObjects: true,
|
||||
port: config.server.port,
|
||||
hostname: config.server.host,
|
||||
beforeAll: async (app) => {
|
||||
app.use(__PREFIX__, async (c) => {
|
||||
return c.redirect(`${__PREFIX__}/`);
|
||||
});
|
||||
},
|
||||
serveStaticOptions: {
|
||||
publicAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
|
||||
},
|
||||
clientAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
|
||||
},
|
||||
},
|
||||
|
||||
// Only log in development mode
|
||||
defaultLogger: import.meta.env.DEV,
|
||||
getLoadContext() {
|
||||
// TODO: This is the place where we can handle reverse proxy translation
|
||||
// This is better than doing it in the OIDC client, since we can do it
|
||||
// for all requests, not just OIDC ones.
|
||||
return appLoadContext;
|
||||
},
|
||||
|
||||
listeningListener(info) {
|
||||
log.info("server", "Running on %s:%s", info.address, info.port);
|
||||
},
|
||||
});
|
||||
|
||||
appLoadContext.auth.start();
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
log.info("server", "Received SIGINT, shutting down...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
log.info("server", "Received SIGTERM, shutting down...");
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// MARK: Production Bootstrap
|
||||
//
|
||||
// The production SSR build entry. Imports the React Router request
|
||||
// listener from `./app`, wraps it with static-asset serving (out of
|
||||
// `build/client`) and basename redirect, then binds an http(s) server.
|
||||
//
|
||||
// This file is NOT loaded in dev — `react-router dev` boots through
|
||||
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
|
||||
// straight to `./app`'s default export.
|
||||
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { composeListener, startHttpServer } from "../../runtime/http";
|
||||
import requestListener, { config } from "./app";
|
||||
|
||||
// `import.meta.url` resolves to `build/server/index.js`; the built
|
||||
// client lives next to it at `build/client/`.
|
||||
const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
|
||||
|
||||
startHttpServer({
|
||||
host: config.server.host,
|
||||
port: config.server.port,
|
||||
listener: composeListener({
|
||||
basename: __PREFIX__,
|
||||
staticRoot: clientDir,
|
||||
immutableAssets: true,
|
||||
requestListener,
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user