diff --git a/packages/plugins/plugin-lib/src/cert/cert-reader.ts b/packages/plugins/plugin-lib/src/cert/cert-reader.ts index cfbf88f2c..277514df6 100644 --- a/packages/plugins/plugin-lib/src/cert/cert-reader.ts +++ b/packages/plugins/plugin-lib/src/cert/cert-reader.ts @@ -135,7 +135,12 @@ export class CertReader { } 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 expires = detail.notAfter; const fingerprints = CertReader.getFingerprintX509(crt); diff --git a/packages/ui/certd-server/src/plugins/index.ts b/packages/ui/certd-server/src/plugins/index.ts index c00a82601..4272c3b4c 100644 --- a/packages/ui/certd-server/src/plugins/index.ts +++ b/packages/ui/certd-server/src/plugins/index.ts @@ -45,4 +45,5 @@ // export * from './plugin-plus/index.js' // export * from './plugin-cert/index.js' // export * from './plugin-zenlayer/index.js' -export * from './plugin-dnsmgr/index.js' \ No newline at end of file +// export * from './plugin-dnsmgr/index.js' +// export * from './plugin-nginx-proxy-manager/index.js' \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/NginxProxyManagerDeploy.yaml b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/NginxProxyManagerDeploy.yaml new file mode 100644 index 000000000..d6a061d6a --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/NginxProxyManagerDeploy.yaml @@ -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(", ")}`); + } + } + }; diff --git a/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/access.ts b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/access.ts new file mode 100644 index 000000000..5daf01abe --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/access.ts @@ -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 | 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 { + return await this.requestWithAuth({ + method: "GET", + url: "/nginx/proxy-hosts", + params: { + expand: "certificate", + ...(searchQuery ? { query: searchQuery } : {}), + }, + }); + } + + async getCertificates(searchQuery?: string): Promise { + return await this.requestWithAuth({ + method: "GET", + url: "/nginx/certificates", + params: searchQuery ? { query: searchQuery } : undefined, + }); + } + + async getCertificatesWithExpand( + searchQuery?: string, + expand: string[] = [] + ): Promise { + return await this.requestWithAuth({ + method: "GET", + url: "/nginx/certificates", + params: { + ...(searchQuery ? { query: searchQuery } : {}), + ...(expand.length > 0 ? { expand: expand.join(",") } : {}), + }, + }); + } + + async findCustomCertificateByNiceName(niceName: string): Promise { + 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 { + return await this.requestWithAuth({ + method: "POST", + url: "/nginx/certificates", + data: { + provider: "other", + nice_name: niceName, + domain_names: domainNames, + }, + }); + } + + async deleteCertificate(certificateId: number): Promise { + await this.requestWithAuth({ + method: "DELETE", + url: `/nginx/certificates/${certificateId}`, + }); + } + + async uploadCertificate( + certificateId: number, + payload: { + certificate: string; + certificateKey: string; + intermediateCertificate?: string; + } + ): Promise { + 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: number, certificateId: number): Promise { + await this.requestWithAuth({ + method: "PUT", + url: `/nginx/proxy-hosts/${hostId}`, + data: { + certificate_id: certificateId, + }, + }); + } + + private async login(): Promise { + 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 { + const initialLogin = await this.request({ + 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({ + 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(config: { + method: string; + url: string; + params?: Record; + data?: unknown; + headers?: Record; + }): Promise { + const token = await this.login(); + const headers = { + ...(config.headers ?? {}), + Authorization: `Bearer ${token}`, + }; + + return await this.request({ + ...config, + headers, + }); + } + + private async request(config: { + method: string; + url: string; + params?: Record; + data?: unknown; + headers?: Record; + }): Promise { + 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 { + const result = await this.verifyAccess(); + this.ctx.logger.info( + `Nginx Proxy Manager 授权验证成功,找到 ${result.proxyHostCount} 个代理主机` + ); + return `成功(${result.proxyHostCount} 个代理主机)`; + } + + async getProxyHostList(req: { searchKey?: string } = {}): Promise { + return await this.getProxyHosts(req.searchKey); + } +} + +new NginxProxyManagerAccess(); \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/index.ts b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/index.ts new file mode 100644 index 000000000..02dc3945d --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/index.ts @@ -0,0 +1,2 @@ +export * from "./plugins/index.js"; +export * from "./access.js"; diff --git a/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/nginxProxyManager.yaml b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/nginxProxyManager.yaml new file mode 100644 index 000000000..f9398a200 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/nginxProxyManager.yaml @@ -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); + } + }; diff --git a/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/index.ts b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/index.ts new file mode 100644 index 000000000..c01421810 --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/index.ts @@ -0,0 +1 @@ +export * from "./plugin-deploy-to-proxy-hosts.js"; \ No newline at end of file diff --git a/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/plugin-deploy-to-proxy-hosts.ts b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/plugin-deploy-to-proxy-hosts.ts new file mode 100644 index 000000000..0c83cf65a --- /dev/null +++ b/packages/ui/certd-server/src/plugins/plugin-nginx-proxy-manager/plugins/plugin-deploy-to-proxy-hosts.ts @@ -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 { + const access = await this.getAccess(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 { + if (!this.accessId) { + throw new Error("请先选择 Nginx Proxy Manager 授权"); + } + + const access = await this.getAccess(this.accessId); + const proxyHosts = await access.getProxyHostList(req); + return this.buildProxyHostOptions(proxyHosts, this.normalizeStringList(this.certDomains)); + } + + private async cleanupOldCertificates(access: NginxProxyManagerAccess, currentCertificateId: number): Promise { + 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(); \ No newline at end of file