feat: add isolated HTTP port forwarding
新增独立预览 Worker 的 HTTP 端口转发,隔离主站登录态。
This commit is contained in:
1 parent
33b161cfba
commit
fc7a0caf81
28 files changed
+1362
-14
No files matched your search
@@ -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
@@ -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`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user