Compare commits

...
13 Commits
Author SHA1 Message Date
xiaojunnuo cc60cd4cd1 v1.44.3 2026-09-06 23:50:28 +08:00
xiaojunnuo dade599acb build: prepare to build 2026-09-06 23:46:14 +08:00
xiaojunnuo 1070417a36 chore: 修复fastly 通配符型号转换格式的问题 2026-09-06 23:45:56 +08:00
xiaojunnuo 81f676f1ca build: prepare to build 2026-09-06 23:29:25 +08:00
xiaojunnuo 0cf0608d3f build: prepare to build 2026-09-06 23:25:52 +08:00
xiaojunnuo dac94b64f0 fix(plugin-service): 修复同步自己开发的云端插件不显示已安装状态问题
将开发者ID、VIP状态、依赖插件、AI检查状态、插件状态、应用ID和作者字段加入插件更新逻辑,完善插件信息同步的完整性
2026-09-06 23:08:31 +08:00
xiaojunnuo 8c88092235 docs: 优化docker镜像文档的排版和内容
调整了README和docker安装指南中的镜像说明部分,将原有的零散说明改为表格形式统一展示,修正了镜像地址的展示位置,补充了稳定版slim/armv7的完整镜像地址信息,清理了重复的说明内容。
2026-09-06 17:28:00 +08:00
Steven ZhuandClaude Sonnet 5 2a2e963914 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>
2026-09-06 16:20:14 +08:00
Steven ZhuandClaude Sonnet 5 1b882a2ca4 AWS ratelimit fix (#796) @szhu25
* Parse PEM chain and import certificate chain

Split the PEM in certInfo.crt into a leaf certificate and intermediate chain (using a lookbehind regex), trim the blocks, and pass the chain to ImportCertificateCommand only when present. Replace console.log with this.logger.info and log the returned CertificateArn. This ensures the leaf cert is uploaded separately from its chain and avoids sending an empty CertificateChain.

* Add AWS retry & CloudFront deployment wait

Introduce robust retry and polling helpers to handle AWS throttling and CloudFront propagation. Added AwsClient.withRetry (exponential backoff, handles common throttling errors, default 5 attempts/base 2s) and waitForDistributionDeployed (polls until distribution Status is "Deployed", default 10min timeout/15s interval). Update deploy-to-cloudfront plugin to use withRetry for Get/UpdateDistribution and importCertificate, pass AwsClient into uploadToACM, and wait for each distribution to finish deploying before continuing to avoid PreconditionFailed errors. Improves reliability when facing rate limits and global CloudFront propagation delays; adds informative logging for retry and deployment status.

* fix(aws): resolve rate limit and runtime import bugs

* fix(aws): broaden retry detection and harden CloudFront poll

- withRetry: retry on Route53 PriorRequestNotComplete, SlowDown, SDK v3
  $retryable.throttling hints and HTTP 429/503; extract detection into
  isRetryableAwsError
- withRetry: add equal jitter to backoff so concurrent pipelines don't
  retry in lockstep
- waitForDistributionDeployed: a throttle that outlives withRetry's budget
  keeps polling until the deadline instead of aborting the deploy
- test: cover withRetry retry/exhaustion/non-throttle paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015dRf6EbAmwb37cpnvgBDrh

* fix(aws): fix build break and make withRetry the sole retry authority

- withRetry<T = any>: default the type param so call sites passing an
  untyped SDK client (@aws-sdk/* is a lazyDependency with no build-time
  types) don't collapse the result to `unknown`. Also annotate the two
  result consumers as `any`. Fixes the TS2339 errors that broke the
  Docker build (aws-client.ts, plugin-deploy-to-cloudfront.ts).
- Set maxAttempts: 1 on every AWS SDK client (ACM/STS/Route53/CloudFront)
  so the SDK's own retry strategy no longer stacks on top of withRetry.
- isRetryableAwsError: broaden to cover what the SDK's `standard` strategy
  used to handle now that it's disabled - $retryable hint, HTTP 429 + any
  5xx, connection-level errors (ECONNRESET/ETIMEDOUT/EAI_AGAIN/...).
- waitForDistributionDeployed: a transient error that outlives withRetry's
  budget now retries on the next poll instead of aborting the whole deploy.
- test: cover 5xx, $retryable, and connection-error retry paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrMVmQyvQjes6t4DGjuBaD

* feat(aws): make CloudFront deploy-wait opt-in (default off)

Polling GetDistribution until Status=Deployed adds 3-8min of pipeline
wall-time per run for no functional gain - CloudFront finishes the
rollout on its own once UpdateDistribution returns 200. Gate it behind
a new "等待部署完成" switch, default false. Retry/backoff on the actual
mutating calls (GetDistributionConfig / UpdateDistribution / ACM import
/ Route53) is unaffected - it wraps those sends directly, not the poll.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BrMVmQyvQjes6t4DGjuBaD

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:19:47 +08:00
xiaojunnuo bd9433478a fix(plugin): 修复插件配置保存与加载的相关问题
1.  修复插件配置保存时使用fullName或name作为标识的问题
2.  为证书申请插件配置补充pluginType字段
3.  新增插件配置全局加载逻辑,启动时同步数据库插件配置到内存注册表
4.  优化配置编辑器默认值与选项添加交互
5.  补充vue依赖导入与上下文注入
2026-09-05 00:27:26 +08:00
xiaojunnuo 32ade8e4a3 build: release 2026-09-03 22:16:49 +08:00
xiaojunnuo f2bcdeeb33 build: publish 2026-09-03 22:05:17 +08:00
xiaojunnuo 4099af7f5d build: trigger build image 2026-09-03 22:05:07 +08:00
61 changed files with 2185 additions and 201 deletions
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
### Bug Fixes
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
### Performance Improvements
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
### Bug Fixes
+10 -15
View File
@@ -105,14 +105,17 @@ https://certd.handfree.work/
#### Docker镜像说明:
##### 1. 镜像地址格式:
##### 1. 镜像地址:
```
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型 (中括号表示可选)
| 镜像仓库 | 最新预览版 | 稳定版 | slim版 | armv7版 |
| --- | --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| Docker Hub | `certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
```
##### 2. 版本标签:
> 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
##### 2. 版本标签说明:
**最新版本标签:**
@@ -131,16 +134,8 @@ registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
| slim | `certd:slim` | Debian slim | 基于glibc,dns解析兼容性好 | `certd:slim-stable` | `certd:1.43.0-slim` |
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` | `certd:1.43.0-armv7` |
##### 3. 镜像地址:
| 镜像仓库 | 最新预览版 | slim | armv7 |
| --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
| Docker Hub | `certd/certd:latest` | `certd:slim` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
> 2. 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
最新版本号:
![GitHub release (latest by date)](https://img.shields.io/github/v/release/certd/certd?label=Latest%20Release&sort=semver)
+7
View File
@@ -3,6 +3,13 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
### Bug Fixes
* 修复插件市场插件安装无效的bug ([15d2bd8](https://github.com/certd/certd/commit/15d2bd8484a9ddd142bf4ef03d839c74405f9e6d))
* preserve Route53 TXT record values ([#791](https://github.com/certd/certd/issues/791)) ([f5430a8](https://github.com/certd/certd/commit/f5430a8925cf6cf4e16c75f2358fbd78437e6705))
## [1.44.1](https://github.com/certd/certd/compare/v1.44.0...v1.44.1) (2026-09-02)
### Bug Fixes
+14 -18
View File
@@ -6,13 +6,17 @@
Certd 提供多种 Docker 镜像版本,您可以根据需要选择:
#### 1. 镜像地址格式:
#### 1. 镜像地址:
```
registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
------------ ↑ 镜像地址 ------------- ↑ 镜像名 -- ↑指定版本- ↑基础系统- ↑最新版本类型
```
#### 2. 版本标签:
| 镜像仓库 | 最新预览版 | 稳定版 | slim版 | armv7版 |
| --- | --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| Docker Hub | `certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:stable` | `certd:slim` | `certd:armv7` |
> 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
#### 2. 版本标签说明:
**最新版本标签:**
@@ -25,22 +29,14 @@ registry.cn-shenzhen.aliyuncs.com/certd/certd:[version-][system-][latest/stable]
> 根据基础镜像不同,分为如下三个分支版本,没有特殊需求选择默认的即可(他们功能是一样的)
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 | 指定版本 |
| --- | --- | --- | --- | --- | --- |
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小,支持x86、ARM架构 | `certd:stable` | `certd:1.43.0` |
| 系统版本 | 版本标签 | 基础系统 | 说明 | 稳定版标签 | 指定版本 |
| --- | --- | --- | --- | --- | --- |
| alpine【默认】 | `certd:latest` | Alpine Linux | 默认版本,镜像体积小 | `certd:stable` | `certd:1.43.0` |
| slim | `certd:slim` | Debian slim | 基于glibc,dns解析兼容性好 | `certd:slim-stable` | `certd:1.43.0-slim` |
| armv7 | `certd:armv7` | Alpine Linux | ARMv7 架构专用版本 | `certd:armv7-stable` | `certd:1.43.0-armv7` |
#### 3. 镜像地址:
| 镜像仓库 | 最新预览版 | slim版 | armv7版 |
| --- | --- | --- | --- |
| 阿里云【默认】 | `registry.cn-shenzhen.aliyuncs.com/certd/certd:latest` | `certd:slim` | `certd:armv7` |
| Docker Hub | `certd/certd:latest` | `certd:slim` | `certd:armv7` |
| GitHub Packages | `ghcr.io/certd/certd:latest` | `certd:slim` | `certd:armv7` |
> 1. 后面的各个版本省略了镜像地址,使用时需要将镜像地址拼接完整。
> 2. 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
> 如果您不确定使用哪个版本,请使用默认的 `certd:latest` 版本。
最新版本号:
![GitHub release (latest by date)](https://img.shields.io/github/v/release/certd/certd?label=Latest%20Release&sort=semver)
+47 -46
View File
@@ -36,52 +36,53 @@
| 32.| **Dokploy授权** | |
| 33.| **Dynadot授权** | |
| 34.| **farcdn授权** | |
| 35.| **FlexCDN授权** | |
| 36.| **Gcore** | Gcore |
| 37.| **Github授权** | |
| 38.| **godaddy授权** | |
| 39.| **HiPM DNSMgr** | HiPM DNSMgr API Token 授权 |
| 40.| **金山云授权** | |
| 41.| **FTP授权** | |
| 42.| **七牛OSS授权** | |
| 43.| **腾讯云COS授权** | 腾讯云对象存储授权,包含地域和存储桶 |
| 44.| **s3/minio授权** | S3/minio oss授权 |
| 45.| **namesilo授权** | |
| 46.| **Next Terminal 授权** | 用于访问 Next Terminal API 的授权配置 |
| 47.| **Nginx Proxy Manager 授权** | 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。 |
| 48.| **1panel授权** | 账号和密码 |
| 49.| **支付宝** | |
| 50.| **白山云授权** | |
| 51.| **宝塔云WAF授权** | 用于连接和管理宝塔云WAF服务的授权配置 |
| 52.| **cdnfly授权** | |
| 53.| **k8s授权** | |
| 54.| **括彩云cdn授权** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 55.| **LeCDN授权** | |
| 56.| **lucky** | |
| 57.| **猫云授权** | |
| 58.| **plesk授权** | |
| 59.| **长亭雷池授权** | |
| 60.| **群晖登录授权** | |
| 61.| **uniCloud** | unicloud授权 |
| 62.| **微信支付** | |
| 63.| **易盾rcdn授权** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 64.| **易发云短信** | sms.yfyidc.cn/ |
| 65.| **易盾DCDN授权** | https://user.yiduncdn.com |
| 66.| **易支付** | |
| 67.| **proxmox** | |
| 68.| **Spaceship.com 授权** | Spaceship.com API 授权插件 |
| 69.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器授权 |
| 70.| **UCloud授权** | 优刻得授权 |
| 71.| **又拍云** | |
| 72.| **网宿授权** | |
| 73.| **西部数码授权** | |
| 74.| **我爱云授权** | 我爱云CDN |
| 75.| **新网授权(代理方式)** | |
| 76.| **新网授权** | |
| 77.| **新网互联授权** | 仅支持代理账号,ip需要加入白名单 |
| 78.| **Zenlayer授权** | Zenlayer授权 |
| 79.| **GoEdge授权** | |
| 80.| **雨云授权** | https://app.rainyun.com/ |
| 35.| **Fastly授权** | Fastly CDN / TLS Custom Certificates API 授权 |
| 36.| **FlexCDN授权** | |
| 37.| **Gcore** | Gcore |
| 38.| **Github授权** | |
| 39.| **godaddy授权** | |
| 40.| **HiPM DNSMgr** | HiPM DNSMgr API Token 授权 |
| 41.| **金山云授权** | |
| 42.| **FTP授权** | |
| 43.| **七牛OSS授权** | |
| 44.| **腾讯云COS授权** | 腾讯云对象存储授权,包含地域和存储桶 |
| 45.| **s3/minio授权** | S3/minio oss授权 |
| 46.| **namesilo授权** | |
| 47.| **Next Terminal 授权** | 用于访问 Next Terminal API 的授权配置 |
| 48.| **Nginx Proxy Manager 授权** | 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。 |
| 49.| **1panel授权** | 账号和密码 |
| 50.| **支付宝** | |
| 51.| **白山云授权** | |
| 52.| **宝塔云WAF授权** | 用于连接和管理宝塔云WAF服务的授权配置 |
| 53.| **cdnfly授权** | |
| 54.| **k8s授权** | |
| 55.| **括彩云cdn授权** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 56.| **LeCDN授权** | |
| 57.| **lucky** | |
| 58.| **猫云授权** | |
| 59.| **plesk授权** | |
| 60.| **长亭雷池授权** | |
| 61.| **群晖登录授权** | |
| 62.| **uniCloud** | unicloud授权 |
| 63.| **微信支付** | |
| 64.| **易盾rcdn授权** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 65.| **易发云短信** | sms.yfyidc.cn/ |
| 66.| **易盾DCDN授权** | https://user.yiduncdn.com |
| 67.| **易支付** | |
| 68.| **proxmox** | |
| 69.| **Spaceship.com 授权** | Spaceship.com API 授权插件 |
| 70.| **Technitium DNS Server** | Technitium DNS Server 自建DNS服务器授权 |
| 71.| **UCloud授权** | 优刻得授权 |
| 72.| **又拍云** | |
| 73.| **网宿授权** | |
| 74.| **西部数码授权** | |
| 75.| **我爱云授权** | 我爱云CDN |
| 76.| **新网授权(代理方式)** | |
| 77.| **新网授权** | |
| 78.| **新网互联授权** | 仅支持代理账号,ip需要加入白名单 |
| 79.| **Zenlayer授权** | Zenlayer授权 |
| 80.| **GoEdge授权** | |
| 81.| **雨云授权** | https://app.rainyun.com/ |
<style module>
table th:first-of-type {
+25 -21
View File
@@ -1,5 +1,5 @@
# 任务插件
共 `135` 款任务插件
共 `139` 款任务插件
## 1. 证书申请
| 序号 | 名称 | 说明 |
@@ -31,26 +31,30 @@
| 4.| **中国移动-部署证书到CDN** | 中国移动自动部署证书到CDN |
| 5.| **多吉云-部署到多吉云CDN** | |
| 6.| **farcdn-更新证书** | www.farcdn.net |
| 7.| **FlexCDN-更新证书** | |
| 8.| **Gcore-刷新Gcore证书** | 刷新现有的证书 |
| 9.| **Gcore-部署证书到Gcore** | 仅上传 并不会部署到cdn |
| 10.| **GoEdge-更新证书** | GoEdge |
| 11.| **金山云-更新CDN证书** | 金山云自动更新CDN证书 |
| 12.| **白山云-更新证书** | |
| 13.| **cdnfly-部署证书到cdnfly** | cdnfly |
| 14.| **天翼云-部署证书到CDN** | 部署证书到天翼云CDN和全站加速 |
| 15.| **括彩云-部署到括彩云CDN** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 16.| **LeCDN-更新证书V2** | 支持新版本LeCDN |
| 17.| **LeCDN-更新证书** | |
| 18.| **Maoyun-更新猫云CDN证书** | |
| 19.| **易盾-部署到易盾DCDN** | 主要是防御,http://user.yiduncdn.com/ |
| 20.| **易盾-部署到易盾RCDN** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 21.| **雨云-更新证书** | app.rainyun.com |
| 22.| **又拍云-部署证书到CDN/USS** | 支持又拍云CDN,又拍云云存储USS |
| 23.| **网宿-更新证书** | 网宿证书自动更新 |
| 24.| **西数-部署到虚拟主机** | 西部数码部署证书到虚拟主机 |
| 25.| **我爱云-部署证书到我爱云CDN** | 部署证书到我爱云CDN |
| 26.| **Zenlayer-刷新证书** | 刷新Zenlayer CDN证书 |
| 7.| **Fastly-部署TLS激活** | 部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名) |
| 8.| **Fastly-清除缓存** | 清除 Fastly 指定 Service 的所有缓存 (Purge All) |
| 9.| **Fastly-更新证书** | 自动更新 Fastly CDN 上的已有自定义证书 |
| 10.| **Fastly-上传证书到Fastly** | 部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN |
| 11.| **FlexCDN-更新证书** | |
| 12.| **Gcore-刷新Gcore证书** | 刷新现有的证书 |
| 13.| **Gcore-部署证书到Gcore** | 仅上传 并不会部署到cdn |
| 14.| **GoEdge-更新证书** | GoEdge |
| 15.| **金山云-更新CDN证书** | 金山云自动更新CDN证书 |
| 16.| **白山云-更新证书** | |
| 17.| **cdnfly-部署证书到cdnfly** | cdnfly |
| 18.| **天翼云-部署证书到CDN** | 部署证书到天翼云CDN和全站加速 |
| 19.| **括彩云-部署到括彩云CDN** | 括彩云CDN,每月免费30G,[注册即领](https://kuocaicdn.com/register?code=8mn536rrzfbf8) |
| 20.| **LeCDN-更新证书V2** | 支持新版本LeCDN |
| 21.| **LeCDN-更新证书** | |
| 22.| **Maoyun-更新猫云CDN证书** | |
| 23.| **易盾-部署到易盾DCDN** | 主要是防御,http://user.yiduncdn.com/ |
| 24.| **易盾-部署到易盾RCDN** | 易盾CDN,每月免费30G,[注册即领](https://rhcdn.yiduncdn.com/register?code=8mn536rrzfbf8) |
| 25.| **雨云-更新证书** | app.rainyun.com |
| 26.| **又拍云-部署证书到CDN/USS** | 支持又拍云CDN,又拍云云存储USS |
| 27.| **网宿-更新证书** | 网宿证书自动更新 |
| 28.| **西数-部署到虚拟主机** | 西部数码部署证书到虚拟主机 |
| 29.| **我爱云-部署证书到我爱云CDN** | 部署证书到我爱云CDN |
| 30.| **Zenlayer-刷新证书** | 刷新Zenlayer CDN证书 |
## 4. 面板
| 序号 | 名称 | 说明 |
+1 -1
View File
@@ -9,5 +9,5 @@
}
},
"npmClient": "pnpm",
"version": "1.44.2"
"version": "1.44.3"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/publishlab/node-acme-client/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/acme-client
## [1.44.2](https://github.com/publishlab/node-acme-client/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/acme-client
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client",
"private": false,
"author": "nmorsman",
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"module": "./dist/index.js",
"main": "./dist/index.js",
@@ -18,7 +18,7 @@
"types"
],
"dependencies": {
"@certd/basic": "^1.44.2",
"@certd/basic": "^1.44.3",
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.9.0",
@@ -76,5 +76,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/basic
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/basic
+1 -1
View File
@@ -1 +1 @@
21:57
23:46
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/basic",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -54,5 +54,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/pipeline
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/pipeline
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/pipeline",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -21,8 +21,8 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/basic": "^1.44.2",
"@certd/plus-core": "^1.44.2",
"@certd/basic": "^1.44.3",
"@certd/plus-core": "^1.44.3",
"dayjs": "^1.11.7",
"lodash-es": "^4.17.21",
"reflect-metadata": "^0.2.2"
@@ -51,5 +51,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/lib-huawei
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/lib-huawei
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-huawei",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
"types": "./dist/d/index.d.ts",
@@ -31,5 +31,5 @@
"prettier": "3.3.3",
"tslib": "^2.8.1"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/lib-iframe
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/lib-iframe
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-iframe",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -37,5 +37,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/jdcloud
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/jdcloud
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/jdcloud",
"version": "1.44.2",
"version": "1.44.3",
"description": "jdcloud openApi sdk",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
@@ -63,5 +63,5 @@
"fetch"
]
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/lib-k8s
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/lib-k8s
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-k8s",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -21,7 +21,7 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/basic": "^1.44.2",
"@certd/basic": "^1.44.3",
"@kubernetes/client-node": "0.21.0"
},
"devDependencies": {
@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/lib-server
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/lib-server
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/lib-server",
"version": "1.44.2",
"version": "1.44.3",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -29,11 +29,11 @@
],
"license": "AGPL",
"dependencies": {
"@certd/acme-client": "^1.44.2",
"@certd/basic": "^1.44.2",
"@certd/pipeline": "^1.44.2",
"@certd/plugin-lib": "^1.44.2",
"@certd/plus-core": "^1.44.2",
"@certd/acme-client": "^1.44.3",
"@certd/basic": "^1.44.3",
"@certd/pipeline": "^1.44.3",
"@certd/plugin-lib": "^1.44.3",
"@certd/plus-core": "^1.44.3",
"@midwayjs/cache": "3.14.0",
"@midwayjs/core": "3.20.11",
"@midwayjs/i18n": "3.20.13",
@@ -69,5 +69,5 @@
"typeorm": "^0.3.20",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/midway-flyway-js
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/midway-flyway-js
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/midway-flyway-js",
"version": "1.44.2",
"version": "1.44.3",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -52,5 +52,5 @@
"typeorm": "^0.3.20",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/plugin-cert
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/plugin-cert
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-cert",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -20,7 +20,7 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/plugin-lib": "^1.44.2"
"@certd/plugin-lib": "^1.44.3"
},
"devDependencies": {
"@types/chai": "^4.3.12",
@@ -38,5 +38,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
**Note:** Version bump only for package @certd/plugin-lib
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
**Note:** Version bump only for package @certd/plugin-lib
+5 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-lib",
"private": false,
"version": "1.44.2",
"version": "1.44.3",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -17,9 +17,9 @@
"lint": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/acme-client": "^1.44.2",
"@certd/basic": "^1.44.2",
"@certd/pipeline": "^1.44.2",
"@certd/acme-client": "^1.44.3",
"@certd/basic": "^1.44.3",
"@certd/pipeline": "^1.44.3",
"dayjs": "^1.11.7",
"jszip": "^3.10.1",
"lodash-es": "^4.17.21",
@@ -45,5 +45,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "fe738fa7d4622016ed7d506c501df85393b6c857"
"gitHead": "67f3daae625ba734e7e390dd84acc9e3bc1930bc"
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
### Bug Fixes
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
### Bug Fixes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-client",
"version": "1.44.2",
"version": "1.44.3",
"private": true,
"scripts": {
"dev": "vite --open",
@@ -104,8 +104,8 @@
"zod-defaults": "^0.1.3"
},
"devDependencies": {
"@certd/lib-iframe": "^1.44.2",
"@certd/pipeline": "^1.44.2",
"@certd/lib-iframe": "^1.44.3",
"@certd/pipeline": "^1.44.3",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^4.3.12",
@@ -113,7 +113,8 @@ const onFinishFailed = (errorInfo: any) => {
const { openConfigDialog } = usePluginConfig();
async function doPluginConfig() {
const certApplyInfo = await getPluginSetting("CertApply");
const certApplyInfo: any = await getPluginSetting("CertApply");
certApplyInfo.pluginType = "plugin";
await openConfigDialog({
row: certApplyInfo,
onSuccess: async () => {
@@ -45,7 +45,7 @@
<script setup lang="tsx">
import { dict, FsRender } from "@fast-crud/fast-crud";
import { cloneDeep, merge, unset } from "lodash-es";
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, provide, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import Rollbackable from "./rollbackable.vue";
import { fillPluginDefine, usePluginStore } from "/@/store/plugin";
@@ -68,7 +68,7 @@ const labelCol = ref({
},
});
const wrapperCol = ref({ span: 16 });
const configForm: any = reactive({});
const configForm: any = reactive({ type: props.plugin.fullName || props.plugin.name });
const showDict = dict({
data: [
@@ -185,9 +185,11 @@ const optionsMappingKey = {
},
editRender(item: any) {
return () => {
const options = item.component?.options || [];
if (options.length === 0) {
return <span class="text-gray-400">该组件没有预设选项</span>;
const options = reactive(cloneDeep(item.component?.options || []));
const showAdd = options.length === 0;
function addOption(opt: any) {
options.push(opt);
}
const onLabelChange = (optValue: string, newLabel: string) => {
@@ -233,6 +235,13 @@ const optionsMappingKey = {
))}
</tbody>
</table>
{showAdd && (
<div class="mt-2">
<a-button type="primary" size={() => addOption({ value: "", label: "" })}>
添加选项
</a-button>
</div>
)}
<div class="helper mt-1">只需填写需要自定义的选项,留空则使用原始显示内容</div>
</div>
);
@@ -289,6 +298,17 @@ onMounted(async () => {
defineExpose({
getForm,
});
provide("getCurrentPluginDefine", () => {
return { value: props.plugin };
});
provide("get:scope", () => {
return getScope();
});
provide("get:plugin:type", () => {
debugger;
return props.plugin.pluginType;
});
</script>
<style lang="less">
@@ -49,7 +49,7 @@ export function usePluginConfig() {
}
}
const res = await api.savePluginSetting({
name: row.fullName,
name: row.fullName || row.name,
sysSetting: {
metadata: {
input: newForm,
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.44.3](https://github.com/certd/certd/compare/v1.44.2...v1.44.3) (2026-09-06)
### Bug Fixes
* **plugin-service:** 修复同步自己开发的云端插件不显示已安装状态问题 ([dac94b6](https://github.com/certd/certd/commit/dac94b64f0ceb7176e2cf07ce220be0aa5ed98d2))
* **plugin:** 修复插件配置保存与加载的相关问题 ([bd94334](https://github.com/certd/certd/commit/bd9433478a43d5d64ce6370386d0fe0aaff07d2e))
### Performance Improvements
* **plugin:** add Fastly custom TLS certificates integration ([#797](https://github.com/certd/certd/issues/797)) [@szhu25](https://github.com/szhu25) ([2a2e963](https://github.com/certd/certd/commit/2a2e963914c4dfc192cbe0a4bbb9c115e626da01))
## [1.44.2](https://github.com/certd/certd/compare/v1.44.1...v1.44.2) (2026-09-03)
### Bug Fixes
@@ -0,0 +1,28 @@
name: fastly
title: Fastly授权
icon: simple-icons:fastly
desc: Fastly CDN / TLS Custom Certificates API 授权
input:
apiKey:
title: API Token
component:
placeholder: Fastly API Key / Token
helper: 前往 Fastly Account Settings -> Personal Access Tokens 创建 Token
required: true
encrypt: true
proxy:
title: HTTP代理
component:
placeholder: http://xxxx.xxx.xx:10811
helper: 可选:是否使用 HTTP 代理访问 Fastly API
required: false
encrypt: false
testRequest:
title: 测试
component:
name: api-test
action: TestRequest
helper: 测试授权是否正确
pluginType: access
type: builtIn
scriptFilePath: /plugins/plugin-fastly/access.js
@@ -150,6 +150,15 @@ input:
helper: 请选择distributions id
order: 0
waitForDeployed:
title: 等待部署完成
value: false
helper: >-
开启后,会轮询等待每个CloudFront分配状态变为Deployed(通常需要几分钟)再结束任务。关闭时,提交证书更新后立即结束——CloudFront会在后台自行完成部署。仅当后续任务依赖证书已全球生效时才需要开启。
component:
name: a-switch
vModel: checked
order: 0
output: {}
pluginType: deploy
type: builtIn
@@ -0,0 +1,95 @@
showRunStrategy: false
default:
strategy:
runStrategy: 1
name: FastlyDeployCert
title: Fastly-部署TLS激活
desc: 部署 Fastly 证书 (创建 TLS Activation 绑定证书到域名)
icon: simple-icons:fastly
group: cdn
input:
cert:
title: 域名证书
helper: 选择前置任务输出的域名证书(将自动上传到 Fastly),或选择前置【Fastly-上传证书到Fastly】任务输出的 Fastly 证书ID。
component:
name: output-selector
from:
- ':cert:'
- FastlyUploadCert
required: true
order: 0
name:
title: 证书名称
helper: 可选。当传入的是域名证书需要先上传时,作为 Fastly 上的自定义证书名称/标签
component:
placeholder: '例如: my-fastly-cert'
required: false
order: 0
accessId:
title: Access授权
helper: Fastly 授权凭证
component:
name: access-selector
type: fastly
required: true
order: 0
domainIds:
title: TLS 域名
component:
name: remote-select
vModel: value
mode: tags
type: plugin
action: onGetTlsDomainList
search: false
pager: false
single: false
watches:
- certDomains
- accessId
required: true
mergeScript: |2-
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
helper: >-
选择要绑定证书的域名,可多选(将为每个域名创建或更新一个 TLS 激活)。列表为你 Fastly 服务上配置的域名,请选择被证书覆盖的具体主机名(如
www.example.com),而非通配 *.example.com(除非确有服务使用通配域名)。列表没有的也可直接输入。
order: 0
configurationId:
title: TLS 配置
component:
name: remote-select
vModel: value
mode: tags
type: plugin
action: onGetTlsConfigurationList
search: false
pager: false
single: true
watches:
- certDomains
- accessId
required: true
mergeScript: |2-
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
helper: 选择关联的 TLS 配置
order: 0
output: {}
pluginType: deploy
type: builtIn
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-deploy-to-service.js
@@ -0,0 +1,49 @@
showRunStrategy: false
default:
strategy:
runStrategy: 1
name: FastlyPurgeCache
title: Fastly-清除缓存
desc: 清除 Fastly 指定 Service 的所有缓存 (Purge All)
icon: simple-icons:fastly
group: cdn
input:
accessId:
title: Access授权
helper: Fastly 授权凭证
component:
name: access-selector
type: fastly
required: true
order: 0
serviceId:
title: Service (服务)
component:
name: remote-select
vModel: value
mode: tags
type: plugin
action: onGetServiceList
search: false
pager: false
single: false
watches:
- certDomains
- accessId
required: true
mergeScript: |2-
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
helper: 选择要清理所有缓存的 Fastly 服务
order: 0
output: {}
pluginType: deploy
type: builtIn
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-purge-cache.js
@@ -0,0 +1,60 @@
showRunStrategy: false
default:
strategy:
runStrategy: 1
name: FastlyRefreshCert
title: Fastly-更新证书
desc: 自动更新 Fastly CDN 上的已有自定义证书
icon: simple-icons:fastly
group: cdn
input:
cert:
title: 域名证书
helper: 请选择前置任务输出的域名证书
component:
name: output-selector
from:
- ':cert:'
required: true
order: 0
accessId:
title: Access授权
helper: Fastly 授权凭证
component:
name: access-selector
type: fastly
required: true
order: 0
certList:
title: 证书列表
component:
name: remote-select
vModel: value
mode: tags
type: plugin
action: onGetCertList
search: false
pager: false
single: false
watches:
- certDomains
- accessId
required: true
mergeScript: |2-
return {
component:{
form: ctx.compute(({form})=>{
return form
})
},
}
helper: >-
选择要更新的 Fastly 证书 (必须是已存在的自定义证书)。注意:Fastly
要求更新后的证书与原证书使用相同私钥,请确保续期时复用私钥(reuse key),否则更新会失败。
order: 0
output: {}
pluginType: deploy
type: builtIn
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-refresh-cert.js
@@ -0,0 +1,49 @@
showRunStrategy: false
default:
strategy:
runStrategy: 1
name: FastlyUploadCert
title: Fastly-上传证书到Fastly
desc: 部署 / 上传 SSL/TLS 自定义证书到 Fastly CDN
icon: simple-icons:fastly
group: cdn
input:
cert:
title: 域名证书
helper: 请选择前置任务输出的域名证书
component:
name: output-selector
from:
- ':cert:'
required: true
order: 0
accessId:
title: Access授权
helper: Fastly 授权凭证
component:
name: access-selector
type: fastly
required: true
order: 0
certificateId:
title: 证书ID (tls_certificate_id)
helper: >-
可选。若填写则对 Fastly 已有证书进行更新(PATCH);留空则新建上传(POST)。注意:Fastly
更新证书要求新证书与原证书使用相同的私钥,请确保续期时复用私钥(reuse key),否则更新会失败。
component:
placeholder: '例如: tls_cert_xxx (留空表示新建)'
required: false
order: 0
name:
title: 证书名称
helper: 可选。上传到 Fastly 上的自定义证书名称/标签
component:
placeholder: '例如: my-fastly-cert'
required: false
order: 0
output:
fastlyCertId:
title: Fastly 证书 ID
pluginType: deploy
type: builtIn
scriptFilePath: /plugins/plugin-fastly/plugins/plugin-upload-cert.js
+14 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-server",
"version": "1.44.2",
"version": "1.44.3",
"description": "fast-server base midway",
"private": true,
"type": "module",
@@ -43,20 +43,20 @@
"lint1": "eslint --fix --ext .ts src"
},
"dependencies": {
"@certd/acme-client": "^1.44.2",
"@certd/basic": "^1.44.2",
"@certd/commercial-core": "^1.44.2",
"@certd/acme-client": "^1.44.3",
"@certd/basic": "^1.44.3",
"@certd/commercial-core": "^1.44.3",
"@certd/cv4pve-api-javascript": "^8.4.2",
"@certd/jdcloud": "^1.44.2",
"@certd/lib-huawei": "^1.44.2",
"@certd/lib-k8s": "^1.44.2",
"@certd/lib-server": "^1.44.2",
"@certd/midway-flyway-js": "^1.44.2",
"@certd/pipeline": "^1.44.2",
"@certd/plugin-cert": "^1.44.2",
"@certd/plugin-lib": "^1.44.2",
"@certd/plugin-plus": "^1.44.2",
"@certd/plus-core": "^1.44.2",
"@certd/jdcloud": "^1.44.3",
"@certd/lib-huawei": "^1.44.3",
"@certd/lib-k8s": "^1.44.3",
"@certd/lib-server": "^1.44.3",
"@certd/midway-flyway-js": "^1.44.3",
"@certd/pipeline": "^1.44.3",
"@certd/plugin-cert": "^1.44.3",
"@certd/plugin-lib": "^1.44.3",
"@certd/plugin-plus": "^1.44.3",
"@certd/plus-core": "^1.44.3",
"@koa/cors": "^5.0.0",
"@midwayjs/bootstrap": "3.20.11",
"@midwayjs/cache": "3.14.0",
@@ -6,6 +6,7 @@ import { getRuntimeDepsService, initRuntimeDepsService } from "@certd/pipeline";
import { pluginRegistry, accessRegistry, notificationRegistry } from "@certd/pipeline";
import { dnsProviderRegistry } from "@certd/plugin-lib";
import { addonRegistry } from "@certd/lib-server";
import { PluginConfigService } from "../plugin/service/plugin-config-service.js";
@Provide()
@Scope(ScopeEnum.Request, { allowDowngrade: true })
@@ -13,6 +14,9 @@ export class AutoLoadPlugins {
@Inject()
pluginService: PluginService;
@Inject()
pluginConfigService: PluginConfigService;
@Config("runtimeDeps")
runtimeDepsConfig: any;
@@ -34,8 +38,8 @@ export class AutoLoadPlugins {
}
// await import("../../plugins/index.js")
await this.pluginService.registerFromDb();
await registerPaymentProviders();
await this.pluginConfigService.loadAllPluginSetting();
registerPaymentProviders();
logger.info(`加载插件完成,加载模式:${process.env.certd_plugin_loadmode}`);
//初始化第三方依赖服务
@@ -1,5 +1,8 @@
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { PluginService } from "./plugin-service.js";
import { accessRegistry, notificationRegistry, pluginRegistry } from "@certd/pipeline";
import { dnsProviderRegistry } from "@certd/plugin-lib";
import { addonRegistry } from "@certd/lib-server";
export type PluginConfig = {
name: string;
@@ -66,15 +69,54 @@ export class PluginConfigService {
};
const { id } = await this.pluginService.add(pluginEntity);
pluginEntity.id = id;
this.loadPluginSetting(name, sysSetting);
} else {
const setting = JSON.parse(pluginEntity.sysSetting || "{}");
if (sysSetting.metadata) {
setting.metadata = sysSetting.metadata;
}
if (sysSetting.input) {
//如果没有新提交,不覆盖旧的input
setting.input = sysSetting.input;
}
await this.pluginService.getRepository().update({ fullName: name }, { sysSetting: JSON.stringify(setting) });
this.loadPluginSetting(name, setting);
}
}
async loadPluginSetting(name: string, sysSetting: any) {
let pluginDefine = null;
if (!pluginDefine) {
pluginDefine = accessRegistry.getDefine(name);
}
if (!pluginDefine) {
pluginDefine = pluginRegistry.getDefine(name);
}
if (!pluginDefine) {
pluginDefine = dnsProviderRegistry.getDefine(name);
}
if (!pluginDefine) {
pluginDefine = addonRegistry.getDefine(name);
}
if (!pluginDefine) {
pluginDefine = notificationRegistry.getDefine(name);
}
if (!pluginDefine) {
return;
}
pluginDefine.sysSetting = sysSetting;
}
async loadAllPluginSetting() {
const pluginSettings = await this.pluginService.getRepository().find({
select: {
fullName: true,
type: true,
sysSetting: true,
},
});
for (const plugin of pluginSettings) {
this.loadPluginSetting(plugin.fullName, JSON.parse(plugin.sysSetting || "{}"));
}
}
@@ -1004,6 +1004,13 @@ export class PluginService extends BaseService<PluginEntity> {
score: itemFromStore.score,
downloadCount: itemFromStore.downloadCount,
latestVersion: itemFromStore.version,
developerId: itemFromStore.developerId,
vip: itemFromStore.vip,
dependPlugins: itemFromStore.dependPlugins,
aiCheckStatus: itemFromStore.aiCheckStatus,
status: itemFromStore.status,
appId: itemFromStore.appId,
author: itemFromStore.author,
};
await this.updateById(updateBean);
} else {
@@ -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';
@@ -35,7 +35,7 @@ async function createAwsClient(records: any[]) {
ChangeResourceRecordSetsCommand,
}),
} as any,
logger: { info() {}, error() {} } as any,
logger: { info() {}, warn() {}, error() {} } as any,
region: "us-east-1",
});
return { client, sentChanges };
@@ -62,10 +62,7 @@ describe("AwsClient.route53ChangeRecord", () => {
const change = sentChanges[0].ChangeBatch.Changes[0];
assert.equal(change.Action, "UPSERT");
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [
{ Value: '"apex-token"' },
{ Value: '"wildcard-token"' },
]);
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [{ Value: '"apex-token"' }, { Value: '"wildcard-token"' }]);
assert.equal(change.ResourceRecordSet.TTL, 300);
});
@@ -92,3 +89,124 @@ describe("AwsClient.route53ChangeRecord", () => {
assert.deepEqual(change.ResourceRecordSet.ResourceRecords, [{ Value: '"wildcard-token"' }]);
});
});
function throttle(name: string, extra: Record<string, any> = {}) {
const err: any = new Error(name);
err.name = name;
return Object.assign(err, extra);
}
describe("AwsClient.withRetry", () => {
it("retries a throttled call and then resolves", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
const result = await client.withRetry(async () => {
calls++;
if (calls < 3) {
throw throttle("ThrottlingException");
}
return "ok";
});
assert.equal(result, "ok");
assert.equal(calls, 3);
});
it("retries Route53 PriorRequestNotComplete", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
const result = await client.withRetry(async () => {
calls++;
if (calls < 2) {
throw throttle("PriorRequestNotComplete");
}
return "done";
});
assert.equal(result, "done");
assert.equal(calls, 2);
});
it("retries on HTTP 429 without a known error code", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
const err: any = new Error("too many requests");
err.$metadata = { httpStatusCode: 429 };
throw err;
}
return null;
});
assert.equal(calls, 2);
});
it("retries transient HTTP 5xx", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
const err: any = new Error("service unavailable");
err.$metadata = { httpStatusCode: 503 };
throw err;
}
return null;
});
assert.equal(calls, 2);
});
it("retries when the SDK marks the error $retryable", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
const err: any = new Error("internal failure");
err.name = "InternalFailure";
err.$retryable = {};
throw err;
}
return null;
});
assert.equal(calls, 2);
});
it("retries connection-level errors", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await client.withRetry(async () => {
calls++;
if (calls < 2) {
throw throttle("Error", { code: "ECONNRESET" });
}
return null;
});
assert.equal(calls, 2);
});
it("does not retry non-throttling errors", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await assert.rejects(
client.withRetry(async () => {
calls++;
throw throttle("AccessDeniedException");
}),
/AccessDeniedException/
);
assert.equal(calls, 1);
});
it("stops after maxAttempts and rethrows the throttling error", async () => {
const { client } = await createAwsClient([]);
let calls = 0;
await assert.rejects(
client.withRetry(async () => {
calls++;
throw throttle("Throttling", { message: "Rate exceeded" });
}, 3),
/Throttling|Rate exceeded/
);
assert.equal(calls, 3);
});
});
@@ -28,6 +28,8 @@ export class AwsClient {
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
secretAccessKey: this.access.secretAccessKey,
},
// Disable the SDK's own retries; withRetry/doRequest is the single retry authority.
maxAttempts: 1,
});
// Split the full PEM chain: first block is the leaf cert, the rest is the intermediate chain
@@ -57,6 +59,7 @@ export class AwsClient {
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
secretAccessKey: this.access.secretAccessKey,
},
maxAttempts: 1,
});
const command = new GetCallerIdentityCommand({});
@@ -75,6 +78,7 @@ export class AwsClient {
accessKeyId: this.access.accessKeyId, // 从环境变量中读取
secretAccessKey: this.access.secretAccessKey,
},
maxAttempts: 1,
});
}
@@ -202,10 +206,107 @@ export class AwsClient {
async doRequest<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch (err) {
this.logger.error(`调用接口失败:${err.Error?.Message || err.message},requestId:${err.requestId}`);
return await this.withRetry(call);
} catch (err: any) {
this.logger.error(`请求失败:${err.Error?.Message || err.message},requestId:${err.requestId}`);
throw err;
}
}
/**
* Retries an AWS SDK call with exponential backoff + jitter while the error is
* retryable. See {@link AwsClient.isRetryableAwsError}.
*
* `T` defaults to `any` so that call sites passing an untyped SDK client
* (`@aws-sdk/*` is a lazyDependency and has no types at build time) don't
* collapse the result to `unknown`.
*/
async withRetry<T = any>(call: () => Promise<T>, maxAttempts = 5, baseDelayMs = 2000): Promise<T> {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await call();
} catch (err: any) {
if (attempt >= maxAttempts || !AwsClient.isRetryableAwsError(err)) {
throw err;
}
const code = err?.name || err?.Code || err?.code || err?.$metadata?.httpStatusCode || "unknown";
const ceiling = baseDelayMs * Math.pow(2, attempt - 1); // 2s, 4s, 8s, 16s …
const delay = Math.round(ceiling / 2 + Math.random() * (ceiling / 2)); // equal jitter: ~1-2s, ~2-4s, ~4-8s, ~8-16s
this.logger.warn(`AWS request failed (${code}), attempt ${attempt}/${maxAttempts}, retrying in ${delay}ms…`);
await utils.sleep(delay);
}
}
throw new Error("Unreachable");
}
/**
* Detects AWS errors that are safe to retry. Because we disable the SDK's own
* retries (maxAttempts: 1 on every client), this is the single source of truth
* and covers what the SDK's `standard` strategy would have retried:
* - throttling / rate-limit codes across ACM / STS / CloudFront / Route53,
* including Route53's PriorRequestNotComplete concurrent-change limit;
* - SDK v3 retry hints (`$retryable`);
* - transient HTTP 429 and 5xx;
* - connection-level errors (ECONNRESET, ETIMEDOUT, EAI_AGAIN, …).
*/
static isRetryableAwsError(err: any): boolean {
if (!err) {
return false;
}
const retryableCodes = new Set([
"TooManyRequestsException",
"ThrottlingException",
"ThrottledException",
"RequestLimitExceeded",
"RequestThrottled",
"Throttling",
"SlowDown",
"PriorRequestNotComplete",
"RequestTimeout",
"RequestTimeoutException",
"TimeoutError",
]);
const connectionCodes = new Set(["ECONNRESET", "ECONNREFUSED", "EPIPE", "ETIMEDOUT", "EAI_AGAIN", "ENOTFOUND", "EHOSTUNREACH", "ENETUNREACH"]);
const code = err.name || err.Code || err.code || "";
const httpStatus = err.$metadata?.httpStatusCode;
return (
retryableCodes.has(code) ||
connectionCodes.has(err.code) ||
err.$retryable != null ||
httpStatus === 429 ||
(typeof httpStatus === "number" && httpStatus >= 500) ||
(typeof err.message === "string" && err.message.toLowerCase().includes("rate exceeded"))
);
}
/**
* Polls a CloudFront distribution until its Status becomes "Deployed".
* CloudFront propagates changes globally and can take several minutes.
*/
async waitForDistributionDeployed(cloudFrontClient: any, distributionId: string, timeoutMs = 600_000, pollIntervalMs = 15_000): Promise<void> {
const { GetDistributionCommand } = await this.access.importRuntime("@aws-sdk/client-cloudfront");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
let status: string | undefined;
try {
const res: any = await this.withRetry(() => cloudFrontClient.send(new GetDistributionCommand({ Id: distributionId })));
status = res?.Distribution?.Status;
} catch (err: any) {
// A retryable error that outlives withRetry's budget shouldn't abort the whole deploy;
// keep polling until the deadline and let genuine (non-retryable) errors propagate.
if (!AwsClient.isRetryableAwsError(err)) {
throw err;
}
this.logger.warn(`CloudFront status check failed (${err?.name || err?.code}), will retry on next poll…`);
await utils.sleep(pollIntervalMs);
continue;
}
this.logger.info(`CloudFront distribution ${distributionId} status: ${status}`);
if (status === "Deployed") {
return;
}
await utils.sleep(pollIntervalMs);
}
throw new Error(`Timed out waiting for CloudFront distribution ${distributionId} to reach Deployed status`);
}
}
@@ -67,15 +67,32 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
)
distributionIds!: string[];
@TaskInput({
title: "等待部署完成",
value: false,
helper: "开启后,会轮询等待每个CloudFront分配状态变为Deployed(通常需要几分钟)再结束任务。" + "关闭时,提交证书更新后立即结束——CloudFront会在后台自行完成部署。" + "仅当后续任务依赖证书已全球生效时才需要开启。",
component: {
name: "a-switch",
vModel: "checked",
},
})
waitForDeployed = false;
async onInstance() {}
async execute(): Promise<void> {
const access = await this.getAccess<AwsAccess>(this.accessId);
const acmClient = new AwsClient({
access,
region: this.region,
logger: this.logger,
});
let certId = this.cert as string;
if (typeof this.cert !== "string") {
//先上传
certId = await this.uploadToACM(access, this.cert);
certId = await this.uploadToACM(acmClient, this.cert);
}
//部署到CloudFront
@@ -86,42 +103,47 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
accessKeyId: access.accessKeyId,
secretAccessKey: access.secretAccessKey,
},
// Disable the SDK's own retries; acmClient.withRetry is the single retry authority.
maxAttempts: 1,
});
// update-distribution
for (const distributionId of this.distributionIds) {
// get-distribution-config
const getDistributionConfigCommand = new GetDistributionConfigCommand({
Id: distributionId,
});
// get-distribution-config (with retry for throttling)
const configData: any = await acmClient.withRetry(() => cloudFrontClient.send(new GetDistributionConfigCommand({ Id: distributionId })));
const configData = await cloudFrontClient.send(getDistributionConfigCommand);
await acmClient.withRetry(() =>
cloudFrontClient.send(
new UpdateDistributionCommand({
DistributionConfig: {
...configData.DistributionConfig,
ViewerCertificate: {
...configData.DistributionConfig.ViewerCertificate,
CloudFrontDefaultCertificate: false,
ACMCertificateArn: certId,
},
},
Id: distributionId,
IfMatch: configData.ETag,
})
)
);
const updateDistributionCommand = new UpdateDistributionCommand({
DistributionConfig: {
...configData.DistributionConfig,
ViewerCertificate: {
...configData.DistributionConfig.ViewerCertificate,
CloudFrontDefaultCertificate: false,
ACMCertificateArn: certId,
},
},
Id: distributionId,
IfMatch: configData.ETag,
});
await cloudFrontClient.send(updateDistributionCommand);
this.logger.info(`部署${distributionId}完成:`);
if (this.waitForDeployed) {
this.logger.info(`证书已提交到 ${distributionId},等待全局部署完成…`);
// CloudFront propagates globally in a few minutes; only block when the
// user opts in (e.g. a downstream task needs the cert already live).
await acmClient.waitForDistributionDeployed(cloudFrontClient, distributionId);
this.logger.info(`部署 ${distributionId} 完成`);
} else {
this.logger.info(`证书已提交到 ${distributionId},CloudFront 将在后台完成部署`);
}
}
this.logger.info("部署完成");
}
private async uploadToACM(access: AwsAccess, cert: CertInfo) {
const acmClient = new AwsClient({
access,
region: this.region,
logger: this.logger,
});
const awsCertARN = await acmClient.importCertificate(cert);
private async uploadToACM(acmClient: AwsClient, cert: CertInfo) {
const awsCertARN = await acmClient.withRetry(() => acmClient.importCertificate(cert));
this.logger.info("证书上传成功,id=", awsCertARN);
return awsCertARN;
}
@@ -140,6 +162,7 @@ export class AwsDeployToCloudFront extends AbstractTaskPlugin {
accessKeyId: access.accessKeyId,
secretAccessKey: access.secretAccessKey,
},
maxAttempts: 1,
});
// list-distributions
const listDistributionsCommand = new ListDistributionsCommand({});
@@ -65,7 +65,7 @@ export class AwsUploadToACM extends AbstractTaskPlugin {
region,
logger: this.logger,
});
this.awsCertARN = await acmClient.importCertificate(cert);
this.awsCertARN = await acmClient.withRetry(() => acmClient.importCertificate(cert));
this.logger.info("证书上传成功,id=", this.awsCertARN);
}
}
@@ -0,0 +1,358 @@
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> {
// encodeURIComponent 按规范保留 `*`,但 Fastly 过滤条件要求通配符编码为 `%2A`。
const encodedDomainId = encodeURIComponent(domainId).replace(/\*/g, "%2A");
const body = await this.doRequestApi(
`/tls/activations?filter[tls_domain.id]=${encodedDomainId}&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,505 @@
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";
@@ -0,0 +1,164 @@
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,104 @@
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,88 @@
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();
+1 -1
View File
@@ -1 +1 @@
00:58
22:05
+1 -1
View File
@@ -1 +1 @@
01:17
22:16