Commit 0c1766d3 authored by xuchentao's avatar xuchentao

feat: add custom domain management

parent f86dd1a6
...@@ -7,6 +7,25 @@ WEBAGENT_USER_PASSWORD=user ...@@ -7,6 +7,25 @@ WEBAGENT_USER_PASSWORD=user
WEBAGENT_SITES_DIR=../WebAgent-sites WEBAGENT_SITES_DIR=../WebAgent-sites
# 已上线的静态产物独立保存,不会被测试环境构建覆盖。 # 已上线的静态产物独立保存,不会被测试环境构建覆盖。
WEBAGENT_PRODUCTION_DIR=../WebAgent-production WEBAGENT_PRODUCTION_DIR=../WebAgent-production
# 自定义域名使用单独的根路径构建产物,不会覆盖上面的生产目录。
WEBAGENT_CUSTOM_DOMAINS_DIR=../WebAgent-custom-domains
# 用户需要把 CNAME 指向这个由你控制的接入域名,例如 domains.your-company.com。
WEBAGENT_DOMAIN_CNAME_TARGET=domains.webagent.local
# 根域名不支持 CNAME/ALIAS 时,可选配置稳定的边缘 IP。
WEBAGENT_DOMAIN_IPV4=
WEBAGENT_DOMAIN_IPV6=
# external 表示由外部 CDN/TLS 服务管理;也可填写 caddy 或 cloudflare 用于界面提示。
WEBAGENT_DOMAIN_HTTPS_MODE=external
# WEBAGENT_DOMAIN_HTTPS_MODE=cloudflare 时必填,需要 Custom Hostnames 编辑权限。
CLOUDFLARE_API_TOKEN=
CLOUDFLARE_ZONE_ID=
# 使用 Caddy On-Demand TLS 时,保护 /internal/domains/allow 授权端点。
WEBAGENT_DOMAIN_ASK_TOKEN=
# 生产服务器可开启自动校验并 reload Nginx;本地没有运行 Nginx 时保持 false。
WEBAGENT_NGINX_AUTO_RELOAD=false
WEBAGENT_NGINX_BIN=nginx
# 待连接域名的所有权、证书和 DNS 自动检查间隔,最小 15000ms。
WEBAGENT_DOMAIN_CHECK_INTERVAL_MS=60000
# 可选:任意兼容 OpenAI Chat Completions 的模型服务。 # 可选:任意兼容 OpenAI Chat Completions 的模型服务。
# 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关, # 默认示例使用国内可访问的 DeepSeek;也可以替换为企业内部网关,
# 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1 # 或阿里云百炼兼容地址:https://dashscope.aliyuncs.com/compatible-mode/v1
......
...@@ -49,6 +49,9 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production ...@@ -49,6 +49,9 @@ WEBAGENT_PRODUCTION_DIR=../WebAgent-production
- 本地 iframe 预览与自动刷新 - 本地 iframe 预览与自动刷新
- 测试环境与生产环境使用独立目录和独立链接 - 测试环境与生产环境使用独立目录和独立链接
- 用户确认后手动发布,并记录生产版本 commit 与发布时间 - 用户确认后手动发布,并记录生产版本 commit 与发布时间
- 为每个官网绑定自定义域名,通过 TXT 验证所有权并检测 CNAME/A/AAAA
- 可接入 Cloudflare for SaaS 自动创建 Custom Hostname、展示证书验证记录并跟踪 HTTPS 状态
- 自定义域名使用独立根路径产物,域名故障不影响系统默认生产链接
- 浏览 Git 历史并安全回滚到任意版本 - 浏览 Git 历史并安全回滚到任意版本
- 生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在 `.runtime/` - 生成站点保存在独立的可配置目录,构建缓存等临时数据隔离在 `.runtime/`
......
...@@ -42,6 +42,10 @@ export class BuildManager { ...@@ -42,6 +42,10 @@ export class BuildManager {
return this.publishAtomically(path.join(config.productionDir, siteId), distPath); return this.publishAtomically(path.join(config.productionDir, siteId), distPath);
} }
async publishCustomDomain(siteId: string, distPath: string): Promise<string> {
return this.publishAtomically(path.join(config.customDomainsDir, siteId), distPath);
}
async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> { async ensureProductionPlaceholder(siteId: string, siteName: string): Promise<string> {
const target = path.join(config.productionDir, siteId); const target = path.join(config.productionDir, siteId);
const indexPath = path.join(target, "index.html"); const indexPath = path.join(target, "index.html");
......
...@@ -12,6 +12,14 @@ const configuredProductionDir = process.env.WEBAGENT_PRODUCTION_DIR?.trim() || " ...@@ -12,6 +12,14 @@ const configuredProductionDir = process.env.WEBAGENT_PRODUCTION_DIR?.trim() || "
const productionDir = path.isAbsolute(configuredProductionDir) const productionDir = path.isAbsolute(configuredProductionDir)
? configuredProductionDir ? configuredProductionDir
: path.resolve(rootDir, configuredProductionDir); : path.resolve(rootDir, configuredProductionDir);
const configuredCustomDomainsDir = process.env.WEBAGENT_CUSTOM_DOMAINS_DIR?.trim() || "../WebAgent-custom-domains";
const customDomainsDir = path.isAbsolute(configuredCustomDomainsDir)
? configuredCustomDomainsDir
: path.resolve(rootDir, configuredCustomDomainsDir);
const configuredDomainCheckInterval = Number(process.env.WEBAGENT_DOMAIN_CHECK_INTERVAL_MS || 60000);
const domainCheckIntervalMs = Number.isFinite(configuredDomainCheckInterval)
? Math.max(15000, configuredDomainCheckInterval)
: 60000;
export const config = { export const config = {
rootDir, rootDir,
...@@ -19,6 +27,7 @@ export const config = { ...@@ -19,6 +27,7 @@ export const config = {
templateDir: path.resolve(currentDir, "../../astro-template"), templateDir: path.resolve(currentDir, "../../astro-template"),
sitesDir, sitesDir,
productionDir, productionDir,
customDomainsDir,
host: process.env.HOST || "127.0.0.1", host: process.env.HOST || "127.0.0.1",
port: Number(process.env.PORT || 3100), port: Number(process.env.PORT || 3100),
frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173", frontendOrigin: process.env.FRONTEND_ORIGIN || "http://localhost:5173",
...@@ -31,6 +40,20 @@ export const config = { ...@@ -31,6 +40,20 @@ export const config = {
baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""), baseUrl: (process.env.OPENAI_BASE_URL || "https://api.deepseek.com/v1").replace(/\/$/, ""),
model: process.env.OPENAI_MODEL || "deepseek-chat", model: process.env.OPENAI_MODEL || "deepseek-chat",
}, },
domains: {
cnameTarget: (process.env.WEBAGENT_DOMAIN_CNAME_TARGET || "domains.webagent.local").trim().toLowerCase().replace(/\.$/, ""),
ipv4: process.env.WEBAGENT_DOMAIN_IPV4?.trim() || "",
ipv6: process.env.WEBAGENT_DOMAIN_IPV6?.trim() || "",
httpsMode: (process.env.WEBAGENT_DOMAIN_HTTPS_MODE || "external") as "external" | "caddy" | "cloudflare",
askToken: process.env.WEBAGENT_DOMAIN_ASK_TOKEN?.trim() || "",
nginxAutoReload: process.env.WEBAGENT_NGINX_AUTO_RELOAD === "true",
nginxBin: process.env.WEBAGENT_NGINX_BIN?.trim() || "nginx",
checkIntervalMs: domainCheckIntervalMs,
cloudflare: {
apiToken: process.env.CLOUDFLARE_API_TOKEN?.trim() || "",
zoneId: process.env.CLOUDFLARE_ZONE_ID?.trim() || "",
},
},
}; };
export const runtimePaths = { export const runtimePaths = {
...@@ -40,6 +63,7 @@ export const runtimePaths = { ...@@ -40,6 +63,7 @@ export const runtimePaths = {
uploads: path.join(config.runtimeDir, "uploads"), uploads: path.join(config.runtimeDir, "uploads"),
pnpmStore: path.join(config.runtimeDir, "pnpm-store"), pnpmStore: path.join(config.runtimeDir, "pnpm-store"),
logs: path.join(config.runtimeDir, "logs"), logs: path.join(config.runtimeDir, "logs"),
domainMaps: path.join(config.runtimeDir, "nginx"),
}; };
export function getPublicPreviewUrl(siteId: string): string { export function getPublicPreviewUrl(siteId: string): string {
......
import crypto from "node:crypto";
import path from "node:path";
import { runtimePaths } from "../config.js";
import { BuildError, BuildManager } from "../build/build-manager.js";
import { GitManager } from "../git/git-manager.js";
import { ensureEnvironmentConfig } from "../sites/ensure-environment-config.js";
import { SiteRepository } from "../sites/site-repository.js";
import { DomainRepository } from "./domain-repository.js";
import { DomainRoutingConfig } from "./domain-routing-config.js";
export class DomainDeploymentService {
private readonly inFlight = new Map<string, Promise<void>>();
constructor(
private readonly sites: SiteRepository,
private readonly domains: DomainRepository,
private readonly git: GitManager,
private readonly builds: BuildManager,
private readonly routing: DomainRoutingConfig,
) {}
schedule(siteId: string, commit: string): Promise<void> {
const current = this.inFlight.get(siteId);
if (current) return current.then(() => this.schedule(siteId, commit));
const operation = this.deploy(siteId, commit).finally(() => this.inFlight.delete(siteId));
this.inFlight.set(siteId, operation);
return operation;
}
private async deploy(siteId: string, commit: string): Promise<void> {
const bindings = await this.domains.list(siteId);
if (!bindings.some((domain) => domain.ownershipStatus === "verified")) return;
const project = this.sites.getProjectPath(siteId);
await this.git.assertCommit(project, commit);
const taskId = "domain_" + crypto.randomBytes(5).toString("hex");
const workspace = path.join(runtimePaths.builds, taskId, "workspace");
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "deploying", lastDeploymentError: undefined }
: {});
try {
await this.git.createWorktree(project, workspace, commit);
await ensureEnvironmentConfig(workspace);
const dist = await this.builds.build(workspace, taskId, "/");
await this.builds.publishCustomDomain(siteId, dist);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "active", deployedCommit: commit, lastDeploymentError: undefined }
: {});
await this.routing.sync();
} catch (error) {
const details = error instanceof BuildError ? error.output.slice(-3000) : error instanceof Error ? error.message : String(error);
await this.domains.updateSite(siteId, (domain) => domain.ownershipStatus === "verified"
? { deploymentStatus: "failed", lastDeploymentError: details }
: {});
throw error;
} finally {
await this.git.removeWorktree(project, workspace);
}
}
}
import type { DomainDnsRecord, DomainTlsStatus } from "@webagent/shared";
import { config } from "../config.js";
import type { StoredDomainBinding } from "./domain-repository.js";
export interface DomainProviderState {
providerHostnameId?: string;
providerStatus?: string;
tlsStatus: DomainTlsStatus;
certificateRecords?: DomainDnsRecord[];
error?: string;
}
export interface DomainProvider {
isConfigured(): boolean;
initialTlsStatus(): DomainTlsStatus;
sync(domain: StoredDomainBinding): Promise<DomainProviderState>;
remove(domain: StoredDomainBinding): Promise<void>;
}
class ManagedExternallyProvider implements DomainProvider {
isConfigured(): boolean { return true; }
initialTlsStatus(): DomainTlsStatus { return "managed"; }
async sync(): Promise<DomainProviderState> { return { tlsStatus: "managed", providerStatus: config.domains.httpsMode }; }
async remove(): Promise<void> {}
}
type CloudflareValidationRecord = {
status?: string;
txt_name?: string;
txt_value?: string;
cname?: string;
cname_target?: string;
};
type CloudflareHostname = {
id: string;
hostname: string;
status?: string;
ownership_verification?: { type?: "txt"; name?: string; value?: string };
verification_errors?: Array<string | { message?: string }>;
ssl?: {
status?: string;
validation_records?: CloudflareValidationRecord[];
validation_errors?: Array<string | { message?: string }>;
};
};
type CloudflareResponse<T> = {
success?: boolean;
result?: T;
errors?: Array<{ message?: string }>;
};
class CloudflareDomainProvider implements DomainProvider {
isConfigured(): boolean {
return Boolean(config.domains.cloudflare.apiToken && config.domains.cloudflare.zoneId);
}
initialTlsStatus(): DomainTlsStatus { return "pending"; }
async sync(domain: StoredDomainBinding): Promise<DomainProviderState> {
if (!this.isConfigured()) throw new Error("Cloudflare for SaaS 尚未配置 API Token 或 Zone ID");
let hostname: CloudflareHostname;
if (domain.providerHostnameId) {
hostname = await this.request<CloudflareHostname>(`/custom_hostnames/${domain.providerHostnameId}`);
} else {
const created = await this.request<CloudflareHostname>("/custom_hostnames", {
method: "POST",
body: JSON.stringify({
hostname: domain.hostname,
custom_metadata: { site_id: domain.siteId, domain_id: domain.domainId },
ssl: { method: "txt", type: "dv", settings: { min_tls_version: "1.2" } },
}),
});
hostname = await this.request<CloudflareHostname>(`/custom_hostnames/${created.id}`);
}
return this.toState(hostname);
}
async remove(domain: StoredDomainBinding): Promise<void> {
if (!domain.providerHostnameId || !this.isConfigured()) return;
await this.request<{ id?: string }>(`/custom_hostnames/${domain.providerHostnameId}`, { method: "DELETE" });
}
private async request<T>(pathname: string, options?: RequestInit): Promise<T> {
const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${config.domains.cloudflare.zoneId}${pathname}`, {
...options,
signal: options?.signal || AbortSignal.timeout(15000),
headers: {
authorization: `Bearer ${config.domains.cloudflare.apiToken}`,
"content-type": "application/json",
...options?.headers,
},
});
const data = await response.json() as CloudflareResponse<T> | T;
if (response.ok && options?.method === "DELETE" && "id" in (data as object)) return data as T;
const envelope = data as CloudflareResponse<T>;
if (!response.ok || !envelope.success || !envelope.result) {
throw new Error(envelope.errors?.map((error) => error.message).filter(Boolean).join(";") || `Cloudflare 请求失败(${response.status})`);
}
return envelope.result;
}
private toState(hostname: CloudflareHostname): DomainProviderState {
const sslStatus = hostname.ssl?.status || "initializing";
const hostnameStatus = hostname.status || "pending";
const active = hostnameStatus === "active" && sslStatus === "active";
const sslFailed = sslStatus !== "active" && sslStatus !== "initializing" && !sslStatus.startsWith("pending");
const hostnameFailed = hostnameStatus !== "active" && hostnameStatus !== "active_redeploying" && !hostnameStatus.startsWith("pending");
const records: DomainDnsRecord[] = [];
const ownership = hostname.ownership_verification;
if (ownership?.name && ownership.value) records.push({ type: "TXT", name: ownership.name, value: ownership.value, purpose: "hostname" });
for (const record of hostname.ssl?.validation_records || []) {
if (record.txt_name && record.txt_value) records.push({ type: "TXT", name: record.txt_name, value: record.txt_value, purpose: "certificate" });
else if (record.cname && record.cname_target) records.push({ type: "CNAME", name: record.cname, value: record.cname_target, purpose: "certificate" });
}
const unique = [...new Map(records.map((record) => [`${record.type}:${record.name}:${record.value}`, record])).values()];
const errors = [...(hostname.verification_errors || []), ...(hostname.ssl?.validation_errors || [])]
.map((error) => typeof error === "string" ? error : error.message)
.filter((message): message is string => Boolean(message));
return {
providerHostnameId: hostname.id,
providerStatus: `hostname:${hostnameStatus}; ssl:${sslStatus}`,
tlsStatus: active ? "active" : sslFailed || hostnameFailed ? "failed" : "pending",
certificateRecords: unique,
error: errors.join(";") || undefined,
};
}
}
export function createDomainProvider(): DomainProvider {
return config.domains.httpsMode === "cloudflare" ? new CloudflareDomainProvider() : new ManagedExternallyProvider();
}
import crypto from "node:crypto";
import path from "node:path";
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
import type { DomainBinding } from "@webagent/shared";
import { config, runtimePaths } from "../config.js";
export type StoredDomainBinding = Omit<DomainBinding, "status" | "verificationHost" | "verificationValue" | "cnameTarget" | "url"> & {
verificationToken: string;
};
export class DomainError extends Error {
constructor(message: string, public readonly statusCode = 400) { super(message); }
}
export class DomainRepository {
private mutationQueue: Promise<unknown> = Promise.resolve();
async list(siteId: string): Promise<StoredDomainBinding[]> {
this.assertSiteId(siteId);
return this.readSite(siteId);
}
async listAll(): Promise<StoredDomainBinding[]> {
const entries = await readdir(runtimePaths.sites, { withFileTypes: true }).catch(() => []);
const groups = await Promise.all(entries
.filter((entry) => entry.isDirectory() && /^site_[a-z0-9]+$/.test(entry.name))
.map((entry) => this.readSite(entry.name)));
return groups.flat();
}
async get(siteId: string, domainId: string): Promise<StoredDomainBinding> {
const domain = (await this.list(siteId)).find((item) => item.domainId === domainId);
if (!domain) throw new DomainError("域名绑定不存在", 404);
return domain;
}
async findByHostname(hostname: string): Promise<StoredDomainBinding | undefined> {
return (await this.listAll()).find((item) => item.hostname === hostname);
}
async create(siteId: string, hostname: string, verificationToken: string, tlsStatus: StoredDomainBinding["tlsStatus"]): Promise<StoredDomainBinding> {
return this.mutate(async () => {
if ((await this.listAll()).some((item) => item.hostname === hostname)) throw new DomainError("该域名已经绑定到其他官网", 409);
const current = await this.readSite(siteId);
const now = new Date().toISOString();
const domain: StoredDomainBinding = {
domainId: "domain_" + crypto.randomBytes(6).toString("hex"),
siteId,
hostname,
isPrimary: current.length === 0,
ownershipStatus: "pending",
dnsStatus: "pending",
deploymentStatus: "pending",
tlsStatus,
verificationToken,
createdAt: now,
updatedAt: now,
};
await this.writeSite(siteId, [...current, domain]);
return domain;
});
}
async update(siteId: string, domainId: string, values: Partial<StoredDomainBinding>): Promise<StoredDomainBinding> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const index = current.findIndex((item) => item.domainId === domainId);
if (index < 0) throw new DomainError("域名绑定不存在", 404);
const next = { ...current[index], ...values, domainId, siteId, updatedAt: new Date().toISOString() };
current[index] = next;
await this.writeSite(siteId, current);
return next;
});
}
async updateSite(siteId: string, values: (domain: StoredDomainBinding) => Partial<StoredDomainBinding>): Promise<StoredDomainBinding[]> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
const updatedAt = new Date().toISOString();
const next = current.map((domain) => ({ ...domain, ...values(domain), domainId: domain.domainId, siteId, updatedAt }));
await this.writeSite(siteId, next);
return next;
});
}
async setPrimary(siteId: string, domainId: string): Promise<StoredDomainBinding> {
return this.mutate(async () => {
const current = await this.readSite(siteId);
if (!current.some((item) => item.domainId === domainId)) throw new DomainError("域名绑定不存在", 404);
const updatedAt = new Date().toISOString();
const next = current.map((item) => ({ ...item, isPrimary: item.domainId === domainId, updatedAt }));
await this.writeSite(siteId, next);
return next.find((item) => item.domainId === domainId)!;
});
}
async remove(siteId: string, domainId: string): Promise<void> {
await this.mutate(async () => {
const current = await this.readSite(siteId);
const removed = current.find((item) => item.domainId === domainId);
if (!removed) throw new DomainError("域名绑定不存在", 404);
const next = current.filter((item) => item.domainId !== domainId);
if (removed.isPrimary && next[0]) next[0] = { ...next[0], isPrimary: true, updatedAt: new Date().toISOString() };
await this.writeSite(siteId, next);
});
}
private getPath(siteId: string): string {
this.assertSiteId(siteId);
return path.join(runtimePaths.sites, siteId, "metadata", "domains.json");
}
private async readSite(siteId: string): Promise<StoredDomainBinding[]> {
try {
const domains = JSON.parse(await readFile(this.getPath(siteId), "utf8")) as StoredDomainBinding[];
return domains.map((domain) => {
const { lastError, ...stored } = domain;
return {
...stored,
tlsStatus: domain.tlsStatus || (config.domains.httpsMode === "cloudflare" ? "pending" : "managed"),
lastDeploymentError: domain.lastDeploymentError || lastError,
};
});
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return [];
throw error;
}
}
private async writeSite(siteId: string, domains: StoredDomainBinding[]): Promise<void> {
const target = this.getPath(siteId);
await mkdir(path.dirname(target), { recursive: true });
const temporary = target + ".tmp";
await writeFile(temporary, JSON.stringify(domains, null, 2) + "\n", "utf8");
await rename(temporary, target);
}
private async mutate<T>(operation: () => Promise<T>): Promise<T> {
const result = this.mutationQueue.then(operation, operation);
this.mutationQueue = result.then(() => undefined, () => undefined);
return result;
}
private assertSiteId(siteId: string): void {
if (!/^site_[a-z0-9]+$/.test(siteId)) throw new DomainError("无效的 siteId", 400);
}
}
import path from "node:path";
import { mkdir, rename, writeFile } from "node:fs/promises";
import { execa } from "execa";
import { config, runtimePaths } from "../config.js";
import { DomainRepository } from "./domain-repository.js";
export class DomainRoutingConfig {
constructor(private readonly domains: DomainRepository) {}
async sync(): Promise<void> {
const active = (await this.domains.listAll())
.filter((domain) => domain.ownershipStatus === "verified" && domain.deploymentStatus === "active")
.sort((a, b) => a.hostname.localeCompare(b.hostname));
const primaryBySite = new Map<string, string>();
for (const domain of active) {
if (domain.isPrimary || !primaryBySite.has(domain.siteId)) primaryBySite.set(domain.siteId, domain.hostname);
}
const routes = active.map((domain) => `${domain.hostname} ${domain.siteId};`).join("\n");
const redirects = active
.filter((domain) => primaryBySite.get(domain.siteId) !== domain.hostname)
.map((domain) => `${domain.hostname} ${primaryBySite.get(domain.siteId)};`)
.join("\n");
await Promise.all([
this.writeAtomically(path.join(runtimePaths.domainMaps, "domains", "routes.map"), routes),
this.writeAtomically(path.join(runtimePaths.domainMaps, "redirects", "routes.map"), redirects),
]);
if (config.domains.nginxAutoReload) {
await execa(config.domains.nginxBin, ["-t"]);
await execa(config.domains.nginxBin, ["-s", "reload"]);
}
}
private async writeAtomically(target: string, content: string): Promise<void> {
await mkdir(path.dirname(target), { recursive: true });
const temporary = target + ".tmp";
await writeFile(temporary, (content || "# No active custom domains") + "\n", "utf8");
await rename(temporary, target);
}
}
import crypto from "node:crypto";
import { promises as dns } from "node:dns";
import { domainToASCII } from "node:url";
import type { DomainBinding, DomainListResult, DomainStatus } from "@webagent/shared";
import { config } from "../config.js";
import { SiteRepository } from "../sites/site-repository.js";
import { DomainError, DomainRepository, type StoredDomainBinding } from "./domain-repository.js";
import type { DomainProvider } from "./domain-provider.js";
import { DomainRoutingConfig } from "./domain-routing-config.js";
export class DomainService {
constructor(
private readonly sites: SiteRepository,
private readonly domains: DomainRepository,
private readonly routing: DomainRoutingConfig,
private readonly provider: DomainProvider,
) {}
async list(siteId: string): Promise<DomainListResult> {
await this.sites.get(siteId);
return {
domains: (await this.domains.list(siteId)).map((domain) => this.present(domain)),
cnameTarget: config.domains.cnameTarget,
configured: !config.domains.cnameTarget.endsWith(".local") && this.provider.isConfigured(),
ipv4: config.domains.ipv4 || undefined,
ipv6: config.domains.ipv6 || undefined,
httpsMode: config.domains.httpsMode,
};
}
async add(siteId: string, input: string): Promise<DomainBinding> {
await this.sites.get(siteId);
if (config.domains.cnameTarget.endsWith(".local")) throw new DomainError("域名接入尚未配置,请先设置 WEBAGENT_DOMAIN_CNAME_TARGET");
if (!this.provider.isConfigured()) throw new DomainError("域名 HTTPS Provider 尚未配置完整");
const hostname = this.normalizeHostname(input);
const verificationToken = "webagent-verification=" + crypto.randomBytes(18).toString("base64url");
return this.present(await this.domains.create(siteId, hostname, verificationToken, this.provider.initialTlsStatus()));
}
async verify(siteId: string, domainId: string): Promise<DomainBinding> {
const domain = await this.domains.get(siteId, domainId);
const [ownership, routing] = await Promise.all([
domain.ownershipStatus === "verified"
? Promise.resolve<{ valid: boolean; error?: string }>({ valid: true })
: this.checkOwnership(domain),
this.checkRouting(domain.hostname),
]);
const errors = [ownership.error, routing.error].filter(Boolean);
let next = await this.domains.update(siteId, domainId, {
ownershipStatus: ownership.valid ? "verified" : "failed",
dnsStatus: routing.valid ? "valid" : "invalid",
lastCheckedAt: new Date().toISOString(),
lastDnsError: errors.length ? errors.join(";") : undefined,
});
if (next.ownershipStatus === "verified") {
try {
const provider = await this.provider.sync(next);
next = await this.domains.update(siteId, domainId, {
providerHostnameId: provider.providerHostnameId,
providerStatus: provider.providerStatus,
tlsStatus: provider.tlsStatus,
certificateRecords: provider.certificateRecords,
lastDnsError: routing.error,
lastTlsError: provider.error,
});
} catch (error) {
next = await this.domains.update(siteId, domainId, {
tlsStatus: "failed",
lastTlsError: error instanceof Error ? error.message : String(error),
});
}
}
return this.present(next);
}
async setPrimary(siteId: string, domainId: string): Promise<DomainBinding> {
const domain = this.present(await this.domains.setPrimary(siteId, domainId));
await this.routing.sync();
return domain;
}
async remove(siteId: string, domainId: string): Promise<void> {
const domain = await this.domains.get(siteId, domainId);
await this.provider.remove(domain);
await this.domains.remove(siteId, domainId);
await this.routing.sync();
}
async isCertificateAllowed(hostnameInput: string): Promise<boolean> {
let hostname: string;
try { hostname = this.normalizeHostname(hostnameInput); } catch { return false; }
const domain = await this.domains.findByHostname(hostname);
return Boolean(domain && domain.ownershipStatus === "verified" && domain.deploymentStatus === "active");
}
present(domain: StoredDomainBinding): DomainBinding {
const status = this.status(domain);
const { verificationToken, lastError: _legacyError, ...stored } = domain;
return {
...stored,
status,
verificationHost: `_webagent-verification.${domain.hostname}`,
verificationValue: verificationToken,
cnameTarget: config.domains.cnameTarget,
url: `https://${domain.hostname}/`,
lastError: [domain.lastDeploymentError, domain.lastTlsError, domain.lastDnsError, _legacyError].filter(Boolean).join(";") || undefined,
};
}
private status(domain: StoredDomainBinding): DomainStatus {
if (domain.deploymentStatus === "failed") return "failed";
if (domain.tlsStatus === "failed") return "failed";
if (domain.ownershipStatus !== "verified") return "pending_verification";
if (domain.deploymentStatus !== "active") return "deploying";
if (domain.tlsStatus === "pending") return "provisioning_tls";
if (domain.dnsStatus !== "valid") return "pending_dns";
return "active";
}
private normalizeHostname(input: string): string {
const raw = input.trim().toLowerCase().replace(/\.$/, "");
if (raw.includes("://") || raw.includes("/") || raw.includes(":") || raw.includes("@")) throw new DomainError("请输入域名,不要包含协议、路径或端口");
const hostname = domainToASCII(raw);
if (!hostname || hostname.length > 253 || !hostname.includes(".")) throw new DomainError("请输入有效的完整域名");
const labels = hostname.split(".");
if (labels.some((label) => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) throw new DomainError("域名格式不正确");
if (["local", "localhost", "test", "invalid", "example"].includes(labels.at(-1)!)) throw new DomainError("不能绑定本地或保留域名");
return hostname;
}
private async checkOwnership(domain: StoredDomainBinding): Promise<{ valid: boolean; error?: string }> {
const host = `_webagent-verification.${domain.hostname}`;
try {
const records = await dns.resolveTxt(host);
const valid = records.some((chunks) => chunks.join("") === domain.verificationToken);
return valid ? { valid: true } : { valid: false, error: `未找到 ${host} 的正确 TXT 记录` };
} catch {
return { valid: false, error: `暂未查询到 ${host} 的 TXT 记录` };
}
}
private async checkRouting(hostname: string): Promise<{ valid: boolean; error?: string }> {
const target = config.domains.cnameTarget;
try {
const cnames = await dns.resolveCname(hostname).catch(() => []);
if (cnames.some((value) => value.toLowerCase().replace(/\.$/, "") === target)) return { valid: true };
const [hostV4, hostV6, targetV4, targetV6] = await Promise.all([
dns.resolve4(hostname).catch(() => []),
dns.resolve6(hostname).catch(() => []),
dns.resolve4(target).catch(() => []),
dns.resolve6(target).catch(() => []),
]);
const accepted = new Set([
...targetV4, ...targetV6,
...(config.domains.ipv4 ? [config.domains.ipv4] : []),
...(config.domains.ipv6 ? [config.domains.ipv6] : []),
]);
if ([...hostV4, ...hostV6].some((address) => accepted.has(address))) return { valid: true };
return { valid: false, error: `域名尚未指向 ${target}` };
} catch {
return { valid: false, error: `暂未查询到指向 ${target} 的 DNS 记录` };
}
}
}
...@@ -23,6 +23,14 @@ export const versionCommitSchema = z.object({ ...@@ -23,6 +23,14 @@ export const versionCommitSchema = z.object({
commit: z.string().regex(/^[0-9a-f]{7,40}$/), commit: z.string().regex(/^[0-9a-f]{7,40}$/),
}); });
export const addDomainSchema = z.object({
hostname: z.string().trim().min(4, "请输入完整域名").max(253),
});
export const updateDomainSchema = z.object({
isPrimary: z.literal(true),
});
export const sitePatchSchema = z.object({ export const sitePatchSchema = z.object({
summary: z.string().trim().min(2).max(200), summary: z.string().trim().min(2).max(200),
operations: z.array(z.discriminatedUnion("type", [ operations: z.array(z.discriminatedUnion("type", [
......
...@@ -9,11 +9,16 @@ import { BuildManager } from "./build/build-manager.js"; ...@@ -9,11 +9,16 @@ import { BuildManager } from "./build/build-manager.js";
import { config, runtimePaths } from "./config.js"; import { config, runtimePaths } from "./config.js";
import { GitManager } from "./git/git-manager.js"; import { GitManager } from "./git/git-manager.js";
import { PreviewProcessManager } from "./preview/preview-process-manager.js"; import { PreviewProcessManager } from "./preview/preview-process-manager.js";
import { chatSchema, createSiteSchema, loginSchema, versionCommitSchema } from "./schemas.js"; import { addDomainSchema, chatSchema, createSiteSchema, loginSchema, updateDomainSchema, versionCommitSchema } from "./schemas.js";
import { CreateSiteService } from "./sites/create-site.js"; import { CreateSiteService } from "./sites/create-site.js";
import { SiteAgentService } from "./sites/site-agent-service.js"; import { SiteAgentService } from "./sites/site-agent-service.js";
import { SiteRepository } from "./sites/site-repository.js"; import { SiteRepository } from "./sites/site-repository.js";
import { SiteVersionService } from "./sites/site-version-service.js"; import { SiteVersionService } from "./sites/site-version-service.js";
import { DomainDeploymentService } from "./domains/domain-deployment-service.js";
import { DomainRepository } from "./domains/domain-repository.js";
import { DomainRoutingConfig } from "./domains/domain-routing-config.js";
import { DomainService } from "./domains/domain-service.js";
import { createDomainProvider } from "./domains/domain-provider.js";
const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 }); const app = Fastify({ logger: { level: process.env.LOG_LEVEL || "info" }, bodyLimit: 1024 * 1024 });
await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] }); await app.register(cors, { origin: [config.frontendOrigin, "http://127.0.0.1:5173"] });
...@@ -25,6 +30,10 @@ const previews = new PreviewProcessManager(); ...@@ -25,6 +30,10 @@ const previews = new PreviewProcessManager();
const createSite = new CreateSiteService(sites, git, builds, previews); const createSite = new CreateSiteService(sites, git, builds, previews);
const siteAgent = new SiteAgentService(sites, git, new AgentLoop()); const siteAgent = new SiteAgentService(sites, git, new AgentLoop());
const versions = new SiteVersionService(sites, git, builds, previews); const versions = new SiteVersionService(sites, git, builds, previews);
const domainRepository = new DomainRepository();
const domainRouting = new DomainRoutingConfig(domainRepository);
const domainDeployments = new DomainDeploymentService(sites, domainRepository, git, builds, domainRouting);
const domainService = new DomainService(sites, domainRepository, domainRouting, createDomainProvider());
const sessions = new Set<string>(); const sessions = new Set<string>();
app.addHook("onRequest", async (request, reply) => { app.addHook("onRequest", async (request, reply) => {
...@@ -61,13 +70,46 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", a ...@@ -61,13 +70,46 @@ app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-version", a
const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit); const body = versionCommitSchema.parse(request.body); return versions.previewVersion(request.params.siteId, body.commit);
}); });
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/preview-draft", async (request) => versions.previewDraft(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => versions.publish(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/publish", async (request) => {
const result = await versions.publish(request.params.siteId);
void domainDeployments.schedule(request.params.siteId, result.commit).catch((error) => app.log.error(error));
return result;
});
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId))); app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/history", async (request) => git.history(sites.getProjectPath(request.params.siteId)));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId)); app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/undo", async (request) => versions.undo(request.params.siteId));
app.get<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request) => domainService.list(request.params.siteId));
app.post<{ Params: { siteId: string } }>("/api/sites/:siteId/domains", async (request, reply) => {
const body = addDomainSchema.parse(request.body);
return reply.code(201).send(await domainService.add(request.params.siteId, body.hostname));
});
app.post<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId/verify", async (request) => {
const domain = await domainService.verify(request.params.siteId, request.params.domainId);
const site = await sites.get(request.params.siteId);
if (domain.ownershipStatus === "verified" && site.publishedCommit) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
return domain;
});
app.patch<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request) => {
updateDomainSchema.parse(request.body);
return domainService.setPrimary(request.params.siteId, request.params.domainId);
});
app.delete<{ Params: { siteId: string; domainId: string } }>("/api/sites/:siteId/domains/:domainId", async (request, reply) => {
await domainService.remove(request.params.siteId, request.params.domainId);
return reply.code(204).send();
});
app.get<{ Querystring: { domain?: string; token?: string } }>("/internal/domains/allow", async (request, reply) => {
if (config.domains.askToken && request.query.token !== config.domains.askToken) return reply.code(403).send();
if (!request.query.domain || !await domainService.isCertificateAllowed(request.query.domain)) return reply.code(404).send();
return reply.code(200).send();
});
app.setErrorHandler((error, _request, reply) => { app.setErrorHandler((error, _request, reply) => {
const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT"; const isMissingFile = error instanceof Error && "code" in error && error.code === "ENOENT";
const status = error instanceof ZodError ? 400 : isMissingFile ? 404 : 500; const explicitStatus = error instanceof Error && "statusCode" in error && typeof error.statusCode === "number" ? error.statusCode : undefined;
const status = error instanceof ZodError ? 400 : explicitStatus || (isMissingFile ? 404 : 500);
const message = error instanceof ZodError const message = error instanceof ZodError
? error.issues.map((item) => item.message).join(";") ? error.issues.map((item) => item.message).join(";")
: error instanceof Error ? error.message : "请求处理失败"; : error instanceof Error ? error.message : "请求处理失败";
...@@ -76,14 +118,15 @@ app.setErrorHandler((error, _request, reply) => { ...@@ -76,14 +118,15 @@ app.setErrorHandler((error, _request, reply) => {
}); });
await sites.ensureRuntime(); await sites.ensureRuntime();
await domainRouting.sync();
await rm(runtimePaths.builds, { recursive: true, force: true }); await rm(runtimePaths.builds, { recursive: true, force: true });
await mkdir(runtimePaths.builds, { recursive: true }); await mkdir(runtimePaths.builds, { recursive: true });
for (const site of await sites.list()) { for (let site of await sites.list()) {
await git.pruneWorktrees(sites.getProjectPath(site.siteId)); await git.pruneWorktrees(sites.getProjectPath(site.siteId));
await builds.ensureProductionPlaceholder(site.siteId, site.name); await builds.ensureProductionPlaceholder(site.siteId, site.name);
if (site.environmentVersion !== 3) { if (site.environmentVersion !== 3) {
await versions.rebuild(site.siteId).catch((error) => app.log.warn(error)); await versions.rebuild(site.siteId).catch((error) => app.log.warn(error));
continue; site = await sites.get(site.siteId);
} }
const previewPath = path.join(runtimePaths.previews, site.siteId); const previewPath = path.join(runtimePaths.previews, site.siteId);
if (await access(previewPath).then(() => true).catch(() => false)) { if (await access(previewPath).then(() => true).catch(() => false)) {
...@@ -95,6 +138,41 @@ for (const site of await sites.list()) { ...@@ -95,6 +138,41 @@ for (const site of await sites.list()) {
})) }))
.catch((error) => app.log.warn(error)); .catch((error) => app.log.warn(error));
} }
if (site.publishedCommit) {
const domains = await domainRepository.list(site.siteId);
if (domains.some((domain) => domain.ownershipStatus === "verified" && (domain.deploymentStatus !== "active" || domain.deployedCommit !== site.publishedCommit))) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
}
} }
await app.listen({ host: config.host, port: config.port }); await app.listen({ host: config.host, port: config.port });
let checkingDomains = false;
const checkPendingDomains = async () => {
if (checkingDomains) return;
checkingDomains = true;
try {
const domains = await domainRepository.listAll();
for (const stored of domains) {
const fullyConnected = stored.ownershipStatus === "verified"
&& stored.dnsStatus === "valid"
&& (stored.tlsStatus === "active" || stored.tlsStatus === "managed")
&& stored.deploymentStatus === "active";
if (fullyConnected) continue;
const refreshed = await domainService.verify(stored.siteId, stored.domainId).catch((error) => {
app.log.warn(error);
return undefined;
});
if (!refreshed || refreshed.ownershipStatus !== "verified") continue;
const site = await sites.get(stored.siteId);
if (site.publishedCommit && refreshed.deploymentStatus !== "deploying"
&& (refreshed.deploymentStatus !== "active" || refreshed.deployedCommit !== site.publishedCommit)) {
void domainDeployments.schedule(site.siteId, site.publishedCommit).catch((error) => app.log.error(error));
}
}
} finally {
checkingDomains = false;
}
};
setInterval(() => void checkPendingDomains(), config.domains.checkIntervalMs).unref();
...@@ -78,6 +78,7 @@ export class SiteRepository { ...@@ -78,6 +78,7 @@ export class SiteRepository {
await Promise.all([ await Promise.all([
...Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })), ...Object.values(runtimePaths).map((directory) => mkdir(directory, { recursive: true })),
mkdir(config.productionDir, { recursive: true }), mkdir(config.productionDir, { recursive: true }),
mkdir(config.customDomainsDir, { recursive: true }),
]); ]);
await this.migrateLegacySites(); await this.migrateLegacySites();
} }
......
...@@ -18,6 +18,45 @@ pnpm --filter @webagent/backend start ...@@ -18,6 +18,45 @@ pnpm --filter @webagent/backend start
- 测试预览:`http://主机地址/previews/site_xxx/` - 测试预览:`http://主机地址/previews/site_xxx/`
- 生产站点:`http://主机地址/sites/site_xxx/` - 生产站点:`http://主机地址/sites/site_xxx/`
## 自定义域名源站
主 Nginx 入口会在请求 `Host` 命中已验证域名映射时直接提供自定义域名产物;未命中时仍走原控制台、API、`/previews/``/sites/` 逻辑。因此 Cloudflare/CDN 使用现有公网源站地址即可,但必须保留用户原始 `Host`
`webagent.conf` 还额外监听 `127.0.0.1:8080`,供 Caddy、同机网关等需要独立源站端口的接入方式使用。外部接入层需要:
1. 保留用户请求的原始 `Host`
2. 将自定义域名流量转发到服务器的 `127.0.0.1:8080`
3. 用户 DNS CNAME 指向 `.env` 中的 `WEBAGENT_DOMAIN_CNAME_TARGET`
后端会生成以下映射文件,Nginx reload 后生效:
```text
.runtime/nginx/domains/routes.map
.runtime/nginx/redirects/routes.map
```
生产环境建议设置 `WEBAGENT_NGINX_AUTO_RELOAD=true`,后端会先执行 `nginx -t`,成功后再 reload。未开启时,修改域名配置后可手动执行:
```bash
nginx -t && nginx -s reload
```
自定义域名内容位于 `WEBAGENT_CUSTOM_DOMAINS_DIR/site_xxx/`,使用 `SITE_BASE_PATH=/` 单独构建。该流程失败不会影响 `/sites/site_xxx/`
### Cloudflare for SaaS
设置以下环境变量后,用户完成 WebAgent TXT 验证时,系统会自动创建 Custom Hostname,并在域名管理页面展示 Cloudflare 返回的 hostname validation 和 certificate validation 记录:
```bash
WEBAGENT_DOMAIN_HTTPS_MODE=cloudflare
CLOUDFLARE_API_TOKEN=具备Custom Hostnames编辑权限的Token
CLOUDFLARE_ZONE_ID=你的SaaS Zone ID
```
Cloudflare Zone 中仍需提前配置 Fallback Origin 和 `WEBAGENT_DOMAIN_CNAME_TARGET` 对应的 CNAME Target。
待连接域名默认每 60 秒自动刷新一次所有权、证书和 DNS 状态,可通过 `WEBAGENT_DOMAIN_CHECK_INTERVAL_MS` 调整。
Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改 `webagent.conf` 中的 `root``alias` Nginx 由 Homebrew service 管理,登录后会自动启动;Fastify 仍需使用 launchd、进程管理器或终端进程保持运行。更换项目目录时,需要修改 `webagent.conf` 中的 `root``alias`
当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。 当前 MVP 没有用户身份、站点权限和请求限流。可以在可信局域网演示;正式暴露到公网前必须补充 HTTPS、鉴权、限流和站点所有权校验。
map $host $webagent_custom_site_id {
hostnames;
default "";
include /Users/mac/Desktop/code/WebAgent/.runtime/nginx/domains/*.map;
}
map $host $webagent_custom_redirect_host {
hostnames;
default "";
include /Users/mac/Desktop/code/WebAgent/.runtime/nginx/redirects/*.map;
}
server { server {
listen 80 default_server; listen 80 default_server;
listen [::]:80 default_server; listen [::]:80 default_server;
...@@ -12,6 +24,18 @@ server { ...@@ -12,6 +24,18 @@ server {
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript image/svg+xml; gzip_types text/plain text/css application/json application/javascript image/svg+xml;
# Cloudflare/CDN 回源时保留用户 Host。只有命中后端生成映射的域名才进入
# 自定义站点目录;平台域名和未知 Host 继续使用原有控制台入口。
error_page 418 = @custom_domain_site;
if ($webagent_custom_redirect_host != "") {
return 308 https://$webagent_custom_redirect_host$request_uri;
}
if ($webagent_custom_site_id != "") {
return 418;
}
location /api/ { location /api/ {
proxy_pass http://127.0.0.1:3100; proxy_pass http://127.0.0.1:3100;
proxy_http_version 1.1; proxy_http_version 1.1;
...@@ -39,6 +63,44 @@ server { ...@@ -39,6 +63,44 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location @custom_domain_site {
root /Users/mac/Desktop/code/WebAgent-custom-domains/$webagent_custom_site_id;
try_files $uri $uri/ $uri/index.html =404;
add_header Cache-Control "no-cache" always;
}
location ~ /\. {
deny all;
}
}
# 自定义域名专用源站入口。公网 HTTPS/CDN 只需要把用户域名流量转发到此端口,
# 不会修改上面的控制台、API、/previews/ 和 /sites/ 域名体系。
server {
listen 127.0.0.1:8080;
server_name _;
charset utf-8;
root /Users/mac/Desktop/code/WebAgent-custom-domains/$webagent_custom_site_id;
index index.html;
if ($webagent_custom_site_id = "") {
return 421;
}
if ($webagent_custom_redirect_host != "") {
return 308 https://$webagent_custom_redirect_host$request_uri;
}
location /api/ {
return 404;
}
location / {
try_files $uri $uri/ $uri/index.html =404;
add_header Cache-Control "no-cache" always;
}
location ~ /\. { location ~ /\. {
deny all; deny all;
} }
......
...@@ -13,6 +13,9 @@ WebAgent-sites/ ...@@ -13,6 +13,9 @@ WebAgent-sites/
WebAgent-production/ WebAgent-production/
└── site_xxxxxx/ # 用户确认发布的生产静态产物 └── site_xxxxxx/ # 用户确认发布的生产静态产物
WebAgent-custom-domains/
└── site_xxxxxx/ # 自定义域名根路径产物,使用 SITE_BASE_PATH=/ 构建
WebAgent/.runtime/ WebAgent/.runtime/
├── builds/ ├── builds/
│ └── task_xxxxxx/ │ └── task_xxxxxx/
...@@ -21,10 +24,11 @@ WebAgent/.runtime/ ...@@ -21,10 +24,11 @@ WebAgent/.runtime/
│ └── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物 │ └── site_xxxxxx/ # 当前代码最近一次构建成功的测试产物
├── uploads/ # 原始上传文件的临时落点 ├── uploads/ # 原始上传文件的临时落点
├── pnpm-store/ # 所有生成站点共享的 pnpm store ├── pnpm-store/ # 所有生成站点共享的 pnpm store
└── logs/ # Agent 与构建日志 ├── logs/ # Agent 与构建日志
└── nginx/ # 根据已验证域名生成的 Host 路由映射
``` ```
`WebAgent-sites/``WebAgent-production/` 是持久数据,应纳入服务器备份;`.runtime/` 是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于 `.runtime/sites/` 的站点会在首次启动时自动迁移到当前配置目录。 `WebAgent-sites/``WebAgent-production/``WebAgent-custom-domains/` 是持久数据,应纳入服务器备份;`.runtime/` 是可重建的测试与运行缓存,不提交 Git。修改路径配置后,系统不会自动迁移外部目录之间的数据,需要由管理员手动移动。旧版本位于 `.runtime/sites/` 的站点会在首次启动时自动迁移到当前配置目录。
## 不变量 ## 不变量
...@@ -35,3 +39,5 @@ WebAgent/.runtime/ ...@@ -35,3 +39,5 @@ WebAgent/.runtime/
5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。 5. `metadata/site.json` 由 WebAgent 独占写入,站点代码不能反向引用它。
6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。 6. 删除站点、清理缓存等破坏性操作必须由独立管理接口实现,不能由 Agent Patch 触发。
7. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。 7. 每个站点拥有稳定的 `siteId` 和预览端口;站点名称不参与路径计算。
8. 自定义域名必须通过 TXT 所有权验证后才允许生成路由和申请证书。
9. 自定义域名构建或 DNS 故障不能覆盖、阻塞或改变 `/sites/site_xxx/` 生产环境。
This diff is collapsed.
import type { ChatResult, CreateSiteInput, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared"; import type { ChatResult, CreateSiteInput, DomainBinding, DomainListResult, GitHistoryItem, PreviewVersionResult, PublishResult, SiteInfo } from "@webagent/shared";
async function request<T>(url: string, options?: RequestInit): Promise<T> { async function request<T>(url: string, options?: RequestInit): Promise<T> {
const headers = new Headers(options?.headers); const headers = new Headers(options?.headers);
...@@ -29,4 +29,9 @@ export const api = { ...@@ -29,4 +29,9 @@ export const api = {
rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }), rebuild: (siteId: string) => request<{ previewUrl: string }>("/api/sites/" + siteId + "/build", { method: "POST" }),
publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }), publish: (siteId: string) => request<PublishResult>("/api/sites/" + siteId + "/publish", { method: "POST" }),
undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }), undo: (siteId: string) => request<PreviewVersionResult>("/api/sites/" + siteId + "/undo", { method: "POST" }),
domains: (siteId: string) => request<DomainListResult>("/api/sites/" + siteId + "/domains"),
addDomain: (siteId: string, hostname: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains", { method: "POST", body: JSON.stringify({ hostname }) }),
verifyDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId + "/verify", { method: "POST" }),
setPrimaryDomain: (siteId: string, domainId: string) => request<DomainBinding>("/api/sites/" + siteId + "/domains/" + domainId, { method: "PATCH", body: JSON.stringify({ isPrimary: true }) }),
removeDomain: (siteId: string, domainId: string) => request<void>("/api/sites/" + siteId + "/domains/" + domainId, { method: "DELETE" }),
}; };
This diff is collapsed.
...@@ -72,3 +72,52 @@ export interface PreviewVersionResult { ...@@ -72,3 +72,52 @@ export interface PreviewVersionResult {
previewUrl: string; previewUrl: string;
commit: string; commit: string;
} }
export type DomainOwnershipStatus = "pending" | "verified" | "failed";
export type DomainDnsStatus = "pending" | "valid" | "invalid";
export type DomainDeploymentStatus = "pending" | "deploying" | "active" | "failed";
export type DomainTlsStatus = "pending" | "active" | "managed" | "failed";
export type DomainStatus = "pending_verification" | "deploying" | "provisioning_tls" | "pending_dns" | "active" | "failed";
export interface DomainDnsRecord {
type: "TXT" | "CNAME";
name: string;
value: string;
purpose: "hostname" | "certificate";
}
export interface DomainBinding {
domainId: string;
siteId: string;
hostname: string;
isPrimary: boolean;
ownershipStatus: DomainOwnershipStatus;
dnsStatus: DomainDnsStatus;
deploymentStatus: DomainDeploymentStatus;
tlsStatus: DomainTlsStatus;
status: DomainStatus;
verificationHost: string;
verificationValue: string;
cnameTarget: string;
url: string;
deployedCommit?: string;
providerHostnameId?: string;
providerStatus?: string;
certificateRecords?: DomainDnsRecord[];
lastCheckedAt?: string;
lastError?: string;
lastDnsError?: string;
lastTlsError?: string;
lastDeploymentError?: string;
createdAt: string;
updatedAt: string;
}
export interface DomainListResult {
domains: DomainBinding[];
cnameTarget: string;
configured: boolean;
ipv4?: string;
ipv6?: string;
httpsMode: "external" | "caddy" | "cloudflare";
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment