跳转到内容
本站为社区维护的非官方中文翻译,与 Cloudflare, Inc. 无关联。本站不提供账户登录,请勿在任何非官方站点输入您的 Cloudflare 账户信息。官方文档:developers.cloudflare.com

迁移出站规则

最后更新 查看 MarkdownAgent 设置

在 Sandbox SDK 0.12 中,ContainerProxy 将你的 Sandbox 类的出站字段应用到容器发送的每个请求。在 1.0 中,你的 Durable Object 为来自容器的 HTTP 和 HTTPS 请求注册自己的 WorkerEntrypoint,入口点按相同顺序应用相同规则。

开始之前

你的 1.0 类已替换 0.12 Sandbox 类,并具有 替换 Sandbox 类 中的 container getter、ensureRunning() 和 startContainer() 方法,以及 ENV 和 INACTIVITY_TIMEOUT_MS 常量。入口点使用 this.ctx.exports,需要兼容日期 2025-11-17 或更晚,或 enable_ctx_exports 标志。0.12 出站规则也需要它。

本页替换 enableInternet、interceptHttps、allowedHosts、deniedHosts、outbound、outboundByHost、outboundHandlers、它们的别名 outboundProxy 和 outboundProxies、运行时 setter(例如 allowHost() 和 setOutboundByHost()),以及 gitCheckout()。从导入和 Worker 的导出中移除 ContainerProxy。对于 0.12 为存储桶挂载添加的 handler,请参阅 迁移存储桶挂载。

