mirror of
https://github.com/certd/certd.git
synced 2026-07-16 02:37:34 +08:00
perf: 支持部署到nginx-proxy-manager
This commit is contained in:
+348
@@ -0,0 +1,348 @@
|
||||
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(", ")}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user