Skip to content

Commit

Permalink
[miniflare] fix: ensure magic proxy works when starting on non-local …
Browse files Browse the repository at this point in the history
…`host`s, and IPv6 addresses can be used as `host`s (#5133)

* fix: ensure magic proxy works when starting on non-local `host`

* fix: ensure IPv6 addresses can be used as `host`s
  • Loading branch information
mrbbot committed Mar 1, 2024
1 parent 82a3f94 commit 42bcc72
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 53 deletions.
7 changes: 7 additions & 0 deletions .changeset/silent-geese-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

fix: ensure internals can access `workerd` when starting on non-local `host`

Previously, if Miniflare was configured to start on a `host` that wasn't `127.0.0.1`, `::1`, `*`, `::`, or `0.0.0.0`, calls to `Miniflare` API methods relying on the magic proxy (e.g. `getKVNamespace()`, `getWorker()`, etc.) would fail. This change ensures `workerd` is always accessible to Miniflare's internals. This also fixes `wrangler dev` when using local network address such as `192.168.0.10` with the `--ip` flag.
7 changes: 7 additions & 0 deletions .changeset/tender-nails-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"miniflare": patch
---

fix: ensure IPv6 addresses can be used as `host`s

Previously, if Miniflare was configured to start on an IPv6 `host`, it could crash. This change ensures IPv6 addresses are handled correctly. This also fixes `wrangler dev` when using IPv6 addresses such as `::1` with the `--ip` flag.
41 changes: 13 additions & 28 deletions packages/miniflare/src/http/server.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,19 @@
import fs from "fs/promises";
import { z } from "zod";
import {
CORE_PLUGIN,
HEADER_CF_BLOB,
SERVICE_ENTRY,
SOCKET_ENTRY,
} from "../plugins";
import { HttpOptions, Socket, Socket_Https } from "../runtime";
import { CORE_PLUGIN, HEADER_CF_BLOB } from "../plugins";
import { HttpOptions, Socket_Https } from "../runtime";
import { Awaitable } from "../workers";
import { CERT, KEY } from "./cert";

export async function configureEntrySocket(
coreOpts: z.infer<typeof CORE_PLUGIN.sharedOptions>
): Promise<Socket> {
const httpOptions = {
// Even though we inject a `cf` object in the entry worker, allow it to
// be customised via `dispatchFetch`
cfBlobHeader: HEADER_CF_BLOB,
};
export const ENTRY_SOCKET_HTTP_OPTIONS: HttpOptions = {
// Even though we inject a `cf` object in the entry worker, allow it to
// be customised via `dispatchFetch`
cfBlobHeader: HEADER_CF_BLOB,
};

export async function getEntrySocketHttpOptions(
coreOpts: z.infer<typeof CORE_PLUGIN.sharedOptions>
): Promise<{ http: HttpOptions } | { https: Socket_Https }> {
let privateKey: string | undefined = undefined;
let certificateChain: string | undefined = undefined;

Expand All @@ -36,12 +31,10 @@ export async function configureEntrySocket(
certificateChain = CERT;
}

let options: { http: HttpOptions } | { https: Socket_Https };

if (privateKey && certificateChain) {
options = {
return {
https: {
options: httpOptions,
options: ENTRY_SOCKET_HTTP_OPTIONS,
tlsOptions: {
keypair: {
privateKey: privateKey,
Expand All @@ -51,16 +44,8 @@ export async function configureEntrySocket(
},
};
} else {
options = {
http: httpOptions,
};
return { http: ENTRY_SOCKET_HTTP_OPTIONS };
}

return {
name: SOCKET_ENTRY,
service: { name: SERVICE_ENTRY },
...options,
};
}

function valueOrFile(
Expand Down
89 changes: 66 additions & 23 deletions packages/miniflare/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,15 @@ import { z } from "zod";
import { fallbackCf, setupCf } from "./cf";
import {
DispatchFetch,
ENTRY_SOCKET_HTTP_OPTIONS,
Headers,
Request,
RequestInit,
Response,
configureEntrySocket,
coupleWebSocket,
fetch,
getAccessibleHosts,
getEntrySocketHttpOptions,
registerAllowUnauthorizedDispatcher,
} from "./http";
import {
Expand All @@ -55,7 +56,9 @@ import {
QueuesError,
R2_PLUGIN_NAME,
ReplaceWorkersTypes,
SERVICE_ENTRY,
SOCKET_ENTRY,
SOCKET_ENTRY_LOCAL,
SharedOptions,
WorkerOptions,
WrappedBindingNames,
Expand Down Expand Up @@ -112,10 +115,14 @@ const DEFAULT_HOST = "127.0.0.1";
function getURLSafeHost(host: string) {
return net.isIPv6(host) ? `[${host}]` : host;
}
function getAccessibleHost(host: string) {
const accessibleHost =
host === "*" || host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
return getURLSafeHost(accessibleHost);
function maybeGetLocallyAccessibleHost(
h: string
): "localhost" | "127.0.0.1" | "[::1]" | undefined {
if (h === "localhost") return "localhost";
if (h === "127.0.0.1" || h === "*" || h === "0.0.0.0" || h === "::") {
return "127.0.0.1";
}
if (h === "::1") return "[::1]";
}

function getServerPort(server: http.Server) {
Expand Down Expand Up @@ -174,7 +181,7 @@ function validateOptions(
// Initialise return values
const pluginSharedOpts = {} as PluginSharedOptions;
const pluginWorkerOpts = Array.from(Array(workerOpts.length)).map(
() => ({} as PluginWorkerOptions)
() => ({}) as PluginWorkerOptions
);

// If we haven't defined multiple workers, shared options and worker options
Expand Down Expand Up @@ -993,7 +1000,7 @@ export class Miniflare {
requestedPort = this.#socketPorts?.get(id);
}
// Otherwise, default to a new random port
return `${host}:${requestedPort ?? 0}`;
return `${getURLSafeHost(host)}:${requestedPort ?? 0}`;
}

async #assembleConfig(loopbackPort: number): Promise<Config> {
Expand Down Expand Up @@ -1023,7 +1030,25 @@ export class Miniflare {
},
];

const sockets: Socket[] = [await configureEntrySocket(sharedOpts.core)];
const sockets: Socket[] = [
{
name: SOCKET_ENTRY,
service: { name: SERVICE_ENTRY },
...(await getEntrySocketHttpOptions(sharedOpts.core)),
},
];
const configuredHost = sharedOpts.core.host ?? DEFAULT_HOST;
if (maybeGetLocallyAccessibleHost(configuredHost) === undefined) {
// If we aren't able to locally access `workerd` on the configured host, configure an additional socket that's
// only accessible on `127.0.0.1:0`
sockets.push({
name: SOCKET_ENTRY_LOCAL,
service: { name: SERVICE_ENTRY },
http: ENTRY_SOCKET_HTTP_OPTIONS,
address: "127.0.0.1:0",
});
}

// Bindings for `ProxyServer` Durable Object
const proxyBindings: Worker_Binding[] = [];

Expand Down Expand Up @@ -1242,13 +1267,11 @@ export class Miniflare {
}

// Reload runtime
const host = this.#sharedOpts.core.host ?? DEFAULT_HOST;
const urlSafeHost = getURLSafeHost(host);
const accessibleHost = getAccessibleHost(host);
const configuredHost = this.#sharedOpts.core.host ?? DEFAULT_HOST;
const entryAddress = this.#getSocketAddress(
SOCKET_ENTRY,
this.#previousSharedOpts?.core.port,
host,
configuredHost,
this.#sharedOpts.core.port
);
let inspectorAddress: string | undefined;
Expand All @@ -1260,10 +1283,14 @@ export class Miniflare {
this.#sharedOpts.core.inspectorPort
);
}
const loopbackAddress = `${
maybeGetLocallyAccessibleHost(configuredHost) ??
getURLSafeHost(configuredHost)
}:${loopbackPort}`;
const runtimeOpts: Abortable & RuntimeOptions = {
signal: this.#disposeController.signal,
entryAddress,
loopbackPort,
loopbackAddress,
requiredSockets,
inspectorAddress,
verbose: this.#sharedOpts.core.verbose,
Expand All @@ -1289,11 +1316,22 @@ export class Miniflare {
const entrySocket = config.sockets?.[0];
const secure = entrySocket !== undefined && "https" in entrySocket;
const previousEntryURL = this.#runtimeEntryURL;

const entryPort = maybeSocketPorts.get(SOCKET_ENTRY);
assert(entryPort !== undefined);
this.#runtimeEntryURL = new URL(
`${secure ? "https" : "http"}://${accessibleHost}:${entryPort}`
);

const maybeAccessibleHost = maybeGetLocallyAccessibleHost(configuredHost);
if (maybeAccessibleHost === undefined) {
// If the configured host wasn't locally accessible, we should've configured a 2nd local entry socket that is
const localEntryPort = maybeSocketPorts.get(SOCKET_ENTRY_LOCAL);
assert(localEntryPort !== undefined, "Expected local entry socket port");
this.#runtimeEntryURL = new URL(`http://127.0.0.1:${localEntryPort}`);
} else {
this.#runtimeEntryURL = new URL(
`${secure ? "https" : "http"}://${maybeAccessibleHost}:${entryPort}`
);
}

if (previousEntryURL?.toString() !== this.#runtimeEntryURL.toString()) {
this.#runtimeDispatcher = new Pool(this.#runtimeEntryURL, {
connect: { rejectUnauthorized: false },
Expand All @@ -1315,19 +1353,23 @@ export class Miniflare {
// Only log and trigger reload if there aren't pending updates
const ready = initial ? "Ready" : "Updated and ready";

const urlSafeHost = getURLSafeHost(configuredHost);
this.#log.info(
`${ready} on ${secure ? "https" : "http"}://${urlSafeHost}:${entryPort}`
);

if (initial) {
const hosts: string[] = [];
if (host === "::" || host === "*" || host === "0.0.0.0") {
if (configuredHost === "::" || configuredHost === "*") {
hosts.push("localhost");
hosts.push("[::1]");
}
if (
configuredHost === "::" ||
configuredHost === "*" ||
configuredHost === "0.0.0.0"
) {
hosts.push(...getAccessibleHosts(true));

if (host !== "0.0.0.0") {
hosts.push("localhost");
hosts.push("[::1]");
}
}

for (const h of hosts) {
Expand Down Expand Up @@ -1418,7 +1460,8 @@ export class Miniflare {

// Construct accessible URL from configured host and port
const host = workerOpts.core.unsafeDirectHost ?? DEFAULT_HOST;
const accessibleHost = getAccessibleHost(host);
const accessibleHost =
maybeGetLocallyAccessibleHost(host) ?? getURLSafeHost(host);
// noinspection HttpUrlsUsage
return new URL(`http://${accessibleHost}:${maybePort}`);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/miniflare/src/plugins/core/proxy/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ const revivers: ReducersRevivers = {
export const PROXY_SECRET = crypto.randomBytes(16);
const PROXY_SECRET_HEX = PROXY_SECRET.toString("hex");

function isClientError(status: number) {
return 400 <= status && status < 500;
}

// Exported public API of the proxy system
export class ProxyClient {
#bridge: ProxyClientBridge;
Expand Down Expand Up @@ -300,6 +304,7 @@ class ProxyStubHandler<T extends object> implements ProxyHandler<T> {
}
async #parseAsyncResponse(resPromise: Promise<Response>): Promise<unknown> {
const res = await resPromise;
assert(!isClientError(res.status));

const typeHeader = res.headers.get(CoreHeaders.OP_RESULT_TYPE);
if (typeHeader === "Promise, ReadableStream") return res.body;
Expand Down Expand Up @@ -339,6 +344,7 @@ class ProxyStubHandler<T extends object> implements ProxyHandler<T> {
return this.#maybeThrow(res, result, this.#parseAsyncResponse);
}
#parseSyncResponse(syncRes: SynchronousResponse, caller: Function): unknown {
assert(!isClientError(syncRes.status));
assert(syncRes.body !== null);
// Unbuffered streams should only be sent as part of async responses
assert(syncRes.headers.get(CoreHeaders.OP_STRINGIFIED_SIZE) === null);
Expand Down
1 change: 1 addition & 0 deletions packages/miniflare/src/plugins/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import { CoreBindings, SharedBindings } from "../../workers";

export const SOCKET_ENTRY = "entry";
export const SOCKET_ENTRY_LOCAL = "entry:local";
const SOCKET_DIRECT_PREFIX = "direct";

export function getDirectSocketName(workerIndex: number) {
Expand Down
4 changes: 2 additions & 2 deletions packages/miniflare/src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type SocketPorts = Map<SocketIdentifier, number /* port */>;

export interface RuntimeOptions {
entryAddress: string;
loopbackPort: number;
loopbackAddress: string;
requiredSockets: SocketIdentifier[];
inspectorAddress?: string;
verbose?: boolean;
Expand Down Expand Up @@ -102,7 +102,7 @@ function getRuntimeArgs(options: RuntimeOptions) {
// (e.g. "streams_enable_constructors"), see https://github.com/cloudflare/workerd/pull/21
"--experimental",
`--socket-addr=${SOCKET_ENTRY}=${options.entryAddress}`,
`--external-addr=${SERVICE_LOOPBACK}=localhost:${options.loopbackPort}`,
`--external-addr=${SERVICE_LOOPBACK}=${options.loopbackAddress}`,
// Configure extra pipe for receiving control messages (e.g. when ready)
"--control-fd=3",
// Read config from stdin
Expand Down
50 changes: 50 additions & 0 deletions packages/miniflare/test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { existsSync } from "fs";
import fs from "fs/promises";
import http from "http";
import { AddressInfo } from "net";
import os from "os";
import path from "path";
import { Writable } from "stream";
import { json, text } from "stream/consumers";
Expand Down Expand Up @@ -189,6 +190,55 @@ test("Miniflare: setOptions: can update host/port", async (t) => {
t.is(state2.loopbackPort, state3.loopbackPort);
});

const interfaces = os.networkInterfaces();
const localInterface = (interfaces["en0"] ?? interfaces["eth0"])?.find(
({ family }) => family === "IPv4"
);
(localInterface === undefined ? test.skip : test)(
"Miniflare: can use local network address as host",
async (t) => {
assert(localInterface !== undefined);
const mf = new Miniflare({
host: localInterface.address,
modules: true,
script: `export default { fetch(request, env) { return env.SERVICE.fetch(request); } }`,
serviceBindings: {
SERVICE() {
return new Response("body");
},
},
});
t.teardown(() => mf.dispose());

let res = await mf.dispatchFetch("https://example.com");
t.is(await res.text(), "body");

const worker = await mf.getWorker();
res = await worker.fetch("https://example.com");
t.is(await res.text(), "body");
}
);
test("Miniflare: can use IPv6 loopback as host", async (t) => {
const mf = new Miniflare({
host: "::1",
modules: true,
script: `export default { fetch(request, env) { return env.SERVICE.fetch(request); } }`,
serviceBindings: {
SERVICE() {
return new Response("body");
},
},
});
t.teardown(() => mf.dispose());

let res = await mf.dispatchFetch("https://example.com");
t.is(await res.text(), "body");

const worker = await mf.getWorker();
res = await worker.fetch("https://example.com");
t.is(await res.text(), "body");
});

test("Miniflare: routes to multiple workers with fallback", async (t) => {
const opts: MiniflareOptions = {
workers: [
Expand Down

0 comments on commit 42bcc72

Please sign in to comment.