在入口点中应用规则

  1. 将 0.12 类的出站字段复制到常量中,并为每个 handler 命名:

    src/index.tsts
    type Handler = { method: string; params?: unknown };
    
    type OutboundHandler = (
    	request: Request,
    	env: Env,
    	params: unknown,
    ) => Response | Promise<Response>;
    
    // The outbound fields of your 0.12 class.
    const ENABLE_INTERNET = false;
    const ALLOWED_HOSTS: string[] | undefined = [
    	"github.com",
    	"registry.npmjs.org",
    	"api.example.com",
    ];
    const DENIED_HOSTS: string[] | undefined = undefined;
    const OUTBOUND_BY_HOST: Record<string, Handler> = {
    	"api.example.com": { method: "withToken" },
    };
    const OUTBOUND: Handler | undefined = undefined;
    
    // Your 0.12 handlers, by name.
    const HANDLERS: Record<string, OutboundHandler> = {
    	withToken: (request, env) => {
    		const headers = new Headers(request.headers);
    		headers.set("Authorization", `Bearer ${env.API_TOKEN}`);
    		return fetch(new Request(request, { headers }));
    	},
    };

    如果你的 0.12 类未设置 enableInternet,将 ENABLE_INTERNET 设为 true(0.12 默认值)。对于类未设置的字段,对 OUTBOUND_BY_HOST 使用 {},对其余常量使用 undefined。

    将 outboundHandlers、outboundByHost 和 outbound 中的每个函数放入 HANDLERS,并在 OUTBOUND_BY_HOST 和 OUTBOUND 中按名称引用它们。0.12 handler 接受 (request, env, ctx)。将其改为接受 (request, env, params),并在原先读取 ctx.params 的地方读取 params。

  2. 将 WorkerEntrypoint 添加到 cloudflare:workers 导入,并添加入口点;它按 0.12 使用的顺序检查每个请求:

    src/index.tsts
    import { WorkerEntrypoint } from "cloudflare:workers";
    
    // The shape that 0.12 stored under OUTBOUND_CONFIGURATION.
    type StoredRules = {
    	outboundByHostOverrides?: Record<string, Handler>;
    	outboundHandlerOverride?: Handler;
    	allowedHosts?: string[];
    	deniedHosts?: string[];
    };
    
    // "*" matches any run of characters, as in 0.12.
    function matches(pattern: string, hostname: string): boolean {
    	const escaped = pattern
    		.split("*")
    		.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"));
    	return new RegExp(`^${escaped.join(".*")}$`).test(hostname);
    }
    
    function lookup(
    	handlers: Record<string, Handler> | undefined,
    	hostname: string,
    ): Handler | undefined {
    	return (
    		handlers?.[hostname] ??
    		Object.entries(handlers ?? {}).find(([pattern]) =>
    			matches(pattern, hostname),
    		)?.[1]
    	);
    }
    
    export class Outbound extends WorkerEntrypoint<Env, StoredRules> {
    	async fetch(request: Request): Promise<Response> {
    		const rules = this.ctx.props;
    		const allowed = rules.allowedHosts ?? ALLOWED_HOSTS;
    		const denied = rules.deniedHosts ?? DENIED_HOSTS;
    		const url = new URL(request.url);
    		const hostname = url.hostname.replace(/\.+$/, "");
    		const listed = (patterns: string[]) =>
    			patterns.some((pattern) => matches(pattern, hostname));
    		const blocked = new Response("Origin is disallowed", {
    			status: 520,
    		});
    
    		if (denied && listed(denied)) {
    			return blocked;
    		}
    
    		if (allowed && !listed(allowed)) {
    			return blocked;
    		}
    
    		const handler = [
    			lookup(rules.outboundByHostOverrides, hostname),
    			lookup(OUTBOUND_BY_HOST, hostname),
    			rules.outboundHandlerOverride,
    			OUTBOUND,
    		].find((handler) => handler && HANDLERS[handler.method]);
    
    		if (handler) {
    			const run = HANDLERS[handler.method];
    			return run(request, this.env, handler.params);
    		}
    
    		if (allowed || ENABLE_INTERNET) {
    			return fetch(request);
    		}
    
    		return blocked;
    	}
    }

    入口点先拦截被拒绝的主机名,再拦截不在允许列表中的主机名。然后按此顺序运行第一个匹配的 handler:sandbox 在运行时设置的主机名 handler、来自你的类的主机名 handler、运行时设置的兜底 handler,以及你的类的兜底 handler。没有 handler 时,如果你设置了允许列表或允许 Internet,则将请求发送到 Internet。被拦截的请求获得状态 520,正文为 Origin is disallowed,与 0.12 相同。

    props 携带 sandbox 在运行时设置的规则,形状与 0.12 存储的相同。与 0.12 一样,入口点会跳过名称不在 HANDLERS 中的 handler,例如 0.12 为存储桶挂载存储的 handler。

  3. 将证书变量添加到 ENV,并在 startContainer() 中注册入口点:

    src/index.tsts
    const RULES = "OUTBOUND_CONFIGURATION";
    const CA = "/etc/cloudflare/certs/cloudflare-containers-ca.crt";
    const BUNDLE = "/etc/ssl/certs/ca-certificates.crt";
    const ENV = {
    	NODE_ENV: "test",
    	NODE_EXTRA_CA_CERTS: CA,
    	REQUESTS_CA_BUNDLE: BUNDLE,
    };
    // Keep a copy of the system bundle, wait up to 10 seconds for the
    // certificate, then write the bundle from the copy and the certificate.
    const SYSTEM = "/etc/ssl/certs/ca-certificates.system.crt";
    const CA_WAIT = `until [ -s ${CA} ]; do sleep 0.1; done`;
    const CA_READY = `timeout 10 sh -c '${CA_WAIT}'`;
    const TRUST = [
    	"sh",
    	"-c",
    	`[ -e ${SYSTEM} ] || { cp ${BUNDLE} ${SYSTEM}.tmp && mv ${SYSTEM}.tmp ${SYSTEM}; }
    	${CA_READY} && cat ${SYSTEM} ${CA} >${BUNDLE}.tmp && mv ${BUNDLE}.tmp ${BUNDLE}`,
    ];
    
    export class MySandbox extends DurableObject<Env> {
    	// ...
    
    	private rules(): StoredRules {
    		return this.ctx.storage.kv.get<StoredRules>(RULES) ?? {};
    	}
    
    	private async intercept(): Promise<void> {
    		const props = this.rules();
    		const outbound = this.ctx.exports.Outbound({ props });
    		await this.container.interceptAllOutboundHttp(outbound);
    		await this.container.interceptOutboundHttps("*", outbound);
    	}
    
    	private async trust(): Promise<void> {
    		const trust = await this.container.exec(TRUST);
    
    		if ((await trust.exitCode) !== 0) {
    			throw new Error("The container did not trust the intercept CA");
    		}
    	}
    
    	private async startContainer(): Promise<void> {
    		const newContainer = !this.container.running;
    
    		if (newContainer) {
    			this.container.start({
    				image: this.container.images.sandbox,
    				instance: "standard-1",
    				env: ENV,
    				enableInternet: ENABLE_INTERNET,
    			});
    		}
    
    		try {
    			if (newContainer) {
    				// Replaces onStart().
    				await this.ctx.storage.put("startedAt", Date.now());
    			}
    			await this.intercept();
    			await this.trust();
    			await this.container.setInactivityTimeout(
    				INACTIVITY_TIMEOUT_MS,
    			);
    		} catch (error) {
    			// The next request starts a new container.
    			await this.container.destroy();
    			throw error;
    		}
    	}
    }

    // ... 代表构造函数、container getter 以及你的其他方法。rules() 读取 0.12 存储每个 sandbox 在运行时设置的规则的键,因此切换后这些规则继续生效。如果你 并排迁移 sandbox,copyFrom0x() 会将该键复制到 1.0 类。

    拦截将端口 80 上的每个请求,以及端口 443 上的每个 HTTPS 请求,发送到入口点。enableInternet 保留 0.12 的值,并仍决定其他端口上的连接。拦截持续到容器停止。startContainer() 为每个新容器注册它,并在重启的 Durable Object 发现容器正在运行时再次注册。再次注册会替换较早的注册,因此部署中断了设置的容器仍会在下一条命令之前获得其拦截。

    在 intercept() 之前注册其他拦截,例如存储桶挂载。在 intercept() 之后注册的主机名拦截永远收不到请求,因为 Outbound 入口点会收到它们。更多信息请参阅 如果你迁移了出站规则。

    如果你的类使用 迁移备份 中的 DirectoryBackup,在 try 块中于你的 intercept() 之前调用 this.backups.intercept()。否则每次备份和还原都会因 BACKUP_TRANSFER 失败,因为你的 Outbound 入口点会收到来自容器的备份请求。仅在 startContainer() 中调用它,不要在每个请求上调用。

    HTTPS 拦截用容器默认不信任的证书签署响应。与 0.12 一样,TRUST 命令将该证书添加到系统捆绑包,curl 和 Git 会读取它。ENV 为 Node.js 和 Python requests 设置 NODE_EXTRA_CA_CERTS 和 REQUESTS_CA_BUNDLE,它们默认不读取捆绑包。保留你的其他 ENV 值,并将 ENV 传给每次 exec() 调用。镜像需要提供捆绑包的 ca-certificates 包。

    证书在 HTTPS 拦截注册后出现。TRUST 最多等待 10 秒。0.12 最多等待 5 秒。如果证书未出现,或追加失败,trust() 会抛出,startContainer() 会停止容器。每个容器获得新证书。快照 会保存来自该容器的证书捆绑包,因此追加到捆绑包会保留较早容器的证书。因此 TRUST 会在添加任何证书之前保留系统捆绑包的副本,并从该副本和当前证书再次写入捆绑包。对同一容器再次运行会写入相同捆绑包。

