mirror of
https://github.com/certd/certd.git
synced 2026-10-13 07:38:56 +08:00
perf(plugin): add Fastly custom TLS certificates integration (#797) @szhu25
* feat(plugin): add Fastly custom TLS certificates integration Introduces a complete set of Fastly plugins for automating custom TLS certificates deployment: - `FastlyAccess`: Handles Fastly API authentication and provides helpers for dynamic data fetching (Services, Domains, Configs). - `FastlyUploadCert`: Uploads a new private key and certificate (2-step POST process) or updates an existing one (PATCH). - `FastlyDeployCert`: Activates a custom TLS certificate by linking it to a TLS configuration and domain via TLS Activations API. - `FastlyRefreshCert`: Bulk updates existing custom TLS certificates. - `FastlyPurgeCache`: Purges all cache for a specified Fastly Service. Includes comprehensive unit tests covering API request formats and error handling for all tasks. * fix(fastly): drop invalid 'multiple' option from refresh-cert input define createRemoteSelectInputDefine has no 'multiple' option; multi-select is already its default (mode: "tags", single: false). The excess property broke the production build (TS2353). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh * fix(fastly): correct TLS list unwrapping, paginate, harden purge - access: ctx.http already unwraps to the response body, so the JSON:API item array is at body.data, not body.data.data — getTlsConfigurations / getTlsDomains / getCertificates always returned [] and the selectors were empty. Fetch via listAllJsonApi, which also pages past Fastly's 20-item default so accounts with more than one page are fully listed. - purge-cache: send no body to /purge_all (auth is header-only) instead of an empty JSON:API object. - upload-cert / refresh-cert: note in the helper text that Fastly requires the renewed cert to reuse the original private key for PATCH updates. - test: cover the list-helper unwrapping, pagination aggregation, and the bare-array /service response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh * fix(fastly): use output-selector for deploy cert ID input The FastlyDeployCert `certificateId` input was a free-text box whose helper pointed users at a non-existent "advanced settings" workflow to grab the upstream output variable. certd only surfaces a prior step's @TaskOutput through an input whose component is `output-selector`, so the FastlyUploadCert `fastlyCertId` output was never selectable. Bind the input to `output-selector` with `from: ["FastlyUploadCert"]`, matching the pattern used by plugin-aliyun deploy-to-cdn. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J * fix(fastly): accept cert or uploaded id in deploy, share upload helpers Rework FastlyDeployCert to mirror the AWS CloudFront pattern instead of demanding a hand-typed / separately-uploaded certificate id: - The `certificateId` text box (and the earlier FastlyUploadCert-only output-selector) is replaced by a `cert: CertInfo | string` input whose output-selector accepts both a cert-apply step's cert and a FastlyUploadCert step's `fastlyCertId`. - execute() binds the id directly when it got a string; when it got a raw CertInfo it uploads to Fastly first (new optional `name` input), then creates the TLS activation. So a plain "apply cert -> activate" pipeline needs no separate upload step. Extract the Fastly certificate create (2-step key + cert) and update (PATCH) flows into FastlyAccess.createCertificate / updateCertificate and reuse them from both FastlyUploadCert and FastlyDeployCert. Tests updated to drive the plugins through a real FastlyAccess with only doRequestApi stubbed; added a deploy case for the raw-CertInfo path. Note: build/tests not run locally (no Node toolchain on this machine) — needs CI / local verification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J * fix(fastly): reuse existing key/cert when Fastly reports "already exists" Fastly deduplicates TLS private keys (and byte-identical certificates) by content, so re-running an upload fails with 400 "... already exists: '<id>'". Recover that id and carry on instead of aborting: - ensurePrivateKey(): on "already exists", parse the id from the error (or match an existing key by name), else throw actionable guidance. - createCertificate(): same treatment for the /tls/certificates POST; when no id is recoverable, tell the user to supply the certificate id so the PATCH (update) path is used. - Shared FastlyAccess.extractExistingId() helper. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J * fix(fastly): per-domain TLS activation, idempotent on renewal The TLS 域名 / TLS 配置 selectors use createRemoteSelectInputDefine, which defaults to multi-select ("tags"), so domainId arrived as an array and was written straight into tls_domain.data.id -> Fastly 500. A tls_activation also binds exactly one domain, so wildcard + apex needs one each. - domainId -> domainIds (string[]); loop, one activation per domain. configurationId gets single:true; both inputs are still normalised in execute() (array-or-scalar, plus legacy `domainId` key). - New FastlyAccess.deployActivation(): looks up an existing activation for the domain+configuration and PATCHes it onto the new certificate; otherwise POSTs a fresh one. Renewals no longer fail on a duplicate. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J * feat(fastly): list service domains for TLS activation, not cert SANs The TLS 域名 selector was populated from GET /tls/domains, which only echoes certificate SAN entries — so a wildcard cert offered "*.g0l.net", which cannot be activated unless a service actually serves that wildcard. onGetTlsDomainList now lists the domains configured on the active version of each Fastly service (new FastlyAccess.getServiceDomains()), which are the names you can actually activate (e.g. www.g0l.net, covered by the wildcard). Cert-derived TLS domains are still appended, tagged "(来自证书)". The field stays free-entry so an unlisted host can be typed in. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TFPQmZU9uvAfR9jHgaPP9J --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
1b882a2ca4
commit
2a2e963914
@@ -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';
|
||||
@@ -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<string, string> = {
|
||||
"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<any, any>({
|
||||
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<any[]> {
|
||||
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<string[]> {
|
||||
const services = await this.getServices();
|
||||
const domains = new Set<string>();
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<any | undefined> {
|
||||
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: '<id>'" (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<string> {
|
||||
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();
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./access.js";
|
||||
export * from "./plugins/index.js";
|
||||
@@ -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";
|
||||
+169
@@ -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<void> {
|
||||
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<string>();
|
||||
|
||||
// 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();
|
||||
@@ -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<void> {
|
||||
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();
|
||||
@@ -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<void> {
|
||||
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();
|
||||
@@ -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<void> {
|
||||
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();
|
||||
Reference in New Issue
Block a user