Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,7 @@ An instance is a name. Its machine config and all of its state live under `~/.fo
| `minGraphqlPoints` | no | 500 |
| `notify` | no | none (搂7) |
| `webAuth` | no | none: the page is localhost-only |
| `webHosts` | no | none: the page is localhost-only |

`workDir` absent means worktrees live at `<repoDir>/.worktrees/<issue>`, so a checkout is
self-contained; add `.worktrees/` to the repository's `.gitignore`. `model` is always passed as
Expand All @@ -598,6 +599,27 @@ check is dropped, and `foreman stop`/`go`/`model`/`cap` send them too. Then
The password is a credential like the Slack webhook: it is never logged or served. Restart the
daemon after changing it.

To reach the page over a private network without credentials, list the names this Mac answers to
on that network:

```json
"webHosts": ["mini", "100.84.252.56"]
```

Each entry is a hostname or an IP literal. The page accepts every entry as a `Host` at
`webPort`, so `http://mini:8090` and `http://100.84.252.56:8090` both load it; an IPv6 literal is
written bare in the list and sent in brackets by the browser (`http://[fd7a::1]:8090`). IP literals
are also listened on, next to `127.0.0.1`, which is always bound. Hostnames are only matched against
`Host`; the daemon never resolves or binds them, so list the IP the name resolves to as well. An
address this Mac does not hold when the daemon starts (the network interface is down, say) logs a
warning and the page stays up on the others; restart the daemon once the interface is back. Each
bound address logs a `web page listening` line with its URL.

Anyone who can reach a listed address gets unauthenticated control of the page: stop, go, model,
cap, unblock and the owner actions. List only addresses on a private network you trust. With
`webAuth` also set, credentials replace the `Host` check as above, and `webHosts` only adds the
listen addresses. Restart the daemon after changing it.

### Which instance a command means

