diff --git a/packages/ui/certd-server/src/plugins/index.ts b/packages/ui/certd-server/src/plugins/index.ts index a14bbc92c..b2bd5918e 100644 --- a/packages/ui/certd-server/src/plugins/index.ts +++ b/packages/ui/certd-server/src/plugins/index.ts @@ -50,3 +50,4 @@ // export * from './plugin-nginx-proxy-manager/index.js' // export * from './plugin-hipmdnsmgr/index.js' // export * from './plugin-dynadot/index.js' +// export * from './plugin-fastly/index.js'; \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/access.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/access.ts new file mode 100644 index 000000000..8cce0970a --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/access.ts @@ -0,0 +1,356 @@ +import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline"; + +/** + * Fastly Access Plugin + * Provides API authentication and HTTP helper for Fastly API endpoints. + */ +@IsAccess({ + name: "fastly", + title: "Fastly授权", + icon: "simple-icons:fastly", + desc: "Fastly CDN / TLS Custom Certificates API 授权", +}) +export class FastlyAccess extends BaseAccess { + @AccessInput({ + title: "API Token", + component: { + placeholder: "Fastly API Key / Token", + }, + helper: "前往 Fastly Account Settings -> Personal Access Tokens 创建 Token", + required: true, + encrypt: true, + }) + apiKey = ""; + + @AccessInput({ + title: "HTTP代理", + component: { + placeholder: "http://xxxx.xxx.xx:10811", + }, + helper: "可选:是否使用 HTTP 代理访问 Fastly API", + required: false, + encrypt: false, + }) + proxy = ""; + + @AccessInput({ + title: "测试", + component: { + name: "api-test", + action: "TestRequest", + }, + helper: "测试授权是否正确", + }) + testRequest = true; + + async onTestRequest() { + await this.doRequestApi("/tls/certificates?page[size]=1", null, "get"); + return "ok"; + } + + async doRequestApi(url: string, data: any = null, method = "post") { + const baseUrl = "https://api.fastly.com"; + const requestUrl = url.startsWith("http") ? url : `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`; + + const headers: Record = { + "Fastly-Key": this.apiKey, + Accept: "application/vnd.api+json", + }; + + if (data && (method.toLowerCase() === "post" || method.toLowerCase() === "patch" || method.toLowerCase() === "put")) { + headers["Content-Type"] = "application/vnd.api+json"; + } + + try { + const res = await this.ctx.http.request({ + url: requestUrl, + method, + headers, + data, + httpProxy: this.proxy, + }); + + return res; + } catch (e: any) { + const errorData = e.response?.data; + if (errorData) { + const errorsStr = JSON.stringify(errorData); + this.ctx.logger.error(`Fastly API Error: ${errorsStr}`); + throw new Error(`Fastly API 请求失败: ${errorsStr}`); + } + throw e; + } + } + + /** + * Fetches every page of a Fastly JSON:API list endpoint. + * ctx.http already unwraps the response to its body, so the item array is at `body.data`. + * Fastly paginates these endpoints at 20 items/page by default; without looping only the + * first page would be visible in the selectors. + */ + async listAllJsonApi(path: string, pageSize = 100): Promise { + const all: any[] = []; + const maxPages = 100; // hard cap to avoid an infinite loop if the API misbehaves + for (let page = 1; page <= maxPages; page++) { + const sep = path.includes("?") ? "&" : "?"; + const body = await this.doRequestApi(`${path}${sep}page[number]=${page}&page[size]=${pageSize}`, null, "get"); + const items = body?.data; + if (!Array.isArray(items) || items.length === 0) { + break; + } + all.push(...items); + const totalPages = body?.meta?.total_pages; + if (totalPages != null ? page >= totalPages : items.length < pageSize) { + break; + } + } + return all; + } + + async getServices() { + // legacy /service endpoint returns a bare array of objects (not JSON:API) + const res = await this.doRequestApi("/service?per_page=200", null, "get"); + return Array.isArray(res) ? res : res?.data || []; + } + + /** + * Every domain configured on the active version of each Fastly service. + * These are the names you can actually create a TLS activation for — unlike + * `/tls/domains`, which just echoes the SAN entries of your certificates + * (so a wildcard cert shows "*.example.com" that no service serves directly). + */ + async getServiceDomains(): Promise { + const services = await this.getServices(); + const domains = new Set(); + for (const svc of services) { + if (!svc?.id) { + continue; + } + try { + const activeVersion = this.resolveActiveVersion(svc); + if (activeVersion == null) { + continue; + } + const list = await this.doRequestApi(`/service/${svc.id}/version/${activeVersion}/domain`, null, "get"); + const items: any[] = Array.isArray(list) ? list : list?.data || []; + for (const d of items) { + if (d?.name) { + domains.add(d.name); + } + } + } catch (e: any) { + this.ctx.logger.error(`获取 Fastly 服务 ${svc.id} 域名失败: ${e?.message}`); + } + } + return [...domains]; + } + + private resolveActiveVersion(svc: any): number | undefined { + const versions: any[] = Array.isArray(svc?.versions) ? svc.versions : []; + const active = versions.find((v: any) => v?.active === true); + if (active?.number != null) { + return active.number; + } + if (typeof svc?.version === "number") { + return svc.version; + } + const numbers = versions.map((v: any) => v?.number).filter((n: any) => typeof n === "number"); + return numbers.length ? Math.max(...numbers) : undefined; + } + + async getTlsConfigurations() { + return this.listAllJsonApi("/tls/configurations"); + } + + async getTlsDomains() { + return this.listAllJsonApi("/tls/domains"); + } + + async getCertificates() { + return this.listAllJsonApi("/tls/certificates"); + } + + /** + * Creates a brand new custom TLS certificate on Fastly. + * Fastly requires the private key to exist first, so this is a 2-step flow: + * upload (or reuse) the key at /tls/private_keys, then create the certificate + * referencing it. Returns the new tls_certificate id. + */ + async createCertificate(certPem: string, keyPem: string, name?: string): Promise { + const privateKeyId = await this.ensurePrivateKey(keyPem, name); + + this.ctx.logger.info("开始上传证书到 Fastly..."); + const certPayload = { + data: { + type: "tls_certificate", + attributes: { + cert_blob: certPem, + ...(name && { name }), + }, + relationships: { + tls_private_key: { + data: { + type: "tls_private_key", + id: privateKeyId, + }, + }, + }, + }, + }; + + try { + const certRes = await this.doRequestApi("/tls/certificates", certPayload, "post"); + return certRes?.data?.id || ""; + } catch (e: any) { + // Fastly also rejects a byte-identical certificate that is already stored. + const message: string = e?.message || ""; + if (!/already exists|already in use/i.test(message)) { + throw e; + } + const existingId = FastlyAccess.extractExistingId(message); + if (existingId) { + this.ctx.logger.info(`Fastly 已存在相同证书,复用 certId: ${existingId}`); + return existingId; + } + throw new Error( + `Fastly 证书已存在:${message}。` + + "该证书可能此前已上传。请在【Fastly-上传证书到Fastly】任务中填写该证书ID以走更新(PATCH)流程。" + ); + } + } + + /** + * Updates an existing custom TLS certificate on Fastly (PATCH). + * Only cert_blob is sent; the private key stays linked to the certificate. + * Fastly requires the replacement certificate to use the same private key as the + * original, so renewals feeding this must reuse their key. + * Returns the tls_certificate id (falls back to the passed id if the API omits it). + */ + async updateCertificate(certificateId: string, certPem: string, name?: string): Promise { + const payload = { + data: { + type: "tls_certificate", + id: certificateId, + attributes: { + cert_blob: certPem, + ...(name && { name }), + }, + }, + }; + const res = await this.doRequestApi(`/tls/certificates/${certificateId}`, payload, "patch"); + return res?.data?.id || certificateId; + } + + /** + * Binds a certificate to one TLS domain under a TLS configuration. + * A tls_activation is per (certificate, configuration, domain), and only one may + * exist per domain+configuration. If one is already there (e.g. on renewal) we + * PATCH it onto the new certificate instead of failing on a duplicate POST. + */ + async deployActivation(certificateId: string, configurationId: string, domainId: string): Promise<{ id: string; action: "created" | "updated" }> { + const existing = await this.findActivation(configurationId, domainId); + if (existing?.id) { + const res = await this.doRequestApi( + `/tls/activations/${existing.id}`, + { + data: { + type: "tls_activation", + id: existing.id, + relationships: { + tls_certificate: { data: { type: "tls_certificate", id: certificateId } }, + }, + }, + }, + "patch" + ); + return { id: res?.data?.id || existing.id, action: "updated" }; + } + + const res = await this.doRequestApi( + "/tls/activations", + { + data: { + type: "tls_activation", + relationships: { + tls_certificate: { data: { type: "tls_certificate", id: certificateId } }, + tls_configuration: { data: { type: "tls_configuration", id: configurationId } }, + tls_domain: { data: { type: "tls_domain", id: domainId } }, + }, + }, + }, + "post" + ); + return { id: res?.data?.id || "", action: "created" }; + } + + private async findActivation(configurationId: string, domainId: string): Promise { + const body = await this.doRequestApi( + `/tls/activations?filter[tls_domain.id]=${encodeURIComponent(domainId)}&page[size]=100`, + null, + "get" + ); + const items: any[] = Array.isArray(body?.data) ? body.data : []; + if (items.length === 0) { + return undefined; + } + // Prefer the activation tied to this configuration; fall back to the sole match. + return items.find((a: any) => a?.relationships?.tls_configuration?.data?.id === configurationId) ?? (items.length === 1 ? items[0] : undefined); + } + + /** + * Uploads the private key, or reuses the one Fastly already stores. + * Fastly deduplicates TLS private keys by content: re-uploading an existing key + * fails with 400 "Key already exists: ''" (common on renewal when the key is + * reused). That id is the existing tls_private_key resource, so recover and use it. + */ + private async ensurePrivateKey(keyPem: string, name?: string): Promise { + this.ctx.logger.info("开始上传私钥到 Fastly..."); + try { + const keyRes = await this.doRequestApi( + "/tls/private_keys", + { data: { type: "tls_private_key", attributes: { key: keyPem, ...(name && { name }) } } }, + "post" + ); + const id = keyRes?.data?.id; + if (!id) { + throw new Error("Fastly 私钥上传失败,未获取到 private key ID"); + } + this.ctx.logger.info(`Fastly 私钥上传成功, privateKeyId: ${id}`); + return id; + } catch (e: any) { + const message: string = e?.message || ""; + if (!/already exists/i.test(message)) { + throw e; + } + const idFromError = FastlyAccess.extractExistingId(message); + if (idFromError) { + this.ctx.logger.info(`Fastly 已存在相同私钥,复用 privateKeyId: ${idFromError}`); + return idFromError; + } + if (name) { + const keys = await this.listAllJsonApi("/tls/private_keys"); + const matched = keys.filter((k: any) => k?.attributes?.name === name); + if (matched.length === 1) { + this.ctx.logger.info(`Fastly 已存在同名私钥,复用 privateKeyId: ${matched[0].id}`); + return matched[0].id; + } + } + throw new Error( + `Fastly 私钥已存在但无法确定其ID:${message}。` + + "请在【Fastly-上传证书到Fastly】任务中填写已有证书ID以走更新(PATCH)流程," + + "或在 Fastly 控制台删除该孤立私钥后重试。" + ); + } + } + + /** + * Pulls the existing resource id out of a Fastly "already exists" error, e.g. + * `... Key already exists: 'EO8Drv7EYjThQ4DUPo4Ot0'`. Returns undefined when the + * message carries no id (the caller then falls back or surfaces guidance). + */ + private static extractExistingId(message: string): string | undefined { + return message.match(/already exists:\s*['"]?([A-Za-z0-9_-]{8,})['"]?/i)?.[1]; + } +} + +new FastlyAccess(); diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/fastly.test.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/fastly.test.ts new file mode 100644 index 000000000..ca7edd58f --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/fastly.test.ts @@ -0,0 +1,506 @@ +import assert from "node:assert/strict"; +import { FastlyAccess } from "./access.js"; +import { FastlyUploadCertPlugin } from "./plugins/plugin-upload-cert.js"; +import { FastlyPurgeCachePlugin } from "./plugins/plugin-purge-cache.js"; +import { FastlyDeployCertPlugin } from "./plugins/plugin-deploy-to-service.js"; +import { FastlyRefreshCertPlugin } from "./plugins/plugin-refresh-cert.js"; +const mockCert = { + crt: "-----BEGIN CERTIFICATE-----\nMOCKCERT\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMOCKINTERMEDIATE\n-----END CERTIFICATE-----", + key: "-----BEGIN PRIVATE KEY-----\nMOCKKEY\n-----END PRIVATE KEY-----", +}; + +// Real FastlyAccess so plugins exercise its createCertificate/updateCertificate +// helpers; only the low-level doRequestApi is stubbed. +function fakeAccess(doRequestApi: (path: string, payload: any, method: string) => any) { + const access = new FastlyAccess(); + (access as any).ctx = { logger: { info: () => {}, error: () => {} } }; + (access as any).doRequestApi = doRequestApi; + return access; +} + +describe("FastlyAccess", () => { + it("should build correct request headers and URL for POST", async () => { + const access = new FastlyAccess(); + access.apiKey = "test-fastly-key"; + + let capturedReq: any = null; + (access as any).ctx = { + http: { + request: async (req: any) => { + capturedReq = req; + return { data: { id: "tls_cert_123" } }; + }, + }, + logger: { info: () => {}, error: () => {} }, + }; + + const res = await access.doRequestApi("/tls/certificates", { test: true }, "post"); + + assert.equal(capturedReq.url, "https://api.fastly.com/tls/certificates"); + assert.equal(capturedReq.headers["Fastly-Key"], "test-fastly-key"); + assert.equal(capturedReq.headers["Accept"], "application/vnd.api+json"); + assert.equal(capturedReq.headers["Content-Type"], "application/vnd.api+json"); + assert.equal(res.data.id, "tls_cert_123"); + }); + + it("should NOT set Content-Type for GET requests", async () => { + const access = new FastlyAccess(); + access.apiKey = "test-key"; + + let capturedReq: any = null; + (access as any).ctx = { + http: { + request: async (req: any) => { + capturedReq = req; + return { data: [] }; + }, + }, + logger: { info: () => {}, error: () => {} }, + }; + + await access.doRequestApi("/tls/certificates?page[size]=1", null, "get"); + assert.equal(capturedReq.headers["Content-Type"], undefined); + assert.equal(capturedReq.headers["Fastly-Key"], "test-key"); + }); + + it("should pass proxy to http request when proxy is set", async () => { + const access = new FastlyAccess(); + access.apiKey = "test-key"; + access.proxy = "http://proxy.example.com:3128"; + + let capturedReq: any = null; + (access as any).ctx = { + http: { + request: async (req: any) => { + capturedReq = req; + return {}; + }, + }, + logger: { info: () => {}, error: () => {} }, + }; + + await access.doRequestApi("/tls/certificates", null, "get"); + assert.equal(capturedReq.httpProxy, "http://proxy.example.com:3128"); + }); +}); + +describe("FastlyAccess list helpers", () => { + function mockAccess(handler: (url: string) => any) { + const access = new FastlyAccess(); + access.apiKey = "k"; + (access as any).ctx = { + http: { request: async (req: any) => handler(req.url) }, + logger: { info: () => {}, error: () => {} }, + }; + return access; + } + + it("getCertificates returns body.data (not body.data.data) and aggregates all pages", async () => { + const urls: string[] = []; + const access = mockAccess((url: string) => { + urls.push(url); + const page = Number(url.match(/page\[number\]=(\d+)/)?.[1]); + // 3 pages total, 2 items each + const pageItems = [ + [{ id: "c1" }, { id: "c2" }], + [{ id: "c3" }, { id: "c4" }], + [{ id: "c5" }, { id: "c6" }], + ]; + return { data: pageItems[page - 1] ?? [], meta: { total_pages: 3 } }; + }); + + const list = await access.getCertificates(); + assert.deepEqual( + list.map((x: any) => x.id), + ["c1", "c2", "c3", "c4", "c5", "c6"] + ); + assert.equal(urls.length, 3); + }); + + it("stops paginating on a short page when total_pages is absent", async () => { + let calls = 0; + const access = mockAccess(() => { + calls++; + // single short page (< pageSize) => no more requests + return { data: [{ id: "d1" }] }; + }); + + const list = await access.getTlsDomains(); + assert.deepEqual(list, [{ id: "d1" }]); + assert.equal(calls, 1); + }); + + it("getServices unwraps a bare array response", async () => { + const access = mockAccess(() => [{ id: "svc1", name: "a" }]); + const list = await access.getServices(); + assert.deepEqual(list, [{ id: "svc1", name: "a" }]); + }); + + it("getServiceDomains collects domains from each service's active version", async () => { + const access = mockAccess((url: string) => { + if (url.endsWith("/service?per_page=200")) { + return [ + { id: "svc1", versions: [{ number: 1, active: false }, { number: 2, active: true }] }, + { id: "svc2", version: 5 }, + ]; + } + if (url.endsWith("/service/svc1/version/2/domain")) { + return [{ name: "www.g0l.net" }, { name: "img.g0l.net" }]; + } + if (url.endsWith("/service/svc2/version/5/domain")) { + return [{ name: "www.g0l.net" }, { name: "api.other.net" }]; + } + throw new Error(`unexpected ${url}`); + }); + + const domains = await access.getServiceDomains(); + assert.deepEqual(domains.sort(), ["api.other.net", "img.g0l.net", "www.g0l.net"]); + }); +}); + +describe("FastlyUploadCertPlugin - new certificate (2-step flow)", () => { + it("should upload private key first then create certificate with relationship", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + plugin.name = "my-cert"; + + const calls: { path: string; payload: any; method: string }[] = []; + + const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => { + calls.push({ path, payload, method }); + if (path === "/tls/private_keys") { + return { data: { id: "pk_abc123" } }; + } + if (path === "/tls/certificates") { + return { data: { id: "tls_cert_new_999" } }; + } + throw new Error(`Unexpected call: ${path}`); + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + // Step 1: private key upload + assert.equal(calls.length, 2); + assert.equal(calls[0].path, "/tls/private_keys"); + assert.equal(calls[0].method, "post"); + assert.equal(calls[0].payload.data.type, "tls_private_key"); + assert.equal(calls[0].payload.data.attributes.key, mockCert.key); + assert.equal(calls[0].payload.data.attributes.name, "my-cert"); + + // Step 2: certificate upload with relationship + assert.equal(calls[1].path, "/tls/certificates"); + assert.equal(calls[1].method, "post"); + assert.equal(calls[1].payload.data.type, "tls_certificate"); + assert.equal(calls[1].payload.data.attributes.cert_blob, mockCert.crt); + assert.equal(calls[1].payload.data.relationships.tls_private_key.data.id, "pk_abc123"); + assert.equal(calls[1].payload.data.relationships.tls_private_key.data.type, "tls_private_key"); + + assert.equal(plugin.fastlyCertId, "tls_cert_new_999"); + }); + + it("should throw if private key upload returns no ID", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + + const mockAccess = fakeAccess(async () => ({ data: {} })); // no id returned + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await assert.rejects( + () => plugin.execute(), + /Fastly 私钥上传失败,未获取到 private key ID/ + ); + }); + + it("reuses the existing private key when Fastly reports it already exists", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + + const calls: { path: string; payload: any; method: string }[] = []; + + const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => { + calls.push({ path, payload, method }); + if (path === "/tls/private_keys") { + throw new Error( + 'Fastly API 请求失败: {"errors":[{"title":"Can\'t create key","detail":"Key already exists: \'EO8Drv7EYjThQ4DUPo4Ot0\'"}]}' + ); + } + if (path === "/tls/certificates") { + return { data: { id: "tls_cert_new_1" } }; + } + throw new Error(`Unexpected call: ${path}`); + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + assert.equal(calls.length, 2); + assert.equal(calls[1].path, "/tls/certificates"); + assert.equal(calls[1].payload.data.relationships.tls_private_key.data.id, "EO8Drv7EYjThQ4DUPo4Ot0"); + assert.equal(plugin.fastlyCertId, "tls_cert_new_1"); + }); + + it("reuses the existing certificate when Fastly reports it already exists", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + + const mockAccess = fakeAccess(async (path: string) => { + if (path === "/tls/private_keys") { + return { data: { id: "pk_1" } }; + } + if (path === "/tls/certificates") { + throw new Error( + 'Fastly API 请求失败: {"errors":[{"title":"Can\'t create certificate","detail":"Certificate already exists: \'aBcDeFgHiJkLmNoPqRsTuV\'"}]}' + ); + } + throw new Error(`Unexpected call: ${path}`); + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + assert.equal(plugin.fastlyCertId, "aBcDeFgHiJkLmNoPqRsTuV"); + }); + + it("surfaces guidance when the certificate already exists without a recoverable id", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + + const mockAccess = fakeAccess(async (path: string) => { + if (path === "/tls/private_keys") { + return { data: { id: "pk_1" } }; + } + if (path === "/tls/certificates") { + throw new Error('Fastly API 请求失败: {"errors":[{"detail":"cert_blob is already in use"}]}'); + } + throw new Error(`Unexpected call: ${path}`); + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await assert.rejects(() => plugin.execute(), /填写该证书ID以走更新\(PATCH\)流程/); + }); +}); + +describe("FastlyUploadCertPlugin - update existing certificate (PATCH)", () => { + it("should PATCH existing certificate with only cert_blob, no private key step", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + plugin.certificateId = "tls_cert_existing_123"; + plugin.name = "updated-cert"; + + const calls: { path: string; payload: any; method: string }[] = []; + + const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => { + calls.push({ path, payload, method }); + return { data: { id: "tls_cert_existing_123" } }; + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + // Only 1 API call for update + assert.equal(calls.length, 1); + assert.equal(calls[0].path, "/tls/certificates/tls_cert_existing_123"); + assert.equal(calls[0].method, "patch"); + assert.equal(calls[0].payload.data.type, "tls_certificate"); + assert.equal(calls[0].payload.data.id, "tls_cert_existing_123"); + assert.equal(calls[0].payload.data.attributes.cert_blob, mockCert.crt); + assert.equal(calls[0].payload.data.attributes.name, "updated-cert"); + // No relationships on PATCH + assert.equal(calls[0].payload.data.relationships, undefined); + + assert.equal(plugin.fastlyCertId, "tls_cert_existing_123"); + }); + + it("should fallback fastlyCertId to certificateId when response has no id", async () => { + const plugin = new FastlyUploadCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + plugin.certificateId = "tls_cert_fallback_id"; + + const mockAccess = fakeAccess(async () => ({ data: {} })); // no id in response + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + assert.equal(plugin.fastlyCertId, "tls_cert_fallback_id"); + }); +}); + +describe("FastlyPurgeCachePlugin", () => { + it("should call purge_all on the provided serviceId", async () => { + const plugin = new FastlyPurgeCachePlugin(); + plugin.accessId = "access-1"; + plugin.serviceId = "svc_123"; + + let capturedUrl = ""; + let capturedMethod = ""; + + const mockAccess = { + doRequestApi: async (path: string, payload: any, method: string) => { + capturedUrl = path; + capturedMethod = method; + return { data: { status: "ok" } }; + } + }; + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + assert.equal(capturedUrl, "/service/svc_123/purge_all"); + assert.equal(capturedMethod, "post"); + }); +}); + +describe("FastlyDeployCertPlugin", () => { + it("creates one tls_activation per domain when none exists yet", async () => { + const plugin = new FastlyDeployCertPlugin(); + plugin.accessId = "access-1"; + plugin.cert = "cert_1"; + plugin.domainIds = ["*.g0l.net", "g0l.net"]; + plugin.configurationId = "cfg_1"; + + const calls: { path: string; payload: any; method: string }[] = []; + + const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => { + calls.push({ path, payload, method }); + if (method === "get") { + return { data: [] }; // no existing activation + } + return { data: { id: "act_new" } }; + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + const posts = calls.filter(c => c.method === "post"); + assert.equal(posts.length, 2); + assert.equal(posts[0].path, "/tls/activations"); + assert.equal(posts[0].payload.data.relationships.tls_certificate.data.id, "cert_1"); + assert.equal(posts[0].payload.data.relationships.tls_configuration.data.id, "cfg_1"); + assert.equal(posts[0].payload.data.relationships.tls_domain.data.id, "*.g0l.net"); + assert.equal(posts[1].payload.data.relationships.tls_domain.data.id, "g0l.net"); + // domain filter is url-encoded (wildcard -> %2A) + assert.ok(calls[0].path.startsWith("/tls/activations?filter[tls_domain.id]=%2A.g0l.net")); + }); + + it("patches the existing activation onto the new cert (renewal)", async () => { + const plugin = new FastlyDeployCertPlugin(); + plugin.accessId = "access-1"; + plugin.cert = "cert_new"; + plugin.domainIds = ["g0l.net"]; + plugin.configurationId = "cfg_1"; + + const calls: { path: string; payload: any; method: string }[] = []; + + const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => { + calls.push({ path, payload, method }); + if (method === "get") { + return { data: [{ id: "act_1", relationships: { tls_configuration: { data: { id: "cfg_1" } } } }] }; + } + return { data: { id: "act_1" } }; + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + const write = calls.find(c => c.method !== "get")!; + assert.equal(write.method, "patch"); + assert.equal(write.path, "/tls/activations/act_1"); + assert.equal(write.payload.data.id, "act_1"); + assert.equal(write.payload.data.relationships.tls_certificate.data.id, "cert_new"); + assert.equal(write.payload.data.relationships.tls_configuration, undefined); + }); + + it("uploads a raw CertInfo to Fastly first, then binds the new cert id", async () => { + const plugin = new FastlyDeployCertPlugin(); + plugin.accessId = "access-1"; + plugin.cert = mockCert as any; + plugin.name = "my-cert"; + plugin.domainIds = ["dom_1"]; + plugin.configurationId = "cfg_1"; + + const calls: { path: string; payload: any; method: string }[] = []; + + const mockAccess = fakeAccess(async (path: string, payload: any, method: string) => { + calls.push({ path, payload, method }); + if (path === "/tls/private_keys") { + return { data: { id: "pk_1" } }; + } + if (path === "/tls/certificates") { + return { data: { id: "tls_cert_uploaded_1" } }; + } + if (method === "get") { + return { data: [] }; + } + return { data: { id: "act_new" } }; + }); + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + assert.equal(calls[0].path, "/tls/private_keys"); + assert.equal(calls[1].path, "/tls/certificates"); + assert.equal(calls[1].payload.data.attributes.cert_blob, mockCert.crt); + const post = calls.find(c => c.path === "/tls/activations" && c.method === "post")!; + assert.equal(post.payload.data.relationships.tls_certificate.data.id, "tls_cert_uploaded_1"); + }); +}); + +describe("FastlyRefreshCertPlugin", () => { + it("should update multiple certificates via PATCH", async () => { + const plugin = new FastlyRefreshCertPlugin(); + plugin.cert = mockCert as any; + plugin.accessId = "access-1"; + plugin.certList = ["cert_a", "cert_b"]; + + const calls: { path: string, payload: any }[] = []; + + const mockAccess = { + doRequestApi: async (path: string, payload: any, method: string) => { + calls.push({ path, payload }); + return { data: { id: path.split('/').pop() } }; + } + }; + + (plugin as any).getAccess = async () => mockAccess; + (plugin as any).logger = { info: () => {}, error: () => {} }; + + await plugin.execute(); + + assert.equal(calls.length, 2); + assert.equal(calls[0].path, "/tls/certificates/cert_a"); + assert.equal(calls[0].payload.data.id, "cert_a"); + assert.equal(calls[0].payload.data.attributes.cert_blob, mockCert.crt); + + assert.equal(calls[1].path, "/tls/certificates/cert_b"); + assert.equal(calls[1].payload.data.id, "cert_b"); + }); +}); diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/index.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/index.ts new file mode 100644 index 000000000..21a066db2 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/index.ts @@ -0,0 +1,2 @@ +export * from "./access.js"; +export * from "./plugins/index.js"; diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/index.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/index.ts new file mode 100644 index 000000000..e763b3a10 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/index.ts @@ -0,0 +1,4 @@ +export * from "./plugin-upload-cert.js"; +export * from "./plugin-purge-cache.js"; +export * from "./plugin-deploy-to-service.js"; +export * from "./plugin-refresh-cert.js"; diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-deploy-to-service.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-deploy-to-service.ts new file mode 100644 index 000000000..2a5e46c42 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-deploy-to-service.ts @@ -0,0 +1,169 @@ +import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline"; +import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert"; +import { createRemoteSelectInputDefine } from "@certd/plugin-lib"; +import { FastlyAccess } from "../access.js"; + +@IsTaskPlugin({ + name: "FastlyDeployCert", + title: "Fastly-部署TLS激活", + desc: "部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名)", + icon: "simple-icons:fastly", + group: pluginGroups.cdn.key, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, +}) +export class FastlyDeployCertPlugin extends AbstractTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: + "选择前置任务输出的域名证书(将自动上传到 Fastly)," + + "或选择前置【Fastly-上传证书到Fastly】任务输出的 Fastly 证书ID。", + component: { + name: "output-selector", + from: [...CertApplyPluginNames, "FastlyUploadCert"], + }, + required: true, + }) + cert!: CertInfo | string; + + @TaskInput({ + title: "证书名称", + helper: "可选。当传入的是域名证书需要先上传时,作为 Fastly 上的自定义证书名称/标签", + component: { + placeholder: "例如: my-fastly-cert", + }, + required: false, + }) + name = ""; + + @TaskInput({ + title: "Access授权", + helper: "Fastly 授权凭证", + component: { + name: "access-selector", + type: "fastly", + }, + required: true, + }) + accessId!: string; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "TLS 域名", + helper: + "选择要绑定证书的域名,可多选(将为每个域名创建或更新一个 TLS 激活)。" + + "列表为你 Fastly 服务上配置的域名,请选择被证书覆盖的具体主机名(如 www.example.com)," + + "而非通配 *.example.com(除非确有服务使用通配域名)。列表没有的也可直接输入。", + action: FastlyDeployCertPlugin.prototype.onGetTlsDomainList.name, + pager: false, + search: false, + required: true, + }) + ) + domainIds!: string[]; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "TLS 配置", + helper: "选择关联的 TLS 配置", + action: FastlyDeployCertPlugin.prototype.onGetTlsConfigurationList.name, + pager: false, + search: false, + single: true, + required: true, + }) + ) + configurationId!: string; + + async onInstance() {} + + async execute(): Promise { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + + // remote-select hands back an array; tolerate a single value too (older config / single:true). + const rawDomains: any = this.domainIds ?? (this as any).domainId; + const domainIds: string[] = Array.isArray(rawDomains) ? rawDomains.filter(Boolean) : rawDomains ? [rawDomains] : []; + const rawConfig: any = this.configurationId; + const configurationId: string = Array.isArray(rawConfig) ? rawConfig[0] : rawConfig; + + if (domainIds.length === 0 || !configurationId) { + throw new Error("请提供完整的 TLS 域名和 TLS 配置"); + } + + // `cert` is a string when it comes from a FastlyUploadCert step (already a Fastly + // tls_certificate id); otherwise it is a raw CertInfo that must be uploaded first. + let certificateId: string; + if (typeof this.cert === "string") { + certificateId = this.cert; + } else { + const certName = this.name && this.name.trim() ? this.name.trim() : undefined; + this.logger.info("未检测到 Fastly 证书ID,先上传证书到 Fastly..."); + certificateId = await access.createCertificate(this.cert.crt, this.cert.key, certName); + this.logger.info(`证书上传成功, Fastly 证书ID: ${certificateId}`); + } + + if (!certificateId) { + throw new Error("未能获取 Fastly 证书ID"); + } + + for (const domainId of domainIds) { + this.logger.info(`开始部署 Fastly TLS 激活 (域名: ${domainId})...`); + const { id, action } = await access.deployActivation(certificateId, configurationId, domainId); + this.logger.info(`Fastly TLS 激活${action === "updated" ? "更新" : "创建"}成功 (域名: ${domainId}), 激活ID: ${id}`); + } + } + + async onGetTlsDomainList() { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + + const [serviceDomains, tlsDomains] = await Promise.all([ + access.getServiceDomains().catch(() => [] as string[]), + access.getTlsDomains().catch(() => [] as any[]), + ]); + + const options: { label: string; value: string }[] = []; + const seen = new Set(); + + // Domains actually served by your Fastly services — these can be activated. + for (const name of serviceDomains) { + if (name && !seen.has(name)) { + seen.add(name); + options.push({ label: name, value: name }); + } + } + + // TLS domains from certificates/subscriptions (incl. wildcards) as extra choices. + for (const item of tlsDomains || []) { + const name = item?.id; + if (name && !seen.has(name)) { + seen.add(name); + options.push({ label: `${name} (来自证书)`, value: name }); + } + } + + return { list: options }; + } + + async onGetTlsConfigurationList() { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + const list = await access.getTlsConfigurations(); + + if (!list || list.length === 0) { + return { list: [] }; + } + + const options = list.map((item: any) => { + return { + label: `${item.attributes?.name || "Unnamed Configuration"} (${item.id})`, + value: item.id, + }; + }); + + return { list: options }; + } +} + +new FastlyDeployCertPlugin(); diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-purge-cache.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-purge-cache.ts new file mode 100644 index 000000000..dadb07e6a --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-purge-cache.ts @@ -0,0 +1,77 @@ +import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline"; +import { createRemoteSelectInputDefine } from "@certd/plugin-lib"; +import { FastlyAccess } from "../access.js"; + +@IsTaskPlugin({ + name: "FastlyPurgeCache", + title: "Fastly-清除缓存", + desc: "清除 Fastly 指定 Service 的所有缓存 (Purge All)", + icon: "simple-icons:fastly", + group: pluginGroups.cdn.key, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, +}) +export class FastlyPurgeCachePlugin extends AbstractTaskPlugin { + @TaskInput({ + title: "Access授权", + helper: "Fastly 授权凭证", + component: { + name: "access-selector", + type: "fastly", + }, + required: true, + }) + accessId!: string; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "Service (服务)", + helper: "选择要清理所有缓存的 Fastly 服务", + action: FastlyPurgeCachePlugin.prototype.onGetServiceList.name, + pager: false, + search: false, + required: true, + }) + ) + serviceId!: string; + + async onInstance() {} + + async execute(): Promise { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + + if (!this.serviceId) { + throw new Error("请选择要清理缓存的 Service"); + } + + this.logger.info(`开始清理 Fastly 服务 [${this.serviceId}] 的所有缓存...`); + + // POST /service/{service_id}/purge_all — no body; auth is via the Fastly-Key header + await access.doRequestApi(`/service/${this.serviceId}/purge_all`, null, "post"); + + this.logger.info(`清理 Fastly 服务 [${this.serviceId}] 缓存成功`); + } + + async onGetServiceList() { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + const services = await access.getServices(); + + if (!services || services.length === 0) { + return { list: [] }; + } + + const options = services.map((item: any) => { + return { + label: `${item.name} (${item.id})`, + value: item.id, + }; + }); + + return { list: options }; + } +} + +new FastlyPurgeCachePlugin(); diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-refresh-cert.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-refresh-cert.ts new file mode 100644 index 000000000..6b1d10994 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-refresh-cert.ts @@ -0,0 +1,106 @@ +import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput } from "@certd/pipeline"; +import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert"; +import { createRemoteSelectInputDefine } from "@certd/plugin-lib"; +import { FastlyAccess } from "../access.js"; + +@IsTaskPlugin({ + name: "FastlyRefreshCert", + title: "Fastly-更新证书", + desc: "自动更新 Fastly CDN 上的已有自定义证书", + icon: "simple-icons:fastly", + group: pluginGroups.cdn.key, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, +}) +export class FastlyRefreshCertPlugin extends AbstractTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "output-selector", + from: [...CertApplyPluginNames], + }, + required: true, + }) + cert!: CertInfo; + + @TaskInput({ + title: "Access授权", + helper: "Fastly 授权凭证", + component: { + name: "access-selector", + type: "fastly", + }, + required: true, + }) + accessId!: string; + + @TaskInput( + createRemoteSelectInputDefine({ + title: "证书列表", + helper: + "选择要更新的 Fastly 证书 (必须是已存在的自定义证书)。" + + "注意:Fastly 要求更新后的证书与原证书使用相同私钥,请确保续期时复用私钥(reuse key),否则更新会失败。", + action: FastlyRefreshCertPlugin.prototype.onGetCertList.name, + pager: false, + search: false, + required: true, + }) + ) + certList!: string[]; + + async onInstance() {} + + async execute(): Promise { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + const certPem = this.cert.crt; + + if (!this.certList || this.certList.length === 0) { + throw new Error("请至少选择一个要更新的证书"); + } + + for (const certId of this.certList) { + this.logger.info(`----------- 开始更新 Fastly 证书:${certId} -----------`); + + const payload: any = { + data: { + type: "tls_certificate", + id: certId, + attributes: { + cert_blob: certPem, + }, + }, + }; + + await access.doRequestApi(`/tls/certificates/${certId}`, payload, "patch"); + this.logger.info(`----------- 更新 Fastly 证书 ${certId} 成功 -----------`); + } + + this.logger.info("Fastly 证书批量更新完成"); + } + + async onGetCertList() { + const access = (await this.getAccess(this.accessId)) as FastlyAccess; + const list = await access.getCertificates(); + + if (!list || list.length === 0) { + throw new Error("没有找到 Fastly 证书"); + } + + const options = list.map((item: any) => { + const name = item.attributes?.name || "Unnamed"; + const issuedTo = item.attributes?.issued_to || "Unknown Domain"; + return { + label: `${name} - ${issuedTo} (${item.id})`, + value: item.id, + }; + }); + + return { list: options }; + } +} + +new FastlyRefreshCertPlugin(); diff --git a/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-upload-cert.ts b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-upload-cert.ts new file mode 100644 index 000000000..10bcf936a --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-fastly/plugins/plugin-upload-cert.ts @@ -0,0 +1,90 @@ +import { AbstractTaskPlugin, IsTaskPlugin, pluginGroups, RunStrategy, TaskInput, TaskOutput } from "@certd/pipeline"; +import { CertApplyPluginNames, CertInfo } from "@certd/plugin-cert"; +import { FastlyAccess } from "../access.js"; + +@IsTaskPlugin({ + name: "FastlyUploadCert", + title: "Fastly-上传证书到Fastly", + desc: "部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN", + icon: "simple-icons:fastly", + group: pluginGroups.cdn.key, + default: { + strategy: { + runStrategy: RunStrategy.SkipWhenSucceed, + }, + }, +}) +export class FastlyUploadCertPlugin extends AbstractTaskPlugin { + @TaskInput({ + title: "域名证书", + helper: "请选择前置任务输出的域名证书", + component: { + name: "output-selector", + from: [...CertApplyPluginNames], + }, + required: true, + }) + cert!: CertInfo; + + @TaskInput({ + title: "Access授权", + helper: "Fastly 授权凭证", + component: { + name: "access-selector", + type: "fastly", + }, + required: true, + }) + accessId!: string; + + @TaskInput({ + title: "证书ID (tls_certificate_id)", + helper: + "可选。若填写则对 Fastly 已有证书进行更新(PATCH);留空则新建上传(POST)。" + + "注意:Fastly 更新证书要求新证书与原证书使用相同的私钥,请确保续期时复用私钥(reuse key),否则更新会失败。", + component: { + placeholder: "例如: tls_cert_xxx (留空表示新建)", + }, + required: false, + }) + certificateId = ""; + + @TaskInput({ + title: "证书名称", + helper: "可选。上传到 Fastly 上的自定义证书名称/标签", + component: { + placeholder: "例如: my-fastly-cert", + }, + required: false, + }) + name = ""; + + @TaskOutput({ + title: "Fastly 证书 ID", + }) + fastlyCertId = ""; + + async onInstance() {} + + async execute(): Promise { + const { cert, accessId, certificateId, name } = this; + const access = (await this.getAccess(accessId)) as FastlyAccess; + + // cert.crt is the full certificate chain (PEM); cert.key is the private key (PEM) + const certName = name && name.trim() ? name.trim() : undefined; + const targetId = certificateId && certificateId.trim(); + + if (targetId) { + // Update an existing Fastly certificate in place (same private key required). + this.logger.info(`开始更新 Fastly 证书 [${targetId}]...`); + this.fastlyCertId = await access.updateCertificate(targetId, cert.crt, certName); + this.logger.info(`Fastly 证书更新成功, ID: ${this.fastlyCertId}`); + } else { + // Create a new Fastly certificate (uploads the private key first). + this.fastlyCertId = await access.createCertificate(cert.crt, cert.key, certName); + this.logger.info(`Fastly 证书新建成功, ID: ${this.fastlyCertId}`); + } + } +} + +new FastlyUploadCertPlugin();