替换运行时 setter

添加更新已存储规则的方法,并用它们再次注册入口点:

src/index.tsts
export class MySandbox extends DurableObject<Env> {
	// ...

	private async updateRules(change: StoredRules): Promise<void> {
		this.ctx.storage.kv.put(RULES, { ...this.rules(), ...change });

		if (this.container.running) {
			await this.intercept();
		}
	}

	async allowHost(hostname: string): Promise<void> {
		const hosts = this.rules().allowedHosts ?? ALLOWED_HOSTS ?? [];
		await this.updateRules({ allowedHosts: [...hosts, hostname] });
	}

	async setOutboundByHost(
		hostname: string,
		method: string,
		params?: unknown,
	): Promise<void> {
		const hosts = { ...this.rules().outboundByHostOverrides };
		hosts[hostname] = { method, params };
		await this.updateRules({ outboundByHostOverrides: hosts });
	}
}

再次注册入口点会替换正在运行的容器的规则,打开的连接会获取新规则而不会被断开。以相同方式编写其他 setter。每个 setter 将新值传给 updateRules():

0.12 要传递的值
setAllowedHosts(hosts)、setDeniedHosts(hosts) allowedHosts 或 deniedHosts,设为 hosts
denyHost()、removeAllowedHost()、removeDeniedHost() deniedHosts 或 allowedHosts,添加或移除主机名
setOutboundByHosts()、removeOutboundByHost() outboundByHostOverrides,添加或移除主机名
setOutboundHandler(method, params) outboundHandlerOverride,设为 { method, params }

