From a95231a4d05bf9a6b7366272d3f7a1b8be8e4777 Mon Sep 17 00:00:00 2001 From: Tim Martin Date: Sat, 30 May 2026 06:14:17 -0500 Subject: [PATCH 1/3] add /health endpoint --- CHANGELOG.md | 6 +++++- Dockerfile | 3 +++ docs/API.md | 29 ++++++++++++++++++++++++++++ src/backend/handlers/health.ts | 35 ++++++++++++++++++++++++++++++++++ src/backend/types.ts | 13 ++++++++++--- src/index.tsx | 4 ++++ 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 src/backend/handlers/health.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 274f369..e0fe1af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,15 @@ ## [Unreleased] +- Add new healthcheck endpoint `GET /health` that is used in as the Docker + image's default healthcheck in + [#xx](https://github.com/t-mart/mousehole/pull/xx) - Greatly reduce the size of the docker image in [#103](https://github.com/t-mart/mousehole/pull/103) - Harden the HTTP and WebSocket boundary with public state serialization, authentication, Host/Origin checks, JSON content-type enforcement, and request - body size limits in [2ac082b](https://github.com/t-mart/mousehole/commit/2ac082b) + body size limits in + [2ac082b](https://github.com/t-mart/mousehole/commit/2ac082b) ## [v0.3.1](https://github.com/t-mart/mousehole/releases/tag/v0.3.1) - 2026-05-23 diff --git a/Dockerfile b/Dockerfile index 6f3e45d..c4519d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,7 @@ COPY --from=install ${BUN_INSTALL_DIR}/node_modules node_modules COPY package.json bunfig.toml ./ COPY src ./src +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD bun -e "process.exit((await fetch('http://localhost:5010/health')).ok ? 0 : 1)" + CMD ["bun", "run", "src/index.tsx"] diff --git a/docs/API.md b/docs/API.md index f3e3979..479831e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -120,6 +120,8 @@ Example response bodies: ### `GET /ok` +**This endpoint will soon be deprecated in favor of `/health`.** + A convenience endpoint to check if MAM needs to be updated with the host IP address. @@ -141,3 +143,30 @@ Example response bodies: If `ok` is true, then the status code is 200. If `ok` is false, then the status code is 503. + +## `/health` + +### `GET /health` + +Health check endpoint. Returns 200 when no MAM update is needed, 503 otherwise. + +Example response bodies: + +- ```json + { + "ok": true + } + ``` + +- ```json + { + "ok": false, + "neededUpdateReason": "ip-changed" + } + ``` + +Possible `neededUpdateReason` values: `no-last-response`, `last-response-error`, +`ip-changed`, `asn-changed`, `cookie-changed`, `response-stale`. + +If `ok` is true, then the status code is 200. If `ok` is false, then the status +code is 503. diff --git a/src/backend/handlers/health.ts b/src/backend/handlers/health.ts new file mode 100644 index 0000000..2e74831 --- /dev/null +++ b/src/backend/handlers/health.ts @@ -0,0 +1,35 @@ +import type { + GetHealthResponseBody, + JSONResponseArgs, +} from "#backend/types.ts"; + +import { getHostInfo } from "#backend/external-api/host-info.ts"; +import { stateFile } from "#backend/store.ts"; +import { getUpdateReason } from "#backend/update.ts"; + +export async function handleGetHealth(): Promise< + JSONResponseArgs +> { + const state = await stateFile.readIfExists(); + const hostInfo = await getHostInfo(); + + const neededUpdateReason = getUpdateReason(state, hostInfo, false); + const ok = neededUpdateReason === undefined; + + if (ok) { + return { + body: { ok: true }, + init: { status: 200 }, + }; + } + + return { + body: { + ok, + neededUpdateReason, + }, + init: { + status: 503, + }, + }; +} diff --git a/src/backend/types.ts b/src/backend/types.ts index d75de99..5e1c8e3 100644 --- a/src/backend/types.ts +++ b/src/backend/types.ts @@ -122,9 +122,7 @@ export const publicSerializedStateSchema = z.object({ lastUpdate: serializedUpdateSchema.optional(), }); -export type PublicSerializedState = z.infer< - typeof publicSerializedStateSchema ->; +export type PublicSerializedState = z.infer; export const getStateResponseBodySchema = publicSerializedStateSchema.extend({ host: hostInfoSchema, @@ -192,6 +190,15 @@ export type GetOkResponseBody = { reason: OkResponseUpdateReason; }; +export type GetHealthResponseBody = + | { + ok: false; + neededUpdateReason: UpdateReason; + } + | { + ok: true; + }; + // // Other // diff --git a/src/index.tsx b/src/index.tsx index 3df7517..5256407 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -4,6 +4,7 @@ import type { JSONResponseArgs } from "#backend/types.ts"; import { config, stateDirPathDeprecationWarning } from "#backend/config.ts"; import { toJSONResponseArgs } from "#backend/error.ts"; +import { handleGetHealth } from "#backend/handlers/health.ts"; import { handlePostLogin } from "#backend/handlers/login.ts"; import { handleGetOk } from "#backend/handlers/ok.ts"; import { handleGetState, handlePutState } from "#backend/handlers/state.ts"; @@ -119,6 +120,9 @@ const server = Bun.serve({ [okEndpointPath]: { GET: async () => makeJSONResponse(await handleGetOk()), }, + "/health": { + GET: async () => makeJSONResponse(await handleGetHealth()), + }, "/web": index, "/web/ws": (request, server) => { const boundaryResponse = guardProtectedRequest(request, { From afdffdd60dfba9de67b55b19ca3bb5033afe0a85 Mon Sep 17 00:00:00 2001 From: Tim Martin Date: Sat, 30 May 2026 06:15:52 -0500 Subject: [PATCH 2/3] update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0fe1af..033857f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Add new healthcheck endpoint `GET /health` that is used in as the Docker image's default healthcheck in - [#xx](https://github.com/t-mart/mousehole/pull/xx) + [#104](https://github.com/t-mart/mousehole/pull/104) - Greatly reduce the size of the docker image in [#103](https://github.com/t-mart/mousehole/pull/103) - Harden the HTTP and WebSocket boundary with public state serialization, From 0cce9625613036abd5f44b20d2421737bf6658af Mon Sep 17 00:00:00 2001 From: Tim Martin Date: Sat, 30 May 2026 06:25:55 -0500 Subject: [PATCH 3/3] add tests --- tests/health.test.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/health.test.ts diff --git a/tests/health.test.ts b/tests/health.test.ts new file mode 100644 index 0000000..18ee26a --- /dev/null +++ b/tests/health.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test"; +import { Temporal } from "temporal-polyfill"; + +import type { HostInfo, State } from "../src/backend/types.ts"; + +import { getUpdateReason } from "../src/backend/update.ts"; + +// getUpdateReason is the pure core of handleGetHealth — it decides whether an +// update is needed and why. Testing it directly covers all health check +// outcomes without needing to mock the file system or network calls. + +const hostInfo: HostInfo = { + ip: "1.2.3.4", + asn: 12_345, + as: "TestASN", +}; + +const goodState: State = { + currentCookie: "cookie", + lastMam: { + request: { + cookie: "cookie", + at: Temporal.Now.zonedDateTimeISO(), + }, + response: { + cookie: "cookie", + httpStatus: 200, + body: { + Success: true, + msg: "No change", + ip: "1.2.3.4", + ASN: 12_345, + AS: "TestASN", + }, + }, + }, +}; + +describe("health check logic", () => { + test("healthy: returns undefined when everything matches", () => { + expect(getUpdateReason(goodState, hostInfo, false)).toBeUndefined(); + }); + + test("no-last-response: state is undefined", () => { + expect(getUpdateReason(undefined, hostInfo, false)).toBe("no-last-response"); + }); + + test("no-last-response: state exists but has no lastMam", () => { + expect(getUpdateReason({ currentCookie: "cookie" }, hostInfo, false)).toBe("no-last-response"); + }); + + test("last-response-error: last MAM response was not 200", () => { + const state: State = { + ...goodState, + lastMam: { + ...goodState.lastMam!, + response: { ...goodState.lastMam!.response, httpStatus: 500 }, + }, + }; + expect(getUpdateReason(state, hostInfo, false)).toBe("last-response-error"); + }); + + test("ip-changed: host IP differs from last response", () => { + expect(getUpdateReason(goodState, { ...hostInfo, ip: "9.9.9.9" }, false)).toBe("ip-changed"); + }); + + test("asn-changed: ASN differs from last response", () => { + expect(getUpdateReason(goodState, { ...hostInfo, asn: 99_999 }, false)).toBe("asn-changed"); + }); + + test("cookie-changed: current cookie differs from last response cookie", () => { + const state: State = { ...goodState, currentCookie: "new-cookie" }; + expect(getUpdateReason(state, hostInfo, false)).toBe("cookie-changed"); + }); + + test("response-stale: last response is older than the stale threshold", () => { + const state: State = { + ...goodState, + lastMam: { + ...goodState.lastMam!, + request: { + ...goodState.lastMam!.request, + at: Temporal.Now.zonedDateTimeISO().subtract({ days: 2 }), + }, + }, + }; + expect(getUpdateReason(state, hostInfo, false)).toBe("response-stale"); + }); +});