fix: 修复上传到cos报runtimeDepsService未初始化的问题

This commit is contained in:
xiaojunnuo
2026-07-15 01:09:57 +08:00
parent b91c9e4ea6
commit 167b303fae
53 changed files with 517 additions and 1533 deletions
@@ -1,348 +0,0 @@
name: NginxProxyManagerDeploy
icon: logos:nginx
title: Nginx Proxy Manager-部署到主机
group: panel
desc: 上传自定义证书到 Nginx Proxy Manager,并绑定到所选主机。
setting: null
sysSetting: null
type: custom
disabled: false
version: 1.0.0
pluginType: deploy
author: samler
input:
cert:
title: 域名证书
helper: 请选择前置任务产出的证书。
component:
name: output-selector
from:
- ":cert:"
required: true
order: 0
certDomains:
title: 证书域名
component:
name: cert-domains-getter
mergeScript: |
return {
component: {
inputKey: ctx.compute(({ form }) => {
return form.cert;
}),
},
}
required: false
order: 0
accessId:
title: NPM授权
component:
name: access-selector
type: samler/nginxProxyManager
helper: 选择用于部署的 Nginx Proxy Manager 授权。
required: true
order: 0
proxyHostIds:
title: 代理主机
component:
name: remote-select
vModel: value
mode: tags
type: plugin
action: onGetProxyHostOptions
search: true
pager: false
multi: true
watches:
- certDomains
- accessId
required: true
helper: 选择要绑定此证书的一个或多个代理主机。
mergeScript: |
return {
component: {
form: ctx.compute(({ form }) => {
return form;
}),
},
}
order: 0
certificateLabel:
title: 证书标识
component:
name: a-input
allowClear: true
placeholder: certd_npm_example_com
helper: 可选。留空时默认使用 certd_npm_<主域名规范化>.
required: false
order: 0
cleanupMatchingCertificates:
title: 自动清理未使用证书
component:
name: a-switch
vModel: checked
helper: 部署成功后,自动删除除当前证书外所有未被任何主机引用的证书。
required: false
order: 0
output: {}
default:
strategy:
runStrategy: 1
showRunStrategy: false
content: |
const { AbstractTaskPlugin } = await import("@certd/pipeline");
const { CertReader } = await import("@certd/plugin-cert");
function normalizeDomain(domain) {
return String(domain ?? "").trim().toLowerCase();
}
function wildcardMatches(pattern, candidate) {
if (!pattern.startsWith("*.")) {
return false;
}
const suffix = pattern.slice(1).toLowerCase();
return candidate.endsWith(suffix);
}
function isDomainMatch(left, right) {
const normalizedLeft = normalizeDomain(left);
const normalizedRight = normalizeDomain(right);
return (
normalizedLeft === normalizedRight ||
wildcardMatches(normalizedLeft, normalizedRight) ||
wildcardMatches(normalizedRight, normalizedLeft)
);
}
function normalizeDomainIdentity(domain) {
return normalizeDomain(domain).replace(/^\*\./, "");
}
function certificateHasBindings(certificate) {
return (
(certificate.proxy_hosts?.length ?? 0) > 0 ||
(certificate.redirection_hosts?.length ?? 0) > 0 ||
(certificate.dead_hosts?.length ?? 0) > 0 ||
(certificate.streams?.length ?? 0) > 0
);
}
function sanitizeDomainSegment(value) {
const sanitized = String(value ?? "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return sanitized || "unknown";
}
function buildDefaultCertificateLabel(cert) {
const mainDomain = CertReader.getMainDomain(cert.crt);
return `certd_npm_${sanitizeDomainSegment(mainDomain)}`;
}
function normalizeStringList(input) {
if (Array.isArray(input)) {
return input;
}
if (input == null || input === "") {
return [];
}
return [input];
}
function resolveCertificateDomains(cert, configuredDomains) {
const configured = normalizeStringList(configuredDomains)
.map((value) => String(value).trim())
.filter(Boolean);
if (configured.length > 0) {
return Array.from(new Set(configured));
}
return new CertReader(cert).getAllDomains();
}
function buildProxyHostLabel(host) {
const domains = host.domain_names?.length ? host.domain_names.join(", ") : "(no domains)";
return `${domains} <#${host.id}>`;
}
function hasAnyCertDomainMatch(host, certDomains) {
if (!certDomains.length) {
return false;
}
const hostDomains = host.domain_names ?? [];
return hostDomains.some((hostDomain) => certDomains.some((certDomain) => isDomainMatch(hostDomain, certDomain)));
}
function buildProxyHostOptions(hosts, certDomains) {
const sortedHosts = [...hosts].sort((left, right) => {
return buildProxyHostLabel(left).localeCompare(buildProxyHostLabel(right));
});
const matched = [];
const unmatched = [];
for (const host of sortedHosts) {
const option = {
label: buildProxyHostLabel(host),
value: String(host.id),
domain: host.domain_names?.[0] ?? "",
};
if (hasAnyCertDomainMatch(host, certDomains)) {
matched.push(option);
} else {
unmatched.push(option);
}
}
if (matched.length && unmatched.length) {
return [
{
label: "匹配证书域名的主机",
options: matched,
},
{
label: "其他代理主机",
options: unmatched,
},
];
}
return matched.length ? matched : unmatched;
}
function normalizeProxyHostIds(proxyHostIds) {
return Array.from(
new Set(
normalizeStringList(proxyHostIds)
.map((value) => Number.parseInt(String(value), 10))
.filter((value) => Number.isInteger(value) && value > 0),
),
);
}
return class NpmDeployToProxyHosts extends AbstractTaskPlugin {
cert;
certDomains;
accessId;
proxyHostIds;
certificateLabel;
cleanupMatchingCertificates = false;
async execute() {
const access = await this.getAccess(this.accessId);
const client = access.createClient();
const proxyHostIds = normalizeProxyHostIds(this.proxyHostIds);
if (proxyHostIds.length === 0) {
throw new Error("请至少选择一个 Nginx Proxy Manager 代理主机");
}
const certificateLabel = this.certificateLabel?.trim() || buildDefaultCertificateLabel(this.cert);
const certificateDomains = resolveCertificateDomains(this.cert, this.certDomains);
let certificate = await client.findCustomCertificateByNiceName(certificateLabel);
if (!certificate) {
this.logger.info(`在 Nginx Proxy Manager 中创建自定义证书 "${certificateLabel}"`);
certificate = await client.createCustomCertificate(certificateLabel, certificateDomains);
} else {
this.logger.info(`复用已有自定义证书 "${certificateLabel}" (#${certificate.id})`);
}
await client.uploadCertificate(certificate.id, {
certificate: this.cert.crt,
certificateKey: this.cert.key,
intermediateCertificate: this.cert.ic,
});
this.logger.info(`证书内容已上传到 Nginx Proxy Manager 证书 #${certificate.id}`);
for (const proxyHostId of proxyHostIds) {
this.logger.info(`将证书 #${certificate.id} 绑定到代理主机 #${proxyHostId}`);
try {
await client.assignCertificateToProxyHost(proxyHostId, certificate.id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`为代理主机 #${proxyHostId} 绑定证书失败:${message}`);
}
}
if (this.cleanupMatchingCertificates === true) {
await this.cleanupOldCertificates(client, certificate.id);
}
this.logger.info(`部署完成,共更新 ${proxyHostIds.length} 个代理主机`);
}
async onGetProxyHostOptions(req = {}) {
if (!this.accessId) {
throw new Error("请先选择 Nginx Proxy Manager 授权");
}
const access = await this.getAccess(this.accessId);
const proxyHosts = await access.getProxyHostList(req);
return buildProxyHostOptions(proxyHosts, normalizeStringList(this.certDomains));
}
async cleanupOldCertificates(client, currentCertificateId) {
const certificates = await client.getCertificatesWithExpand(undefined, [
"proxy_hosts",
"redirection_hosts",
"dead_hosts",
"streams",
]);
const candidates = certificates.filter((certificate) => {
return certificate.id !== currentCertificateId;
});
if (candidates.length === 0) {
this.logger.info("未发现可自动清理的旧证书");
return;
}
const deletedIds = [];
const skippedInUse = [];
const failedDeletes = [];
for (const candidate of candidates) {
if (certificateHasBindings(candidate)) {
skippedInUse.push(`#${candidate.id} ${candidate.nice_name}`);
continue;
}
this.logger.info(`自动清理旧证书 #${candidate.id} ${candidate.nice_name}`);
try {
await client.deleteCertificate(candidate.id);
deletedIds.push(candidate.id);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failedDeletes.push(`#${candidate.id} ${candidate.nice_name}: ${message}`);
}
}
if (deletedIds.length > 0) {
this.logger.info(`自动清理完成,共删除 ${deletedIds.length} 张旧证书:${deletedIds.map((id) => `#${id}`).join(", ")}`);
} else {
this.logger.info("未删除任何旧证书");
}
if (skippedInUse.length > 0) {
this.logger.info(`以下旧证书仍被其他资源引用,已跳过清理:${skippedInUse.join(", ")}`);
}
if (failedDeletes.length > 0) {
this.logger.warn(`以下旧证书清理失败,已跳过:${failedDeletes.join(", ")}`);
}
}
};
@@ -1,347 +0,0 @@
name: nginxProxyManager
icon: logos:nginx
title: Nginx Proxy Manager 授权
group: null
desc: 用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。
setting: null
sysSetting: null
type: custom
disabled: false
version: 1.0.0
pluginType: access
author: samler
input:
endpoint:
title: NPM 地址
component:
name: a-input
allowClear: true
placeholder: https://npm.example.com
helper: 请输入 Nginx Proxy Manager 根地址,不要带 /api 后缀。
required: true
email:
title: 邮箱
component:
name: a-input
allowClear: true
placeholder: admin@example.com
required: true
password:
title: 密码
component:
name: a-input-password
allowClear: true
placeholder: 请输入密码
required: true
encrypt: true
totpSecret:
title: TOTP 密钥
component:
name: a-input-password
allowClear: true
placeholder: Optional base32 TOTP secret
helper: 当 Nginx Proxy Manager 账号开启 2FA 时必填。
required: false
encrypt: true
ignoreTls:
title: 忽略无效 TLS
component:
name: a-switch
vModel: checked
helper: 仅在 Nginx Proxy Manager 使用自签 HTTPS 证书时开启。
required: false
testRequest:
title: 测试
component:
name: api-test
action: TestRequest
helper: 测试登录并拉取代理主机列表。
content: |
const runtimeImport = import;
const { BaseAccess } = await runtimeImport("@certd/pipeline");
const httpsModule = await runtimeImport("node:https");
const { URL } = await runtimeImport("node:url");
const axiosModule = await runtimeImport("axios");
const formDataModule = await runtimeImport("form-data");
const https = httpsModule.default ?? httpsModule;
const axios = axiosModule.default ?? axiosModule;
const FormData = formDataModule.default ?? formDataModule;
function normalizeEndpoint(endpoint) {
const trimmed = String(endpoint ?? "").trim();
if (!trimmed) {
throw new Error("Nginx Proxy Manager 地址不能为空");
}
const withoutTrailingSlash = trimmed.replace(/\/+$/, "");
return withoutTrailingSlash.endsWith("/api")
? withoutTrailingSlash.slice(0, -4)
: withoutTrailingSlash;
}
function buildHttpsAgent(endpoint, ignoreTls) {
const url = new URL(endpoint);
if (url.protocol !== "https:") {
return undefined;
}
return new https.Agent({
rejectUnauthorized: !ignoreTls,
});
}
function describeError(error, action) {
if (axios.isAxiosError(error)) {
const status = error.response?.status;
const data = error.response?.data;
const message =
data?.error?.message ||
data?.error ||
data?.message ||
(typeof data === "string" ? data : null) ||
error.message;
return new Error(`${action} failed${status ? ` (${status})` : ""}: ${message}`);
}
if (error instanceof Error) {
return new Error(`${action} failed: ${error.message}`);
}
return new Error(`${action} failed`);
}
class NginxProxyManagerClient {
constructor(options) {
this.options = options;
this.importFn = options.importFn;
this.endpoint = normalizeEndpoint(options.endpoint);
this.apiBaseUrl = `${this.endpoint}/api`;
this.token = undefined;
this.tokenPromise = undefined;
this.httpClient = axios.create({
baseURL: this.apiBaseUrl,
timeout: 30000,
maxBodyLength: Infinity,
maxContentLength: Infinity,
httpsAgent: buildHttpsAgent(this.endpoint, options.ignoreTls === true),
headers: {
Accept: "application/json",
},
});
}
async verifyAccess() {
const proxyHosts = await this.getProxyHosts();
return {
proxyHostCount: proxyHosts.length,
};
}
async getProxyHosts(searchQuery) {
return await this.requestWithAuth({
method: "GET",
url: "/nginx/proxy-hosts",
params: {
expand: "certificate",
...(searchQuery ? { query: searchQuery } : {}),
},
});
}
async getCertificates(searchQuery) {
return await this.requestWithAuth({
method: "GET",
url: "/nginx/certificates",
params: searchQuery ? { query: searchQuery } : undefined,
});
}
async getCertificatesWithExpand(searchQuery, expand = []) {
return await this.requestWithAuth({
method: "GET",
url: "/nginx/certificates",
params: {
...(searchQuery ? { query: searchQuery } : {}),
...(expand.length > 0 ? { expand: expand.join(",") } : {}),
},
});
}
async findCustomCertificateByNiceName(niceName) {
const certificates = await this.getCertificates(niceName);
return certificates.find((certificate) => {
return certificate.provider === "other" && certificate.nice_name === niceName;
});
}
async createCustomCertificate(niceName, domainNames = []) {
return await this.requestWithAuth({
method: "POST",
url: "/nginx/certificates",
data: {
provider: "other",
nice_name: niceName,
domain_names: domainNames,
},
});
}
async deleteCertificate(certificateId) {
await this.requestWithAuth({
method: "DELETE",
url: `/nginx/certificates/${certificateId}`,
});
}
async uploadCertificate(certificateId, payload) {
const form = new FormData();
form.append("certificate", Buffer.from(payload.certificate, "utf8"), {
filename: "fullchain.pem",
contentType: "application/x-pem-file",
});
form.append("certificate_key", Buffer.from(payload.certificateKey, "utf8"), {
filename: "privkey.pem",
contentType: "application/x-pem-file",
});
if (payload.intermediateCertificate) {
form.append("intermediate_certificate", Buffer.from(payload.intermediateCertificate, "utf8"), {
filename: "chain.pem",
contentType: "application/x-pem-file",
});
}
await this.requestWithAuth({
method: "POST",
url: `/nginx/certificates/${certificateId}/upload`,
data: form,
headers: form.getHeaders(),
});
}
async assignCertificateToProxyHost(hostId, certificateId) {
await this.requestWithAuth({
method: "PUT",
url: `/nginx/proxy-hosts/${hostId}`,
data: {
certificate_id: certificateId,
},
});
}
async login() {
if (this.token) {
return this.token;
}
if (!this.tokenPromise) {
this.tokenPromise = this.performLogin().finally(() => {
this.tokenPromise = undefined;
});
}
this.token = await this.tokenPromise;
return this.token;
}
async performLogin() {
const initialLogin = await this.request({
method: "POST",
url: "/tokens",
data: {
identity: this.options.email,
secret: this.options.password,
},
});
if (initialLogin.token) {
return initialLogin.token;
}
if (!initialLogin.requires_2fa || !initialLogin.challenge_token) {
throw new Error("登录失败:Nginx Proxy Manager 未返回访问令牌");
}
if (!this.options.totpSecret) {
throw new Error("登录失败:该 Nginx Proxy Manager 账号启用了 2FA,但未配置 totpSecret");
}
let code;
try {
const { authenticator } = await this.importFn("otplib");
code = authenticator.generate(this.options.totpSecret);
} catch (error) {
throw describeError(error, "Generating TOTP code");
}
const completedLogin = await this.request({
method: "POST",
url: "/tokens/2fa",
data: {
challenge_token: initialLogin.challenge_token,
code,
},
});
if (!completedLogin.token) {
throw new Error("2FA 登录失败:Nginx Proxy Manager 未返回访问令牌");
}
return completedLogin.token;
}
async requestWithAuth(config) {
const token = await this.login();
const headers = {
...(config.headers ?? {}),
Authorization: `Bearer ${token}`,
};
return await this.request({
...config,
headers,
});
}
async request(config) {
const action = `${config.method ?? "GET"} ${config.url ?? "/"}`;
try {
const response = await this.httpClient.request(config);
return response.data;
} catch (error) {
throw describeError(error, action);
}
}
}
return class NginxProxyManagerAccess extends BaseAccess {
endpoint = "";
email = "";
password = "";
totpSecret = "";
ignoreTls = false;
testRequest = true;
createClient() {
return new NginxProxyManagerClient({
endpoint: this.endpoint,
email: this.email,
password: this.password,
totpSecret: this.totpSecret || undefined,
ignoreTls: this.ignoreTls === true,
importFn: this.importRuntime.bind(this),
});
}
async onTestRequest() {
const client = this.createClient();
const result = await client.verifyAccess();
this.ctx.logger.info(`Nginx Proxy Manager 授权验证成功,找到 ${result.proxyHostCount} 个代理主机`);
return `成功(${result.proxyHostCount} 个代理主机)`;
}
async getProxyHostList(req = {}) {
const client = this.createClient();
return await client.getProxyHosts(req.searchKey);
}
};