Every command that needs an instance resolves it in this order and stops at the first hit:
Expand Down
1 change: 1 addition & 0 deletions src/cli/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ export async function runDaemon(
const web = await startWebServer({
port: cfg.webPort,
auth: cfg.webAuth,
hosts: cfg.webHosts,
status: () =>
describeStatus({
state: store.get(),
Expand Down
12 changes: 12 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,15 @@ describe("webAuth", () => {
).toThrow();
});
});

describe("webHosts", () => {
it("is absent by default and accepts hostnames and IP literals", () => {
expect(parseConfig(JSON.stringify(base)).webHosts).toBeUndefined();
const cfg = parseConfig(JSON.stringify({ ...base, webHosts: ["mini", "100.84.252.56"] }));
expect(cfg.webHosts).toEqual(["mini", "100.84.252.56"]);
});
it("rejects an empty entry and a non-list", () => {
expect(() => parseConfig(JSON.stringify({ ...base, webHosts: [""] }))).toThrow();
expect(() => parseConfig(JSON.stringify({ ...base, webHosts: "mini" }))).toThrow();
});
});
7 changes: 7 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ export const ConfigSchema = z.object({
* a credential like `slackWebhookUrl`: never logged, never served.
*/
webAuth: WebAuthSchema.optional(),
/**
* Extra names the page answers to, each a hostname or an IP literal, so a private network can
* reach it without `webAuth`. Every entry is an accepted `Host` at `webPort`; IP literals are
* also listened on, next to `127.0.0.1`. Hostnames are never resolved or bound. Absent, the page
* is localhost-only. Anyone who can reach a listed address controls the page unauthenticated.
*/
webHosts: z.array(z.string().min(1)).optional(),
});
export type ForemanConfig = Omit<z.infer<typeof ConfigSchema>, "workDir"> & {
owner: string;
Expand Down
126 changes: 124 additions & 2 deletions src/web.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@ import { readFileSync } from "node:fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { FeedEntry } from "./feed.ts";
import type { NextReport } from "./next.ts";
import { CAP_CHOICES, MODEL_CHOICES } from "./state-file.ts";
import type { StatusReport } from "./status.ts";
import { basicAuthorization, createWebServer, isLocalHost } from "./web.ts";
import {
basicAuthorization,
createWebServer,
isLocalHost,
listenAddresses,
startWebServer,
type WebDeps,
} from "./web.ts";

const report: StatusReport = {
host: "mac-a",
Expand Down Expand Up @@ -394,6 +401,121 @@ describe("isLocalHost", () => {
expect(isLocalHost("evil.example:8090", 8090)).toBe(false);
expect(isLocalHost(undefined, 8090)).toBe(false);
});
it("accepts nothing beyond loopback when webHosts is absent or empty", () => {
expect(isLocalHost("mini:8090", 8090)).toBe(false);
expect(isLocalHost("mini:8090", 8090, [])).toBe(false);
});
it("accepts each webHosts entry at the page's port, and nothing else", () => {
const hosts = ["mini", "100.84.252.56", "fd7a:115c::1"];
expect(isLocalHost("mini:8090", 8090, hosts)).toBe(true);
expect(isLocalHost("MINI:8090", 8090, hosts)).toBe(true);
expect(isLocalHost("100.84.252.56:8090", 8090, hosts)).toBe(true);
expect(isLocalHost("127.0.0.1:8090", 8090, hosts)).toBe(true);
expect(isLocalHost("mini:8091", 8090, hosts)).toBe(false);
expect(isLocalHost("mini", 8090, hosts)).toBe(false);
expect(isLocalHost("evil.example:8090", 8090, hosts)).toBe(false);
expect(isLocalHost("mini.evil.example:8090", 8090, hosts)).toBe(false);
});
it("expects an IPv6 entry in brackets, the way a browser sends it", () => {
const hosts = ["fd7a:115c::1"];
expect(isLocalHost("[fd7a:115c::1]:8090", 8090, hosts)).toBe(true);
expect(isLocalHost("fd7a:115c::1:8090", 8090, hosts)).toBe(false);
expect(isLocalHost("[fd7a:115c::1]:8091", 8090, hosts)).toBe(false);
});
});

describe("listenAddresses", () => {
it("is loopback alone when webHosts is absent", () => {
expect(listenAddresses(undefined)).toEqual(["127.0.0.1"]);
expect(listenAddresses([])).toEqual(["127.0.0.1"]);
});
it("adds the IP literals, never the hostnames, and binds each address once", () => {
expect(
listenAddresses(["mini", "100.84.252.56", "fd7a:115c::1", "127.0.0.1", "100.84.252.56"]),
).toEqual(["127.0.0.1", "100.84.252.56", "fd7a:115c::1"]);
});
});

describe("web server with webHosts", () => {
const deps: WebDeps = {
port: 0,
hosts: ["mini", "::1"],
status: () => report,
next: async () => nextReport,
act: async () => "",
feed: () => [],
owner: async () => "",
ownerItems: () => [],
needsYouItems: () => [],
unblock: async () => "",
setModel: async () => "",
setCap: async () => "",
refresh: async () => "",
phaseTasks: () => [],
setTaskModel: async () => "",
modelChoices: () => [],
html: "<title>Foreman</title>",
};

/** node:http so the Host header can be set. */
const get = (address: string, port: number, host: string) =>
new Promise<number>((resolve, reject) => {
const req = http.request(
{ host: address, port, path: "/api/status", headers: { host } },
(res) => {
res.resume();
res.on("end", () => resolve(res.statusCode ?? 0));
},
);
req.on("error", reject);
req.end();
});

it("answers a listed Host and still refuses a foreign one", async () => {
const server = createWebServer(deps);
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
const port = (server.address() as AddressInfo).port;
try {
expect(await get("127.0.0.1", port, `mini:${port}`)).toBe(200);
expect(await get("127.0.0.1", port, `[::1]:${port}`)).toBe(200);
expect(await get("127.0.0.1", port, `evil.example:${port}`)).toBe(403);
} finally {
server.close();
}
});

it("listens on loopback and on each IP literal, and closes them together", async () => {
const server = await startWebServer(deps);
if (!server) throw new Error("web page not started");
const port = (server.address() as AddressInfo).port;
try {
expect(await get("127.0.0.1", port, `127.0.0.1:${port}`)).toBe(200);
expect(await get("::1", port, `[::1]:${port}`)).toBe(200);
} finally {
await new Promise((r) => server.close(r));
}
await expect(get("::1", port, `[::1]:${port}`)).rejects.toThrow();
});

it("warns and keeps loopback when an extra address cannot be bound", async () => {
const lines: string[] = [];
const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => {
lines.push(String(chunk));
return true;
});
// 192.0.2.0/24 is reserved for documentation, so no interface ever holds it.
const server = await startWebServer({ ...deps, hosts: ["192.0.2.1"] });
spy.mockRestore();
if (!server) throw new Error("web page not started");
const port = (server.address() as AddressInfo).port;
try {
expect(await get("127.0.0.1", port, `127.0.0.1:${port}`)).toBe(200);
const warn = lines.map((l) => JSON.parse(l)).find((l) => l.level === "warn");
expect(warn).toMatchObject({ msg: "web page not started", address: "192.0.2.1" });
} finally {
server.close();
}
});
});

describe("web.html", () => {
Expand Down
65 changes: 56 additions & 9 deletions src/web.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { timingSafeEqual } from "node:crypto";
import { readFileSync } from "node:fs";
import http from "node:http";
import type { AddressInfo } from "node:net";
import { type AddressInfo, isIP } from "node:net";
import { join } from "node:path";
import { z } from "zod";
import type { WebAuth } from "./config.ts";
Expand Down Expand Up @@ -31,6 +31,11 @@ export interface WebDeps {
* tunnel can reach the page; absent, the page stays localhost-only (spec 搂7).
*/
auth?: WebAuth;
/**
* Extra names the page answers to (hostnames or IP literals). Each is an accepted Host at the
* page's port; IP literals are also listened on next to 127.0.0.1. Absent, localhost only.
*/
hosts?: string[];
status: () => StatusReport;
next: () => Promise<NextReport>;
act: (cmd: WebCommand) => Promise<string>;
Expand Down Expand Up @@ -59,9 +64,31 @@ export interface WebDeps {
html?: string;
}

export function isLocalHost(hostHeader: string | undefined, port: number): boolean {
/** An entry as a browser writes it in `Host`: an IPv6 literal goes in brackets. */
function hostForm(entry: string): string {
return isIP(entry) === 6 ? `[${entry}]` : entry;
}

/**
* Whether `Host` names this page: loopback, or one of `hosts`, at exactly this port. The DNS
* rebinding guard (spec 搂7): a hostile page's requests carry the hostile page's name.
*/
export function isLocalHost(
hostHeader: string | undefined,
port: number,
hosts: string[] = [],
): boolean {
if (!hostHeader) return false;
return hostHeader === `127.0.0.1:${port}` || hostHeader === `localhost:${port}`;
const got = hostHeader.toLowerCase();
return ["127.0.0.1", "localhost", ...hosts].some(
(h) => got === `${hostForm(h).toLowerCase()}:${port}`,
);
}

/** Where the page listens: 127.0.0.1 always, then each IP literal in `hosts`, once each. */
export function listenAddresses(hosts: string[] | undefined): string[] {
const ips = (hosts ?? []).filter((h) => isIP(h) !== 0);
return [...new Set(["127.0.0.1", ...ips])];
}

/** The `Authorization` value a client sends for these credentials; what `ctl` uses too. */
Expand Down Expand Up @@ -137,7 +164,7 @@ export function createWebServer(d: WebDeps): http.Server {
res.setHeader("www-authenticate", 'Basic realm="foreman"');
return json(res, 401, { ok: false, error: "unauthorized" });
}
} else if (!isLocalHost(req.headers.host, bound)) {
} else if (!isLocalHost(req.headers.host, bound, d.hosts)) {
// Spec 搂7: every route is localhost-only, not just the POST actions.
return json(res, 403, { ok: false, error: "localhost only" });
}
Expand Down Expand Up @@ -224,17 +251,37 @@ export function createWebServer(d: WebDeps): http.Server {
return server;
}

/** Listens on 127.0.0.1:<port>. A busy port is a warning, not a failure. */
export function startWebServer(d: WebDeps): Promise<http.Server | null> {
/** Listens on one address; a failure is a warning, not a failure, and resolves null. */
function listenOn(d: WebDeps, port: number, address: string): Promise<http.Server | null> {
return new Promise((resolve) => {
const server = createWebServer(d);
server.once("error", (err: NodeJS.ErrnoException) => {
log("warn", "web page not started", { port: d.port, error: err.code ?? err.message });
log("warn", "web page not started", { port, address, error: err.code ?? err.message });
resolve(null);
});
server.listen(d.port, "127.0.0.1", () => {
log("info", "web page listening", { url: `http://127.0.0.1:${d.port}` });
server.listen(port, address, () => {
const bound = (server.address() as AddressInfo).port;
log("info", "web page listening", { url: `http://${hostForm(address)}:${bound}` });
resolve(server);
});
});
}

/**
* Listens on 127.0.0.1:<port>, then on each IP literal in `hosts` at the same port. A busy port
* or an address this Mac does not hold (its interface is down) is a warning, not a failure; the
* extra addresses are only tried once loopback is up. Closing the returned server closes them all.
*/
export async function startWebServer(d: WebDeps): Promise<http.Server | null> {
const [loopback, ...extra] = listenAddresses(d.hosts);
const server = await listenOn(d, d.port, loopback ?? "127.0.0.1");
if (!server) return null;
const port = (server.address() as AddressInfo).port;
const others = (await Promise.all(extra.map((a) => listenOn(d, port, a)))).filter(
(s): s is http.Server => s !== null,
);
server.once("close", () => {
for (const s of others) s.close();
});
return server;
}
Loading