From 8872a8f90ae87afc675b0da74edcc2caa5e08524 Mon Sep 17 00:00:00 2001 From: bcoll Date: Fri, 1 Mar 2024 12:21:49 +0000 Subject: [PATCH 1/2] fix: ensure magic proxy works when starting on non-local `host` --- .changeset/silent-geese-leave.md | 7 ++ packages/miniflare/src/http/server.ts | 41 +++------ packages/miniflare/src/index.ts | 87 ++++++++++++++----- .../src/plugins/core/proxy/client.ts | 6 ++ .../miniflare/src/plugins/shared/constants.ts | 1 + packages/miniflare/src/runtime/index.ts | 4 +- packages/miniflare/test/index.spec.ts | 30 +++++++ 7 files changed, 124 insertions(+), 52 deletions(-) create mode 100644 .changeset/silent-geese-leave.md diff --git a/.changeset/silent-geese-leave.md b/.changeset/silent-geese-leave.md new file mode 100644 index 000000000000..7adbb042dee5 --- /dev/null +++ b/.changeset/silent-geese-leave.md @@ -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. diff --git a/packages/miniflare/src/http/server.ts b/packages/miniflare/src/http/server.ts index b363ceeb7453..3f25857e7a85 100644 --- a/packages/miniflare/src/http/server.ts +++ b/packages/miniflare/src/http/server.ts @@ -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 -): Promise { - 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 +): Promise<{ http: HttpOptions } | { https: Socket_Https }> { let privateKey: string | undefined = undefined; let certificateChain: string | undefined = undefined; @@ -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, @@ -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( diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 73b50e73864b..8d126934e34a 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -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 { @@ -55,7 +56,9 @@ import { QueuesError, R2_PLUGIN_NAME, ReplaceWorkersTypes, + SERVICE_ENTRY, SOCKET_ENTRY, + SOCKET_ENTRY_LOCAL, SharedOptions, WorkerOptions, WrappedBindingNames, @@ -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) { @@ -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 @@ -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[] = []; @@ -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; @@ -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, @@ -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 }, @@ -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) { @@ -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}`); } diff --git a/packages/miniflare/src/plugins/core/proxy/client.ts b/packages/miniflare/src/plugins/core/proxy/client.ts index 138285a2a5af..90371c73aa7f 100644 --- a/packages/miniflare/src/plugins/core/proxy/client.ts +++ b/packages/miniflare/src/plugins/core/proxy/client.ts @@ -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; @@ -300,6 +304,7 @@ class ProxyStubHandler implements ProxyHandler { } async #parseAsyncResponse(resPromise: Promise): Promise { const res = await resPromise; + assert(!isClientError(res.status)); const typeHeader = res.headers.get(CoreHeaders.OP_RESULT_TYPE); if (typeHeader === "Promise, ReadableStream") return res.body; @@ -339,6 +344,7 @@ class ProxyStubHandler implements ProxyHandler { 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); diff --git a/packages/miniflare/src/plugins/shared/constants.ts b/packages/miniflare/src/plugins/shared/constants.ts index 32c306e826a2..13a9871a142b 100644 --- a/packages/miniflare/src/plugins/shared/constants.ts +++ b/packages/miniflare/src/plugins/shared/constants.ts @@ -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) { diff --git a/packages/miniflare/src/runtime/index.ts b/packages/miniflare/src/runtime/index.ts index 621ac87e950a..cd5070e5ab09 100644 --- a/packages/miniflare/src/runtime/index.ts +++ b/packages/miniflare/src/runtime/index.ts @@ -29,7 +29,7 @@ export type SocketPorts = Map; export interface RuntimeOptions { entryAddress: string; - loopbackPort: number; + loopbackAddress: string; requiredSockets: SocketIdentifier[]; inspectorAddress?: string; verbose?: boolean; @@ -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 diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index d577354339c4..61c58892944f 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -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"; @@ -189,6 +190,35 @@ 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: routes to multiple workers with fallback", async (t) => { const opts: MiniflareOptions = { workers: [ From 5c813e6eb353b09fad9d1be9c06071773bd8bd06 Mon Sep 17 00:00:00 2001 From: bcoll Date: Fri, 1 Mar 2024 12:23:09 +0000 Subject: [PATCH 2/2] fix: ensure IPv6 addresses can be used as `host`s --- .changeset/tender-nails-tickle.md | 7 +++++++ packages/miniflare/src/index.ts | 2 +- packages/miniflare/test/index.spec.ts | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .changeset/tender-nails-tickle.md diff --git a/.changeset/tender-nails-tickle.md b/.changeset/tender-nails-tickle.md new file mode 100644 index 000000000000..42ee87bd3528 --- /dev/null +++ b/.changeset/tender-nails-tickle.md @@ -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. diff --git a/packages/miniflare/src/index.ts b/packages/miniflare/src/index.ts index 8d126934e34a..6f863ec6f9e3 100644 --- a/packages/miniflare/src/index.ts +++ b/packages/miniflare/src/index.ts @@ -1000,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 { diff --git a/packages/miniflare/test/index.spec.ts b/packages/miniflare/test/index.spec.ts index 61c58892944f..352efca8e182 100644 --- a/packages/miniflare/test/index.spec.ts +++ b/packages/miniflare/test/index.spec.ts @@ -218,6 +218,26 @@ const localInterface = (interfaces["en0"] ?? interfaces["eth0"])?.find( 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 = {