mirror of
https://github.com/certd/certd.git
synced 2026-04-24 20:57:26 +08:00
perf: 支持部署到nginx-proxy-manager
This commit is contained in:
@@ -135,7 +135,12 @@ export class CertReader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static readCertDetail(crt: string) {
|
static readCertDetail(crt: string) {
|
||||||
const detail = crypto.readCertificateInfo(crt.toString());
|
let detail: CertificateInfo;
|
||||||
|
try {
|
||||||
|
detail = crypto.readCertificateInfo(crt.toString());
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error("证书解析失败:" + e.message + "(请确定证书格式,是否与私钥搞反?)");
|
||||||
|
}
|
||||||
const effective = detail.notBefore;
|
const effective = detail.notBefore;
|
||||||
const expires = detail.notAfter;
|
const expires = detail.notAfter;
|
||||||
const fingerprints = CertReader.getFingerprintX509(crt);
|
const fingerprints = CertReader.getFingerprintX509(crt);
|
||||||
|
|||||||
@@ -45,4 +45,5 @@
|
|||||||
// export * from './plugin-plus/index.js'
|
// export * from './plugin-plus/index.js'
|
||||||
// export * from './plugin-cert/index.js'
|
// export * from './plugin-cert/index.js'
|
||||||
// export * from './plugin-zenlayer/index.js'
|
// export * from './plugin-zenlayer/index.js'
|
||||||
export * from './plugin-dnsmgr/index.js'
|
// export * from './plugin-dnsmgr/index.js'
|
||||||
|
// export * from './plugin-nginx-proxy-manager/index.js'
|
||||||
+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(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { AccessInput, BaseAccess, IsAccess } from "@certd/pipeline";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import { authenticator } from "otplib";
|
||||||
|
|
||||||
|
export interface ProxyHost {
|
||||||
|
id: number;
|
||||||
|
domain_names?: string[];
|
||||||
|
certificate_id?: number;
|
||||||
|
proxy_hosts?: unknown[];
|
||||||
|
redirection_hosts?: unknown[];
|
||||||
|
dead_hosts?: unknown[];
|
||||||
|
streams?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Certificate {
|
||||||
|
id: number;
|
||||||
|
nice_name: string;
|
||||||
|
provider: string;
|
||||||
|
domain_names?: string[];
|
||||||
|
proxy_hosts?: unknown[];
|
||||||
|
redirection_hosts?: unknown[];
|
||||||
|
dead_hosts?: unknown[];
|
||||||
|
streams?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenResponse {
|
||||||
|
token?: string;
|
||||||
|
requires_2fa?: boolean;
|
||||||
|
challenge_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEndpoint(endpoint: string): string {
|
||||||
|
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 describeError(error: unknown, action: string): Error {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return new Error(`${action} failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
return new Error(`${action} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@IsAccess({
|
||||||
|
name: "nginxProxyManager",
|
||||||
|
title: "Nginx Proxy Manager 授权",
|
||||||
|
desc: "用于登录 Nginx Proxy Manager,并为代理主机证书部署提供授权。",
|
||||||
|
icon: "logos:nginx",
|
||||||
|
})
|
||||||
|
export class NginxProxyManagerAccess extends BaseAccess {
|
||||||
|
@AccessInput({
|
||||||
|
title: "NPM 地址",
|
||||||
|
component: {
|
||||||
|
name: "a-input",
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: "https://npm.example.com",
|
||||||
|
},
|
||||||
|
helper: "请输入 Nginx Proxy Manager 根地址,不要带 /api 后缀。",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
endpoint = "";
|
||||||
|
|
||||||
|
@AccessInput({
|
||||||
|
title: "邮箱",
|
||||||
|
component: {
|
||||||
|
name: "a-input",
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: "admin@example.com",
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
email = "";
|
||||||
|
|
||||||
|
@AccessInput({
|
||||||
|
title: "密码",
|
||||||
|
component: {
|
||||||
|
name: "a-input-password",
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: "请输入密码",
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
encrypt: true,
|
||||||
|
})
|
||||||
|
password = "";
|
||||||
|
|
||||||
|
@AccessInput({
|
||||||
|
title: "TOTP 密钥",
|
||||||
|
component: {
|
||||||
|
name: "a-input-password",
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: "Optional base32 TOTP secret",
|
||||||
|
},
|
||||||
|
helper: "当 Nginx Proxy Manager 账号开启 2FA 时必填。",
|
||||||
|
required: false,
|
||||||
|
encrypt: true,
|
||||||
|
})
|
||||||
|
totpSecret = "";
|
||||||
|
|
||||||
|
@AccessInput({
|
||||||
|
title: "忽略无效 TLS",
|
||||||
|
component: {
|
||||||
|
name: "a-switch",
|
||||||
|
vModel: "checked",
|
||||||
|
},
|
||||||
|
helper: "仅在 Nginx Proxy Manager 使用自签 HTTPS 证书时开启。",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
ignoreTls = false;
|
||||||
|
|
||||||
|
@AccessInput({
|
||||||
|
title: "测试",
|
||||||
|
component: {
|
||||||
|
name: "api-test",
|
||||||
|
action: "onTestRequest",
|
||||||
|
},
|
||||||
|
helper: "测试登录并拉取代理主机列表。",
|
||||||
|
})
|
||||||
|
testRequest = true;
|
||||||
|
|
||||||
|
private token: string | undefined;
|
||||||
|
private tokenPromise: Promise<string> | undefined;
|
||||||
|
|
||||||
|
private get apiBaseUrl(): string {
|
||||||
|
const endpoint = normalizeEndpoint(this.endpoint);
|
||||||
|
return `${endpoint}/api`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyAccess(): Promise<{ proxyHostCount: number }> {
|
||||||
|
const proxyHosts = await this.getProxyHosts();
|
||||||
|
return {
|
||||||
|
proxyHostCount: proxyHosts.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProxyHosts(searchQuery?: string): Promise<ProxyHost[]> {
|
||||||
|
return await this.requestWithAuth<ProxyHost[]>({
|
||||||
|
method: "GET",
|
||||||
|
url: "/nginx/proxy-hosts",
|
||||||
|
params: {
|
||||||
|
expand: "certificate",
|
||||||
|
...(searchQuery ? { query: searchQuery } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCertificates(searchQuery?: string): Promise<Certificate[]> {
|
||||||
|
return await this.requestWithAuth<Certificate[]>({
|
||||||
|
method: "GET",
|
||||||
|
url: "/nginx/certificates",
|
||||||
|
params: searchQuery ? { query: searchQuery } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCertificatesWithExpand(
|
||||||
|
searchQuery?: string,
|
||||||
|
expand: string[] = []
|
||||||
|
): Promise<Certificate[]> {
|
||||||
|
return await this.requestWithAuth<Certificate[]>({
|
||||||
|
method: "GET",
|
||||||
|
url: "/nginx/certificates",
|
||||||
|
params: {
|
||||||
|
...(searchQuery ? { query: searchQuery } : {}),
|
||||||
|
...(expand.length > 0 ? { expand: expand.join(",") } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCustomCertificateByNiceName(niceName: string): Promise<Certificate | undefined> {
|
||||||
|
const certificates = await this.getCertificates(niceName);
|
||||||
|
return certificates.find((certificate) => {
|
||||||
|
return certificate.provider === "other" && certificate.nice_name === niceName;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCustomCertificate(
|
||||||
|
niceName: string,
|
||||||
|
domainNames: string[] = []
|
||||||
|
): Promise<Certificate> {
|
||||||
|
return await this.requestWithAuth<Certificate>({
|
||||||
|
method: "POST",
|
||||||
|
url: "/nginx/certificates",
|
||||||
|
data: {
|
||||||
|
provider: "other",
|
||||||
|
nice_name: niceName,
|
||||||
|
domain_names: domainNames,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCertificate(certificateId: number): Promise<void> {
|
||||||
|
await this.requestWithAuth<void>({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/nginx/certificates/${certificateId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadCertificate(
|
||||||
|
certificateId: number,
|
||||||
|
payload: {
|
||||||
|
certificate: string;
|
||||||
|
certificateKey: string;
|
||||||
|
intermediateCertificate?: string;
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
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<void>({
|
||||||
|
method: "POST",
|
||||||
|
url: `/nginx/certificates/${certificateId}/upload`,
|
||||||
|
data: form,
|
||||||
|
headers: form.getHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignCertificateToProxyHost(hostId: number, certificateId: number): Promise<void> {
|
||||||
|
await this.requestWithAuth<void>({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/nginx/proxy-hosts/${hostId}`,
|
||||||
|
data: {
|
||||||
|
certificate_id: certificateId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performLogin(): Promise<string> {
|
||||||
|
const initialLogin = await this.request<TokenResponse>({
|
||||||
|
method: "POST",
|
||||||
|
url: "/tokens",
|
||||||
|
data: {
|
||||||
|
identity: this.email,
|
||||||
|
secret: this.password,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (initialLogin.token) {
|
||||||
|
return initialLogin.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialLogin.requires_2fa || !initialLogin.challenge_token) {
|
||||||
|
throw new Error("登录失败:Nginx Proxy Manager 未返回访问令牌");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.totpSecret) {
|
||||||
|
throw new Error(
|
||||||
|
"登录失败:该 Nginx Proxy Manager 账号启用了 2FA,但未配置 totpSecret"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let code: string;
|
||||||
|
try {
|
||||||
|
code = authenticator.generate(this.totpSecret);
|
||||||
|
} catch (error) {
|
||||||
|
throw describeError(error, "Generating TOTP code");
|
||||||
|
}
|
||||||
|
|
||||||
|
const completedLogin = await this.request<TokenResponse>({
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestWithAuth<T>(config: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
data?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}): Promise<T> {
|
||||||
|
const token = await this.login();
|
||||||
|
const headers = {
|
||||||
|
...(config.headers ?? {}),
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return await this.request<T>({
|
||||||
|
...config,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(config: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
data?: unknown;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}): Promise<T> {
|
||||||
|
const action = `${config.method ?? "GET"} ${config.url ?? "/"}`;
|
||||||
|
try {
|
||||||
|
const response = await this.ctx.http.request({
|
||||||
|
url: `${this.apiBaseUrl}${config.url}`,
|
||||||
|
method: config.method,
|
||||||
|
params: config.params,
|
||||||
|
data: config.data,
|
||||||
|
headers: config.headers,
|
||||||
|
timeout: 30000,
|
||||||
|
httpsAgent: this.ignoreTls ? {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
} : undefined,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
throw describeError(error, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onTestRequest(): Promise<string> {
|
||||||
|
const result = await this.verifyAccess();
|
||||||
|
this.ctx.logger.info(
|
||||||
|
`Nginx Proxy Manager 授权验证成功,找到 ${result.proxyHostCount} 个代理主机`
|
||||||
|
);
|
||||||
|
return `成功(${result.proxyHostCount} 个代理主机)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProxyHostList(req: { searchKey?: string } = {}): Promise<ProxyHost[]> {
|
||||||
|
return await this.getProxyHosts(req.searchKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new NginxProxyManagerAccess();
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./plugins/index.js";
|
||||||
|
export * from "./access.js";
|
||||||
+344
@@ -0,0 +1,344 @@
|
|||||||
|
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 { BaseAccess } = await import("@certd/pipeline");
|
||||||
|
const httpsModule = await import("node:https");
|
||||||
|
const { URL } = await import("node:url");
|
||||||
|
const axiosModule = await import("axios");
|
||||||
|
const formDataModule = await import("form-data");
|
||||||
|
const { authenticator } = await import("otplib");
|
||||||
|
|
||||||
|
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.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 {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from "./plugin-deploy-to-proxy-hosts.js";
|
||||||
+350
@@ -0,0 +1,350 @@
|
|||||||
|
import {
|
||||||
|
AbstractTaskPlugin,
|
||||||
|
IsTaskPlugin,
|
||||||
|
pluginGroups,
|
||||||
|
RunStrategy,
|
||||||
|
TaskInput,
|
||||||
|
} from "@certd/pipeline";
|
||||||
|
import { CertInfo, CertReader } from "@certd/plugin-cert";
|
||||||
|
import { NginxProxyManagerAccess, ProxyHost } from "../access.js";
|
||||||
|
|
||||||
|
interface ProxyHostOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@IsTaskPlugin({
|
||||||
|
name: "NginxProxyManagerDeploy",
|
||||||
|
title: "Nginx Proxy Manager-部署到主机",
|
||||||
|
desc: "上传自定义证书到 Nginx Proxy Manager,并绑定到所选主机。",
|
||||||
|
icon: "logos:nginx",
|
||||||
|
group: pluginGroups.panel.key,
|
||||||
|
default: {
|
||||||
|
strategy: {
|
||||||
|
runStrategy: RunStrategy.SkipWhenSucceed,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class NginxProxyManagerDeploy extends AbstractTaskPlugin {
|
||||||
|
@TaskInput({
|
||||||
|
title: "域名证书",
|
||||||
|
helper: "请选择前置任务产出的证书。",
|
||||||
|
component: {
|
||||||
|
name: "output-selector",
|
||||||
|
from: [":cert:"],
|
||||||
|
},
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
cert!: CertInfo;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "证书域名",
|
||||||
|
component: {
|
||||||
|
name: "cert-domains-getter",
|
||||||
|
},
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
certDomains!: string[];
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "NPM授权",
|
||||||
|
component: {
|
||||||
|
name: "access-selector",
|
||||||
|
type: "nginxProxyManager",
|
||||||
|
},
|
||||||
|
helper: "选择用于部署的 Nginx Proxy Manager 授权。",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
accessId!: string;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "代理主机",
|
||||||
|
component: {
|
||||||
|
name: "remote-select",
|
||||||
|
vModel: "value",
|
||||||
|
mode: "tags",
|
||||||
|
type: "plugin",
|
||||||
|
action: "onGetProxyHostOptions",
|
||||||
|
search: true,
|
||||||
|
pager: false,
|
||||||
|
multi: true,
|
||||||
|
watches: ["certDomains", "accessId"],
|
||||||
|
},
|
||||||
|
helper: "选择要绑定此证书的一个或多个代理主机。",
|
||||||
|
required: true,
|
||||||
|
})
|
||||||
|
proxyHostIds!: string | string[];
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "证书标识",
|
||||||
|
component: {
|
||||||
|
name: "a-input",
|
||||||
|
allowClear: true,
|
||||||
|
placeholder: "certd_npm_example_com",
|
||||||
|
},
|
||||||
|
helper: "可选。留空时默认使用 certd_npm_<主域名规范化>。",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
certificateLabel?: string;
|
||||||
|
|
||||||
|
@TaskInput({
|
||||||
|
title: "自动清理未使用证书",
|
||||||
|
component: {
|
||||||
|
name: "a-switch",
|
||||||
|
vModel: "checked",
|
||||||
|
},
|
||||||
|
helper: "部署成功后,自动删除除当前证书外所有未被任何主机引用的证书。",
|
||||||
|
required: false,
|
||||||
|
})
|
||||||
|
cleanupMatchingCertificates = false;
|
||||||
|
|
||||||
|
private normalizeDomain(domain: string): string {
|
||||||
|
return String(domain ?? "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private wildcardMatches(pattern: string, candidate: string): boolean {
|
||||||
|
if (!pattern.startsWith("*.")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const suffix = pattern.slice(1).toLowerCase();
|
||||||
|
return candidate.endsWith(suffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDomainMatch(left: string, right: string): boolean {
|
||||||
|
const normalizedLeft = this.normalizeDomain(left);
|
||||||
|
const normalizedRight = this.normalizeDomain(right);
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedLeft === normalizedRight ||
|
||||||
|
this.wildcardMatches(normalizedLeft, normalizedRight) ||
|
||||||
|
this.wildcardMatches(normalizedRight, normalizedLeft)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeDomainSegment(value: string): string {
|
||||||
|
const sanitized = String(value ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.replace(/_+/g, "_");
|
||||||
|
|
||||||
|
return sanitized || "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDefaultCertificateLabel(cert: CertInfo): string {
|
||||||
|
const mainDomain = CertReader.getMainDomain(cert.crt);
|
||||||
|
return `certd_npm_${this.sanitizeDomainSegment(mainDomain)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStringList(input: string | string[] | null | undefined): string[] {
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input == null || input === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [input];
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveCertificateDomains(cert: CertInfo, configuredDomains: string | string[] | null | undefined): string[] {
|
||||||
|
const configured = this.normalizeStringList(configuredDomains)
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (configured.length > 0) {
|
||||||
|
return Array.from(new Set(configured));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CertReader(cert).getAllDomains();
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProxyHostLabel(host: ProxyHost): string {
|
||||||
|
const domains = host.domain_names?.length ? host.domain_names.join(", ") : "(no domains)";
|
||||||
|
return `${domains} <#${host.id}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasAnyCertDomainMatch(host: ProxyHost, certDomains: string[]): boolean {
|
||||||
|
if (!certDomains.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostDomains = host.domain_names ?? [];
|
||||||
|
return hostDomains.some((hostDomain) =>
|
||||||
|
certDomains.some((certDomain) => this.isDomainMatch(hostDomain, certDomain))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildProxyHostOptions(hosts: ProxyHost[], certDomains: string[]) {
|
||||||
|
const sortedHosts = [...hosts].sort((left, right) => {
|
||||||
|
return this.buildProxyHostLabel(left).localeCompare(this.buildProxyHostLabel(right));
|
||||||
|
});
|
||||||
|
|
||||||
|
const matched: { label: string; value: string; domain: string }[] = [];
|
||||||
|
const unmatched: { label: string; value: string; domain: string }[] = [];
|
||||||
|
|
||||||
|
for (const host of sortedHosts) {
|
||||||
|
const option = {
|
||||||
|
label: this.buildProxyHostLabel(host),
|
||||||
|
value: String(host.id),
|
||||||
|
domain: host.domain_names?.[0] ?? "",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeProxyHostIds(proxyHostIds: string | string[] | number | null | undefined): number[] {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
this.normalizeStringList(proxyHostIds as string | string[] | null | undefined)
|
||||||
|
.map((value) => Number.parseInt(String(value), 10))
|
||||||
|
.filter((value) => Number.isInteger(value) && value > 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private certificateHasBindings(certificate: { proxy_hosts?: unknown[]; redirection_hosts?: unknown[]; dead_hosts?: unknown[]; streams?: unknown[] }): boolean {
|
||||||
|
return (
|
||||||
|
(certificate.proxy_hosts?.length ?? 0) > 0 ||
|
||||||
|
(certificate.redirection_hosts?.length ?? 0) > 0 ||
|
||||||
|
(certificate.dead_hosts?.length ?? 0) > 0 ||
|
||||||
|
(certificate.streams?.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(): Promise<void> {
|
||||||
|
const access = await this.getAccess<NginxProxyManagerAccess>(this.accessId);
|
||||||
|
const proxyHostIds = this.normalizeProxyHostIds(this.proxyHostIds);
|
||||||
|
|
||||||
|
if (proxyHostIds.length === 0) {
|
||||||
|
throw new Error("请至少选择一个 Nginx Proxy Manager 代理主机");
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateLabel =
|
||||||
|
this.certificateLabel?.trim() || this.buildDefaultCertificateLabel(this.cert);
|
||||||
|
const certificateDomains = this.resolveCertificateDomains(this.cert, this.certDomains);
|
||||||
|
|
||||||
|
let certificate = await access.findCustomCertificateByNiceName(certificateLabel);
|
||||||
|
if (!certificate) {
|
||||||
|
this.logger.info(`在 Nginx Proxy Manager 中创建自定义证书 "${certificateLabel}"`);
|
||||||
|
certificate = await access.createCustomCertificate(certificateLabel, certificateDomains);
|
||||||
|
} else {
|
||||||
|
this.logger.info(`复用已有自定义证书 "${certificateLabel}" (#${certificate.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await access.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 access.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(access, certificate.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`部署完成,共更新 ${proxyHostIds.length} 个代理主机`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onGetProxyHostOptions(req: { searchKey?: string } = {}): Promise<ProxyHostOption[] | { label: string; options: ProxyHostOption[] }[]> {
|
||||||
|
if (!this.accessId) {
|
||||||
|
throw new Error("请先选择 Nginx Proxy Manager 授权");
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await this.getAccess<NginxProxyManagerAccess>(this.accessId);
|
||||||
|
const proxyHosts = await access.getProxyHostList(req);
|
||||||
|
return this.buildProxyHostOptions(proxyHosts, this.normalizeStringList(this.certDomains));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cleanupOldCertificates(access: NginxProxyManagerAccess, currentCertificateId: number): Promise<void> {
|
||||||
|
const certificates = await access.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: number[] = [];
|
||||||
|
const skippedInUse: string[] = [];
|
||||||
|
const failedDeletes: string[] = [];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (this.certificateHasBindings(candidate)) {
|
||||||
|
skippedInUse.push(`#${candidate.id} ${candidate.nice_name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.info(`自动清理旧证书 #${candidate.id} ${candidate.nice_name}`);
|
||||||
|
try {
|
||||||
|
await access.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(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new NginxProxyManagerDeploy();
|
||||||
Reference in New Issue
Block a user