mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 262c7bf82a | |||
| f8b23a5c89 | |||
| 775b81a7fa | |||
| 62817efa6e | |||
| b95d601ff6 | |||
| ea27c846e2 | |||
| 8c508e0602 | |||
| 0a51182eed | |||
| 22a521dff5 | |||
| de07372427 | |||
| 2584a4ef55 | |||
| 30b528c491 | |||
| d5acba88c7 | |||
| d7f1d665a4 | |||
| 7901f37002 | |||
| 7a62359b6a | |||
| 2e38a1d5e3 | |||
| 7221f1c2c3 | |||
| 56c5e5ac8c | |||
| 09be95c7bc | |||
| 0512565f8e | |||
| d4eee702e9 |
@@ -1,3 +1,24 @@
|
||||
# 0.7.0-beta.4 (May 31, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- **Headplane now requires Headscale 0.27.0 or newer.** Support for 0.26.x has been dropped. If `/version` returns 404 (the endpoint was added in 0.27.0), Headplane logs an error and keeps retrying so an in-place Headscale upgrade is picked up without a restart.
|
||||
- **Replaced the OpenAPI hash detection with `/version`.** Capabilities are now derived from the version reported by `/version` instead of fingerprinting the OpenAPI schema. This dramatically simplifies version detection and works with every supported release out of the box.
|
||||
- **Made Headscale boot resilient.** Headplane now boots even when Headscale is unreachable; capabilities default permissively and a background retry settles them once Headscale responds. No more cold-start ordering problems with docker-compose.
|
||||
- **Added optional in-process TLS termination.** Setting `server.tls_cert_path` and `server.tls_key_path` makes Headplane serve HTTPS/1.1 on `server.port` directly — no reverse proxy required. `server.cookie_secure` is auto-forced to `true` (with a warning) whenever TLS is enabled, since browsers refuse `Secure`-less cookies over HTTPS. HTTP/2 and HTTP/3 are intentionally not supported in-process; terminate those at a reverse proxy if you need them (closes [#403](https://github.com/tale/headplane/issues/403)).
|
||||
- **Made the bundled Docker healthcheck zero-config across HTTP and HTTPS.** Headplane writes its loopback URL (scheme, port, and basename included) to `/tmp/headplane-listen` on startup, and `hp_healthcheck` reads that file and probes the URL verbatim. Enabling TLS or changing `server.port` no longer requires any healthcheck-specific configuration. Native installs are unaffected — the listen file is only written when `HEADPLANE_LISTEN_FILE` is set, which the Dockerfile does automatically.
|
||||
- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)).
|
||||
- Documented [Custom Certificate Authorities](/configuration/tls#custom-certificate-authorities) for trusting private or self-signed CAs across every outbound TLS connection (OIDC, Headscale, Docker, etc.) via Node's `NODE_EXTRA_CA_CERTS`. This replaces the previous workaround of rebuilding the Docker image to extend the system trust store (closes [#313](https://github.com/tale/headplane/issues/313)).
|
||||
- Fixed user-management actions (link, change role, transfer ownership) using the wrong ID type for unlinked Headplane users. Form fields are now explicitly `headplane_user_id` vs. `headscale_user_id`, and the auth layer no longer round-trips through Headscale to recover the OIDC subject.
|
||||
- Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)).
|
||||
- Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id.
|
||||
- Fixed dialog panels growing beyond the viewport; dialog content is now constrained and scrollable (via [#556](https://github.com/tale/headplane/pull/556)).
|
||||
- Fixed focus rings on inputs and buttons inside dialogs being clipped by the scrollable content container.
|
||||
- Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)).
|
||||
- Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)).
|
||||
|
||||
---
|
||||
|
||||
# 0.7.0-beta.3 (May 14, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
@@ -57,6 +57,11 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
# Tells Headplane to publish its loopback healthcheck URL to this
|
||||
# file on startup; `hp_healthcheck` reads it. Docker-only — native
|
||||
# installs don't ship a consumer.
|
||||
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
@@ -73,5 +78,7 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -58,7 +58,8 @@ function Panel(props: DialogPanelProps) {
|
||||
return (
|
||||
<AlertDialog.Popup
|
||||
className={cn(
|
||||
"w-full max-w-lg rounded-xl p-4",
|
||||
"flex w-full max-w-lg flex-col rounded-xl p-4",
|
||||
"max-h-[90dvh]",
|
||||
"outline-hidden",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
@@ -67,6 +68,7 @@ function Panel(props: DialogPanelProps) {
|
||||
>
|
||||
<Form
|
||||
method={method ?? "POST"}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
@@ -77,8 +79,13 @@ function Panel(props: DialogPanelProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
{/* px-1 -mx-1 gives focus rings on inputs/buttons room to render
|
||||
without being clipped by overflow-y-auto (which implicitly forces
|
||||
overflow-x: auto per CSS spec). */}
|
||||
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1">
|
||||
{children}
|
||||
</div>
|
||||
<div className="mt-5 flex shrink-0 justify-end gap-3">
|
||||
{variant === "unactionable" ? (
|
||||
<AlertDialog.Close render={<Button>Close</Button>} />
|
||||
) : (
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Tooltip({ children, content, className }: TooltipProps)
|
||||
{children}
|
||||
</BaseTooltip.Trigger>
|
||||
<BaseTooltip.Portal>
|
||||
<BaseTooltip.Positioner sideOffset={4}>
|
||||
<BaseTooltip.Positioner side="top" sideOffset={4} collisionPadding={8}>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
"z-50 rounded-lg p-3 text-sm w-48",
|
||||
|
||||
+3
-6
@@ -31,10 +31,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
@@ -48,10 +45,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await api.isHealthy();
|
||||
const isHealthy = await context.headscale.health();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await api.apiKeys.list();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"],
|
||||
"0.28.0": ["0.28.0"]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"0.26.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.26.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.28.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
|
||||
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
}
|
||||
}
|
||||
@@ -26,10 +26,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
const { policy, updatedAt } = await api.policy.set(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
@@ -55,71 +54,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
|
||||
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
|
||||
// releases are no longer supported.
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes("parsing hujson")) {
|
||||
const cutIndex = message.indexOf("err: hujson:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes("unmarshalling policy")) {
|
||||
const cutIndex = message.indexOf("err:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,10 +29,9 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
const { policy, updatedAt } = await api.policy.get();
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
|
||||
@@ -33,9 +33,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
// Build a client with the candidate API key the user just submitted, so the
|
||||
// GET /api/v1/apikey call below validates the key against Headscale itself.
|
||||
const api = context.headscale.client(apiKey);
|
||||
try {
|
||||
const apiKeys = await api.getApiKeys();
|
||||
const apiKeys = await api.apiKeys.list();
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const urlState = qp.get("s") ?? undefined;
|
||||
|
||||
const oidcService = context.oidc?.service;
|
||||
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
|
||||
const oidcStatus = oidcService
|
||||
? await oidcService.discover().then(
|
||||
(r) => (r.ok ? oidcService.status() : oidcService.status()),
|
||||
@@ -32,7 +32,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (context.oidc?.disableApiKeyLogin && oidcStatus?.state === "ready" && urlState !== "logout") {
|
||||
if (
|
||||
oidcService &&
|
||||
context.config.oidc?.disable_api_key_login &&
|
||||
oidcStatus?.state === "ready" &&
|
||||
urlState !== "logout"
|
||||
) {
|
||||
return redirect("/oidc/start");
|
||||
}
|
||||
|
||||
|
||||
@@ -23,15 +23,20 @@ export async function action({ request, context }: ActionFunctionArgs<AppContext
|
||||
// ended. Disabled by default because the post_logout_redirect_uri must be
|
||||
// pre-registered on the IdP — turning this on without registering it would
|
||||
// strand users on the IdP's error page.
|
||||
if (principal?.kind === "oidc" && context.oidc?.useEndSession && context.oidc.service) {
|
||||
const status = context.oidc.service.status();
|
||||
if (
|
||||
principal?.kind === "oidc" &&
|
||||
context.oidc.state === "enabled" &&
|
||||
context.config.oidc?.use_end_session
|
||||
) {
|
||||
const service = context.oidc.value;
|
||||
const status = service.status();
|
||||
if (status.state !== "ready") {
|
||||
// Trigger discovery if it hasn't happened yet so we can find the
|
||||
// end_session_endpoint without forcing a re-login.
|
||||
await context.oidc.service.discover();
|
||||
await service.discover();
|
||||
}
|
||||
|
||||
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
|
||||
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
|
||||
if (endSessionUrl) {
|
||||
url = endSessionUrl;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
import type { Route } from "./+types/oidc-callback";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const service = context.oidc?.service;
|
||||
if (!service) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
if (context.oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
|
||||
}
|
||||
const service = context.oidc.value;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
@@ -56,8 +56,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
});
|
||||
|
||||
try {
|
||||
const hsApi = context.hsApi.getRuntimeClient(context.headscaleApiKey!);
|
||||
const hsUsers = await hsApi.getUsers();
|
||||
// Looks up the Headscale user that matches this OIDC identity. We use
|
||||
// the configured admin API key here — not a per-request one — because
|
||||
// there is no per-request key yet (the session is being created).
|
||||
const hsApi = context.headscale.client(context.headscaleApiKey!);
|
||||
const hsUsers = await hsApi.users.list();
|
||||
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
|
||||
if (hsUser) {
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
@@ -68,7 +71,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Only persist the id_token when RP-initiated logout is enabled — otherwise
|
||||
// we'd be storing a credential we never use.
|
||||
const idToken = context.oidc?.useEndSession ? identity.idToken : undefined;
|
||||
const idToken = context.config.oidc?.use_end_session ? identity.idToken : undefined;
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
|
||||
@@ -10,10 +10,10 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
return redirect("/");
|
||||
} catch {}
|
||||
|
||||
const service = context.oidc?.service;
|
||||
if (!service) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
if (context.oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
|
||||
}
|
||||
const service = context.oidc.value;
|
||||
|
||||
const result = await service.startFlow();
|
||||
if (!result.ok) {
|
||||
|
||||
@@ -16,9 +16,6 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
// We only need it for health checks which don't require auth
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
@@ -39,7 +36,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Tailnet renamed successfully" };
|
||||
}
|
||||
case "toggle_magic": {
|
||||
@@ -55,7 +52,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Magic DNS state updated successfully" };
|
||||
}
|
||||
case "remove_ns": {
|
||||
@@ -88,7 +85,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Nameserver removed successfully" };
|
||||
}
|
||||
case "add_ns": {
|
||||
@@ -123,7 +120,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Nameserver added successfully" };
|
||||
}
|
||||
case "remove_domain": {
|
||||
@@ -141,7 +138,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Domain removed successfully" };
|
||||
}
|
||||
case "add_domain": {
|
||||
@@ -161,7 +158,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Domain added successfully" };
|
||||
}
|
||||
case "remove_record": {
|
||||
@@ -183,7 +180,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "DNS record removed successfully" };
|
||||
}
|
||||
case "add_record": {
|
||||
@@ -205,7 +202,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "DNS record added successfully" };
|
||||
}
|
||||
case "override_dns": {
|
||||
@@ -222,7 +219,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "DNS override updated successfully" };
|
||||
}
|
||||
default:
|
||||
|
||||
+3
-5
@@ -24,12 +24,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
// Unclaimed users they can pick from before anything else.
|
||||
let unlinked = false;
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
context.oidc.state === "enabled" &&
|
||||
principal.kind === "oidc" &&
|
||||
!principal.user.headscaleUserId
|
||||
) {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
|
||||
let headscaleUsers: { id: string; name: string }[] = [];
|
||||
try {
|
||||
@@ -64,8 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
// No UI access — show the download/connect page
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
|
||||
let linkedUserName: string | undefined;
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
|
||||
@@ -52,7 +52,9 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
onValueChange={(v) => form.setValue("user", v)}
|
||||
placeholder="Select a user"
|
||||
items={data.users.map((user) => ({
|
||||
value: user.id,
|
||||
// Headscale's v1/node/register endpoint resolves the owner by
|
||||
// username via GetUserByName, so we must pass user.name (not id).
|
||||
value: user.name,
|
||||
label: getUserDisplayName(user),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -7,10 +7,9 @@ import { Capabilities } from "~/server/web/roles";
|
||||
import type { Route } from "./+types/machine";
|
||||
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
@@ -41,7 +40,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
const node = await api.nodes.register(user, registrationKey);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
@@ -54,7 +53,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.getNode(nodeId);
|
||||
const node = await api.nodes.get(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
@@ -77,19 +76,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
const name = String(formData.get("name"));
|
||||
await api.renameNode(nodeId, name);
|
||||
await api.nodes.rename(nodeId, name);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine renamed" };
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
await api.deleteNode(nodeId);
|
||||
await api.nodes.delete(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
case "expire": {
|
||||
await api.expireNode(nodeId);
|
||||
await api.nodes.expire(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine expired" };
|
||||
}
|
||||
@@ -103,7 +102,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.setNodeTags(
|
||||
await api.nodes.setTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||
);
|
||||
@@ -172,7 +171,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
await api.nodes.approveRoutes(nodeId, newApproved);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Routes updated" };
|
||||
}
|
||||
@@ -185,7 +184,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
if (!api.nodes.reassignUser) {
|
||||
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await api.nodes.reassignUser(nodeId, user);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine reassigned" };
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import Routes from "./dialogs/routes";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
if (!params.id) {
|
||||
throw new Error("No machine ID provided");
|
||||
}
|
||||
@@ -38,7 +37,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
const { api } = await context.apiForRequest(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
@@ -50,18 +49,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(null, { status: 404 });
|
||||
}
|
||||
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
|
||||
const lookup = await agents?.lookup([node.nodeKey]);
|
||||
const [enhancedNode] = mapNodes([node], lookup);
|
||||
const tags = [...node.tags].toSorted();
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||
const agentSync = context.agents?.lastSync();
|
||||
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
|
||||
return {
|
||||
agent: agentSync
|
||||
? {
|
||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: agentSync.nodeCount,
|
||||
nodeKey: context.agents?.agentNodeKey(),
|
||||
nodeKey: agents?.agentNodeKey(),
|
||||
}
|
||||
: undefined,
|
||||
existingTags: sortNodeTags(nodes),
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(context.auth.getHeadscaleApiKey(principal));
|
||||
const { api } = await context.apiForRequest(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
@@ -45,17 +45,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
|
||||
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||
const agentSync = context.agents?.lastSync();
|
||||
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
|
||||
return {
|
||||
agent: agentSync
|
||||
? {
|
||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: agentSync.nodeCount,
|
||||
nodeKey: context.agents?.agentNodeKey(),
|
||||
nodeKey: agents?.agentNodeKey(),
|
||||
}
|
||||
: undefined,
|
||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||
|
||||
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
|
||||
import type { Route } from "./+types/agent";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
await context.auth.require(request);
|
||||
|
||||
if (!context.agents) {
|
||||
return { enabled: false as const };
|
||||
if (context.agents.state !== "enabled") {
|
||||
return { enabled: false as const, reason: context.agents.reason };
|
||||
}
|
||||
|
||||
const sync = context.agents.lastSync();
|
||||
const sync = context.agents.value.lastSync();
|
||||
return {
|
||||
enabled: true as const,
|
||||
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
||||
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.auth.require(request);
|
||||
|
||||
if (!context.agents) {
|
||||
return { success: false, error: "Agent is not enabled" };
|
||||
if (context.agents.state !== "enabled") {
|
||||
return { success: false, error: context.agents.reason };
|
||||
}
|
||||
|
||||
await context.agents.triggerSync();
|
||||
const sync = context.agents.lastSync();
|
||||
await context.agents.value.triggerSync();
|
||||
const sync = context.agents.value.lastSync();
|
||||
return { success: !sync.error, error: sync.error };
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
||||
<Title>Headplane Agent</Title>
|
||||
<Notice title="Agent Not Enabled">
|
||||
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
|
||||
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
|
||||
<Link external styled to="https://headplane.dev/docs/agent">
|
||||
documentation
|
||||
</Link>
|
||||
|
||||
@@ -7,9 +7,7 @@ import type { PreAuthKey } from "~/types";
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
@@ -22,7 +20,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
|
||||
async function checkSelfServiceOwnership(userId: string) {
|
||||
if (canGenerateAny || !canGenerateOwn) return;
|
||||
const [targetUser] = await api.getUsers(userId);
|
||||
const [targetUser] = await api.users.list({ id: userId });
|
||||
if (!targetUser) {
|
||||
throw data("User not found.", { status: 404 });
|
||||
}
|
||||
@@ -86,13 +84,13 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
const key = await api.createPreAuthKey(
|
||||
const key = await api.preAuthKeys.create({
|
||||
user,
|
||||
ephemeral === "on",
|
||||
reusable === "on",
|
||||
date,
|
||||
aclTags.length > 0 ? aclTags : null,
|
||||
);
|
||||
ephemeral: ephemeral === "on",
|
||||
reusable: reusable === "on",
|
||||
expiration: date,
|
||||
aclTags: aclTags.length > 0 ? aclTags : null,
|
||||
});
|
||||
|
||||
return data({ success: true as const, key: key.key });
|
||||
}
|
||||
@@ -114,7 +112,15 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
await checkSelfServiceOwnership(user);
|
||||
await api.expirePreAuthKey(user, { id: keyId, key } as unknown as PreAuthKey);
|
||||
// `user` here is the Headscale numeric user id (form field is wired
|
||||
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
|
||||
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
|
||||
// looks at `key.id` (the stable preauthkey id).
|
||||
await api.preAuthKeys.expire({
|
||||
id: keyId,
|
||||
key,
|
||||
user: { id: user },
|
||||
} as unknown as PreAuthKey);
|
||||
return data("Pre-auth key expired");
|
||||
}
|
||||
|
||||
|
||||
@@ -19,9 +19,7 @@ import AuthKeyRow from "./auth-key-row";
|
||||
import AddAuthKey from "./dialogs/add-auth-key";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const users = usersSnap.data;
|
||||
@@ -31,10 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
|
||||
let allKeys: PreAuthKey[] | null = null;
|
||||
try {
|
||||
allKeys = await api.getAllPreAuthKeys();
|
||||
} catch {
|
||||
// Older versions don't support this endpoint
|
||||
if (api.preAuthKeys.listAll) {
|
||||
try {
|
||||
allKeys = await api.preAuthKeys.listAll();
|
||||
} catch {
|
||||
// Treat any failure as "no global list available" and fall through.
|
||||
}
|
||||
}
|
||||
|
||||
if (allKeys !== null) {
|
||||
@@ -67,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
.filter((u) => u.id?.length > 0)
|
||||
.map(async (user) => {
|
||||
try {
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
|
||||
return { preAuthKeys, success: true as const, user };
|
||||
} catch (error) {
|
||||
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
|
||||
|
||||
@@ -8,7 +8,8 @@ import type { Route } from "./+types/overview";
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
isOidcEnabled: context.oidc?.service.status().state === "ready",
|
||||
isOidcEnabled:
|
||||
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
// We only need healthchecks which don't rely on an API key
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
switch (action) {
|
||||
case "add_domain": {
|
||||
const domain = formData.get("domain")?.toString()?.trim();
|
||||
@@ -48,7 +46,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Domain added successfully.");
|
||||
}
|
||||
|
||||
@@ -76,7 +74,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Domain removed successfully.");
|
||||
}
|
||||
|
||||
@@ -97,7 +95,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Group added successfully.");
|
||||
}
|
||||
|
||||
@@ -126,7 +124,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Group removed successfully.");
|
||||
}
|
||||
|
||||
@@ -147,7 +145,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("User added successfully.");
|
||||
}
|
||||
|
||||
@@ -176,7 +174,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("User removed successfully.");
|
||||
}
|
||||
|
||||
|
||||
+11
-14
@@ -37,22 +37,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(sshErrors.wasm_missing, 405);
|
||||
}
|
||||
|
||||
if (context.agents == null) {
|
||||
if (context.agents.state !== "enabled") {
|
||||
throw data(sshErrors.agent_required, 400);
|
||||
}
|
||||
|
||||
const principal = await context.auth.require(request);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data(sshErrors.oidc_required, 403);
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const hostname = params.id;
|
||||
const username = new URL(request.url).searchParams.get("user") || undefined;
|
||||
|
||||
const nodes = await api.getNodes();
|
||||
const nodes = await api.nodes.list();
|
||||
const node = nodes.find((n) => n.givenName === hostname);
|
||||
if (!node) {
|
||||
throw data(sshErrors.node_not_found(hostname), 404);
|
||||
@@ -67,20 +64,20 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
// The user must exist within Headscale to generate a pre-auth key
|
||||
const users = await api.getUsers();
|
||||
const users = await api.users.list();
|
||||
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!hsUser) {
|
||||
throw data(sshErrors.user_not_linked, 404);
|
||||
}
|
||||
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
hsUser.id,
|
||||
true,
|
||||
false,
|
||||
new Date(Date.now() + 60 * 1000), // 1 minute expiry
|
||||
null,
|
||||
);
|
||||
const preAuthKey = await api.preAuthKeys.create({
|
||||
user: hsUser.id,
|
||||
ephemeral: true,
|
||||
reusable: false,
|
||||
expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry
|
||||
aclTags: null,
|
||||
});
|
||||
|
||||
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
|
||||
import Delete from "../dialogs/delete-user";
|
||||
import Rename from "../dialogs/rename-user";
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
|
||||
interface HeadscaleUserMenuProps {
|
||||
user: UnlinkedHeadscaleUser;
|
||||
}
|
||||
|
||||
type Modal = "rename" | "delete" | null;
|
||||
|
||||
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
// Headscale-managed OIDC users cannot be renamed via the API.
|
||||
const canRename = user.provider !== "oidc";
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal === "rename" && canRename && (
|
||||
<Rename
|
||||
isOpen={modal === "rename"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "delete" && (
|
||||
<Delete
|
||||
isOpen={modal === "delete"}
|
||||
machines={user.machines}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
|
||||
<Ellipsis className="h-5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
|
||||
{canRename && <MenuSeparator />}
|
||||
<MenuItem variant="danger" onClick={() => setModal("delete")}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import StatusCircle from "~/components/status-circle";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
import HeadscaleUserMenu from "./headscale-user-menu";
|
||||
|
||||
interface HeadscaleUserRowProps {
|
||||
user: UnlinkedHeadscaleUser;
|
||||
writable?: boolean;
|
||||
}
|
||||
|
||||
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
|
||||
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
|
||||
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,24 +54,24 @@ export default function UserMenu({
|
||||
{modal === "reassign" && (
|
||||
<Reassign
|
||||
displayName={displayName}
|
||||
headplaneUserId={user.id}
|
||||
isOpen={modal === "reassign"}
|
||||
role={user.role}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
userId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
/>
|
||||
)}
|
||||
{modal === "link" && (
|
||||
<LinkUser
|
||||
currentLink={currentLink}
|
||||
displayName={displayName}
|
||||
headplaneUserId={user.id}
|
||||
headscaleUsers={linkableUsers}
|
||||
isOpen={modal === "link"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
userId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
/>
|
||||
)}
|
||||
{modal === "transfer" && (
|
||||
@@ -81,7 +81,7 @@ export default function UserMenu({
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
targetDisplayName={displayName}
|
||||
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
targetHeadplaneUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
|
||||
</Text>
|
||||
)}
|
||||
<input name="action_id" type="hidden" value="delete_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkUserProps {
|
||||
userId: string;
|
||||
headplaneUserId: string;
|
||||
displayName: string;
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
currentLink?: string;
|
||||
@@ -14,7 +14,7 @@ interface LinkUserProps {
|
||||
}
|
||||
|
||||
export default function LinkUser({
|
||||
userId,
|
||||
headplaneUserId,
|
||||
displayName,
|
||||
headscaleUsers,
|
||||
currentLink,
|
||||
@@ -34,7 +34,7 @@ export default function LinkUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="link_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-2",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
interface ReassignProps {
|
||||
userId: string;
|
||||
headplaneUserId: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
isOpen: boolean;
|
||||
@@ -16,7 +16,7 @@ interface ReassignProps {
|
||||
}
|
||||
|
||||
export default function ReassignUser({
|
||||
userId,
|
||||
headplaneUserId,
|
||||
displayName,
|
||||
role,
|
||||
isOpen,
|
||||
@@ -38,7 +38,7 @@ export default function ReassignUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="reassign_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
|
||||
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
|
||||
{Object.keys(Roles)
|
||||
.filter((r) => r !== "owner")
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
update any ACL policies that may refer to this user by their old username.
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="rename_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||
<Input
|
||||
defaultValue={user.name}
|
||||
required
|
||||
|
||||
@@ -4,14 +4,14 @@ import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface TransferOwnershipProps {
|
||||
targetUserId: string;
|
||||
targetHeadplaneUserId: string;
|
||||
targetDisplayName: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function TransferOwnership({
|
||||
targetUserId,
|
||||
targetHeadplaneUserId,
|
||||
targetDisplayName,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
@@ -29,7 +29,7 @@ export default function TransferOwnership({
|
||||
ownership.
|
||||
</Notice>
|
||||
<input name="action_id" type="hidden" value="transfer_ownership" />
|
||||
<input name="user_id" type="hidden" value={targetUserId} />
|
||||
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -54,8 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
let apiError: string | undefined;
|
||||
|
||||
try {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
@@ -235,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
)}
|
||||
>
|
||||
{loaderData.unlinkedHeadscaleUsers.map((user) => (
|
||||
<HeadscaleUserRow key={user.id} user={user} />
|
||||
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
@@ -24,8 +23,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
switch (action) {
|
||||
case "create_user": {
|
||||
const name = formData.get("username")?.toString();
|
||||
@@ -38,33 +36,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.createUser(name, email, displayName);
|
||||
await api.users.create({ name, email, displayName });
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
return { message: "User created successfully" };
|
||||
}
|
||||
case "delete_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", {
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!headscaleUserId) {
|
||||
throw data("Missing `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.deleteUser(userId);
|
||||
await api.users.delete(headscaleUserId);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
return { message: "User deleted successfully" };
|
||||
}
|
||||
case "rename_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
const newName = formData.get("new_name")?.toString();
|
||||
if (!userId || !newName) {
|
||||
if (!headscaleUserId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
const users = await api.users.list({ id: headscaleUserId });
|
||||
const user = users.find((user) => user.id === headscaleUserId);
|
||||
if (!user) {
|
||||
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.provider === "oidc") {
|
||||
@@ -74,34 +72,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.renameUser(userId, newName);
|
||||
await api.users.rename(headscaleUserId, newName);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
return { message: "User renamed successfully" };
|
||||
}
|
||||
case "reassign_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
const newRole = formData.get("new_role")?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data("Missing `user_id` or `new_role` in the form data.", {
|
||||
if (!headplaneUserId || !newRole) {
|
||||
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.auth.reassignSubject(subject, newRole as Role);
|
||||
|
||||
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role);
|
||||
if (!result) {
|
||||
throw data("Failed to reassign user role.", { status: 500 });
|
||||
}
|
||||
@@ -113,23 +97,12 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
throw data("Only the owner can transfer ownership.", { status: 403 });
|
||||
}
|
||||
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", { status: 400 });
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
if (!headplaneUserId) {
|
||||
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const targetSubject = getOidcSubject(user);
|
||||
if (!targetSubject) {
|
||||
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
|
||||
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId);
|
||||
if (!result) {
|
||||
throw data("Failed to transfer ownership.", { status: 500 });
|
||||
}
|
||||
@@ -137,26 +110,15 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
return { message: "Ownership transferred successfully" };
|
||||
}
|
||||
case "link_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!userId || !headscaleUserId) {
|
||||
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
|
||||
if (!headplaneUserId || !headscaleUserId) {
|
||||
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
|
||||
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
|
||||
if (!linked) {
|
||||
throw data("That Headscale user is already linked to another account.", { status: 409 });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Route } from "./+types/healthz";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
const healthy = await api.isHealthy();
|
||||
const healthy = await context.headscale.health();
|
||||
|
||||
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
|
||||
status: healthy ? 200 : 500,
|
||||
|
||||
@@ -34,14 +34,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
const healthy = await api.isHealthy();
|
||||
const healthy = await context.headscale.health();
|
||||
|
||||
const body = {
|
||||
status: healthy ? "healthy" : "unhealthy",
|
||||
headplane_version: __VERSION__,
|
||||
headscale_canonical_version: healthy ? context.hsApi.apiVersion : "unknown",
|
||||
headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown",
|
||||
internal_versions: {
|
||||
node: versions.node,
|
||||
v8: versions.v8,
|
||||
|
||||
@@ -4,9 +4,7 @@ import log from "~/utils/log";
|
||||
import type { Route } from "./+types/live";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
|
||||
// Ensure resources are loaded before streaming
|
||||
await Promise.all([
|
||||
|
||||
@@ -63,7 +63,7 @@ constructs everything that needs to live for the lifetime of the
|
||||
process:
|
||||
|
||||
- the SQLite client (`db`)
|
||||
- the Headscale REST interface (`hsApi`)
|
||||
- the Headscale REST interface (`headscale`)
|
||||
- the optional Headplane agent manager (`agents`)
|
||||
- the auth service (`auth`)
|
||||
- the optional OIDC service (`oidc`)
|
||||
|
||||
+18
-1
@@ -35,11 +35,28 @@ try {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ((config.server.tls_cert_path || config.server.tls_key_path) && !config.server.cookie_secure) {
|
||||
log.warn(
|
||||
"server",
|
||||
"TLS is enabled but `server.cookie_secure` is false; forcing it to true (browsers reject Secure-less cookies over HTTPS)",
|
||||
);
|
||||
config.server.cookie_secure = true;
|
||||
}
|
||||
|
||||
const ctx = await createAppContext(config);
|
||||
ctx.auth.start();
|
||||
ctx.startServices();
|
||||
|
||||
export { config };
|
||||
|
||||
/**
|
||||
* Disposes the per-process context. Invoked by the production
|
||||
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
|
||||
* reload.
|
||||
*/
|
||||
export async function dispose(): Promise<void> {
|
||||
await ctx.dispose();
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -42,6 +42,12 @@ const serverConfig = type({
|
||||
cookie_secure: "boolean = true",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer = 86400",
|
||||
|
||||
// TLS termination. When both `tls_cert_path` and `tls_key_path`
|
||||
// are provided, Headplane serves HTTPS on `server.port`. When
|
||||
// either is set, `cookie_secure` is forced to `true`.
|
||||
tls_cert_path: "string?",
|
||||
tls_key_path: "string?",
|
||||
});
|
||||
|
||||
const partialServerConfig = type({
|
||||
@@ -55,6 +61,9 @@ const partialServerConfig = type({
|
||||
cookie_secure: "boolean?",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer?",
|
||||
|
||||
tls_cert_path: "string?",
|
||||
tls_key_path: "string?",
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
|
||||
export abstract class Integration<T> {
|
||||
protected context: NonNullable<T>;
|
||||
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
|
||||
}
|
||||
|
||||
abstract isAvailable(): Promise<boolean> | boolean;
|
||||
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
|
||||
abstract onConfigChange(headscale: Headscale): Promise<void> | void;
|
||||
abstract get name(): string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises";
|
||||
import { type } from "arktype";
|
||||
import { Client } from "undici";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
|
||||
return this.client !== undefined && this.containerId !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +290,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug("config", "Checking Headscale status (attempt %d)", attempts);
|
||||
const status = await client.isHealthy();
|
||||
const status = await headscale.health();
|
||||
if (status === false) {
|
||||
throw new Error("Headscale is not running");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
||||
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||
import { type } from "arktype";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -154,12 +154,12 @@ export default class KubernetesIntegration extends Integration<typeof configSche
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await signalAndWaitHealthy(client, {
|
||||
await signalAndWaitHealthy(headscale, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
||||
import { kill } from "node:process";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
/**
|
||||
@@ -75,12 +75,12 @@ export interface SignalHeadscaleOptions {
|
||||
|
||||
/**
|
||||
* Sends a signal to the headscale process and waits for it to become healthy.
|
||||
* @param client The RuntimeApiClient to check health
|
||||
* @param headscale The Headscale instance to health-check
|
||||
* @param options Options for signaling and waiting
|
||||
* @returns True if headscale became healthy, false otherwise
|
||||
*/
|
||||
export async function signalAndWaitHealthy(
|
||||
client: RuntimeApiClient,
|
||||
headscale: Headscale,
|
||||
options: SignalHeadscaleOptions,
|
||||
): Promise<boolean> {
|
||||
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
|
||||
@@ -96,7 +96,7 @@ export async function signalAndWaitHealthy(
|
||||
await setTimeout(retryDelayMs);
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const healthy = await client.isHealthy();
|
||||
const healthy = await headscale.health();
|
||||
if (healthy) {
|
||||
log.info("config", "Headscale is healthy after restart");
|
||||
return true;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { platform } from "node:os";
|
||||
|
||||
import { type } from "arktype";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -51,12 +51,12 @@ export default class ProcIntegration extends Integration<typeof configSchema.ful
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await signalAndWaitHealthy(client, {
|
||||
await signalAndWaitHealthy(headscale, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
|
||||
+132
-56
@@ -5,12 +5,13 @@ 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 { disabled, enabled, type Feature } from "./feature";
|
||||
import { createHeadscale, type HeadscaleClient } 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";
|
||||
import { type AgentManager, createAgentManager } from "./hp-agent";
|
||||
import { createOidcService, type OidcService } from "./oidc/provider";
|
||||
import { createAuthService, type Principal } from "./web/auth";
|
||||
|
||||
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||
|
||||
@@ -20,27 +21,21 @@ declare module "react-router" {
|
||||
|
||||
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,
|
||||
);
|
||||
const headscale = await createHeadscale({
|
||||
url: config.headscale.url,
|
||||
certPath: 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 agents = await buildAgents(
|
||||
config,
|
||||
headscale.capabilities.preAuthKeysHaveStableIds,
|
||||
headscaleApiKey ? headscale.client(headscaleApiKey) : undefined,
|
||||
db,
|
||||
);
|
||||
|
||||
const auth = createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
@@ -54,49 +49,130 @@ export async function createAppContext(config: HeadplaneConfig) {
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
const oidc = buildOidc(config, headscaleApiKey);
|
||||
|
||||
const hsLive = createLiveStore([nodesResource, usersResource]);
|
||||
const hs = await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
);
|
||||
const integration = await loadIntegration(config.integration);
|
||||
|
||||
// Disposers run in reverse-registration order on shutdown.
|
||||
const disposers: Array<() => Promise<void> | void> = [
|
||||
() => auth.stop(),
|
||||
() => hsLive.dispose(),
|
||||
() => headscale.dispose(),
|
||||
];
|
||||
if (agents.state === "enabled") {
|
||||
disposers.push(() => agents.value.dispose());
|
||||
}
|
||||
|
||||
async function apiForRequest(
|
||||
request: Request,
|
||||
): Promise<{ principal: Principal; api: HeadscaleClient }> {
|
||||
const principal = await auth.require(request);
|
||||
const apiKey = auth.getHeadscaleApiKey(principal);
|
||||
return { principal, api: headscale.client(apiKey) };
|
||||
}
|
||||
|
||||
function startServices() {
|
||||
auth.start();
|
||||
}
|
||||
|
||||
async function dispose() {
|
||||
for (const d of [...disposers].reverse()) {
|
||||
try {
|
||||
await d();
|
||||
} catch (error) {
|
||||
log.warn("server", "Error during shutdown: %s", String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
db,
|
||||
hsApi,
|
||||
headscale,
|
||||
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),
|
||||
hsLive,
|
||||
hs,
|
||||
integration,
|
||||
apiForRequest,
|
||||
startServices,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOidc(
|
||||
config: HeadplaneConfig,
|
||||
headscaleApiKey: string | undefined,
|
||||
): Feature<OidcService> {
|
||||
if (!config.oidc) {
|
||||
return disabled("OIDC is not configured");
|
||||
}
|
||||
if (config.oidc.enabled === false) {
|
||||
return disabled("OIDC is disabled in the configuration");
|
||||
}
|
||||
if (!headscaleApiKey) {
|
||||
return disabled("OIDC requires headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
return enabled(
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildAgents(
|
||||
config: HeadplaneConfig,
|
||||
supportsTagOnlyKeys: boolean,
|
||||
apiClient: HeadscaleClient | undefined,
|
||||
db: Awaited<ReturnType<typeof createDbClient>>,
|
||||
): Promise<Feature<AgentManager>> {
|
||||
const agentConfig = config.integration?.agent;
|
||||
if (!agentConfig?.enabled) {
|
||||
return disabled("Agent is not enabled in the configuration");
|
||||
}
|
||||
if (!apiClient) {
|
||||
return disabled("Agent requires headscale.api_key to be configured");
|
||||
}
|
||||
if (!supportsTagOnlyKeys) {
|
||||
return disabled("Agent requires Headscale 0.28 or newer");
|
||||
}
|
||||
|
||||
const manager = await createAgentManager(
|
||||
agentConfig,
|
||||
config.headscale.url,
|
||||
apiClient,
|
||||
supportsTagOnlyKeys,
|
||||
db,
|
||||
);
|
||||
if (!manager) {
|
||||
return disabled("Agent failed to initialize (see logs)");
|
||||
}
|
||||
return enabled(manager);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// MARK: Feature<T>
|
||||
//
|
||||
// A two-state tagged union used in place of `T | undefined` for
|
||||
// optional features on the AppContext. Carries a human-readable
|
||||
// reason when the feature is disabled so loaders can surface it (or
|
||||
// choose to ignore it).
|
||||
//
|
||||
// This is deliberately *not* a Result/Either. It does not represent
|
||||
// async readiness or retryable failure — it represents "is this
|
||||
// feature wired up at all." Services that have their own runtime
|
||||
// status (e.g. OIDC discovery) keep that on the service itself.
|
||||
|
||||
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
|
||||
|
||||
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
|
||||
|
||||
export const disabled = (reason: string): Feature<never> => ({
|
||||
state: "disabled",
|
||||
reason,
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// MARK: Headscale Capabilities
|
||||
//
|
||||
// Behavioural facts about the connected Headscale server, derived
|
||||
// once from `ServerVersion` at boot. Capabilities are named for *what
|
||||
// changed* — not for the version number it changed in — so a reader
|
||||
// who has never seen Headscale can tell what each flag controls
|
||||
// without consulting a release note.
|
||||
//
|
||||
// Add a capability here when you find yourself reaching for a raw
|
||||
// version comparison in endpoint or route code. Adding a capability
|
||||
// is also the right answer when a new Headscale release changes wire
|
||||
// format or removes an endpoint.
|
||||
|
||||
import { gte, type ServerVersion } from "./server-version";
|
||||
|
||||
export interface Capabilities {
|
||||
/**
|
||||
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
|
||||
* user filter) returns every key in the system, and
|
||||
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
|
||||
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
|
||||
* supported. Introduced in 0.28.0.
|
||||
*/
|
||||
readonly preAuthKeysHaveStableIds: boolean;
|
||||
|
||||
/**
|
||||
* Node tags are a flat `tags: string[]` field on the wire.
|
||||
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
|
||||
* that the client had to union itself. Introduced in 0.28.0.
|
||||
*/
|
||||
readonly nodeTagsAreFlat: boolean;
|
||||
|
||||
/**
|
||||
* A node's owning user is immutable after creation;
|
||||
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
|
||||
* 0.28.0+.
|
||||
*/
|
||||
readonly nodeOwnerIsImmutable: boolean;
|
||||
}
|
||||
|
||||
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
||||
return {
|
||||
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
|
||||
nodeTagsAreFlat: gte(version, "0.28.0"),
|
||||
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Key } from "~/types";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface ApiKeyEndpoints {
|
||||
/**
|
||||
* Retrieves all API keys from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Key` objects representing the API keys.
|
||||
*/
|
||||
getApiKeys(): Promise<Key[]>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
|
||||
getApiKeys: async () => {
|
||||
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey);
|
||||
|
||||
return apiKeys;
|
||||
},
|
||||
}));
|
||||
@@ -1,75 +0,0 @@
|
||||
import {
|
||||
composeEndpoints,
|
||||
defineApiEndpoints,
|
||||
type ExtractApiEndpoints,
|
||||
type UnionToIntersection,
|
||||
} from "../factory";
|
||||
import type { HeadscaleApiInterface } from "../index";
|
||||
import apiKeyEndpoints from "./api-keys";
|
||||
import nodeEndpoints from "./nodes";
|
||||
import policyEndpoints from "./policy";
|
||||
import preAuthKeyEndpoints from "./pre-auth-keys";
|
||||
import userEndpoints from "./users";
|
||||
|
||||
interface HealthcheckEndpoint {
|
||||
/**
|
||||
* Checks if the Headscale instance is healthy.
|
||||
*
|
||||
* @returns A boolean indicating if the instance is healthy.
|
||||
*/
|
||||
isHealthy(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>((client, apiKey) => ({
|
||||
isHealthy: async () => {
|
||||
try {
|
||||
const res = await client.rawFetch("/health", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
// This doesn't really matter
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.statusCode === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* A constant list of all endpoint groups.
|
||||
* Add new endpoint groups here.
|
||||
*/
|
||||
export const endpointSets = [
|
||||
apiKeyEndpoints,
|
||||
healthcheckEndpoint,
|
||||
nodeEndpoints,
|
||||
policyEndpoints,
|
||||
preAuthKeyEndpoints,
|
||||
userEndpoints,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* All of the available API methods when interacting with Headscale's API.
|
||||
* We have wrapped each operation with nice methods and parameters to make it
|
||||
* easier to do integration testing by spinning up an actual Headscale instance
|
||||
* and calling these methods against it.
|
||||
*
|
||||
* We also have the benefit of supporting multiple Headscale versions by
|
||||
* passing in different internal implementations based on the OpenAPI spec.
|
||||
*/
|
||||
export type RuntimeApiClient = UnionToIntersection<
|
||||
ExtractApiEndpoints<(typeof endpointSets)[number]>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Composes all endpoint groups into a single runtime API client.
|
||||
*
|
||||
* @param client - The client helpers for making API requests.
|
||||
* @param apiKey - The API key for authentication.
|
||||
* @returns A fully composed runtime API client.
|
||||
*/
|
||||
export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) =>
|
||||
composeEndpoints(endpointSets, client, apiKey);
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import type { HeadscaleApiInterface } from "..";
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
interface RawMachine extends Omit<Machine, "tags"> {
|
||||
tags?: string[];
|
||||
forcedTags?: string[];
|
||||
validTags?: string[];
|
||||
invalidTags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the tags of a RawMachine based on the Headscale version.
|
||||
*
|
||||
* @param client The Headscale API client helper.
|
||||
* @param node The RawMachine object to normalize.
|
||||
* @returns A Machine object with normalized tags.
|
||||
*/
|
||||
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return { ...node, tags: node.tags ?? [] } as Machine;
|
||||
}
|
||||
|
||||
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
|
||||
|
||||
return { ...node, tags } as Machine;
|
||||
}
|
||||
|
||||
export interface NodeEndpoints {
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to retrieve.
|
||||
* @returns A `Machine` object representing the node.
|
||||
*/
|
||||
getNode(id: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Registers a new node (machine) with the given user and key.
|
||||
*
|
||||
* @param user The user to associate with the node.
|
||||
* @param key The registration key for the node.
|
||||
* @returns A `Machine` object representing the newly registered node.
|
||||
*/
|
||||
registerNode(user: string, key: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Approves routes for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param routes An array of routes to approve for the node.
|
||||
*/
|
||||
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to rename.
|
||||
* @param newName The new name for the node.
|
||||
*/
|
||||
renameNode(id: string, newName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets tags for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param tags An array of tags to set for the node.
|
||||
*/
|
||||
setNodeTags(id: string, tags: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the user for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param user The user to set for the node.
|
||||
*/
|
||||
setNodeUser(id: string, user: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
|
||||
return nodes.map((node) => normalizeTags(client, node));
|
||||
},
|
||||
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"GET",
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
|
||||
},
|
||||
|
||||
registerNode: async (user, key) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append("user", user);
|
||||
qp.append("key", key);
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"POST",
|
||||
`v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
key,
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
|
||||
},
|
||||
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
"POST",
|
||||
`v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
// Headscale 0.28.0 got rid of node reassignment to users
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,41 +0,0 @@
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface PolicyEndpoints {
|
||||
/**
|
||||
* Retrieves the current ACL policy from the Headscale instance.
|
||||
*
|
||||
* @returns The ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
|
||||
|
||||
/**
|
||||
* Sets the ACL policy for the Headscale instance.
|
||||
*
|
||||
* @param policy The ACL policy as a string.
|
||||
* @returns The updated ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
|
||||
getPolicy: async () => {
|
||||
const { policy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>("GET", "v1/policy", apiKey);
|
||||
|
||||
return {
|
||||
policy,
|
||||
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
|
||||
};
|
||||
},
|
||||
|
||||
setPolicy: async (policy) => {
|
||||
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>("PUT", "v1/policy", apiKey, { policy });
|
||||
|
||||
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
|
||||
},
|
||||
}));
|
||||
@@ -1,93 +0,0 @@
|
||||
import type { PreAuthKey } from "~/types";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface PreAuthKeyEndpoints {
|
||||
/**
|
||||
* List all pre-auth keys. Requires Headscale 0.28+.
|
||||
*/
|
||||
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Retrieves all pre-authentication keys for a specific user.
|
||||
*
|
||||
* @param user The user to retrieve pre-authentication keys for.
|
||||
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
|
||||
*/
|
||||
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Creates a new pre-authentication key.
|
||||
* User can be null for tag-only keys (requires Headscale 0.28+).
|
||||
*/
|
||||
createPreAuthKey(
|
||||
user: string | null,
|
||||
ephemeral: boolean,
|
||||
reusable: boolean,
|
||||
expiration: Date | null,
|
||||
aclTags: string[] | null,
|
||||
): Promise<PreAuthKey>;
|
||||
|
||||
/**
|
||||
* Expires a specific pre-authentication key for a user.
|
||||
*
|
||||
* @param user The user associated with the pre-authentication key.
|
||||
* @param key The pre-authentication key to expire.
|
||||
*/
|
||||
expirePreAuthKey(user: string, key: PreAuthKey): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
|
||||
getAllPreAuthKeys: async () => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, {});
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
getPreAuthKeys: async (user) => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, { user });
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
|
||||
const body: Record<string, unknown> = {
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
body.user = user;
|
||||
}
|
||||
|
||||
if (aclTags && aclTags.length > 0) {
|
||||
body.aclTags = aclTags;
|
||||
}
|
||||
|
||||
const { preAuthKey } = await client.apiFetch<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>("POST", "v1/preauthkey", apiKey, body);
|
||||
|
||||
return preAuthKey;
|
||||
},
|
||||
|
||||
expirePreAuthKey: async (user, key) => {
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
|
||||
id: key.id,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
|
||||
user,
|
||||
key: key.key,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,87 +0,0 @@
|
||||
import type { User } from "~/types";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface UserEndpoints {
|
||||
/**
|
||||
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
|
||||
*
|
||||
* @param id Optional ID of the user to retrieve.
|
||||
* @param name Optional name of the user to retrieve.
|
||||
* @param email Optional email of the user to retrieve.
|
||||
* @returns An array of `User` objects representing the users.
|
||||
*/
|
||||
getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
|
||||
|
||||
/**
|
||||
* Creates a new user in the Headscale instance.
|
||||
*
|
||||
* @param username The username of the new user.
|
||||
* @param email Optional email of the new user.
|
||||
* @param displayName Optional display name of the new user.
|
||||
* @param pictureUrl Optional picture URL of the new user.
|
||||
* @returns A `User` object representing the newly created user.
|
||||
*/
|
||||
createUser(
|
||||
username: string,
|
||||
email?: string,
|
||||
displayName?: string,
|
||||
pictureUrl?: string,
|
||||
): Promise<User>;
|
||||
|
||||
/**
|
||||
* Deletes a specific user by its ID.
|
||||
*
|
||||
* @param id The ID of the user to delete.
|
||||
*/
|
||||
deleteUser(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific user by its ID.
|
||||
*
|
||||
* @param id The ID of the user to rename.
|
||||
* @param newName The new name for the user.
|
||||
*/
|
||||
renameUser(id: string, newName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
|
||||
getUsers: async (id, name, email) => {
|
||||
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
|
||||
|
||||
if (moreThanOneFilter) {
|
||||
throw new Error("Only one of id, name, or email filters can be provided");
|
||||
}
|
||||
|
||||
const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, {
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
});
|
||||
|
||||
return users;
|
||||
},
|
||||
|
||||
createUser: async (username, email, displayName, pictureUrl) => {
|
||||
const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, {
|
||||
name: username,
|
||||
email,
|
||||
displayName,
|
||||
pictureUrl,
|
||||
});
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
deleteUser: async (id) => {
|
||||
await client.apiFetch<void>("DELETE", `v1/user/${id}`, apiKey);
|
||||
},
|
||||
|
||||
renameUser: async (oldId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
"POST",
|
||||
`v1/user/${oldId}/rename/${encodeURIComponent(newName)}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { HeadscaleApiInterface } from "../api";
|
||||
|
||||
/**
|
||||
* Creates a strongly-typed group factory for a given endpoint interface.
|
||||
*
|
||||
* Example:
|
||||
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
|
||||
*/
|
||||
|
||||
export interface EndpointFactory<T extends object> {
|
||||
__type?: T;
|
||||
(client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T;
|
||||
}
|
||||
|
||||
export function defineApiEndpoints<T extends object>(
|
||||
factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T,
|
||||
): EndpointFactory<T> {
|
||||
return factories;
|
||||
}
|
||||
|
||||
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T> ? T : never;
|
||||
export type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
|
||||
k: infer I,
|
||||
) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Compose multiple endpoint sets into a single typed runtime client
|
||||
*/
|
||||
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
|
||||
factories: T,
|
||||
clientHelpers: any,
|
||||
apiKey: string,
|
||||
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
|
||||
const instances = factories.map((f) => f(clientHelpers, apiKey));
|
||||
return Object.assign({}, ...instances) as any;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { dereference } from "@readme/openapi-parser";
|
||||
import { OpenAPIV2 } from "openapi-types";
|
||||
|
||||
/**
|
||||
* A map of operation IDs to their hashes.
|
||||
*/
|
||||
export interface DocumentHash {
|
||||
[operationId: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an OpenAPI v2 document, generate a map of operations with hashes.
|
||||
* This gives us deterministic identifers to determine the version of Headscale
|
||||
* that is being used at runtime.
|
||||
*
|
||||
* @param doc The OpenAPI v2 document to hash.
|
||||
* @returns A map of operation IDs to their hashes.
|
||||
*/
|
||||
export async function hashOpenApiDocument(doc: OpenAPIV2.Document): Promise<DocumentHash> {
|
||||
const spec = await dereference(doc);
|
||||
const hashes: DocumentHash = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
+141
-306
@@ -1,328 +1,163 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { dereference } from "@readme/openapi-parser";
|
||||
import type { OpenAPIV2 } from "openapi-types";
|
||||
import { data } from "react-router";
|
||||
import { Agent, type Dispatcher, request } from "undici";
|
||||
// MARK: Headscale API
|
||||
//
|
||||
// The public entry point for talking to a Headscale server. At boot
|
||||
// we try `GET /version` (unauthenticated, present since Headscale
|
||||
// 0.27.0 — the minimum version Headplane supports) to derive a
|
||||
// typed `Capabilities` object. Boot outcomes:
|
||||
//
|
||||
// - success: parse the response, derive capabilities, done.
|
||||
// - 404: Headscale is reachable but predates 0.27.0 and is no
|
||||
// longer supported. Log an error and keep retrying so an
|
||||
// upgrade is picked up without a Headplane restart.
|
||||
// - any other failure (network, 5xx, parse): Headplane still
|
||||
// boots with `version = unknown` (capabilities-permissive) and
|
||||
// a background retry. This handles docker-compose start-order
|
||||
// races without making the whole process unhappy.
|
||||
//
|
||||
// Capabilities are always derived from `version`; once detection
|
||||
// finishes there's no further state to track.
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import endpointSets, { RuntimeApiClient } from "./endpoints";
|
||||
import { undiciToFriendlyError } from "./error";
|
||||
import { HeadscaleAPIError, isApiError } from "./error-client";
|
||||
import { detectApiVersion, isAtLeast, type Version } from "./version";
|
||||
import { type Capabilities, capabilitiesFor } from "./capabilities";
|
||||
import { isDataWithApiError } from "./error-client";
|
||||
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
|
||||
import { makeNodeApi, type NodeApi } from "./resources/nodes";
|
||||
import { makePolicyApi, type PolicyApi } from "./resources/policy";
|
||||
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
|
||||
import { makeUserApi, type UserApi } from "./resources/users";
|
||||
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
|
||||
import { createTransport } from "./transport";
|
||||
|
||||
/**
|
||||
* A low-level composed interface for interacting with the Headscale API.
|
||||
* This interface provides direct access to the underlying Undici agent
|
||||
* and methods for making API requests.
|
||||
*
|
||||
* It is also responsible for handling OpenAPI spec polling and hashing to
|
||||
* determine the implementations of API methods when requested for use.
|
||||
*/
|
||||
export interface HeadscaleApiInterface {
|
||||
/**
|
||||
* The underlying Undici agent used for making requests.
|
||||
*/
|
||||
undiciAgent: Agent;
|
||||
const MIN_SUPPORTED_VERSION = "0.27.0";
|
||||
|
||||
/**
|
||||
* The base URL of the Headscale API.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*/
|
||||
openapiHashes: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* The detected API version of the connected Headscale instance.
|
||||
*/
|
||||
apiVersion: Version;
|
||||
|
||||
/**
|
||||
* Retrieves a runtime API client for the given API key.
|
||||
*
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @returns A `RuntimeApiClient` instance for interacting with the API.
|
||||
*/
|
||||
getRuntimeClient(apiKey: string): RuntimeApiClient;
|
||||
|
||||
/**
|
||||
* A set of helper methods made available to API method implementations.
|
||||
* The idea is to make interacting with the API easier by providing
|
||||
* common functionality that can be reused across multiple methods.
|
||||
*/
|
||||
clientHelpers: {
|
||||
/**
|
||||
* Checks if the connected Headscale instance's API version
|
||||
* is at least the specified version.
|
||||
*
|
||||
* @param version The version to check against.
|
||||
* @returns `true` if the API version is at least the specified version, `false` otherwise.
|
||||
*/
|
||||
isAtleast(version: Version): boolean;
|
||||
|
||||
/**
|
||||
* Makes a raw fetch request to the Headscale API via the Undici agent.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API.
|
||||
*
|
||||
* @param path The API path to request.
|
||||
* @param options Optional request options.
|
||||
* @returns A promise that resolves to the response data.
|
||||
*/
|
||||
rawFetch(
|
||||
path: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData>;
|
||||
|
||||
/**
|
||||
* Makes a typed API fetch request to the Headscale API.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API and parse the response.
|
||||
*
|
||||
* @param method The HTTP method to use.
|
||||
* @param apiPath The API path to request.
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @param bodyOrQuery Optional body or query parameters.
|
||||
* @returns A promise that resolves to the typed response data.
|
||||
*/
|
||||
apiFetch<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T>;
|
||||
};
|
||||
export interface Headscale {
|
||||
readonly version: ServerVersion;
|
||||
readonly capabilities: Capabilities;
|
||||
/** True if the Headscale server's `/health` endpoint returns 200. */
|
||||
health(): Promise<boolean>;
|
||||
/** Build an API client bound to a specific Headscale API key. */
|
||||
client(apiKey: string): HeadscaleClient;
|
||||
/** Stop background work and close the underlying HTTP agent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Headscale API client interface.
|
||||
*
|
||||
* @param baseUrl The base URL of the Headscale API.
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
|
||||
*/
|
||||
export async function createHeadscaleInterface(
|
||||
baseUrl: string,
|
||||
certPath?: string,
|
||||
): Promise<HeadscaleApiInterface> {
|
||||
const undiciAgent = await createUndiciAgent(certPath);
|
||||
let openapiHashes: Record<string, string> | null = null;
|
||||
let apiVersion: Version;
|
||||
|
||||
const rawFetch = async (
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData> => {
|
||||
const method = options?.method ?? "GET";
|
||||
log.debug("api", "%s %s", method, url);
|
||||
|
||||
try {
|
||||
const res = await request(new URL(url, baseUrl), {
|
||||
dispatcher: undiciAgent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `Headplane/${__VERSION__}`,
|
||||
},
|
||||
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
||||
throw data(errorBody, {
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const apiFetch = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
let url = `/api/${apiPath}`;
|
||||
const options: Partial<Dispatcher.RequestOptions> = {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (bodyOrQuery) {
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(bodyOrQuery)) {
|
||||
if (value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if ([...params.keys()].length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
} else {
|
||||
options.body = JSON.stringify(bodyOrQuery);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const res = await rawFetch(url, options);
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${apiPath}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
};
|
||||
export interface HeadscaleClient {
|
||||
nodes: NodeApi;
|
||||
users: UserApi;
|
||||
policy: PolicyApi;
|
||||
preAuthKeys: PreAuthKeyApi;
|
||||
apiKeys: ApiKeyApi;
|
||||
}
|
||||
|
||||
export interface CreateHeadscaleOptions {
|
||||
url: string;
|
||||
certPath?: string;
|
||||
/**
|
||||
* Polls the OpenAPI spec endpoint and generates operation hashes.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*
|
||||
* @returns A promise that resolves to the OpenAPI operation hashes.
|
||||
* How often to retry `/version` while Headscale is unreachable.
|
||||
* Defaults to 30 seconds. Exposed for tests.
|
||||
*/
|
||||
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const res = await rawFetch("/swagger/v1/openapiv2.json");
|
||||
if (res.statusCode !== 200) {
|
||||
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
|
||||
return null;
|
||||
}
|
||||
retryIntervalMs?: number;
|
||||
}
|
||||
|
||||
const body = await res.body.json();
|
||||
const spec = await dereference(body as OpenAPIV2.Document);
|
||||
const hashes = generateSpecHashes(spec);
|
||||
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
|
||||
return hashes;
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
|
||||
}
|
||||
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
return null;
|
||||
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
|
||||
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
|
||||
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
|
||||
|
||||
let version: ServerVersion = parseServerVersion("unreachable");
|
||||
let capabilities: Capabilities = capabilitiesFor(version);
|
||||
let detected = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let disposed = false;
|
||||
|
||||
function settle(parsed: ServerVersion) {
|
||||
version = parsed;
|
||||
capabilities = capabilitiesFor(parsed);
|
||||
detected = true;
|
||||
if (parsed.unknown) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Could not parse Headscale version %s, assuming newest known capabilities",
|
||||
parsed.raw,
|
||||
);
|
||||
} else {
|
||||
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
const isAtleast = (version: Version): boolean => {
|
||||
return isAtLeast(apiVersion, version);
|
||||
};
|
||||
|
||||
openapiHashes = await fetchAndHashOpenapi();
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
|
||||
setInterval(async () => {
|
||||
const hashes = await fetchAndHashOpenapi();
|
||||
if (hashes) {
|
||||
openapiHashes = hashes;
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
async function detectOnce(): Promise<boolean> {
|
||||
try {
|
||||
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
|
||||
settle(parseServerVersion(raw));
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 404 means Headscale is reachable but predates 0.27.0 (where
|
||||
// /version was introduced). That server is below the supported
|
||||
// floor, so we don't settle — leave capabilities permissive and
|
||||
// keep retrying in case the operator upgrades in place.
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 404) {
|
||||
log.error(
|
||||
"api",
|
||||
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
|
||||
MIN_SUPPORTED_VERSION,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
log.debug("api", "Headscale /version probe failed: %s", String(error));
|
||||
return false;
|
||||
}
|
||||
}, 60_000); // every 60 seconds
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (disposed || detected) return;
|
||||
retryTimer = setTimeout(async () => {
|
||||
retryTimer = undefined;
|
||||
if (disposed) return;
|
||||
if (await detectOnce()) return;
|
||||
scheduleRetry();
|
||||
}, retryIntervalMs);
|
||||
// Don't keep the event loop alive on this timer alone — Headplane
|
||||
// should still shut down cleanly while we're waiting to retry.
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
if (!(await detectOnce())) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
|
||||
retryIntervalMs,
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
return {
|
||||
undiciAgent,
|
||||
baseUrl,
|
||||
openapiHashes,
|
||||
apiVersion,
|
||||
getRuntimeClient: (apiKey: string) => {
|
||||
return endpointSets(
|
||||
{
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
// Getters so callers always observe the latest detected values
|
||||
// without having to know about the retry loop.
|
||||
get version() {
|
||||
return version;
|
||||
},
|
||||
clientHelpers: {
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
get capabilities() {
|
||||
return capabilities;
|
||||
},
|
||||
health: () => transport.health(),
|
||||
client(apiKey) {
|
||||
return {
|
||||
nodes: makeNodeApi(transport, capabilities, apiKey),
|
||||
users: makeUserApi(transport, capabilities, apiKey),
|
||||
policy: makePolicyApi(transport, capabilities, apiKey),
|
||||
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
|
||||
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
|
||||
};
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
await transport.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Undici agent for making HTTP requests.
|
||||
*
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to an `Agent` instance.
|
||||
*/
|
||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||
if (!certPath) {
|
||||
return new Agent();
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("config", "Loading certificate from %s", certPath);
|
||||
const data = await readFile(certPath, "utf8");
|
||||
|
||||
log.info("config", "Using certificate from %s", certPath);
|
||||
return new Agent({ connect: { ca: data.trim() } });
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to load Headscale TLS cert: %s", error);
|
||||
log.debug("config", "Error Details: %o", error);
|
||||
return new Agent();
|
||||
}
|
||||
}
|
||||
|
||||
function generateSpecHashes(spec: OpenAPIV2.Document) {
|
||||
const hashes: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Key } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface ApiKeyApi {
|
||||
list(): Promise<Key[]>;
|
||||
}
|
||||
|
||||
export function makeApiKeyApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): ApiKeyApi {
|
||||
return {
|
||||
list: async () => {
|
||||
const { apiKeys } = await transport.request<{ apiKeys: Key[] }>({
|
||||
method: "GET",
|
||||
path: "v1/apikey",
|
||||
apiKey,
|
||||
});
|
||||
return apiKeys;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
interface RawMachine extends Omit<Machine, "tags"> {
|
||||
tags?: string[];
|
||||
forcedTags?: string[];
|
||||
validTags?: string[];
|
||||
invalidTags?: string[];
|
||||
}
|
||||
|
||||
export interface NodeApi {
|
||||
list(): Promise<Machine[]>;
|
||||
get(id: string): Promise<Machine>;
|
||||
delete(id: string): Promise<void>;
|
||||
register(user: string, key: string): Promise<Machine>;
|
||||
approveRoutes(id: string, routes: string[]): Promise<void>;
|
||||
expire(id: string): Promise<void>;
|
||||
rename(id: string, newName: string): Promise<void>;
|
||||
setTags(id: string, tags: string[]): Promise<void>;
|
||||
/**
|
||||
* Reassign a node to a different user. Only present when
|
||||
* `capabilities.nodeOwnerIsImmutable` is false (Headscale < 0.28).
|
||||
*/
|
||||
reassignUser?: (id: string, user: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function makeNodeApi(
|
||||
transport: Transport,
|
||||
capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): NodeApi {
|
||||
function normalize(raw: RawMachine): Machine {
|
||||
if (capabilities.nodeTagsAreFlat) {
|
||||
return { ...raw, tags: raw.tags ?? [] } as Machine;
|
||||
}
|
||||
const tags = Array.from(new Set([...(raw.forcedTags ?? []), ...(raw.validTags ?? [])]));
|
||||
return { ...raw, tags } as Machine;
|
||||
}
|
||||
|
||||
const api: NodeApi = {
|
||||
list: async () => {
|
||||
const { nodes } = await transport.request<{ nodes: RawMachine[] }>({
|
||||
method: "GET",
|
||||
path: "v1/node",
|
||||
apiKey,
|
||||
});
|
||||
return nodes.map(normalize);
|
||||
},
|
||||
get: async (id) => {
|
||||
const { node } = await transport.request<{ node: RawMachine }>({
|
||||
method: "GET",
|
||||
path: `v1/node/${id}`,
|
||||
apiKey,
|
||||
});
|
||||
return normalize(node);
|
||||
},
|
||||
delete: async (id) => {
|
||||
await transport.request({ method: "DELETE", path: `v1/node/${id}`, apiKey });
|
||||
},
|
||||
register: async (user, key) => {
|
||||
// Headscale's node-register endpoint expects the registration
|
||||
// params as both query string and body — preserved as-is.
|
||||
const qp = new URLSearchParams();
|
||||
qp.append("user", user);
|
||||
qp.append("key", key);
|
||||
const { node } = await transport.request<{ node: RawMachine }>({
|
||||
method: "POST",
|
||||
path: `v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
body: { user, key },
|
||||
});
|
||||
return normalize(node);
|
||||
},
|
||||
approveRoutes: async (id, routes) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/approve_routes`,
|
||||
apiKey,
|
||||
body: { routes },
|
||||
});
|
||||
},
|
||||
expire: async (id) => {
|
||||
await transport.request({ method: "POST", path: `v1/node/${id}/expire`, apiKey });
|
||||
},
|
||||
rename: async (id, newName) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/rename/${encodeURIComponent(newName)}`,
|
||||
apiKey,
|
||||
});
|
||||
},
|
||||
setTags: async (id, tags) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/tags`,
|
||||
apiKey,
|
||||
body: { tags },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (!capabilities.nodeOwnerIsImmutable) {
|
||||
api.reassignUser = async (id, user) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/node/${id}/user`,
|
||||
apiKey,
|
||||
body: { user },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface PolicyApi {
|
||||
get(): Promise<{ policy: string; updatedAt: Date | null }>;
|
||||
set(policy: string): Promise<{ policy: string; updatedAt: Date }>;
|
||||
}
|
||||
|
||||
export function makePolicyApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): PolicyApi {
|
||||
return {
|
||||
get: async () => {
|
||||
const { policy, updatedAt } = await transport.request<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>({ method: "GET", path: "v1/policy", apiKey });
|
||||
return {
|
||||
policy,
|
||||
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
|
||||
};
|
||||
},
|
||||
set: async (policy) => {
|
||||
const { policy: newPolicy, updatedAt } = await transport.request<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>({ method: "PUT", path: "v1/policy", apiKey, body: { policy } });
|
||||
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { PreAuthKey } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface CreatePreAuthKeyOptions {
|
||||
/** Owning user ID, or `null` for tag-only keys (0.28+). */
|
||||
user: string | null;
|
||||
ephemeral: boolean;
|
||||
reusable: boolean;
|
||||
expiration: Date | null;
|
||||
aclTags: string[] | null;
|
||||
}
|
||||
|
||||
export interface PreAuthKeyApi {
|
||||
/**
|
||||
* List every pre-auth key on the server. Only present when
|
||||
* `capabilities.preAuthKeysHaveStableIds` is true (Headscale 0.28+).
|
||||
* Pre-0.28 callers must use {@link listForUser}.
|
||||
*/
|
||||
listAll?: () => Promise<PreAuthKey[]>;
|
||||
|
||||
listForUser(userId: string): Promise<PreAuthKey[]>;
|
||||
|
||||
create(opts: CreatePreAuthKeyOptions): Promise<PreAuthKey>;
|
||||
|
||||
expire(key: PreAuthKey): Promise<void>;
|
||||
}
|
||||
|
||||
export function makePreAuthKeyApi(
|
||||
transport: Transport,
|
||||
capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): PreAuthKeyApi {
|
||||
const api: PreAuthKeyApi = {
|
||||
listForUser: async (userId) => {
|
||||
const { preAuthKeys } = await transport.request<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>({ method: "GET", path: "v1/preauthkey", apiKey, query: { user: userId } });
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
create: async ({ user, ephemeral, reusable, expiration, aclTags }) => {
|
||||
const body: Record<string, unknown> = {
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
};
|
||||
if (user) body.user = user;
|
||||
if (aclTags && aclTags.length > 0) body.aclTags = aclTags;
|
||||
|
||||
const { preAuthKey } = await transport.request<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>({ method: "POST", path: "v1/preauthkey", apiKey, body });
|
||||
return preAuthKey;
|
||||
},
|
||||
|
||||
expire: async (key) => {
|
||||
if (capabilities.preAuthKeysHaveStableIds) {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: "v1/preauthkey/expire",
|
||||
apiKey,
|
||||
body: { id: key.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Pre-0.28: expire takes the owning user's ID (a uint64 — Headscale
|
||||
// rejects names with `proto: invalid value for uint64 field user`)
|
||||
// plus the key string.
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: "v1/preauthkey/expire",
|
||||
apiKey,
|
||||
body: { user: key.user?.id ?? "", key: key.key },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
if (capabilities.preAuthKeysHaveStableIds) {
|
||||
api.listAll = async () => {
|
||||
const { preAuthKeys } = await transport.request<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>({ method: "GET", path: "v1/preauthkey", apiKey });
|
||||
return preAuthKeys;
|
||||
};
|
||||
}
|
||||
|
||||
return api;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { User } from "~/types";
|
||||
|
||||
import type { Capabilities } from "../capabilities";
|
||||
import type { Transport } from "../transport";
|
||||
|
||||
export interface CreateUserOptions {
|
||||
name: string;
|
||||
email?: string;
|
||||
displayName?: string;
|
||||
pictureUrl?: string;
|
||||
}
|
||||
|
||||
export interface ListUsersFilter {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface UserApi {
|
||||
list(filter?: ListUsersFilter): Promise<User[]>;
|
||||
create(opts: CreateUserOptions): Promise<User>;
|
||||
delete(id: string): Promise<void>;
|
||||
rename(id: string, newName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function makeUserApi(
|
||||
transport: Transport,
|
||||
_capabilities: Capabilities,
|
||||
apiKey: string,
|
||||
): UserApi {
|
||||
return {
|
||||
list: async (filter) => {
|
||||
const { id, name, email } = filter ?? {};
|
||||
const moreThanOneFilter = [id, name, email].filter((v) => v !== undefined).length > 1;
|
||||
if (moreThanOneFilter) {
|
||||
throw new Error("Only one of id, name, or email filters can be provided");
|
||||
}
|
||||
const { users } = await transport.request<{ users: User[] }>({
|
||||
method: "GET",
|
||||
path: "v1/user",
|
||||
apiKey,
|
||||
query: { id, name, email },
|
||||
});
|
||||
return users;
|
||||
},
|
||||
create: async ({ name, email, displayName, pictureUrl }) => {
|
||||
const { user } = await transport.request<{ user: User }>({
|
||||
method: "POST",
|
||||
path: "v1/user",
|
||||
apiKey,
|
||||
body: { name, email, displayName, pictureUrl },
|
||||
});
|
||||
return user;
|
||||
},
|
||||
delete: async (id) => {
|
||||
await transport.request({ method: "DELETE", path: `v1/user/${id}`, apiKey });
|
||||
},
|
||||
rename: async (id, newName) => {
|
||||
await transport.request({
|
||||
method: "POST",
|
||||
path: `v1/user/${id}/rename/${encodeURIComponent(newName)}`,
|
||||
apiKey,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// MARK: ServerVersion
|
||||
//
|
||||
// Parses the response from Headscale's `GET /version` endpoint into a
|
||||
// structured value that capability checks can reason about. The
|
||||
// endpoint exists in every Headscale release we support (0.27.0+) and
|
||||
// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`,
|
||||
// or `dev` for untagged builds.
|
||||
//
|
||||
// Comparisons (`gte`) are lenient about prerelease tags by design:
|
||||
// `0.28.0-beta.1` is treated as `0.28.0` for capability gating. The
|
||||
// behavioural changes that capabilities gate on always land in the
|
||||
// first prerelease of a minor version, so a strict semver
|
||||
// interpretation would lock prerelease users out of features that
|
||||
// their server actually has.
|
||||
|
||||
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
|
||||
|
||||
export interface ServerVersion {
|
||||
readonly major: number;
|
||||
readonly minor: number;
|
||||
readonly patch: number;
|
||||
readonly prerelease: string | undefined;
|
||||
readonly build: string | undefined;
|
||||
/** The raw string as reported by Headscale (e.g. `v0.28.0-beta.1`, `dev`). */
|
||||
readonly raw: string;
|
||||
/**
|
||||
* True when the server reported a version we couldn't parse —
|
||||
* typically `dev` for an untagged Headscale build. Capability checks
|
||||
* treat unknown versions as having every known capability so we
|
||||
* exercise the modern code paths against unfamiliar servers rather
|
||||
* than silently falling back to compatibility shims.
|
||||
*/
|
||||
readonly unknown: boolean;
|
||||
}
|
||||
|
||||
export function parseServerVersion(raw: string): ServerVersion {
|
||||
const match = SEMVER_RE.exec(raw.trim());
|
||||
if (!match) {
|
||||
return {
|
||||
major: 0,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
prerelease: undefined,
|
||||
build: undefined,
|
||||
raw,
|
||||
unknown: true,
|
||||
};
|
||||
}
|
||||
const [, maj, min, pat, pre, build] = match;
|
||||
return {
|
||||
major: Number(maj),
|
||||
minor: Number(min),
|
||||
patch: Number(pat),
|
||||
prerelease: pre,
|
||||
build,
|
||||
raw,
|
||||
unknown: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pretty-print a parsed version for logs (no `v` prefix, includes prerelease). */
|
||||
export function formatServerVersion(version: ServerVersion): string {
|
||||
if (version.unknown) return version.raw;
|
||||
const core = `${version.major}.${version.minor}.${version.patch}`;
|
||||
return version.prerelease ? `${core}-${version.prerelease}` : core;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when `version` is at least `target` (ignoring prerelease
|
||||
* tags — see module-level note). `target` must be a plain semver
|
||||
* string like `0.28.0`. Throws on a malformed target since those come
|
||||
* from static capability tables, not user input.
|
||||
*/
|
||||
export function gte(version: ServerVersion, target: string): boolean {
|
||||
if (version.unknown) return true;
|
||||
const t = parseTarget(target);
|
||||
if (version.major !== t.major) return version.major > t.major;
|
||||
if (version.minor !== t.minor) return version.minor > t.minor;
|
||||
return version.patch >= t.patch;
|
||||
}
|
||||
|
||||
interface TargetVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
function parseTarget(target: string): TargetVersion {
|
||||
const match = SEMVER_RE.exec(target);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid capability target version: ${target}`);
|
||||
}
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// MARK: Headscale Transport
|
||||
//
|
||||
// Internal HTTP transport for talking to a Headscale server. Owns
|
||||
// the Undici agent (and any custom CA), the base URL, error
|
||||
// translation, and the distinction between authenticated `/api/v1`
|
||||
// calls and unauthenticated public endpoints (`/version`, `/health`).
|
||||
//
|
||||
// This module is intentionally not exported from the package; all
|
||||
// consumers should go through `Headscale` and `HeadscaleClient` in
|
||||
// `./index.ts`, never the transport directly.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { data } from "react-router";
|
||||
import { Agent, type Dispatcher, request } from "undici";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { undiciToFriendlyError } from "./error";
|
||||
import { type HeadscaleAPIError, isApiError } from "./error-client";
|
||||
|
||||
export interface TransportRequest {
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
/** API path without the `/api/` prefix (e.g. `v1/node`). */
|
||||
path: `v1/${string}`;
|
||||
apiKey: string;
|
||||
/** JSON request body for non-GET/DELETE requests. */
|
||||
body?: Record<string, unknown>;
|
||||
/** Query parameters for GET/DELETE requests. */
|
||||
query?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
/**
|
||||
* Send an authenticated JSON request against `/api/{path}`.
|
||||
* Throws a React Router `data()` 502 response on transport errors
|
||||
* and a typed `HeadscaleAPIError` (wrapped in `data()`) on API
|
||||
* errors with statusCode >= 400.
|
||||
*/
|
||||
request<T>(opts: TransportRequest): Promise<T>;
|
||||
|
||||
/**
|
||||
* Send an unauthenticated GET against the server root
|
||||
* (e.g. `/version`, `/health`). Returns parsed JSON.
|
||||
*/
|
||||
getPublic<T>(path: `/${string}`): Promise<T>;
|
||||
|
||||
/** True if `GET /health` returns 200. Never throws. */
|
||||
health(): Promise<boolean>;
|
||||
|
||||
/** Shut down the underlying Undici agent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TransportOptions {
|
||||
url: string;
|
||||
certPath?: string;
|
||||
}
|
||||
|
||||
export async function createTransport(opts: TransportOptions): Promise<Transport> {
|
||||
const agent = await createUndiciAgent(opts.certPath);
|
||||
const baseUrl = opts.url;
|
||||
|
||||
async function rawRequest(
|
||||
url: string,
|
||||
options: Partial<Dispatcher.RequestOptions> & { method: string },
|
||||
): Promise<Dispatcher.ResponseData> {
|
||||
log.debug("api", "%s %s", options.method, url);
|
||||
try {
|
||||
return await request(new URL(url, baseUrl), {
|
||||
dispatcher: agent,
|
||||
headers: {
|
||||
...options.headers,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options.body,
|
||||
method: options.method,
|
||||
});
|
||||
} catch (error) {
|
||||
const errorBody = undiciToFriendlyError(error, `${options.method} ${url}`);
|
||||
throw data(errorBody, { status: 502, statusText: "Bad Gateway" });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async request<T>({ method, path, apiKey, body, query }: TransportRequest): Promise<T> {
|
||||
let url = `/api/${path}`;
|
||||
const options: Partial<Dispatcher.RequestOptions> & { method: string } = {
|
||||
method,
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
};
|
||||
|
||||
if (query && (method === "GET" || method === "DELETE")) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
if ([...params.keys()].length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
} else if (body && method !== "GET" && method !== "DELETE") {
|
||||
options.body = JSON.stringify(body);
|
||||
options.headers = { ...options.headers, "Content-Type": "application/json" };
|
||||
}
|
||||
|
||||
const res = await rawRequest(url, options);
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug("api", "%s %s failed with status %d", method, path, res.statusCode);
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${path}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
},
|
||||
|
||||
async getPublic<T>(path: `/${string}`): Promise<T> {
|
||||
const res = await rawRequest(path, { method: "GET" });
|
||||
if (res.statusCode >= 400) {
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `GET ${path}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
return res.body.json() as Promise<T>;
|
||||
},
|
||||
|
||||
async health() {
|
||||
try {
|
||||
const res = await rawRequest("/health", { method: "GET" });
|
||||
// Drain the body so the connection can be reused.
|
||||
await res.body.dump();
|
||||
return res.statusCode === 200;
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
log.debug("api", "Health check failed: %d", error.statusCode);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
async dispose() {
|
||||
await agent.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||
if (!certPath) return new Agent();
|
||||
try {
|
||||
log.debug("config", "Loading certificate from %s", certPath);
|
||||
const cert = await readFile(certPath, "utf8");
|
||||
log.info("config", "Using certificate from %s", certPath);
|
||||
return new Agent({ connect: { ca: cert.trim() } });
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to load Headscale TLS cert: %s", error);
|
||||
log.debug("config", "Error Details: %o", error);
|
||||
return new Agent();
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import canonicals from "~/openapi-canonical-families.json";
|
||||
import hashes from "~/openapi-operation-hashes.json";
|
||||
import log from "~/utils/log";
|
||||
|
||||
/**
|
||||
* The known API versions based on operation hashes.
|
||||
*/
|
||||
export type Version = keyof typeof hashes;
|
||||
const VERSIONS = Object.keys(hashes) as Version[];
|
||||
|
||||
/**
|
||||
* Detects the closest matching API version using operation hashes.
|
||||
* Falls back to the latest known version and emits a warning if unknown.
|
||||
*
|
||||
* @param observed - A mapping of operation identifiers to their hashes.
|
||||
* @returns The detected API version.
|
||||
*/
|
||||
export function detectApiVersion(observed: Record<string, string> | null): Version {
|
||||
if (!observed) {
|
||||
const latest = VERSIONS.at(-1)!;
|
||||
log.warn("api", "No operation hashes observed, defaulting to version %s", latest);
|
||||
return latest;
|
||||
}
|
||||
|
||||
let bestVersion: Version | null = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const [version, known] of Object.entries(hashes) as [Version, Record<string, string>][]) {
|
||||
let score = 0;
|
||||
for (const [op, hash] of Object.entries(observed)) {
|
||||
if (known[op] === hash) score++;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestVersion = version;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestVersion || bestScore === 0) {
|
||||
const latest = VERSIONS.at(-1)!;
|
||||
log.warn("api", "Could not determine API version, defaulting to %s", latest);
|
||||
return latest;
|
||||
}
|
||||
|
||||
if (bestScore < Object.keys(observed).length) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Partial version match: %d/%d endpoints for version %s",
|
||||
bestScore,
|
||||
Object.keys(observed).length,
|
||||
bestVersion,
|
||||
);
|
||||
}
|
||||
|
||||
const canonical = Object.entries(canonicals).find(([_, family]) =>
|
||||
family.includes(bestVersion),
|
||||
)?.[0] as Version | undefined;
|
||||
|
||||
if (!canonical) {
|
||||
log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion);
|
||||
|
||||
return bestVersion;
|
||||
}
|
||||
|
||||
if (canonical !== bestVersion) {
|
||||
log.info(
|
||||
"api",
|
||||
"Canonicalizing detected version %s → %s (same schema)",
|
||||
bestVersion,
|
||||
canonical,
|
||||
);
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current version is at least the baseline version.
|
||||
*
|
||||
* @param current - The current API version.
|
||||
* @param baseline - The baseline API version to compare against.
|
||||
* @returns True if current is at least baseline, false otherwise.
|
||||
*/
|
||||
export function isAtLeast(current: Version, baseline: Version) {
|
||||
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { RuntimeApiClient } from "./api/endpoints";
|
||||
import type { HeadscaleClient } from "./api";
|
||||
|
||||
/**
|
||||
* Defines a resource that can be fetched and polled by the live store.
|
||||
@@ -19,7 +19,7 @@ export interface ResourceDefinition<T> {
|
||||
/**
|
||||
* A callback to fire to get the latest data for this resource
|
||||
*/
|
||||
readonly fetch: (client: RuntimeApiClient) => Promise<T>;
|
||||
readonly fetch: (client: HeadscaleClient) => Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,12 +37,12 @@ export function defineResource<T>(
|
||||
|
||||
export const nodesResource = defineResource("nodes", {
|
||||
pollInterval: 5_000,
|
||||
fetch: (api) => api.getNodes(),
|
||||
fetch: (api) => api.nodes.list(),
|
||||
});
|
||||
|
||||
export const usersResource = defineResource("users", {
|
||||
pollInterval: 15_000,
|
||||
fetch: (api) => api.getUsers(),
|
||||
fetch: (api) => api.users.list(),
|
||||
});
|
||||
|
||||
interface Snapshot<T> {
|
||||
@@ -54,8 +54,8 @@ interface Snapshot<T> {
|
||||
type ChangeListener = (resourceKey: string, version: string) => void;
|
||||
|
||||
export interface LiveStore {
|
||||
get<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<Snapshot<T>>;
|
||||
refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void>;
|
||||
get<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<Snapshot<T>>;
|
||||
refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void>;
|
||||
getVersions(): Record<string, string>;
|
||||
subscribe(listener: ChangeListener): () => void;
|
||||
dispose(): void;
|
||||
@@ -66,7 +66,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
const serializedCache = new Map<string, string>();
|
||||
const listeners = new Set<ChangeListener>();
|
||||
const intervals = new Map<string, ReturnType<typeof setInterval>>();
|
||||
let storedApiClient: RuntimeApiClient | undefined;
|
||||
let storedApiClient: HeadscaleClient | undefined;
|
||||
let versionCounter = 0;
|
||||
|
||||
function notifyListeners(resourceKey: string, version: string) {
|
||||
@@ -77,7 +77,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
|
||||
async function fetchResource(
|
||||
resource: ResourceDefinition<unknown>,
|
||||
apiClient: RuntimeApiClient,
|
||||
apiClient: HeadscaleClient,
|
||||
): Promise<void> {
|
||||
const data = await resource.fetch(apiClient);
|
||||
const json = JSON.stringify(data);
|
||||
@@ -138,7 +138,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
return {
|
||||
async get<T>(
|
||||
resource: ResourceDefinition<T>,
|
||||
apiClient: RuntimeApiClient,
|
||||
apiClient: HeadscaleClient,
|
||||
): Promise<Snapshot<T>> {
|
||||
storedApiClient = apiClient;
|
||||
const def = findResource(resource.key);
|
||||
@@ -154,7 +154,7 @@ export function createLiveStore(resources: ResourceDefinition<unknown>[]): LiveS
|
||||
return snapshots.get(resource.key) as Snapshot<T>;
|
||||
},
|
||||
|
||||
async refresh<T>(resource: ResourceDefinition<T>, apiClient: RuntimeApiClient): Promise<void> {
|
||||
async refresh<T>(resource: ResourceDefinition<T>, apiClient: HeadscaleClient): Promise<void> {
|
||||
storedApiClient = apiClient;
|
||||
const def = findResource(resource.key);
|
||||
if (!def) {
|
||||
|
||||
+12
-8
@@ -11,7 +11,7 @@ import log from "~/utils/log";
|
||||
|
||||
import { HeadplaneConfig } from "./config/config-schema";
|
||||
import { hostInfo } from "./db/schema";
|
||||
import { RuntimeApiClient } from "./headscale/api/endpoints";
|
||||
import type { HeadscaleClient } from "./headscale/api";
|
||||
|
||||
export interface AgentManager {
|
||||
lookup(nodeKeys: string[]): Promise<Record<string, HostInfo>>;
|
||||
@@ -46,7 +46,7 @@ async function hasExistingState(workDir: string): Promise<boolean> {
|
||||
export async function createAgentManager(
|
||||
agentConfig: NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> | undefined,
|
||||
headscaleUrl: string,
|
||||
apiClient: RuntimeApiClient,
|
||||
apiClient: HeadscaleClient,
|
||||
supportsTagOnlyKeys: boolean,
|
||||
db: NodeSQLiteDatabase,
|
||||
): Promise<AgentManager | undefined> {
|
||||
@@ -101,9 +101,13 @@ export async function createAgentManager(
|
||||
|
||||
async function generateAuthKey(): Promise<string> {
|
||||
const expiration = new Date(Date.now() + 5 * 60_000);
|
||||
const pak = await apiClient.createPreAuthKey(null, false, false, expiration, [
|
||||
`tag:${hostName}`,
|
||||
]);
|
||||
const pak = await apiClient.preAuthKeys.create({
|
||||
user: null,
|
||||
ephemeral: false,
|
||||
reusable: false,
|
||||
expiration,
|
||||
aclTags: [`tag:${hostName}`],
|
||||
});
|
||||
return pak.key;
|
||||
}
|
||||
|
||||
@@ -272,7 +276,7 @@ export async function createAgentManager(
|
||||
*/
|
||||
async function pruneStaleHostInfo() {
|
||||
try {
|
||||
const nodes = await apiClient.getNodes();
|
||||
const nodes = await apiClient.nodes.list();
|
||||
const activeKeys = nodes.map((n) => n.nodeKey);
|
||||
|
||||
if (activeKeys.length === 0) {
|
||||
@@ -298,11 +302,11 @@ export async function createAgentManager(
|
||||
|
||||
async function pruneEphemeralNodes() {
|
||||
try {
|
||||
const nodes = await apiClient.getNodes();
|
||||
const nodes = await apiClient.nodes.list();
|
||||
const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online);
|
||||
|
||||
for (const node of toPrune) {
|
||||
await apiClient.deleteNode(node.id);
|
||||
await apiClient.nodes.delete(node.id);
|
||||
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+44
-2
@@ -8,23 +8,65 @@
|
||||
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
|
||||
// straight to `./app`'s default export.
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { exit } from "node:process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { composeListener, startHttpServer } from "../../runtime/http";
|
||||
import requestListener, { config } from "./app";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { type StartOptions, composeListener, startHttpServer } from "../../runtime/http";
|
||||
import requestListener, { config, dispose } 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");
|
||||
|
||||
let tls: StartOptions["tls"];
|
||||
const { tls_cert_path: certPath, tls_key_path: keyPath } = config.server;
|
||||
if (certPath || keyPath) {
|
||||
if (!certPath || !keyPath) {
|
||||
log.error(
|
||||
"server",
|
||||
"TLS misconfigured: both `server.tls_cert_path` and `server.tls_key_path` must be provided",
|
||||
);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const [cert, key] = await Promise.all([readFile(certPath), readFile(keyPath)]);
|
||||
tls = { cert, key };
|
||||
} catch (err) {
|
||||
log.error("server", "Failed to read TLS material: %s", err);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// `HEADPLANE_LISTEN_FILE` is a Docker-specific contract: the
|
||||
// Dockerfile sets it to `/tmp/headplane-listen` so the bundled
|
||||
// `hp_healthcheck` binary can discover the URL to probe. Native
|
||||
// installs don't ship a consumer, so we just skip writing anything
|
||||
// if the var isn't set.
|
||||
const listenFilePath = process.env.HEADPLANE_LISTEN_FILE;
|
||||
const listenFile = listenFilePath
|
||||
? {
|
||||
path: listenFilePath,
|
||||
// Full URL including `__PREFIX__` so the Go binary can GET it
|
||||
// verbatim — no path joining, no basename knowledge needed.
|
||||
url: `${tls ? "https" : "http"}://127.0.0.1:${config.server.port}${__PREFIX__}/healthz`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
startHttpServer({
|
||||
host: config.server.host,
|
||||
port: config.server.port,
|
||||
tls,
|
||||
listenFile,
|
||||
listener: composeListener({
|
||||
basename: __PREFIX__,
|
||||
staticRoot: clientDir,
|
||||
immutableAssets: true,
|
||||
requestListener,
|
||||
}),
|
||||
onShutdown: dispose,
|
||||
});
|
||||
|
||||
+18
-41
@@ -77,13 +77,12 @@ export interface AuthService {
|
||||
|
||||
linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean>;
|
||||
unlinkHeadscaleUser(userId: string): Promise<void>;
|
||||
linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean>;
|
||||
listUsers(): Promise<HeadplaneUser[]>;
|
||||
claimedHeadscaleUserIds(): Promise<Set<string>>;
|
||||
roleForSubject(subject: string): Promise<Role | undefined>;
|
||||
roleForHeadscaleUser(headscaleUserId: string): Promise<Role | undefined>;
|
||||
transferOwnership(currentOwnerSubject: string, newOwnerSubject: string): Promise<boolean>;
|
||||
reassignSubject(subject: string, role: Role): Promise<boolean>;
|
||||
transferOwnership(currentOwnerUserId: string, newOwnerUserId: string): Promise<boolean>;
|
||||
reassignUser(userId: string, role: Role): Promise<boolean>;
|
||||
pruneExpiredSessions(): Promise<void>;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
@@ -370,23 +369,6 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
.where(eq(users.id, userId));
|
||||
}
|
||||
|
||||
async function linkHeadscaleUserBySubject(
|
||||
subject: string,
|
||||
headscaleUserId: string,
|
||||
): Promise<boolean> {
|
||||
const [user] = await opts.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return linkHeadscaleUser(user.id, headscaleUserId);
|
||||
}
|
||||
|
||||
async function listUsers(): Promise<HeadplaneUser[]> {
|
||||
return opts.db.select().from(users);
|
||||
}
|
||||
@@ -428,13 +410,17 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
}
|
||||
|
||||
async function transferOwnership(
|
||||
currentOwnerSubject: string,
|
||||
newOwnerSubject: string,
|
||||
currentOwnerUserId: string,
|
||||
newOwnerUserId: string,
|
||||
): Promise<boolean> {
|
||||
if (currentOwnerUserId === newOwnerUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [current] = await opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, currentOwnerSubject))
|
||||
.where(eq(users.id, currentOwnerUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!current || current.role !== "owner") {
|
||||
@@ -444,10 +430,10 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
const [target] = await opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, newOwnerSubject))
|
||||
.where(eq(users.id, newOwnerUserId))
|
||||
.limit(1);
|
||||
|
||||
if (!target || target.id === current.id) {
|
||||
if (!target) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -464,24 +450,16 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function reassignSubject(subject: string, role: Role): Promise<boolean> {
|
||||
const currentRole = await roleForSubject(subject);
|
||||
if (currentRole === "owner") {
|
||||
async function reassignUser(userId: string, role: Role): Promise<boolean> {
|
||||
const [user] = await opts.db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||
if (!user || user.role === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await opts.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
role,
|
||||
caps: capsForRole(role),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { role, caps: capsForRole(role), updated_at: new Date() },
|
||||
});
|
||||
.update(users)
|
||||
.set({ role, caps: capsForRole(role), updated_at: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -512,13 +490,12 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
findOrCreateUser,
|
||||
linkHeadscaleUser,
|
||||
unlinkHeadscaleUser,
|
||||
linkHeadscaleUserBySubject,
|
||||
listUsers,
|
||||
claimedHeadscaleUserIds,
|
||||
roleForSubject,
|
||||
roleForHeadscaleUser,
|
||||
transferOwnership,
|
||||
reassignSubject,
|
||||
reassignUser,
|
||||
pruneExpiredSessions,
|
||||
start,
|
||||
stop,
|
||||
|
||||
@@ -1,18 +1,46 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// hp_healthcheck pings the Headplane healthz endpoint. It's invoked
|
||||
// from the Docker HEALTHCHECK directive inside the same container
|
||||
// as Headplane itself.
|
||||
//
|
||||
// To stay fully zero-config, Headplane writes the exact URL the
|
||||
// healthcheck should hit — scheme, port, and basename included — to
|
||||
// listenFile when it starts accepting connections (see
|
||||
// runtime/http.ts and app/server/main.ts). This binary just reads
|
||||
// that file and GETs the URL verbatim. No env vars, no YAML
|
||||
// parsing, no path-joining, no compile-time knowledge of the
|
||||
// basename.
|
||||
//
|
||||
// If the file is missing (e.g. the server hasn't finished booting
|
||||
// on the very first probe, or this is an old image being run with
|
||||
// a new healthcheck) we fall back to the historical default.
|
||||
const (
|
||||
listenFile = "/tmp/headplane-listen"
|
||||
defaultURL = "http://localhost:3000/admin/healthz"
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := readListenFile()
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
// Self-signed certs are normal for in-process TLS termination
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://localhost:3000/admin/healthz")
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -25,5 +53,16 @@ func main() {
|
||||
}
|
||||
|
||||
fmt.Println("Health check passed.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func readListenFile() string {
|
||||
data, err := os.ReadFile(listenFile)
|
||||
if err != nil {
|
||||
return defaultURL
|
||||
}
|
||||
url := strings.TrimSpace(string(data))
|
||||
if url == "" {
|
||||
return defaultURL
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -17,8 +17,19 @@ server:
|
||||
# Whether cookies should be marked as Secure
|
||||
# * Should be false if running without HTTPs
|
||||
# * Should be true if running behind a reverse proxy with HTTPs
|
||||
# When `tls_cert_path`/`tls_key_path` are set below this is forced
|
||||
# to true automatically.
|
||||
cookie_secure: true
|
||||
|
||||
# Optional TLS termination. When both `tls_cert_path` and
|
||||
# `tls_key_path` are set, Headplane serves HTTPS on `server.port`.
|
||||
# Both files must be PEM encoded.
|
||||
#
|
||||
# If you are terminating TLS at a reverse proxy (recommended for most
|
||||
# deployments) leave these unset.
|
||||
# tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
|
||||
# tls_key_path: "/var/lib/headplane/tls/privkey.pem"
|
||||
|
||||
# The maximum age of the session cookie in seconds
|
||||
cookie_max_age: 86400 # 1 day in seconds
|
||||
|
||||
@@ -58,6 +69,11 @@ headscale:
|
||||
# If you use the TLS configuration in Headscale, and you are not using
|
||||
# Let's Encrypt for your certificate, pass in the path to the certificate.
|
||||
# (This has no effect if `url` does not start with `https://`)
|
||||
#
|
||||
# Note: this pins Headscale's connection to exactly this one cert. If you
|
||||
# have a private CA you want trusted everywhere (Headscale, OIDC, Docker,
|
||||
# etc.), set `NODE_EXTRA_CA_CERTS=/path/to/ca.pem` on the Headplane
|
||||
# process instead — see https://headplane.net/configuration/tls#custom-certificate-authorities
|
||||
# tls_cert_path: "/var/lib/headplane/tls.crt"
|
||||
|
||||
# Optional, public URL if its different from the `headscale.url`
|
||||
|
||||
@@ -38,6 +38,7 @@ export default defineConfig({
|
||||
link: "/configuration",
|
||||
items: [
|
||||
{ text: "Common Issues", link: "/configuration/common-issues" },
|
||||
{ text: "TLS & Certificates", link: "/configuration/tls" },
|
||||
{
|
||||
text: "Sensitive Values",
|
||||
link: "/configuration#sensitive-values",
|
||||
|
||||
@@ -86,6 +86,13 @@ To enable debug logging, set the **`HEADPLANE_DEBUG_LOG=true`** environment vari
|
||||
This will enable all debug logs for Headplane, which could fill up log space very quickly.
|
||||
This is not recommended in production environments.
|
||||
|
||||
## TLS & Certificates
|
||||
|
||||
Anything to do with TLS — terminating HTTPS in-process, trusting a private
|
||||
CA for outbound connections to Headscale or your OIDC provider, the
|
||||
interaction with `cookie_secure` and the Docker healthcheck — lives on its
|
||||
own page: [TLS & Certificates](./tls).
|
||||
|
||||
## Reverse Proxying
|
||||
|
||||
Reverse proxying is very common when deploying web applications. Headscale and
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# TLS & Certificates
|
||||
|
||||
Everything Headplane does with TLS lives on this page: terminating HTTPS
|
||||
in-process, trusting private certificate authorities for outbound
|
||||
connections, and the interactions with cookies and the bundled Docker
|
||||
healthcheck.
|
||||
|
||||
## Custom Certificate Authorities
|
||||
|
||||
If you front any of the services Headplane talks to — your Headscale server,
|
||||
your OIDC provider, an HTTPS Docker daemon, etc. — with a private or
|
||||
self-signed certificate authority, Headplane needs to trust that CA. The
|
||||
cleanest way to do that is the standard Node.js `NODE_EXTRA_CA_CERTS`
|
||||
environment variable, which is honoured for **every** outbound TLS
|
||||
connection in the process (OIDC discovery, Headscale API, Docker, anything
|
||||
else that uses `fetch` or `https`).
|
||||
|
||||
Provide it as a path to a PEM-encoded bundle of one or more CA certificates:
|
||||
|
||||
```bash
|
||||
NODE_EXTRA_CA_CERTS=/etc/headplane/extra-cas.pem
|
||||
```
|
||||
|
||||
In Docker, bind-mount the bundle and pass the variable through:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
environment:
|
||||
NODE_EXTRA_CA_CERTS: /etc/headplane/extra-cas.pem
|
||||
volumes:
|
||||
- "./internal-ca.pem:/etc/headplane/extra-cas.pem:ro"
|
||||
- "./config.yaml:/etc/headplane/config.yaml"
|
||||
- "./headplane-data:/var/lib/headplane"
|
||||
```
|
||||
|
||||
The bundle is added **on top of** the system trust store, so public
|
||||
certificates (Let's Encrypt, ZeroSSL, etc.) keep working.
|
||||
|
||||
> `headscale.tls_cert_path` is a narrower knob that pins Headscale's API
|
||||
> connection to exactly one certificate, bypassing the rest of the trust
|
||||
> store. It still has its place if you want Headplane to refuse anything
|
||||
> other than that specific cert for Headscale, but `NODE_EXTRA_CA_CERTS` is
|
||||
> the right tool whenever you simply want to add a CA to the trusted set.
|
||||
|
||||
## TLS Termination
|
||||
|
||||
Headplane can terminate TLS itself when both `server.tls_cert_path` and
|
||||
`server.tls_key_path` are set in the configuration file. Both must point to
|
||||
PEM-encoded files that Headplane can read.
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 443
|
||||
tls_cert_path: "/var/lib/headplane/tls/fullchain.pem"
|
||||
tls_key_path: "/var/lib/headplane/tls/privkey.pem"
|
||||
```
|
||||
|
||||
When TLS is configured Headplane serves HTTPS/1.1 on `server.port`. HTTP/2
|
||||
and HTTP/3 are intentionally not supported in-process — terminate those at a
|
||||
reverse proxy (e.g. Caddy or Traefik) and forward to Headplane over HTTP/1.1
|
||||
if you need them today.
|
||||
|
||||
`server.cookie_secure` is forced to `true` whenever TLS is enabled (browsers
|
||||
refuse `Secure`-less cookies over HTTPS); a warning is logged if your config
|
||||
had it set to `false`.
|
||||
|
||||
For most deployments we still recommend terminating TLS at a reverse proxy
|
||||
(see [Reverse Proxying](./#reverse-proxying)) so you can share certificates
|
||||
with Headscale and other services. Built-in TLS is meant for the simpler
|
||||
"Headplane on a single box" scenarios.
|
||||
|
||||
## Healthcheck
|
||||
|
||||
The bundled Docker healthcheck picks up the right scheme and port
|
||||
automatically — Headplane writes its loopback URL to `/tmp/headplane-listen`
|
||||
when it starts, and the healthcheck reads it from there. So flipping TLS on
|
||||
or off requires no healthcheck-specific configuration; everything just
|
||||
works.
|
||||
@@ -171,7 +171,7 @@ export interface AppRuntime {
|
||||
db: DbClient;
|
||||
auth: AuthService;
|
||||
oidc?: OidcService;
|
||||
hsApi: HeadscaleInterface;
|
||||
headscale: Headscale;
|
||||
agents?: AgentManager;
|
||||
stop(): Promise<void>;
|
||||
}
|
||||
@@ -300,7 +300,7 @@ function createTestRuntime(overrides: Partial<AppRuntime> = {}): AppRuntime {
|
||||
config: testConfig,
|
||||
db: createTestDb(),
|
||||
auth: createTestAuth(),
|
||||
hsApi: createTestHsApi(),
|
||||
headscale: createTestHeadscale(),
|
||||
stop: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ with Docker.
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose
|
||||
- Headscale version 0.26.0 or later installed and running
|
||||
- Headscale version 0.27.0 or later installed and running
|
||||
- A [completed configuration file](./index.md#configuration) for Headplane.
|
||||
|
||||
## Installation
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
headplane:
|
||||
image: ghcr.io/tale/headplane:latest
|
||||
healthcheck:
|
||||
test: ["/bin/hp_healthcheck"]
|
||||
test: ["CMD", "/bin/hp_healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 5s
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
# Read-only access to the Docker socket (or a proxy)
|
||||
- "/var/run/docker.sock:/var/run/docker.sock:ro"
|
||||
headscale:
|
||||
image: headscale/headscale:0.26.0
|
||||
image: headscale/headscale:0.27.1
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
@@ -239,7 +239,7 @@ services:
|
||||
- "traefik.http.routers.headplane.entrypoints=websecure"
|
||||
- "traefik.http.routers.headplane.tls=true"
|
||||
headscale:
|
||||
image: headscale/headscale:0.26.0
|
||||
image: headscale/headscale:0.27.1
|
||||
container_name: headscale
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
|
||||
@@ -19,7 +19,7 @@ advanced features, making it suitable for local testing and development.
|
||||
## Prerequisites
|
||||
|
||||
- Docker (and optionally Docker Compose)
|
||||
- Headscale version 0.26.0 or later installed and running
|
||||
- Headscale version 0.27.0 or later installed and running
|
||||
- A [completed configuration file](/index.md#configuration) for Headplane.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -20,7 +20,7 @@ or prefer to avoid containers.
|
||||
- A Linux-based operating system (e.g, Ubuntu, Debian, CentOS, Fedora)
|
||||
- Go version 1.25.1 installed (only needed to build Headplane)
|
||||
- Node.js version 22.16.x and [pnpm](https://pnpm.io/) version 10.4.x installed
|
||||
- Headscale version 0.26.0 or later installed and running
|
||||
- Headscale version 0.27.0 or later installed and running
|
||||
- A [completed configuration file](./index.md#configuration) for Headplane.
|
||||
|
||||
Before building and running Headplane, ensure that the directory defined in
|
||||
|
||||
Generated
+3
-3
@@ -40,11 +40,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776949667,
|
||||
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
|
||||
"lastModified": 1779536132,
|
||||
"narHash": "sha256-q+fF42iv/geEbHfgSzy3tS0FF/EyD6XTZ98E6yxiBO8=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
|
||||
"rev": "3d8f0f3f72a6cd4d93d0ad13203f2ea1cb7e1456",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ in
|
||||
inherit (finalAttrs) pname version src;
|
||||
fetcherVersion = 3;
|
||||
pnpm = pnpm_10;
|
||||
hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
|
||||
hash = "sha256-msrc7poYMCAdhny0qyOUYo1Qt6FKIobtxLJ479E5O0g=";
|
||||
};
|
||||
|
||||
buildPhase = ''
|
||||
|
||||
+1
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "headplane",
|
||||
"version": "0.7.0-beta.3",
|
||||
"version": "0.7.0-beta.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
@@ -15,7 +15,6 @@
|
||||
"docs:dev": "vitepress dev docs",
|
||||
"docs:build": "vitepress build docs",
|
||||
"docs:preview": "vitepress preview docs",
|
||||
"gen:hashes": "tsx tests/generate-openapi-hashes.ts",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt"
|
||||
},
|
||||
@@ -32,7 +31,6 @@
|
||||
"@kubernetes/client-node": "^1.4.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@react-router/node": "^7.14.0",
|
||||
"@readme/openapi-parser": "^6.0.1",
|
||||
"@uiw/react-codemirror": "4.25.9",
|
||||
"arktype": "^2.2.0",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -42,7 +40,6 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.8.0",
|
||||
"mime": "^4.1.0",
|
||||
"openapi-types": "^12.1.3",
|
||||
"react": "19.2.5",
|
||||
"react-codemirror-merge": "4.25.9",
|
||||
"react-dom": "19.2.5",
|
||||
|
||||
Generated
+1
-126
@@ -47,9 +47,6 @@ importers:
|
||||
'@react-router/node':
|
||||
specifier: ^7.14.0
|
||||
version: 7.14.0(react-router@7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(typescript@6.0.2)
|
||||
'@readme/openapi-parser':
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1(openapi-types@12.1.3)
|
||||
'@uiw/react-codemirror':
|
||||
specifier: 4.25.9
|
||||
version: 4.25.9(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.12.3)(@codemirror/state@6.6.0)(@codemirror/view@6.41.0)(@lezer/common@1.5.2))(@codemirror/language@6.12.3)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.41.0)(codemirror@6.0.1(@lezer/common@1.5.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -77,9 +74,6 @@ importers:
|
||||
mime:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
openapi-types:
|
||||
specifier: ^12.1.3
|
||||
version: 12.1.3
|
||||
react:
|
||||
specifier: 19.2.5
|
||||
version: 19.2.5
|
||||
@@ -180,12 +174,6 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@apidevtools/json-schema-ref-parser@14.2.1':
|
||||
resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==}
|
||||
engines: {node: '>= 20'}
|
||||
peerDependencies:
|
||||
'@types/json-schema': ^7.0.15
|
||||
|
||||
'@ark/schema@0.56.0':
|
||||
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
|
||||
|
||||
@@ -998,10 +986,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
'@humanwhocodes/momoa@2.0.4':
|
||||
resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
|
||||
'@iconify-json/simple-icons@1.2.48':
|
||||
resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==}
|
||||
|
||||
@@ -1480,22 +1464,6 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
'@readme/better-ajv-errors@2.4.0':
|
||||
resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
ajv: 4.11.8 - 8
|
||||
|
||||
'@readme/openapi-parser@6.0.1':
|
||||
resolution: {integrity: sha512-uMtwMPVv86Xr5Y6A+QFrxLwWW1irJp3TIz7R3Zs2WaX383MPYX5kaQemPBsaHCu58ZULd+bosNIGUoFW8KM5Ew==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
openapi-types: '>=7'
|
||||
|
||||
'@readme/openapi-schemas@3.1.0':
|
||||
resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@remix-run/node-fetch-server@0.13.0':
|
||||
resolution: {integrity: sha512-1EsNo0ZpgXu/90AWoRZf/oE3RVTUS80tiTUpt+hv5pjtAkw7icN4WskDwz/KdAw5ARbJLMhZBrO1NqThmy/McA==}
|
||||
|
||||
@@ -1844,9 +1812,6 @@ packages:
|
||||
'@types/js-yaml@4.0.9':
|
||||
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
|
||||
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/linkify-it@5.0.0':
|
||||
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
|
||||
|
||||
@@ -2116,17 +2081,6 @@ packages:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv-draft-04@1.0.0:
|
||||
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
||||
peerDependencies:
|
||||
ajv: ^8.5.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@8.18.0:
|
||||
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2738,15 +2692,9 @@ packages:
|
||||
exsolve@1.0.8:
|
||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -2963,9 +2911,6 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -2976,10 +2921,6 @@ packages:
|
||||
engines: {node: '>=18.0.0'}
|
||||
hasBin: true
|
||||
|
||||
jsonpointer@5.0.1:
|
||||
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
@@ -3051,10 +2992,6 @@ packages:
|
||||
resolution: {integrity: sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A==}
|
||||
hasBin: true
|
||||
|
||||
leven@3.1.0:
|
||||
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
libsql@0.5.29:
|
||||
resolution: {integrity: sha512-8lMP8iMgiBzzoNbAPQ59qdVcj6UaE/Vnm+fiwX4doX4Narook0a4GPKWBEv+CR8a1OwbfkgL18uBfBjWdF0Fzg==}
|
||||
cpu: [x64, arm64, wasm32, arm]
|
||||
@@ -3322,9 +3259,6 @@ packages:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
openid-client@6.8.2:
|
||||
resolution: {integrity: sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==}
|
||||
|
||||
@@ -3507,10 +3441,6 @@ packages:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
@@ -3873,6 +3803,7 @@ packages:
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
valibot@1.3.1:
|
||||
@@ -4132,11 +4063,6 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)':
|
||||
dependencies:
|
||||
'@types/json-schema': 7.0.15
|
||||
js-yaml: 4.1.1
|
||||
|
||||
'@ark/schema@0.56.0':
|
||||
dependencies:
|
||||
'@ark/util': 0.56.0
|
||||
@@ -4888,8 +4814,6 @@ snapshots:
|
||||
protobufjs: 7.5.4
|
||||
yargs: 17.7.2
|
||||
|
||||
'@humanwhocodes/momoa@2.0.4': {}
|
||||
|
||||
'@iconify-json/simple-icons@1.2.48':
|
||||
dependencies:
|
||||
'@iconify/types': 2.0.0
|
||||
@@ -5296,28 +5220,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 6.0.2
|
||||
|
||||
'@readme/better-ajv-errors@2.4.0(ajv@8.18.0)':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.0
|
||||
'@babel/runtime': 7.29.2
|
||||
'@humanwhocodes/momoa': 2.0.4
|
||||
ajv: 8.18.0
|
||||
jsonpointer: 5.0.1
|
||||
leven: 3.1.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
'@readme/openapi-parser@6.0.1(openapi-types@12.1.3)':
|
||||
dependencies:
|
||||
'@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15)
|
||||
'@readme/better-ajv-errors': 2.4.0(ajv@8.18.0)
|
||||
'@readme/openapi-schemas': 3.1.0
|
||||
'@types/json-schema': 7.0.15
|
||||
ajv: 8.18.0
|
||||
ajv-draft-04: 1.0.0(ajv@8.18.0)
|
||||
openapi-types: 12.1.3
|
||||
|
||||
'@readme/openapi-schemas@3.1.0': {}
|
||||
|
||||
'@remix-run/node-fetch-server@0.13.0': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.15':
|
||||
@@ -5584,8 +5486,6 @@ snapshots:
|
||||
|
||||
'@types/js-yaml@4.0.9': {}
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/linkify-it@5.0.0': {}
|
||||
|
||||
'@types/markdown-it@14.1.2':
|
||||
@@ -5880,17 +5780,6 @@ snapshots:
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-draft-04@1.0.0(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
@@ -6459,12 +6348,8 @@ snapshots:
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -6673,8 +6558,6 @@ snapshots:
|
||||
|
||||
jsesc@3.0.2: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonpath-plus@10.3.0:
|
||||
@@ -6683,8 +6566,6 @@ snapshots:
|
||||
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
|
||||
jsep: 1.4.0
|
||||
|
||||
jsonpointer@5.0.1: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
@@ -6758,8 +6639,6 @@ snapshots:
|
||||
lefthook-windows-arm64: 2.1.5
|
||||
lefthook-windows-x64: 2.1.5
|
||||
|
||||
leven@3.1.0: {}
|
||||
|
||||
libsql@0.5.29:
|
||||
dependencies:
|
||||
'@neon-rs/load': 0.0.4
|
||||
@@ -7001,8 +6880,6 @@ snapshots:
|
||||
is-inside-container: 1.0.0
|
||||
wsl-utils: 0.1.0
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
openid-client@6.8.2:
|
||||
dependencies:
|
||||
jose: 6.2.2
|
||||
@@ -7256,8 +7133,6 @@ snapshots:
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
+37
-5
@@ -4,7 +4,7 @@
|
||||
// React Router request listener from `@react-router/node`) and serves
|
||||
// static assets out of a directory.
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { stat, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
type IncomingMessage,
|
||||
type RequestListener,
|
||||
@@ -172,6 +172,21 @@ export interface StartOptions {
|
||||
listener: RequestListener;
|
||||
tls?: HttpsServerOptions;
|
||||
logger?: Logger;
|
||||
/**
|
||||
* If set, writes `url` to `path` once the server is accepting
|
||||
* connections. Used by the bundled Docker healthcheck binary to
|
||||
* discover the right scheme, port, and basename without any
|
||||
* duplicate configuration. The caller assembles the URL so that
|
||||
* the runtime stays generic.
|
||||
*/
|
||||
listenFile?: { path: string; url: string };
|
||||
/**
|
||||
* Optional async hook invoked on SIGINT/SIGTERM after the HTTP
|
||||
* server stops accepting new connections but before the process
|
||||
* exits. Use this to dispose long-lived resources (timers,
|
||||
* subprocesses, DB handles).
|
||||
*/
|
||||
onShutdown?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -187,17 +202,34 @@ export function startHttpServer(opts: StartOptions): Server {
|
||||
server.listen(opts.port, opts.host, () => {
|
||||
const proto = opts.tls ? "https" : "http";
|
||||
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
|
||||
|
||||
if (opts.listenFile) {
|
||||
const { path, url } = opts.listenFile;
|
||||
writeFile(path, `${url}\n`, "utf8").catch((err) => {
|
||||
log.error("Failed to write listen file %s: %s", path, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
const shutdown = async (signal: string) => {
|
||||
log.info("Received %s, shutting down...", signal);
|
||||
server.close(() => process.exit(0));
|
||||
server.close(async () => {
|
||||
if (opts.onShutdown) {
|
||||
try {
|
||||
await opts.onShutdown();
|
||||
} catch (err) {
|
||||
log.error("Error during shutdown hook: %o", err);
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force exit if connections don't drain in time.
|
||||
setTimeout(() => process.exit(0), 5_000).unref();
|
||||
};
|
||||
|
||||
process.once("SIGINT", () => shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
||||
process.once("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
+20
-3
@@ -18,6 +18,11 @@ export interface DevServerOptions {
|
||||
publicDir: string;
|
||||
}
|
||||
|
||||
interface AppModule {
|
||||
default: RequestListener;
|
||||
dispose?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function headplaneDevServer(options: DevServerOptions): Plugin {
|
||||
return {
|
||||
name: "headplane:dev-server",
|
||||
@@ -30,6 +35,12 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
|
||||
res.end("Server entry not loaded yet");
|
||||
};
|
||||
|
||||
// Track the last-loaded module identity so we can call its
|
||||
// `dispose` when Vite swaps in a new one on HMR. Without this
|
||||
// the LiveStore and other long-lived services leak intervals
|
||||
// and subprocesses on every reload.
|
||||
let currentModule: AppModule | null = null;
|
||||
|
||||
const composed = composeListener({
|
||||
basename: options.basename,
|
||||
staticRoot: options.publicDir,
|
||||
@@ -43,9 +54,15 @@ export function headplaneDevServer(options: DevServerOptions): Plugin {
|
||||
return () => {
|
||||
server.middlewares.use(async (req, res, next) => {
|
||||
try {
|
||||
const mod = (await server.ssrLoadModule(options.entry)) as {
|
||||
default: RequestListener;
|
||||
};
|
||||
const mod = (await server.ssrLoadModule(options.entry)) as AppModule;
|
||||
if (currentModule && currentModule !== mod) {
|
||||
try {
|
||||
await currentModule.dispose?.();
|
||||
} catch (err) {
|
||||
server.config.logger.warn(`[headplane:dev-server] dispose failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
currentModule = mod;
|
||||
appListener = mod.default;
|
||||
composed(req, res);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
|
||||
import type { OpenAPIV2 } from "openapi-types";
|
||||
import { request } from "undici";
|
||||
|
||||
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
|
||||
|
||||
const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json";
|
||||
const CANONICAL_LOCATION = "app/openapi-canonical-families.json";
|
||||
|
||||
const SPEC_MAP = {
|
||||
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
||||
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
|
||||
"0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
"0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
|
||||
} as const;
|
||||
|
||||
async function hashOpenApiOperations(specUrl: string) {
|
||||
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
|
||||
const res = await request(url);
|
||||
if (res.statusCode !== 200) {
|
||||
console.error("Failed to fetch OpenAPI spec:", res.statusCode);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const body = (await res.body.json()) as OpenAPIV2.Document;
|
||||
return hashOpenApiDocument(body);
|
||||
}
|
||||
|
||||
async function collectCanonicalizedFamilies(
|
||||
newHashes: readonly (readonly [string, Record<string, string>])[],
|
||||
) {
|
||||
const canonicalizedFamilies: Record<string, string[]> = {};
|
||||
for (const [version, hashes] of newHashes) {
|
||||
const signature = JSON.stringify(hashes);
|
||||
let canonical: string | null = null;
|
||||
|
||||
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
|
||||
const existingSignature = JSON.stringify(
|
||||
newHashes.find(([v]) => v === existingCanonical)![1],
|
||||
);
|
||||
|
||||
if (existingSignature === signature) {
|
||||
canonical = existingCanonical;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!canonical) {
|
||||
canonicalizedFamilies[version] = [version];
|
||||
continue;
|
||||
}
|
||||
|
||||
canonicalizedFamilies[canonical].push(version);
|
||||
if (
|
||||
version.localeCompare(canonical, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}) > 0
|
||||
) {
|
||||
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
|
||||
delete canonicalizedFamilies[canonical];
|
||||
}
|
||||
}
|
||||
|
||||
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
|
||||
canonicalizedFamilies[canonical] = family.sort((a, b) =>
|
||||
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
|
||||
);
|
||||
}
|
||||
|
||||
return canonicalizedFamilies;
|
||||
}
|
||||
|
||||
async function writeHashes(hashes: Record<string, Record<string, string>>) {
|
||||
const path = resolve(cwd(), HASH_FILE_LOCATION);
|
||||
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
|
||||
const path = resolve(cwd(), CANONICAL_LOCATION);
|
||||
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
|
||||
}
|
||||
|
||||
const newHashes = await Promise.all(
|
||||
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
|
||||
const hashes = await hashOpenApiOperations(specUrl);
|
||||
return [version, hashes] as const;
|
||||
}),
|
||||
);
|
||||
|
||||
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
|
||||
|
||||
console.log("Writing new OpenAPI operation hashes to file");
|
||||
await writeHashes(Object.fromEntries(newHashes));
|
||||
await writeCanonicalizedFamilies(canonicalizedFamilies);
|
||||
@@ -5,7 +5,7 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
|
||||
describe.for(HS_VERSIONS)("Headscale %s: API Keys", (version) => {
|
||||
test("api keys can be fetched", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const apiKeys = await client.getApiKeys();
|
||||
const apiKeys = await client.apiKeys.list();
|
||||
expect(Array.isArray(apiKeys)).toBe(true);
|
||||
expect(apiKeys.length).toBe(1);
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const tailnetNode = await getNode(version);
|
||||
|
||||
const user = await client.createUser("node-reg@");
|
||||
const node = await client.registerNode(user.name, tailnetNode.authCode);
|
||||
const user = await client.users.create({ name: "node-reg@" });
|
||||
const node = await client.nodes.register(user.name, tailnetNode.authCode);
|
||||
expect(node).toBeDefined();
|
||||
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
|
||||
expect(node.name).toBe(tailnetNode.nodeName);
|
||||
@@ -19,12 +19,12 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
test("nodes can be retrieved", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const { nodeName } = await getNode(version);
|
||||
const nodes = await client.getNodes();
|
||||
const nodes = await client.nodes.list();
|
||||
const node = nodes.find((n) => n.name === nodeName);
|
||||
expect(node).toBeDefined();
|
||||
expect(node?.name).toBe(nodeName);
|
||||
|
||||
const fetchedNode = await client.getNode(node!.id);
|
||||
const fetchedNode = await client.nodes.get(node!.id);
|
||||
expect(fetchedNode).toBeDefined();
|
||||
expect(fetchedNode.id).toBe(node!.id);
|
||||
workingNodeId = node!.id;
|
||||
@@ -35,41 +35,43 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
const { nodeName } = await getNode(version);
|
||||
const newName = `${nodeName}-renamed`;
|
||||
|
||||
await client.renameNode(workingNodeId, newName);
|
||||
const renamedNode = await client.getNode(workingNodeId);
|
||||
await client.nodes.rename(workingNodeId, newName);
|
||||
const renamedNode = await client.nodes.get(workingNodeId);
|
||||
expect(renamedNode).toBeDefined();
|
||||
expect(renamedNode.givenName).toBe(newName);
|
||||
});
|
||||
|
||||
test("nodes can be reassigned to another user", async (context) => {
|
||||
const bootstrap = await getBootstrapClient(version);
|
||||
if (bootstrap.clientHelpers.isAtleast("0.28.0")) {
|
||||
// Reassigning a node owner was removed in 0.28.
|
||||
if (bootstrap.capabilities.nodeOwnerIsImmutable) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const client = await getRuntimeClient(version);
|
||||
const user = await client.createUser("node-reassign@");
|
||||
const user = await client.users.create({ name: "node-reassign@" });
|
||||
|
||||
await client.setNodeUser(workingNodeId, user.id);
|
||||
const reassignedNode = await client.getNode(workingNodeId);
|
||||
// reassignUser is only defined on pre-0.28 clients, hence the guard above.
|
||||
await client.nodes.reassignUser!(workingNodeId, user.id);
|
||||
const reassignedNode = await client.nodes.get(workingNodeId);
|
||||
expect(reassignedNode).toBeDefined();
|
||||
expect(reassignedNode.user.name).toBe(user.name);
|
||||
expect(reassignedNode.user?.name).toBe(user.name);
|
||||
});
|
||||
|
||||
test("nodes can be expired", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
await client.expireNode(workingNodeId);
|
||||
await client.nodes.expire(workingNodeId);
|
||||
|
||||
const expiredNode = await client.getNode(workingNodeId);
|
||||
const expiredNode = await client.nodes.get(workingNodeId);
|
||||
expect(expiredNode).toBeDefined();
|
||||
expect(expiredNode.expiry).toBeDefined();
|
||||
});
|
||||
|
||||
test("nodes can be deleted", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
await client.deleteNode(workingNodeId);
|
||||
await client.nodes.delete(workingNodeId);
|
||||
|
||||
const nodes = await client.getNodes();
|
||||
const nodes = await client.nodes.list();
|
||||
const node = nodes.find((n) => n.id === workingNodeId);
|
||||
expect(node).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "../setup/env";
|
||||
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS } from "../setup/env";
|
||||
|
||||
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
|
||||
test("pre-auth keys can be created", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const preAuthKeyUser = await client.createUser("preauthkeyuser@");
|
||||
const preAuthKeyUser = await client.users.create({ name: "preauthkeyuser@" });
|
||||
expect(preAuthKeyUser).toBeDefined();
|
||||
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
|
||||
|
||||
const expiry = new Date(Date.now() + 3600 * 1000);
|
||||
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
|
||||
const preAuthKey = await client.preAuthKeys.create({
|
||||
user: preAuthKeyUser.id,
|
||||
ephemeral: false,
|
||||
reusable: false,
|
||||
expiration: expiry,
|
||||
aclTags: null,
|
||||
});
|
||||
|
||||
expect(preAuthKey).toBeDefined();
|
||||
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
|
||||
@@ -22,12 +28,18 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
|
||||
test("pre-auth keys can be created with ACL tags", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
|
||||
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
|
||||
expect(preAuthKeyUser).toBeDefined();
|
||||
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
|
||||
|
||||
const aclTags = ["tag:test1", "tag:test2"];
|
||||
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
|
||||
const preAuthKey = await client.preAuthKeys.create({
|
||||
user: preAuthKeyUser.id,
|
||||
ephemeral: true,
|
||||
reusable: true,
|
||||
expiration: null,
|
||||
aclTags,
|
||||
});
|
||||
|
||||
expect(preAuthKey).toBeDefined();
|
||||
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
|
||||
@@ -38,13 +50,19 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
|
||||
test("tag-only pre-auth keys (0.28+)", async (context) => {
|
||||
const bootstrap = await getBootstrapClient(version);
|
||||
if (!bootstrap.clientHelpers.isAtleast("0.28.0")) {
|
||||
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const client = await getRuntimeClient(version);
|
||||
const aclTags = ["tag:server", "tag:prod"];
|
||||
const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags);
|
||||
const preAuthKey = await client.preAuthKeys.create({
|
||||
user: null,
|
||||
ephemeral: false,
|
||||
reusable: true,
|
||||
expiration: null,
|
||||
aclTags,
|
||||
});
|
||||
|
||||
expect(preAuthKey).toBeDefined();
|
||||
expect(preAuthKey.user).toBeNull();
|
||||
@@ -55,44 +73,44 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
|
||||
test("pre-auth keys can be listed", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
|
||||
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
|
||||
expect(preAuthKeyUser).toBeDefined();
|
||||
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
|
||||
|
||||
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
|
||||
const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
|
||||
expect(Array.isArray(preAuthKeys)).toBe(true);
|
||||
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
|
||||
const isAtLeast = await getIsAtLeast(version);
|
||||
if (!isAtLeast("0.28.0")) {
|
||||
const bootstrap = await getBootstrapClient(version);
|
||||
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const client = await getRuntimeClient(version);
|
||||
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
|
||||
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
|
||||
expect(preAuthKeyUser).toBeDefined();
|
||||
|
||||
const allKeys = await client.getAllPreAuthKeys();
|
||||
const allKeys = await client.preAuthKeys.listAll!();
|
||||
expect(Array.isArray(allKeys)).toBe(true);
|
||||
expect(allKeys.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
|
||||
const userSpecificKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
|
||||
for (const userKey of userSpecificKeys) {
|
||||
const found = allKeys.find((k) => k.key === userKey.key);
|
||||
expect(found).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
|
||||
const isAtLeast = await getIsAtLeast(version);
|
||||
if (!isAtLeast("0.28.0")) {
|
||||
test("listAll returns keys with correct structure (0.28+)", async (context) => {
|
||||
const bootstrap = await getBootstrapClient(version);
|
||||
if (!bootstrap.capabilities.preAuthKeysHaveStableIds) {
|
||||
context.skip();
|
||||
}
|
||||
|
||||
const client = await getRuntimeClient(version);
|
||||
const allKeys = await client.getAllPreAuthKeys();
|
||||
const allKeys = await client.preAuthKeys.listAll!();
|
||||
|
||||
for (const key of allKeys) {
|
||||
expect(key.id).toBeDefined();
|
||||
@@ -107,16 +125,16 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
|
||||
|
||||
test("pre-auth keys can be expired", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
|
||||
const [preAuthKeyUser] = await client.users.list({ name: "preauthkeyuser@" });
|
||||
expect(preAuthKeyUser).toBeDefined();
|
||||
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
|
||||
|
||||
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
|
||||
const preAuthKeys = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
|
||||
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
|
||||
const preAuthKeyToExpire = preAuthKeys[0];
|
||||
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire);
|
||||
await client.preAuthKeys.expire(preAuthKeyToExpire);
|
||||
|
||||
const preAuthKeysAfterExpire = await client.getPreAuthKeys(preAuthKeyUser.id);
|
||||
const preAuthKeysAfterExpire = await client.preAuthKeys.listForUser(preAuthKeyUser.id);
|
||||
const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key);
|
||||
expect(expiredKey).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -5,19 +5,19 @@ import { getRuntimeClient, HS_VERSIONS } from "../setup/env";
|
||||
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
test("users can be created", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const user = await client.createUser("tale@");
|
||||
const user = await client.users.create({ name: "tale@" });
|
||||
expect(user).toBeDefined();
|
||||
expect(user.name).toBe("tale@");
|
||||
});
|
||||
|
||||
test("users can be created with attributes", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const user = await client.createUser(
|
||||
"test-user@",
|
||||
"test-user@example.com",
|
||||
"Test User",
|
||||
"https://github.com/tale.png",
|
||||
);
|
||||
const user = await client.users.create({
|
||||
name: "test-user@",
|
||||
email: "test-user@example.com",
|
||||
displayName: "Test User",
|
||||
pictureUrl: "https://github.com/tale.png",
|
||||
});
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.name).toBe("test-user@");
|
||||
@@ -28,14 +28,14 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
|
||||
test("users can be listed", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const users = await client.getUsers();
|
||||
const users = await client.users.list();
|
||||
expect(Array.isArray(users)).toBe(true);
|
||||
expect(users.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("users can be listed by name", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const users = await client.getUsers(undefined, "tale@");
|
||||
const users = await client.users.list({ name: "tale@" });
|
||||
expect(Array.isArray(users)).toBe(true);
|
||||
expect(users.length).toBe(1);
|
||||
expect(users[0].name).toBe("tale@");
|
||||
@@ -43,7 +43,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
|
||||
test("users can be listed by email", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const users = await client.getUsers(undefined, undefined, "test-user@example.com");
|
||||
const users = await client.users.list({ email: "test-user@example.com" });
|
||||
expect(Array.isArray(users)).toBe(true);
|
||||
expect(users.length).toBe(1);
|
||||
expect(users[0].email).toBe("test-user@example.com");
|
||||
@@ -51,24 +51,24 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
|
||||
|
||||
test("users can be renamed", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const usersBefore = await client.getUsers(undefined, "tale@");
|
||||
const usersBefore = await client.users.list({ name: "tale@" });
|
||||
expect(usersBefore.length).toBe(1);
|
||||
const user = usersBefore[0];
|
||||
|
||||
await client.renameUser(user.id, "renamed-user@");
|
||||
const usersAfter = await client.getUsers(undefined, "renamed-user@");
|
||||
await client.users.rename(user.id, "renamed-user@");
|
||||
const usersAfter = await client.users.list({ name: "renamed-user@" });
|
||||
expect(usersAfter.length).toBe(1);
|
||||
expect(usersAfter[0].id).toBe(user.id);
|
||||
});
|
||||
|
||||
test("users can be deleted", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const usersBefore = await client.getUsers(undefined, "test-user@");
|
||||
const usersBefore = await client.users.list({ name: "test-user@" });
|
||||
expect(usersBefore.length).toBe(1);
|
||||
const user = usersBefore[0];
|
||||
|
||||
await client.deleteUser(user.id);
|
||||
const usersAfter = await client.getUsers(undefined, "test-user@");
|
||||
await client.users.delete(user.id);
|
||||
const usersAfter = await client.users.list({ name: "test-user@" });
|
||||
expect(usersAfter.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import canonicals from "~/openapi-canonical-families.json";
|
||||
import { gte } from "~/server/headscale/api/server-version";
|
||||
|
||||
import { getBootstrapClient, getRuntimeClient, HS_VERSIONS, Version } from "../setup/env";
|
||||
|
||||
function getCanonicalVersion(version: Version) {
|
||||
const canonical = Object.entries(canonicals).find(([_, family]) =>
|
||||
family.includes(version),
|
||||
)?.[0] as Version | undefined;
|
||||
|
||||
if (!canonical) {
|
||||
return version;
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
import { getBootstrapClient, HS_VERSIONS, Version } from "../setup/env";
|
||||
|
||||
describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => {
|
||||
test("the runtime client is usable", async () => {
|
||||
const bootstrapper = await getBootstrapClient(version);
|
||||
const runtimeClient = bootstrapper.getRuntimeClient("test-api-key");
|
||||
const runtimeClient = bootstrapper.client("test-api-key");
|
||||
expect(runtimeClient).toBeDefined();
|
||||
});
|
||||
|
||||
test("the runtime client has the correct canonical API version", async () => {
|
||||
test("the server version reported by /version matches the running container", async () => {
|
||||
const bootstrapper = await getBootstrapClient(version);
|
||||
expect(bootstrapper.apiVersion).toBe(getCanonicalVersion(version));
|
||||
expect(bootstrapper.version.unknown).toBe(false);
|
||||
const reported = `${bootstrapper.version.major}.${bootstrapper.version.minor}.${bootstrapper.version.patch}`;
|
||||
expect(reported).toBe(version);
|
||||
});
|
||||
|
||||
test("capabilities are derived correctly from the detected version", async (context) => {
|
||||
const bootstrapper = await getBootstrapClient(version);
|
||||
const v = bootstrapper.version;
|
||||
expect(bootstrapper.capabilities.preAuthKeysHaveStableIds).toBe(gte(v, "0.28.0"));
|
||||
expect(bootstrapper.capabilities.nodeTagsAreFlat).toBe(gte(v, "0.28.0"));
|
||||
expect(bootstrapper.capabilities.nodeOwnerIsImmutable).toBe(gte(v, "0.28.0"));
|
||||
// If a future version is added to HS_VERSIONS before this test is
|
||||
// updated, surface that explicitly rather than passing silently.
|
||||
const known: Version[] = ["0.27.0", "0.27.1", "0.28.0"];
|
||||
if (!known.includes(version)) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("the health check endpoint works", async () => {
|
||||
const client = await getRuntimeClient(version);
|
||||
const health = await client.isHealthy();
|
||||
const bootstrapper = await getBootstrapClient(version);
|
||||
const health = await bootstrapper.health();
|
||||
expect(health).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,8 +81,8 @@ describe("DockerIntegration", () => {
|
||||
socketPath: "/var/run/docker.sock",
|
||||
});
|
||||
|
||||
const mockClient = {
|
||||
isHealthy: async () => {
|
||||
const mockHeadscale = {
|
||||
health: async () => {
|
||||
try {
|
||||
const res = await dockerClient.request({
|
||||
method: "GET",
|
||||
@@ -96,9 +96,9 @@ describe("DockerIntegration", () => {
|
||||
},
|
||||
} as any;
|
||||
|
||||
await integration.onConfigChange(mockClient);
|
||||
await integration.onConfigChange(mockHeadscale);
|
||||
|
||||
const healthy = await mockClient.isHealthy();
|
||||
const healthy = await mockHeadscale.health();
|
||||
expect(healthy).toBe(true);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import hashes from "~/openapi-operation-hashes.json";
|
||||
import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api";
|
||||
import { createHeadscale, type Headscale } from "~/server/headscale/api";
|
||||
|
||||
import { type HeadscaleEnv, startHeadscale } from "./start-headscale";
|
||||
import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
|
||||
|
||||
export type Version = keyof typeof hashes;
|
||||
export const HS_VERSIONS = Object.keys(hashes) as Version[];
|
||||
// The set of Headscale versions integration tests run against. Listed
|
||||
// explicitly (rather than derived from a generated manifest) so the
|
||||
// supported version matrix lives next to the code that uses it.
|
||||
export const HS_VERSIONS = ["0.27.0", "0.27.1", "0.28.0"] as const;
|
||||
export type Version = (typeof HS_VERSIONS)[number];
|
||||
|
||||
interface VersionStateEntry {
|
||||
env: HeadscaleEnv;
|
||||
tailscaleNode: TailscaleNodeEnv;
|
||||
bootstrap: HeadscaleApiInterface;
|
||||
bootstrap: Headscale;
|
||||
}
|
||||
|
||||
const versionState = new Map<Version, VersionStateEntry>();
|
||||
@@ -21,7 +23,7 @@ async function ensureVersion(version: Version) {
|
||||
|
||||
const env = await startHeadscale(version);
|
||||
const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080));
|
||||
const bootstrap = await createHeadscaleInterface(env.apiUrl);
|
||||
const bootstrap = await createHeadscale({ url: env.apiUrl });
|
||||
|
||||
const entry = { env, tailscaleNode, bootstrap };
|
||||
versionState.set(version, entry);
|
||||
@@ -35,12 +37,7 @@ export async function getBootstrapClient(version: Version) {
|
||||
|
||||
export async function getRuntimeClient(version: Version) {
|
||||
const { env, bootstrap } = await ensureVersion(version);
|
||||
return bootstrap.getRuntimeClient(env.apiKey);
|
||||
}
|
||||
|
||||
export async function getIsAtLeast(version: Version) {
|
||||
const { env, bootstrap } = await ensureVersion(version);
|
||||
return bootstrap.clientHelpers.isAtleast;
|
||||
return bootstrap.client(env.apiKey);
|
||||
}
|
||||
|
||||
export async function getNode(version: Version) {
|
||||
@@ -52,7 +49,9 @@ export async function getNode(version: Version) {
|
||||
}
|
||||
|
||||
export async function stopAllVersions() {
|
||||
for (const { env, tailscaleNode } of versionState.values()) {
|
||||
for (const { env, tailscaleNode, bootstrap } of versionState.values()) {
|
||||
await bootstrap.dispose();
|
||||
|
||||
await env.container.stop({
|
||||
remove: true,
|
||||
removeVolumes: true,
|
||||
|
||||
@@ -2,9 +2,9 @@ import { createInterface } from "node:readline";
|
||||
|
||||
import tc from "testcontainers";
|
||||
|
||||
import hashes from "~/openapi-operation-hashes.json";
|
||||
|
||||
export type Version = keyof typeof hashes;
|
||||
// Imported by `env.ts` which owns the canonical version list; keep this
|
||||
// loose to avoid an import cycle.
|
||||
export type Version = string;
|
||||
|
||||
export interface TailscaleNodeEnv {
|
||||
container: tc.StartedTestContainer;
|
||||
|
||||
@@ -78,15 +78,9 @@ describe("linkHeadscaleUser", () => {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
expect(user?.headscale_user_id).toBeNull();
|
||||
});
|
||||
|
||||
test("linkHeadscaleUserBySubject works through subject lookup", async () => {
|
||||
await auth.findOrCreateUser("sub-1");
|
||||
const result = await auth.linkHeadscaleUserBySubject("sub-1", "hs-1");
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reassignSubject", () => {
|
||||
describe("reassignUser", () => {
|
||||
let auth: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -95,9 +89,9 @@ describe("reassignSubject", () => {
|
||||
|
||||
test("updates an existing non-owner role", async () => {
|
||||
await auth.findOrCreateUser("sub-owner");
|
||||
await auth.findOrCreateUser("sub-user");
|
||||
const userId = await auth.findOrCreateUser("sub-user");
|
||||
|
||||
const result = await auth.reassignSubject("sub-user", "admin");
|
||||
const result = await auth.reassignUser(userId, "admin");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const role = await auth.roleForSubject("sub-user");
|
||||
@@ -105,18 +99,15 @@ describe("reassignSubject", () => {
|
||||
});
|
||||
|
||||
test("returns false for owner demotion attempt", async () => {
|
||||
await auth.findOrCreateUser("sub-owner");
|
||||
const ownerId = await auth.findOrCreateUser("sub-owner");
|
||||
|
||||
const result = await auth.reassignSubject("sub-owner", "member");
|
||||
const result = await auth.reassignUser(ownerId, "member");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("creates user with role if subject doesn't exist yet (upsert behavior)", async () => {
|
||||
const result = await auth.reassignSubject("sub-new", "auditor");
|
||||
expect(result).toBe(true);
|
||||
|
||||
const role = await auth.roleForSubject("sub-new");
|
||||
expect(role).toBe("auditor");
|
||||
test("returns false when user does not exist", async () => {
|
||||
const result = await auth.reassignUser("01ARZ3NDEKTSV4RRFFQ69G5FAV", "auditor");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,10 +119,10 @@ describe("transferOwnership", () => {
|
||||
});
|
||||
|
||||
test("swaps roles: owner→admin, target→owner", async () => {
|
||||
await auth.findOrCreateUser("sub-owner");
|
||||
await auth.findOrCreateUser("sub-target");
|
||||
const ownerId = await auth.findOrCreateUser("sub-owner");
|
||||
const targetId = await auth.findOrCreateUser("sub-target");
|
||||
|
||||
const result = await auth.transferOwnership("sub-owner", "sub-target");
|
||||
const result = await auth.transferOwnership(ownerId, targetId);
|
||||
expect(result).toBe(true);
|
||||
|
||||
expect(await auth.roleForSubject("sub-owner")).toBe("admin");
|
||||
@@ -139,24 +130,24 @@ describe("transferOwnership", () => {
|
||||
});
|
||||
|
||||
test("returns false if caller is not owner", async () => {
|
||||
await auth.findOrCreateUser("sub-owner");
|
||||
await auth.findOrCreateUser("sub-other");
|
||||
const ownerId = await auth.findOrCreateUser("sub-owner");
|
||||
const otherId = await auth.findOrCreateUser("sub-other");
|
||||
|
||||
const result = await auth.transferOwnership("sub-other", "sub-owner");
|
||||
const result = await auth.transferOwnership(otherId, ownerId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false if target doesn't exist", async () => {
|
||||
await auth.findOrCreateUser("sub-owner");
|
||||
const ownerId = await auth.findOrCreateUser("sub-owner");
|
||||
|
||||
const result = await auth.transferOwnership("sub-owner", "sub-ghost");
|
||||
const result = await auth.transferOwnership(ownerId, "01ARZ3NDEKTSV4RRFFQ69G5FAV");
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false if target is same user as owner", async () => {
|
||||
await auth.findOrCreateUser("sub-owner");
|
||||
const ownerId = await auth.findOrCreateUser("sub-owner");
|
||||
|
||||
const result = await auth.transferOwnership("sub-owner", "sub-owner");
|
||||
const result = await auth.transferOwnership(ownerId, ownerId);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("Login action validation", () => {
|
||||
const request = mockRequest(formData);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: { getRuntimeClient: vi.fn() },
|
||||
headscale: { client: vi.fn() },
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("Login action validation", () => {
|
||||
const request = mockRequest(formData);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: { getRuntimeClient: vi.fn() },
|
||||
headscale: { client: vi.fn() },
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
|
||||
@@ -87,8 +87,8 @@ describe("Login action validation", () => {
|
||||
.mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
@@ -114,8 +114,8 @@ describe("Login action validation", () => {
|
||||
.mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
@@ -141,8 +141,8 @@ describe("Login action validation", () => {
|
||||
.mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]);
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: vi.fn() },
|
||||
};
|
||||
@@ -173,8 +173,8 @@ describe("Login action validation", () => {
|
||||
const mockCreateSession = vi.fn().mockResolvedValue("session-cookie");
|
||||
|
||||
const mockContext = {
|
||||
hsApi: {
|
||||
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
|
||||
headscale: {
|
||||
client: () => ({ apiKeys: { list: mockGetApiKeys } }),
|
||||
},
|
||||
sessions: { createSession: mockCreateSession },
|
||||
};
|
||||
|
||||
@@ -1,65 +1,59 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { HeadscaleApiInterface } from "~/server/headscale/api";
|
||||
import nodeEndpoints from "~/server/headscale/api/endpoints/nodes";
|
||||
import userEndpoints from "~/server/headscale/api/endpoints/users";
|
||||
import { capabilitiesFor } from "~/server/headscale/api/capabilities";
|
||||
import { makeNodeApi } from "~/server/headscale/api/resources/nodes";
|
||||
import { makeUserApi } from "~/server/headscale/api/resources/users";
|
||||
import { parseServerVersion } from "~/server/headscale/api/server-version";
|
||||
import type { Transport, TransportRequest } from "~/server/headscale/api/transport";
|
||||
|
||||
type ApiFetchArgs = Parameters<HeadscaleApiInterface["clientHelpers"]["apiFetch"]>;
|
||||
|
||||
function createApiClientRecorder() {
|
||||
const calls: Array<{
|
||||
method: ApiFetchArgs[0];
|
||||
apiPath: ApiFetchArgs[1];
|
||||
apiKey: ApiFetchArgs[2];
|
||||
bodyOrQuery: ApiFetchArgs[3];
|
||||
}> = [];
|
||||
|
||||
const client = {
|
||||
isAtleast: () => false,
|
||||
rawFetch: async () => {
|
||||
throw new Error("rawFetch should not be called");
|
||||
},
|
||||
apiFetch: async <T>(
|
||||
method: ApiFetchArgs[0],
|
||||
apiPath: ApiFetchArgs[1],
|
||||
apiKey: ApiFetchArgs[2],
|
||||
bodyOrQuery?: ApiFetchArgs[3],
|
||||
) => {
|
||||
calls.push({ method, apiPath, apiKey, bodyOrQuery });
|
||||
function createTransportRecorder() {
|
||||
const calls: TransportRequest[] = [];
|
||||
const transport: Transport = {
|
||||
async request<T>(opts: TransportRequest): Promise<T> {
|
||||
calls.push(opts);
|
||||
return undefined as T;
|
||||
},
|
||||
} as HeadscaleApiInterface["clientHelpers"];
|
||||
|
||||
return { calls, client };
|
||||
async getPublic<T>(): Promise<T> {
|
||||
throw new Error("getPublic should not be called");
|
||||
},
|
||||
async health() {
|
||||
throw new Error("health should not be called");
|
||||
},
|
||||
async dispose() {},
|
||||
};
|
||||
return { calls, transport };
|
||||
}
|
||||
|
||||
const capabilities = capabilitiesFor(parseServerVersion("0.28.0"));
|
||||
|
||||
describe("Headscale API path encoding", () => {
|
||||
test("encodes node rename names as a single URL segment", async () => {
|
||||
const { calls, client } = createApiClientRecorder();
|
||||
const { calls, transport } = createTransportRecorder();
|
||||
|
||||
await nodeEndpoints(client, "api-key").renameNode("2", "../../1/expire");
|
||||
await makeNodeApi(transport, capabilities, "api-key").rename("2", "../../1/expire");
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
apiPath: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
|
||||
path: "v1/node/2/rename/..%2F..%2F1%2Fexpire",
|
||||
apiKey: "api-key",
|
||||
bodyOrQuery: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("encodes user rename names as a single URL segment", async () => {
|
||||
const { calls, client } = createApiClientRecorder();
|
||||
const { calls, transport } = createTransportRecorder();
|
||||
|
||||
await userEndpoints(client, "api-key").renameUser("3", "../../../user/4/rename/pwned-user@");
|
||||
await makeUserApi(transport, capabilities, "api-key").rename(
|
||||
"3",
|
||||
"../../../user/4/rename/pwned-user@",
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
apiPath: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
|
||||
path: "v1/user/3/rename/..%2F..%2F..%2Fuser%2F4%2Frename%2Fpwned-user%40",
|
||||
apiKey: "api-key",
|
||||
bodyOrQuery: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
// MARK: createHeadscale resilience
|
||||
//
|
||||
// These tests exercise the boot-time behaviour where Headscale may be
|
||||
// unreachable. They use a Node http server to stand in for Headscale so
|
||||
// we control whether `/version` resolves at boot, after a retry, or not
|
||||
// at all.
|
||||
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { AddressInfo } from "node:net";
|
||||
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createHeadscale } from "~/server/headscale/api";
|
||||
|
||||
let servers: Server[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
servers.map(
|
||||
(s) =>
|
||||
new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
servers = [];
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function startVersionServer(handler: (req: Request) => Response): Promise<{
|
||||
url: string;
|
||||
callCount: () => number;
|
||||
setResponse: (handler: (req: Request) => Response) => void;
|
||||
}> {
|
||||
let current = handler;
|
||||
let count = 0;
|
||||
const server = createServer((req, res) => {
|
||||
count++;
|
||||
const fullUrl = `http://${req.headers.host}${req.url}`;
|
||||
const request = new Request(fullUrl, { method: req.method });
|
||||
const response = current(request);
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers));
|
||||
response.text().then((body) => res.end(body));
|
||||
});
|
||||
servers.push(server);
|
||||
return new Promise((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
resolve({
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
callCount: () => count,
|
||||
setResponse: (h) => {
|
||||
current = h;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("createHeadscale boot-time resilience", () => {
|
||||
test("detects version when Headscale is reachable at boot", async () => {
|
||||
const { url } = await startVersionServer(() =>
|
||||
Response.json({ version: "v0.28.0", commit: "abc", buildTime: "", go: "", dirty: false }),
|
||||
);
|
||||
|
||||
const headscale = await createHeadscale({ url });
|
||||
expect(headscale.version.raw).toBe("v0.28.0");
|
||||
expect(headscale.version.unknown).toBe(false);
|
||||
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true);
|
||||
await headscale.dispose();
|
||||
});
|
||||
|
||||
test("boots with permissive defaults when /version fails at boot", async () => {
|
||||
const { url } = await startVersionServer(() => new Response("nope", { status: 503 }));
|
||||
|
||||
const headscale = await createHeadscale({ url, retryIntervalMs: 1_000_000 });
|
||||
// Should not throw, and should default to "unknown" with permissive caps.
|
||||
expect(headscale.version.unknown).toBe(true);
|
||||
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true);
|
||||
await headscale.dispose();
|
||||
});
|
||||
|
||||
test("a 404 from /version is treated as below the supported floor and keeps retrying", async () => {
|
||||
const probe = await startVersionServer(() => new Response("not found", { status: 404 }));
|
||||
|
||||
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 });
|
||||
// 404 = pre-0.27 Headscale. We do NOT settle on an inferred version;
|
||||
// capabilities stay permissive and the background retry keeps probing
|
||||
// so an in-place upgrade is picked up without a Headplane restart.
|
||||
expect(headscale.version.unknown).toBe(true);
|
||||
|
||||
probe.setResponse(() =>
|
||||
Response.json({ version: "v0.28.0", commit: "x", buildTime: "", go: "", dirty: false }),
|
||||
);
|
||||
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline && headscale.version.unknown) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
|
||||
expect(headscale.version.unknown).toBe(false);
|
||||
expect(headscale.version.raw).toBe("v0.28.0");
|
||||
await headscale.dispose();
|
||||
});
|
||||
|
||||
test("background retry promotes to detected version once Headscale comes online", async () => {
|
||||
const probe = await startVersionServer(() => new Response("nope", { status: 503 }));
|
||||
|
||||
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 20 });
|
||||
expect(headscale.version.unknown).toBe(true);
|
||||
|
||||
probe.setResponse(() =>
|
||||
Response.json({ version: "v0.27.1", commit: "x", buildTime: "", go: "", dirty: false }),
|
||||
);
|
||||
|
||||
// Poll for up to 2s for the background retry to pick up the new response.
|
||||
const deadline = Date.now() + 2000;
|
||||
while (Date.now() < deadline && headscale.version.unknown) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
|
||||
expect(headscale.version.unknown).toBe(false);
|
||||
expect(headscale.version.raw).toBe("v0.27.1");
|
||||
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(false);
|
||||
await headscale.dispose();
|
||||
});
|
||||
|
||||
test("dispose cancels the background retry", async () => {
|
||||
const probe = await startVersionServer(() => new Response("nope", { status: 503 }));
|
||||
|
||||
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 });
|
||||
await headscale.dispose();
|
||||
|
||||
const before = probe.callCount();
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
// At most a tiny number of additional calls if a retry was already
|
||||
// queued at dispose time, but the cadence should have stopped.
|
||||
expect(probe.callCount() - before).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { capabilitiesFor } from "~/server/headscale/api/capabilities";
|
||||
import {
|
||||
formatServerVersion,
|
||||
gte,
|
||||
parseServerVersion,
|
||||
} from "~/server/headscale/api/server-version";
|
||||
|
||||
describe("parseServerVersion", () => {
|
||||
test("parses release versions with and without a leading v", () => {
|
||||
expect(parseServerVersion("0.28.0")).toMatchObject({
|
||||
major: 0,
|
||||
minor: 28,
|
||||
patch: 0,
|
||||
prerelease: undefined,
|
||||
unknown: false,
|
||||
});
|
||||
expect(parseServerVersion("v0.27.1")).toMatchObject({
|
||||
major: 0,
|
||||
minor: 27,
|
||||
patch: 1,
|
||||
unknown: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("captures prerelease and build metadata", () => {
|
||||
const v = parseServerVersion("v0.28.0-beta.1+abcdef");
|
||||
expect(v.major).toBe(0);
|
||||
expect(v.minor).toBe(28);
|
||||
expect(v.patch).toBe(0);
|
||||
expect(v.prerelease).toBe("beta.1");
|
||||
expect(v.build).toBe("abcdef");
|
||||
expect(v.unknown).toBe(false);
|
||||
});
|
||||
|
||||
test("flags unparseable versions (e.g. dev builds)", () => {
|
||||
const v = parseServerVersion("dev");
|
||||
expect(v.unknown).toBe(true);
|
||||
expect(v.raw).toBe("dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("gte", () => {
|
||||
test("compares plain versions correctly", () => {
|
||||
const v = parseServerVersion("0.28.0");
|
||||
expect(gte(v, "0.27.0")).toBe(true);
|
||||
expect(gte(v, "0.28.0")).toBe(true);
|
||||
expect(gte(v, "0.29.0")).toBe(false);
|
||||
});
|
||||
|
||||
test("ignores prerelease tags so betas get modern capabilities", () => {
|
||||
// 0.28.0-beta.1 ships every wire format change that 0.28.0 does, so
|
||||
// strict semver (where 0.28.0-beta.1 < 0.28.0) would lock beta users
|
||||
// out of features their server actually supports.
|
||||
const beta = parseServerVersion("0.28.0-beta.1");
|
||||
expect(gte(beta, "0.28.0")).toBe(true);
|
||||
expect(gte(beta, "0.27.0")).toBe(true);
|
||||
});
|
||||
|
||||
test("unknown versions are treated as the newest known release", () => {
|
||||
const dev = parseServerVersion("dev");
|
||||
expect(gte(dev, "9.9.9")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatServerVersion", () => {
|
||||
test("renders release and prerelease versions without a leading v", () => {
|
||||
expect(formatServerVersion(parseServerVersion("v0.28.0"))).toBe("0.28.0");
|
||||
expect(formatServerVersion(parseServerVersion("0.28.0-beta.1"))).toBe("0.28.0-beta.1");
|
||||
});
|
||||
|
||||
test("falls back to the raw string for unknown versions", () => {
|
||||
expect(formatServerVersion(parseServerVersion("dev"))).toBe("dev");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilitiesFor", () => {
|
||||
test("0.28.0 enables every modern flag", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.28.0"));
|
||||
expect(caps).toEqual({
|
||||
preAuthKeysHaveStableIds: true,
|
||||
nodeTagsAreFlat: true,
|
||||
nodeOwnerIsImmutable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("0.28.0-beta.1 matches 0.28.0 capabilities (prerelease gating)", () => {
|
||||
expect(capabilitiesFor(parseServerVersion("0.28.0-beta.1"))).toEqual(
|
||||
capabilitiesFor(parseServerVersion("0.28.0")),
|
||||
);
|
||||
});
|
||||
|
||||
test("0.27.1 lacks every 0.28-gated capability", () => {
|
||||
const caps = capabilitiesFor(parseServerVersion("0.27.1"));
|
||||
expect(caps.preAuthKeysHaveStableIds).toBe(false);
|
||||
expect(caps.nodeTagsAreFlat).toBe(false);
|
||||
expect(caps.nodeOwnerIsImmutable).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user