feat: add isolated HTTP port forwarding

新增独立预览 Worker 的 HTTP 端口转发,隔离主站登录态。
This commit is contained in:
gdz66601 committed 2026-09-24 18:54:18 +08:00
1 parent 33b161cfba
commit fc7a0caf81
28 files changed
+1362 -14

No files matched your search

+6 -1
View File
@@ -21,6 +21,11 @@ interface AccessPolicy {
}
export async function resolveWorkerHostname(api: CloudflareApi, settings: DeploymentSettings): Promise<string> {
const subdomain = await resolveWorkersDevSubdomain(api, settings);
return `${settings.workerName}.${subdomain}.workers.dev`;
}
export async function resolveWorkersDevSubdomain(api: CloudflareApi, settings: DeploymentSettings): Promise<string> {
const path = `/accounts/${settings.accountId}/workers/subdomain`;
let subdomain: string | null = null;
try {
@@ -34,7 +39,7 @@ export async function resolveWorkerHostname(api: CloudflareApi, settings: Deploy
subdomain = `${settings.workerName}-${settings.accountId.slice(0, 8)}`;
await api.request(path, 'PUT', { subdomain });
}
return `${settings.workerName}.${subdomain}.workers.dev`;
return subdomain;
}
export async function ensureAccess(
+16 -8
View File
@@ -3,20 +3,22 @@ import { appendFile, readFile, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { parse, stringify } from 'smol-toml';
import { ensureDatabase } from './cloudflare-d1.ts';
import { createDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
import { createDeploymentConfig, createPreviewDeploymentConfig, readDeploymentSettings } from './deployment-config.ts';
import { CloudflareApi, resolveAccountId } from './cloudflare-api.ts';
import { resolveWorkerHostname } from './cloudflare-access.ts';
import { resolveWorkersDevSubdomain } from './cloudflare-access.ts';
import { previewOrigin } from '../src/forwarding/security.ts';
import { maskSecrets, prepareEncryptionSecret, readWorkerSecretNames, readWorkerVariable } from './deployment-secrets.ts';
import { prepareAuthentication, requiredAuthSecrets } from './deployment-auth.ts';
import { readWorkspaceState, updateWorkspaceState } from './workspace-state.ts';
const root = fileURLToPath(new URL('../', import.meta.url));
const generatedConfig = '.wrangler.generated.toml';
const previewGeneratedConfig = '.wrangler.preview.generated.toml';
const wrangler = fileURLToPath(new URL('../node_modules/wrangler/bin/wrangler.js', import.meta.url));
async function runWrangler(args: string[], input?: string): Promise<void> {
async function runWrangler(args: string[], input?: string, configPath = generatedConfig): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, [wrangler, ...args, '--config', generatedConfig], {
const child = spawn(process.execPath, [wrangler, ...args, '--config', configPath], {
cwd: root,
env: { ...process.env, CI: 'true' },
stdio: [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'],
@@ -42,7 +44,10 @@ async function main(): Promise<void> {
settings.accountId = await resolveAccountId(api, settings.accountId);
process.env.CLOUDFLARE_ACCOUNT_ID = settings.accountId;
const existingSecrets = await readWorkerSecretNames(api, settings);
const hostname = settings.customDomain || await resolveWorkerHostname(api, settings);
const workersSubdomain = await resolveWorkersDevSubdomain(api, settings);
const hostname = settings.customDomain || `${settings.workerName}.${workersSubdomain}.workers.dev`;
const previewHostname = settings.previewDomain || `${settings.workerName}-preview.${workersSubdomain}.workers.dev`;
const preview = previewOrigin(`https://${previewHostname}`, `https://${hostname}`);
const database = await ensureDatabase(settings);
const workspace = await readWorkspaceState(api, settings, database);
const deployedProvider = workspace.authProvider
@@ -63,7 +68,7 @@ async function main(): Promise<void> {
const secrets = { ...authentication.secrets, ...await prepareEncryptionSecret(api, settings, database, existingSecrets) };
maskSecrets(secrets);
const config = createDeploymentConfig(template, settings, database, {
hostname, githubAdminId: authentication.githubAdminId,
hostname, previewOrigin: preview, githubAdminId: authentication.githubAdminId,
});
// 临时配置放在仓库根目录,保持 assets、main、migrations_dir 的相对路径语义。
await writeFile(new URL(`../${generatedConfig}`, import.meta.url), stringify(config), 'utf8');
@@ -72,14 +77,17 @@ async function main(): Promise<void> {
// Secret 只通过标准输入发送,不写临时文件或命令行参数;首次部署由 Wrangler 创建草稿 Worker。
if (Object.keys(secrets).length) await runWrangler(['secret', 'bulk'], JSON.stringify(secrets));
await runWrangler(['deploy']);
// Worker 成功发布后再切换状态;失败的发布不会让旧版本提前失去当前认证方式。
await updateWorkspaceState(api, settings, database, workspace, authentication.githubAdminId);
// Wrangler 默认保留既有 Secret;部署后再次核对名称,避免把缺少认证的版本当作成功。
const deployedSecrets = await readWorkerSecretNames(api, settings);
for (const name of ['ENCRYPTION_KEY', ...requiredAuthSecrets(settings.authProvider)]) {
if (!deployedSecrets.has(name)) throw new Error(`部署后缺少 Worker Secret:${name}。`);
}
console.log(`部署完成:https://${hostname}`);
// 预览 Worker 只做代理入口和 DO 绑定,不执行迁移、不写入任何 Secret。
const previewConfig = createPreviewDeploymentConfig(template, settings, preview);
await writeFile(new URL(`../${previewGeneratedConfig}`, import.meta.url), stringify(previewConfig), 'utf8');
await runWrangler(['deploy'], undefined, previewGeneratedConfig);
console.log(`部署完成:https://${hostname},预览:https://${previewHostname}`);
if (process.env.GITHUB_STEP_SUMMARY) {
await appendFile(process.env.GITHUB_STEP_SUMMARY,
`## EdgeSSH 部署完成\n\n入口:https://${hostname}\n\n登录方式:${settings.authProvider}\n\n`
+26 -2
View File
@@ -10,6 +10,7 @@ export interface DeploymentSettings {
databaseName: string;
databaseId?: string;
customDomain?: string;
previewDomain?: string;
authProvider: 'cloudflare' | 'github';
githubClientId?: string;
githubAdmin?: string;
@@ -38,6 +39,7 @@ export function readDeploymentSettings(
if (accountId && !/^[a-f0-9]{32}$/i.test(accountId)) throw new Error('CLOUDFLARE_ACCOUNT_ID 必须是 32 位十六进制账户 ID。');
const workerName = env.WORKER_NAME?.trim() || String(template.name);
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(workerName)) throw new Error('WORKER_NAME 必须是 1–63 位小写字母、数字或连字符,且不能以连字符开头。');
if (`${workerName}-preview`.length > 63) throw new Error('WORKER_NAME 加上 -preview 后不能超过 63 位。');
const databases = template.d1_databases as TomlTable[] | undefined;
const database = databases?.find((binding) => binding.binding === 'DB');
@@ -57,6 +59,13 @@ export function readDeploymentSettings(
if (customDomain?.toLowerCase().endsWith('.workers.dev')) {
throw new Error('CUSTOM_DOMAIN 不能填写 workers.dev 地址;使用 Worker 自带域名时请删除或留空该配置。');
}
const previewDomain = env.PREVIEW_DOMAIN?.trim();
if (previewDomain && !/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(previewDomain)) {
throw new Error('PREVIEW_DOMAIN 只能填写完整域名,不能包含协议、路径或通配符。');
}
if (previewDomain?.toLowerCase().endsWith('.workers.dev')) {
throw new Error('PREVIEW_DOMAIN 不能填写 workers.dev 地址;请使用自定义跨站域名,或留空自动生成预览地址。');
}
const adminEmail = authProvider === 'cloudflare' ? env.ADMIN_EMAIL?.trim().toLowerCase() : undefined;
if (adminEmail && !/^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/.test(adminEmail)) {
@@ -98,7 +107,7 @@ export function readDeploymentSettings(
return {
accountId, apiToken: env.CLOUDFLARE_API_TOKEN!.trim(), workerName,
databaseName, databaseId, customDomain, authProvider, githubClientId, githubAdmin, githubAdminId,
databaseName, databaseId, customDomain, previewDomain, authProvider, githubClientId, githubAdmin, githubAdminId,
adminEmail, identityProviderIds, secrets,
};
}
@@ -107,7 +116,7 @@ export function createDeploymentConfig(
template: TomlTable,
settings: DeploymentSettings,
database: Database,
runtime?: { hostname: string; githubAdminId?: string },
runtime?: { hostname: string; previewOrigin?: string; githubAdminId?: string },
): TomlTable {
return {
...template,
@@ -120,6 +129,7 @@ export function createDeploymentConfig(
...template.vars as TomlTable,
AUTH_PROVIDER: settings.authProvider,
...(runtime ? { APP_ORIGIN: `https://${runtime.hostname}` } : {}),
...(runtime?.previewOrigin ? { PREVIEW_ORIGIN: runtime.previewOrigin } : {}),
...(settings.authProvider === 'github' ? {
GITHUB_CLIENT_ID: settings.githubClientId!,
...(runtime?.githubAdminId ? { GITHUB_ADMIN_ID: runtime.githubAdminId } : {}),
@@ -131,3 +141,17 @@ export function createDeploymentConfig(
: binding),
};
}
export function createPreviewDeploymentConfig(template: TomlTable, settings: DeploymentSettings, previewOrigin: string): TomlTable {
const previewTemplate = {
name: `${settings.workerName}-preview`,
main: 'src/preview-worker.ts',
compatibility_date: template.compatibility_date,
workers_dev: !settings.previewDomain,
preview_urls: false,
vars: { PREVIEW_ORIGIN: previewOrigin },
durable_objects: { bindings: [{ name: 'SSH_SESSIONS', class_name: 'SSHSessionDO', script_name: settings.workerName }] },
...(settings.previewDomain ? { routes: [{ pattern: settings.previewDomain, custom_domain: true }] } : {}),
};
return previewTemplate as TomlTable;
}