与 0.12 一样,sandbox 设置的每个列表都会替换该类对该 sandbox 的列表。

请求有何变化

0.12 仅在你的类设置 interceptHttps = true 时检查 HTTPS 请求。没有它时,当 enableInternet 为 false 时,对允许主机名的 HTTPS 请求会失败;当为 true 时,对被拒绝主机名的 HTTPS 请求会成功。入口点检查每个 HTTPS 请求,因此依赖任一行为的命令现在都会遵循你的规则。

在 0.12 中,当你的类将 handler 声明为静态字段(例如 static outboundByHost = { … })时,如果 TypeScript 将类字段编译为定义,handler 永远不会运行。这是目标 ES2022 及以后的默认行为。该字段跳过了注册 handler 的 setter,因此对这些主机名的请求会进入 Internet 或获得 520。在 1.0 中,handler 会运行。

对 IP 地址的 HTTPS 请求(例如 https://203.0.113.10/)在拦截 * 且 enableInternet 为 false 时会失败。请使用主机名。

替换 gitCheckout()

在 0.12 中,gitCheckout() 将仓库克隆到 /workspace:

src/index.ts (0.12)ts
const repo = "https://github.com/octocat/Hello-World";
await sandbox.gitCheckout(repo, { depth: 1 });

在 1.0 中,在 Durable Object 方法中运行 git clone:

src/index.ts (1.0)ts
export class MySandbox extends DurableObject<Env> {
	// ...

	async clone(url: string, dir: string): Promise<void> {
		await this.ensureRunning();

		const git = ["git", "clone", "--filter=blob:none", "--depth", "1"];
		const clone = await this.container.exec(
			["timeout", "-k", "5", "600", ...git, "--", url, dir],
			{ cwd: "/workspace", env: ENV },
		);
		const output = await clone.output();

		if (output.exitCode !== 0) {
			throw new Error(new TextDecoder().decode(output.stderr));
		}
	}
}

0.12 使用 --filter=blob:none 克隆到 /workspace/<repository name>,并在 600 秒后停止克隆。为 branch 选项传递 --branch。要读取 0.12 返回的分支,以克隆目录为 cwd 运行 git branch --show-current。镜像需要 Git,你的规则必须允许 Git 服务器的主机名,例如 github.com。要在不向 sandbox 提供令牌的情况下克隆私有仓库,请参阅 克隆私有仓库。

检查规则

部署切换后,在镜像具有 curl 和 Git 的 sandbox 中运行这些命令。你的规则拦截的主机名会返回 0.12 响应:

curl -s -w ' %{http_code}\n' https://example.com/
Origin is disallowed 520

允许的主机名可通过 HTTPS 工作,命令上无需证书选项:

git ls-remote https://github.com/octocat/Hello-World HEAD
7fd1a60b01f91b314f59955a4e4d4e80d8edf11d	HEAD

在 0.12 中调用过 allowHost() 的 sandbox 中,对该主机名的请求在切换后仍然成功。

相关资源

这篇文档对您有帮助吗?