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(", ")}`); } } };