Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[miniflare] fix: ensure magic proxy works when starting on non-local hosts, and IPv6 addresses can be used as hosts #5133

Merged
merged 2 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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.
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
87 changes: 65 additions & 22 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this makes any difference, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so, just formatted with a different version of Prettier I guess.

);

// If we haven't defined multiple workers, shared options and worker options
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
30 changes: 30 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,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: [
Expand Down