feat(ssl): 证书签发链路搬到国内机 Docker,清理 1Panel 过期证书
架构定案(B+C):CF Worker 免费版 CPU 硬顶 10ms(cron 同), 不再购买 Paid($5/月≈¥36),改为—— B. Worker 留免费版 + 代码优化(把 CPU 压进预算) C. 签发+部署整条链路搬国内机 Docker 容器 代码 - acme.ts: 缓存 signingKey 为 Promise,单次签发 importKey 12→1 次 (实测 importKey 126µs / sign 84µs;一次签发 3.3ms → 1.4ms) - deployer.ts: 新增 DogeCloudDeployer.ping();修正 cert_id → id 的注释 - dnsprovider.ts: 新增 RemoteDns(把 DNS-01 写 TXT 委托给国内机 cn-dns-helper) - tools/certkeeper-config.mjs: 域名配置抽为唯一事实源(两个消费方共用) - tools/export-certkeeper-data.mjs: 导出国内机数据目录 新增部署单元 - deploy/cn-certkeeper: 签发+部署容器(只绑 127.0.0.1:8019,compose 管理) 含 FileKV(文件系统版 KVNamespace)、带鉴权 HTTP、每日 4:10 续期 - deploy/cn-dns-helper: DNS-01 写 TXT 助手(只绑 127.0.0.1:8018) 文档 - 函数版证书管家-方案.md 新增第九章:B+C 定案、实测 CPU 数据、 容器验收记录、1Panel 过期证书清理记录、t-t.live 两套管理冲突 - 标注旧 8.3 节「免费版跑不了签发」为未实测误判 一并纳入:.gitignore 忽略 deploy/cn-certkeeper/lib/(tsc 编译产物)
This commit is contained in:
1 parent
a3bc10c68c
commit
1427c47dcf
15 files changed
+1387
-52
No files matched your search
@@ -29,3 +29,4 @@ cnb-secrets.yml
|
|||||||
.edgeone/
|
.edgeone/
|
||||||
# Cloudflare 部署自检产物
|
# Cloudflare 部署自检产物
|
||||||
blog-admin/.deploy-check/
|
blog-admin/.deploy-check/
|
||||||
|
deploy/cn-certkeeper/lib/
|
||||||
@@ -109,6 +109,23 @@ export class AcmeClient {
|
|||||||
private nonce: string | null = null;
|
private nonce: string | null = null;
|
||||||
private readonly log: AcmeLogger;
|
private readonly log: AcmeLogger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ★ 账户私钥的 CryptoKey 缓存(2026-10-06 加)。
|
||||||
|
*
|
||||||
|
* 原来 signJws() 每次调用都 `importKey('jwk', ...)`。一次签发有 ~11 次 JWS,
|
||||||
|
* 本地实测(.editor-tmp/cpu-bench4.mjs,3000 次迭代):
|
||||||
|
* importKey('jwk') 单次 126 µs;若密钥已就绪,纯 sign 只要 84 µs。
|
||||||
|
* 也就是说每次签发白烧 ≈ 1.4 ms。Workers 免费版 CPU 硬顶 10 ms,
|
||||||
|
* 这 1.4 ms 值得省;就算跑在国内机,少一次密钥解析也没坏处。
|
||||||
|
*
|
||||||
|
* 缓存安全性:账户密钥(this.account.jwk)在实例生命周期内**不变**,
|
||||||
|
* 而一次签发自始至终用同一个实例(见 certissue.ts 的 issueDomain)。
|
||||||
|
*
|
||||||
|
* 存 Promise 而不是 CryptoKey:并发调用时只真正 import 一次
|
||||||
|
* (存 CryptoKey 的话,两个并发请求会各 import 一次,结果一样但白花 CPU)。
|
||||||
|
*/
|
||||||
|
private signingKey: Promise<CryptoKey> | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly directoryUrl: string,
|
private readonly directoryUrl: string,
|
||||||
private readonly account: { jwk: JsonWebKey; kid: string },
|
private readonly account: { jwk: JsonWebKey; kid: string },
|
||||||
@@ -168,14 +185,29 @@ export class AcmeClient {
|
|||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取(并缓存)账户签名密钥 —— 见 signingKey 字段注释。
|
||||||
|
* 只在第一次调用时真的 importKey,之后复用同一个 CryptoKey。
|
||||||
|
*/
|
||||||
|
private importSigningKey(): Promise<CryptoKey> {
|
||||||
|
if (!this.signingKey) {
|
||||||
|
// 失败时清掉缓存,否则一次网络/参数抖动会被永久缓存成 reject
|
||||||
|
this.signingKey = (crypto.subtle.importKey(
|
||||||
|
'jwk',
|
||||||
|
this.account.jwk,
|
||||||
|
{ name: 'ECDSA', namedCurve: 'P-256' },
|
||||||
|
false,
|
||||||
|
['sign'],
|
||||||
|
) as Promise<CryptoKey>).catch((e) => {
|
||||||
|
this.signingKey = null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.signingKey;
|
||||||
|
}
|
||||||
|
|
||||||
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
|
private async signJws(protectedHeader: Record<string, unknown>, payload: unknown): Promise<string> {
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await this.importSigningKey();
|
||||||
'jwk',
|
|
||||||
this.account.jwk,
|
|
||||||
{ name: 'ECDSA', namedCurve: 'P-256' },
|
|
||||||
false,
|
|
||||||
['sign'],
|
|
||||||
);
|
|
||||||
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
|
const signingInput = `${b64uJson(protectedHeader)}.${b64uJson(payload)}`;
|
||||||
const sig = await crypto.subtle.sign(
|
const sig = await crypto.subtle.sign(
|
||||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
|||||||
@@ -134,6 +134,30 @@ export class DogeCloudDeployer implements Deployer {
|
|||||||
return d.data as T;
|
return d.data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 只读探活:列一次 CDN 域名,验证 AK/SK 与连通性。
|
||||||
|
*
|
||||||
|
* ★ 与 OnePanelDeployer.ping 保持**同一签名**(返回对象、不抛错),
|
||||||
|
* 这样调用方(cn-certkeeper 的 /preflight、Worker 的 /ssl/selfcheck)
|
||||||
|
* 能统一处理,不必为每种部署器各写一套判错逻辑。
|
||||||
|
*/
|
||||||
|
async ping(): Promise<{ ok: boolean; error?: string; hint?: string; detail?: string }> {
|
||||||
|
try {
|
||||||
|
const d = await this.call<{ domains?: unknown[] }>('/cdn/domain/list.json', {});
|
||||||
|
const n = Array.isArray(d?.domains) ? d.domains.length : 0;
|
||||||
|
return { ok: true, detail: `${n} 个 CDN 域名` };
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: msg,
|
||||||
|
hint: /签名|signature|auth|TOKEN/i.test(msg)
|
||||||
|
? 'AK/SK 不对或签名算法有变(多吉云 → 个人中心 → API 密钥)'
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
|
async deploy(cert: DeployCert, opts: DeployOptions): Promise<DeployResult> {
|
||||||
const log = opts.log || (() => {});
|
const log = opts.log || (() => {});
|
||||||
const details: string[] = [];
|
const details: string[] = [];
|
||||||
@@ -142,7 +166,8 @@ export class DogeCloudDeployer implements Deployer {
|
|||||||
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
|
const certId = await this.uploadOrReuse(cert, opts.dogecloudDomains || [], log);
|
||||||
details.push(`证书 #${certId}`);
|
details.push(`证书 #${certId}`);
|
||||||
|
|
||||||
// ② 逐个域名绑定(★ 字段名是 cert_id,下划线)
|
// ② 逐个域名绑定(★ 字段名是 `id` —— 实测传 `cert_id` 会被服务端**无视**,
|
||||||
|
// 见本文件顶部「用假 id 999999 做对照实验」那段)
|
||||||
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
|
const domains = (opts.dogecloudDomains || []).map((s) => s.trim()).filter(Boolean);
|
||||||
if (!domains.length) {
|
if (!domains.length) {
|
||||||
log('多吉云:没有配置要绑定的域名,只上传不绑定');
|
log('多吉云:没有配置要绑定的域名,只上传不绑定');
|
||||||
|
|||||||
@@ -278,11 +278,107 @@ export class CloudflareDns implements DnsProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================== 远程转发(国内机)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 TXT 增删**委托给国内机上的 cn-dns-helper**(Docker 容器)。
|
||||||
|
*
|
||||||
|
* ★ 为什么需要这么一层:
|
||||||
|
* Worker 侧的 `cloudflare` 凭据是 Workers/KV/D1 专用的,没有 Zone/DNS 权限,
|
||||||
|
* 直接做 DNS-01 会被 CF 回 403。国内机的 certimate 里有一条**有完整 DNS 权限**
|
||||||
|
* 的 token —— 与其让用户去 CF 后台重新签发 token、再往 Worker 塞一份写权限凭据,
|
||||||
|
* 不如把「写 TXT」的执行权留在国内机,Worker 只持有一个**只能改 DNS 的窄权限**
|
||||||
|
* 共享密钥。权限面更小,且复用现有基础设施。
|
||||||
|
*
|
||||||
|
* 契约见 deploy/cn-dns-helper/src/server.mjs。
|
||||||
|
*/
|
||||||
|
export class RemoteDns implements DnsProvider {
|
||||||
|
readonly kind = 'remote';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly baseUrl: string,
|
||||||
|
private readonly helperToken: string,
|
||||||
|
) {
|
||||||
|
if (!baseUrl) throw new Error('远程 DNS 凭据缺少 serverUrl');
|
||||||
|
if (!helperToken) throw new Error('远程 DNS 凭据缺少 helperToken');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async call(path: string, init: RequestInit): Promise<any> {
|
||||||
|
const url = this.baseUrl.replace(/\/+$/, '') + path;
|
||||||
|
let resp: Response;
|
||||||
|
try {
|
||||||
|
resp = await fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Auth-Token': this.helperToken,
|
||||||
|
...(init.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// 网络层错误单独包装:国内机不可达时,报错要能一眼看出是「通道」问题
|
||||||
|
throw new Error(`连不上 DNS 助手(${url}):${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
const text = await resp.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`DNS 助手返回非 JSON(HTTP ${resp.status}):${text.slice(0, 200)}`);
|
||||||
|
}
|
||||||
|
if (resp.status === 401) throw new Error('DNS 助手鉴权失败(helperToken 不匹配)');
|
||||||
|
if (!resp.ok) throw new Error(`DNS 助手出错(HTTP ${resp.status}):${data?.error || text.slice(0, 200)}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `_acme-challenge.200181.xyz` → zone=`200181.xyz`、记录名保持全名(CF 接受 FQDN) */
|
||||||
|
private split(fqdn: string): { zone: string; name: string } {
|
||||||
|
const name = fqdn.replace(/\.$/, '').toLowerCase();
|
||||||
|
return { zone: this.rootOf(name), name };
|
||||||
|
}
|
||||||
|
|
||||||
|
private rootOf(name: string): string {
|
||||||
|
const parts = name.split('.');
|
||||||
|
if (parts.length <= 2) return name;
|
||||||
|
return parts.slice(-2).join('.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTxt(fqdn: string, value: string): Promise<void> {
|
||||||
|
const { zone, name } = this.split(fqdn);
|
||||||
|
await this.call('/dns/txt', { method: 'POST', body: JSON.stringify({ zone, name, value }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async delTxt(fqdn: string, value: string): Promise<void> {
|
||||||
|
const { zone, name } = this.split(fqdn);
|
||||||
|
await this.call('/dns/del', { method: 'POST', body: JSON.stringify({ zone, name, value }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
async listTxt(fqdn: string): Promise<string[]> {
|
||||||
|
const { zone, name } = this.split(fqdn);
|
||||||
|
const out = await this.call(`/dns/list?zone=${encodeURIComponent(zone)}&name=${encodeURIComponent(name)}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
return (out?.records || []).map((r: any) => String(r.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只读探活:selfcheck 用,验证国内机通道 + token 是否都好使 */
|
||||||
|
async ping(): Promise<string> {
|
||||||
|
const zone = this.pingZone;
|
||||||
|
const out = await this.call(`/dns/list?zone=${encodeURIComponent(zone)}&name=_acme-selfcheck.${zone}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
return `可达(zone ${zone} 下现有 ${(out?.records || []).length} 条 TXT)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ping 时用哪个 zone 探(构造时按凭据的 zoneName 指定,默认 200181.xyz) */
|
||||||
|
pingZone = '200181.xyz';
|
||||||
|
}
|
||||||
|
|
||||||
// ==================================================================== 工厂
|
// ==================================================================== 工厂
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按凭据记录造一个 DNS 客户端。
|
* 按凭据记录造一个 DNS 客户端。
|
||||||
* ★ 只认识 tencentcloud / cloudflare 两类;其余类型抛错而不是静默返回 null ——
|
* ★ 只认识 tencentcloud / cloudflare / remote 三类;其余类型抛错而不是静默返回 null ——
|
||||||
* 静默返回会让「配置错了」表现为「验证一直 pending 到超时」,排查起来很痛苦。
|
* 静默返回会让「配置错了」表现为「验证一直 pending 到超时」,排查起来很痛苦。
|
||||||
*/
|
*/
|
||||||
export function makeDnsProvider(rec: AccessRecord): DnsProvider {
|
export function makeDnsProvider(rec: AccessRecord): DnsProvider {
|
||||||
@@ -303,7 +399,15 @@ export function makeDnsProvider(rec: AccessRecord): DnsProvider {
|
|||||||
rec.zoneName ? String(rec.zoneName) : undefined,
|
rec.zoneName ? String(rec.zoneName) : undefined,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new Error(`DNS-01 不支持凭据类型「${t}」(目前只支持 tencentcloud / cloudflare)`);
|
if (t === 'remote') {
|
||||||
|
// 走国内机 cn-dns-helper 转发(用于 CF token 无 DNS 权限的场景)
|
||||||
|
const base = String(rec.serverUrl || '');
|
||||||
|
const ht = String(rec.helperToken || '');
|
||||||
|
const p = new RemoteDns(base, ht);
|
||||||
|
if (rec.zoneName) p.pingZone = String(rec.zoneName);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
throw new Error(`DNS-01 不支持凭据类型「${t}」(支持 tencentcloud / cloudflare / remote)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================================================================== crypto 小工具
|
// ==================================================================== crypto 小工具
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* 证书管家的**域名配置唯一事实源**。
|
||||||
|
*
|
||||||
|
* 为什么单独抽出来(2026-10-06):
|
||||||
|
* 这份配置有两个消费方 ——
|
||||||
|
* ① `seed-ssl-config.mjs`:写进 Cloudflare KV(Worker 侧 UI / 探针 / 自检用)
|
||||||
|
* ② `export-certkeeper-data.mjs`:生成国内机 cn-certkeeper 的数据目录
|
||||||
|
* 原先 CONFIG 内联在 seed 脚本里,而那个脚本**一被 import 就执行**
|
||||||
|
* (读 .env、连 KV 开始写),第 ② 个消费方没法安全复用。
|
||||||
|
* 抽出来之后两边共用一份定义,不会出现「改了 KV 忘了改国内机」。
|
||||||
|
*
|
||||||
|
* ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` **精确对上**,
|
||||||
|
* 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CONFIG = {
|
||||||
|
version: 1,
|
||||||
|
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
|
||||||
|
domains: [
|
||||||
|
{
|
||||||
|
name: 'usj.cc',
|
||||||
|
san: ['usj.cc', '*.usj.cc'],
|
||||||
|
dns: 'tencent-usj',
|
||||||
|
deploy: ['dogecloud', '1panel'],
|
||||||
|
// ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` 精确对上,
|
||||||
|
// 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的
|
||||||
|
// (面板上**没有** primaryDomain 为 `usj.cc` 的网站 —— 它只是证书名)。
|
||||||
|
dogecloud_domains: ['usj.cc', 'www.usj.cc', 'artalk.usj.cc'],
|
||||||
|
one_panel_sites: [
|
||||||
|
'artalk.usj.cc', // blog 评论后端
|
||||||
|
'openlist.usj.cc', // 网盘
|
||||||
|
'wifi.usj.cc',
|
||||||
|
'openwrt.usj.cc',
|
||||||
|
'vw.usj.cc', // vaultwarden(alias 才是这个名字)
|
||||||
|
],
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 't-t.live',
|
||||||
|
san: ['t-t.live', '*.t-t.live'],
|
||||||
|
dns: 'tencent-tt',
|
||||||
|
deploy: ['1panel'],
|
||||||
|
one_panel_sites: ['t-t.live', 'www.t-t.live', 'pl.t-t.live', 'pwd.t-t.live', 'certd.t-t.live'],
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '200181.xyz',
|
||||||
|
san: ['200181.xyz', '*.200181.xyz'],
|
||||||
|
dns: 'cloudflare',
|
||||||
|
deploy: ['1panel'],
|
||||||
|
one_panel_sites: ['ssh.200181.xyz'],
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 把证书管家的配置 + 凭据导出成**国内机 cn-certkeeper 可直接用的数据目录**。
|
||||||
|
*
|
||||||
|
* 为什么需要它:
|
||||||
|
* 签发链路搬到了国内机(Workers 免费版 CPU 只有 10ms,签发要 ~4ms 起步,
|
||||||
|
* 擦着上限跑不划算)。国内机要独立跑完整的「探针 → 签发 → 部署」,
|
||||||
|
* 就得有一份自己的配置。这里从**同一个事实源**生成,避免两边配置漂移:
|
||||||
|
* · 凭据:`E:/GitHub/secrets-backup/certkeeper-seeds.json`(明文底账)
|
||||||
|
* · 域名:`tools/certkeeper-config.mjs`(与 seed-ssl-config.mjs 共用)
|
||||||
|
*
|
||||||
|
* ★ 凭据用与 Worker 侧**完全相同**的 sealJson + 同一个 TOKEN_SECRET 加密,
|
||||||
|
* 所以产物格式与线上 KV 一致 —— 将来要拿 Worker KV 的备份直接替换数据目录
|
||||||
|
* 也能读。反过来也一样。
|
||||||
|
*
|
||||||
|
* 跑法:
|
||||||
|
* node tools/export-certkeeper-data.mjs --out <目录>
|
||||||
|
* TOKEN_SECRET 取值顺序:--token-secret 参数 > 环境变量 > .dev.vars
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { FileKV } from '../../deploy/cn-certkeeper/src/kv-file.mjs';
|
||||||
|
import { CONFIG } from './certkeeper-config.mjs';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.resolve(HERE, '..'); // blog-admin/
|
||||||
|
const REPO = path.resolve(ROOT, '..'); // 仓库根
|
||||||
|
|
||||||
|
const { sealJson } = require(path.join(ROOT, '.selftest-ssl/lib/certvault.js'));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 参数
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const argOf = (name) => {
|
||||||
|
const i = argv.indexOf(name);
|
||||||
|
return i >= 0 ? argv[i + 1] : undefined;
|
||||||
|
};
|
||||||
|
const OUT = argOf('--out');
|
||||||
|
if (!OUT) {
|
||||||
|
console.error('用法:node tools/export-certkeeper-data.mjs --out <目录> [--token-secret <值>]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOKEN_SECRET:参数 > 环境变量 > .dev.vars
|
||||||
|
let secret = argOf('--token-secret') || process.env.TOKEN_SECRET || '';
|
||||||
|
if (!secret) {
|
||||||
|
const devVars = path.join(ROOT, '.dev.vars');
|
||||||
|
if (fs.existsSync(devVars)) {
|
||||||
|
const m = fs.readFileSync(devVars, 'utf8').match(/^TOKEN_SECRET\s*=\s*"?([^"\r\n]+)"?/m);
|
||||||
|
if (m) secret = m[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!secret) throw new Error('没拿到 TOKEN_SECRET(用 --token-secret / 环境变量 / blog-admin/.dev.vars 任一提供)');
|
||||||
|
|
||||||
|
// 编译产物必须先就位
|
||||||
|
if (!fs.existsSync(path.join(ROOT, '.selftest-ssl/lib/certvault.js'))) {
|
||||||
|
throw new Error('缺少编译产物:先跑 `npm run selftest:ssl:build`(在 blog-admin 下)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- seeds
|
||||||
|
const seedsPath = path.join(REPO, '../secrets-backup/certkeeper-seeds.json');
|
||||||
|
const resolvedSeeds = fs.existsSync(seedsPath)
|
||||||
|
? seedsPath
|
||||||
|
: path.resolve('E:/GitHub/secrets-backup/certkeeper-seeds.json');
|
||||||
|
if (!fs.existsSync(resolvedSeeds)) throw new Error(`找不到 seeds 文件:${resolvedSeeds}`);
|
||||||
|
const seeds = JSON.parse(fs.readFileSync(resolvedSeeds, 'utf8'));
|
||||||
|
if (!Array.isArray(seeds.access)) throw new Error('seeds 里没有 access 数组');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 生成
|
||||||
|
console.log('导出到:', path.resolve(OUT));
|
||||||
|
console.log('TOKEN_SECRET 长度:', secret.length, '(应与 Worker 侧一致)');
|
||||||
|
console.log('seeds:', resolvedSeeds);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
const kv = new FileKV(OUT);
|
||||||
|
const env = { TOKEN_SECRET: secret };
|
||||||
|
|
||||||
|
// ① 域名配置(明文,与 certstore.saveConfig 的写法一致)
|
||||||
|
await kv.put('certkeeper:config', JSON.stringify(CONFIG));
|
||||||
|
console.log(`✓ certkeeper:config(${CONFIG.domains.length} 组域名)`);
|
||||||
|
for (const d of CONFIG.domains) {
|
||||||
|
console.log(` ${d.name.padEnd(12)} dns=${d.dns.padEnd(14)} deploy=[${d.deploy.join(', ')}]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log('凭据(AES-GCM 加密,与 Worker 侧同一把密钥):');
|
||||||
|
for (const a of seeds.access) {
|
||||||
|
const rec = { type: a.type, note: a.note, ...(a.fields || {}) };
|
||||||
|
await kv.put('certkeeper:access:' + a.name, await sealJson(env, rec));
|
||||||
|
console.log(` ✓ ${a.name.padEnd(16)} ${String(a.type).padEnd(14)} ${Object.keys(a.fields || {}).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 回读校验:确保写得进去也读得出来(并且能解回明文)
|
||||||
|
console.log();
|
||||||
|
console.log('回读校验:');
|
||||||
|
const back = await kv.get('certkeeper:config');
|
||||||
|
const parsed = JSON.parse(back);
|
||||||
|
console.log(` ✓ config 回读:${parsed.domains.length} 组域名`);
|
||||||
|
|
||||||
|
const { openJson } = require(path.join(ROOT, '.selftest-ssl/lib/certvault.js'));
|
||||||
|
let okCount = 0;
|
||||||
|
for (const a of seeds.access) {
|
||||||
|
const raw = await kv.get('certkeeper:access:' + a.name);
|
||||||
|
const opened = await openJson(env, raw);
|
||||||
|
if (opened.type !== a.type) throw new Error(`凭据 ${a.name} 回读后 type 不一致`);
|
||||||
|
okCount++;
|
||||||
|
}
|
||||||
|
console.log(` ✓ ${okCount} 条凭据全部解回明文(type 一致)`);
|
||||||
|
|
||||||
|
// ③ list 语义校验(certstore.listAccess 会用到)
|
||||||
|
const listed = await kv.list({ prefix: 'certkeeper:access:' });
|
||||||
|
console.log(` ✓ list 前缀查询命中 ${listed.keys.length} 条`);
|
||||||
|
|
||||||
|
console.log();
|
||||||
|
console.log('完成。数据目录结构:');
|
||||||
|
const walk = (d, indent = ' ') => {
|
||||||
|
for (const e of fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const full = path.join(d, e.name);
|
||||||
|
if (e.isDirectory()) {
|
||||||
|
console.log(indent + e.name + '/');
|
||||||
|
walk(full, indent + ' ');
|
||||||
|
} else {
|
||||||
|
console.log(indent + e.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(OUT);
|
||||||
@@ -76,46 +76,10 @@ const ACCESS = SEEDS.access; // [{ name, type, note, fields: {...} }]
|
|||||||
// Cloudflare 的 `imql`(apiTokenForZone)没进来:现有 `cloudflare` 那条已够用。
|
// Cloudflare 的 `imql`(apiTokenForZone)没进来:现有 `cloudflare` 那条已够用。
|
||||||
// (seeds 文件里同样只收了国内机那条,与当初迁移时的取舍一致。)
|
// (seeds 文件里同样只收了国内机那条,与当初迁移时的取舍一致。)
|
||||||
|
|
||||||
const CONFIG = {
|
// ★ CONFIG 已抽到 ./certkeeper-config.mjs(唯一事实源,国内机导出脚本也用同一份)
|
||||||
version: 1,
|
// 见那个文件的头注释:原先是内联在这里,但 export-certkeeper-data.mjs
|
||||||
notify: { emails: ['177018615@qq.com'], daysBefore: 30 },
|
// 需要复用却又不能 import 本脚本(本脚本一 import 就会连 KV 开始写)。
|
||||||
domains: [
|
import { CONFIG } from './certkeeper-config.mjs';
|
||||||
{
|
|
||||||
name: 'usj.cc',
|
|
||||||
san: ['usj.cc', '*.usj.cc'],
|
|
||||||
dns: 'tencent-usj',
|
|
||||||
deploy: ['dogecloud', '1panel'],
|
|
||||||
// ★ 站点名必须与 1Panel 里的 `primaryDomain` 或 `alias` 精确对上,
|
|
||||||
// 否则部署时会被跳过。下面这些是 2026-10-06 从面板实测出来的
|
|
||||||
// (面板上**没有** primaryDomain 为 `usj.cc` 的网站 —— 它只是证书名)。
|
|
||||||
dogecloud_domains: ['usj.cc', 'www.usj.cc', 'artalk.usj.cc'],
|
|
||||||
one_panel_sites: [
|
|
||||||
'artalk.usj.cc', // blog 评论后端
|
|
||||||
'openlist.usj.cc', // 网盘
|
|
||||||
'wifi.usj.cc',
|
|
||||||
'openwrt.usj.cc',
|
|
||||||
'vw.usj.cc', // vaultwarden(alias 才是这个名字)
|
|
||||||
],
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 't-t.live',
|
|
||||||
san: ['t-t.live', '*.t-t.live'],
|
|
||||||
dns: 'tencent-tt',
|
|
||||||
deploy: ['1panel'],
|
|
||||||
one_panel_sites: ['t-t.live', 'www.t-t.live', 'pl.t-t.live', 'pwd.t-t.live', 'certd.t-t.live'],
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '200181.xyz',
|
|
||||||
san: ['200181.xyz', '*.200181.xyz'],
|
|
||||||
dns: 'cloudflare',
|
|
||||||
deploy: ['1panel'],
|
|
||||||
one_panel_sites: ['ssh.200181.xyz'],
|
|
||||||
disabled: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 写入
|
// ---------------------------------------------------------------- 写入
|
||||||
const devVars = readFileSync(join(ROOT, '.dev.vars'), 'utf8');
|
const devVars = readFileSync(join(ROOT, '.dev.vars'), 'utf8');
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# cn-certkeeper 镜像 —— 零 npm 依赖,所以没有 package.json / 锁文件 / npm ci
|
||||||
|
#
|
||||||
|
# 作用:在国内机上跑证书的「探针 → 签发 → 部署」整条链路。
|
||||||
|
# 为什么不在 Worker 里跑:见 src/serve.mjs 顶部注释 ——
|
||||||
|
# Workers 免费版 CPU 硬顶 10ms(Cron 也是 10ms),付费版 $5/月≈¥36。
|
||||||
|
# 实测一次签发的密码学工作量约 3~4ms,乘上「Workers 比本地慢」的系数后
|
||||||
|
# 正好擦着 10ms 上限跑。国内机 CPU 不受限、Docker 也现成,搬过来更划算。
|
||||||
|
#
|
||||||
|
# ★ lib/ 是 blog-admin 的 tsc 编译产物,**不随仓库走**(已 gitignore)。
|
||||||
|
# 构建前先在 blog-admin 里跑 `npm run selftest:ssl:build`,
|
||||||
|
# 再 `cp -r blog-admin/.selftest-ssl/lib deploy/cn-certkeeper/lib`。
|
||||||
|
# 好处是:业务代码只有一份(blog-admin/src/lib/*.ts),Worker 与国内机
|
||||||
|
# 跑的是同一份编译产物,不会出现「两边逻辑漂移」。
|
||||||
|
#
|
||||||
|
# ★ apk 源换阿里云:国内机器直连 dl-cdn.alpinelinux.org 慢到不可用。
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
ARG APK_MIRROR=mirrors.aliyun.com
|
||||||
|
RUN if [ -n "$APK_MIRROR" ]; then \
|
||||||
|
sed -i "s#dl-cdn.alpinelinux.org#$APK_MIRROR#g" /etc/apk/repositories; \
|
||||||
|
fi \
|
||||||
|
&& apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY lib/ ./lib/
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# 数据目录(compose 里挂卷进来)
|
||||||
|
VOLUME ["/data"]
|
||||||
|
|
||||||
|
EXPOSE 8019
|
||||||
|
|
||||||
|
# 缺 AUTH_TOKEN / TOKEN_SECRET 时 serve.mjs 会自己 process.exit(1),容器随即退出 ——
|
||||||
|
# 刻意的:宁可起不来,也不能以「无鉴权」或「解不开凭据」的状态空转。
|
||||||
|
CMD ["node", "src/serve.mjs"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
cn-certkeeper:
|
||||||
|
build: /srv/cn-certkeeper
|
||||||
|
image: cn-certkeeper:local
|
||||||
|
container_name: cn-certkeeper
|
||||||
|
restart: unless-stopped
|
||||||
|
# ★ 只绑本机回环:容器内的 HOST 必须是 0.0.0.0,否则 -p 映射进不来
|
||||||
|
# (docker 的端口映射打的是容器 IP,不是容器内的 127.0.0.1)。
|
||||||
|
# 外部要访问就经 1Panel/openresty 反代,容器端口不直接对公网。
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8019:8019"
|
||||||
|
environment:
|
||||||
|
# 时区:日志和「每日 4:10 续期」都按这个走,不设就成 UTC(差 8 小时)
|
||||||
|
TZ: Asia/Shanghai
|
||||||
|
DATA_DIR: /data
|
||||||
|
HOST: 0.0.0.0
|
||||||
|
PORT: "8019"
|
||||||
|
# AUTH_TOKEN:HTTP 接口的共享密钥(人工触发 / 查状态用)
|
||||||
|
AUTH_TOKEN: ${AUTH_TOKEN:?}
|
||||||
|
# TOKEN_SECRET:★ 必须与 Cloudflare Worker 侧**完全一致** ——
|
||||||
|
# 凭据是用它派生 AES 密钥加密的,不一致就整片解不开。
|
||||||
|
TOKEN_SECRET: ${TOKEN_SECRET:?}
|
||||||
|
# 每天 04:10(北京时间)跑一次续期检查
|
||||||
|
RENEW_HOUR: "4"
|
||||||
|
RENEW_MINUTE: "10"
|
||||||
|
# 启动时做一次只读探测(只握手看剩余天数,不签发、不写 DNS)
|
||||||
|
RUN_ON_START: check
|
||||||
|
PROBE_TIMEOUT_MS: "10000"
|
||||||
|
volumes:
|
||||||
|
- /srv/cn-certkeeper/data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8019/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 8s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* 文件系统版的 KVNamespace —— 让证书管家的编译产物(原本跑在 Cloudflare
|
||||||
|
* Workers 上)能**原样**跑在 Node / 国内机容器里,一行业务代码都不用改。
|
||||||
|
*
|
||||||
|
* 为什么是「照 KVNamespace 接口实现」而不是「改造 certstore 的读写」:
|
||||||
|
* certstore / certvault / certissue 这三个模块已经在 Worker 上跑通了,
|
||||||
|
* 也过了 117 项自测。改它们的存储层等于把风险引进来。这里只补一个
|
||||||
|
* 适配器,让 `env.RSS_KV` 在 Node 里有同样语义 —— 业务代码零改动。
|
||||||
|
*
|
||||||
|
* 键位 → 文件路径的映射(可逆,便于直接 cat 查看):
|
||||||
|
* certkeeper:config → <dir>/certkeeper/config.kv
|
||||||
|
* certkeeper:access:tencent-usj → <dir>/certkeeper/access/tencent-usj.kv
|
||||||
|
* certkeeper:cert:usj.cc → <dir>/certkeeper/cert/usj.cc.kv
|
||||||
|
* 每段做 URL 编码(`:` 是分隔符,段内不允许出现);最后一段加 `.kv`
|
||||||
|
* 后缀,避免「键名恰好和子目录同名」时互相遮挡。
|
||||||
|
*
|
||||||
|
* 与真 KV 的差异(都无害,且**不会**被上层依赖到):
|
||||||
|
* · `list` 一次全量返回,不模拟分页(certstore 的 listKeys 见 list_complete
|
||||||
|
* 就停,行为一致;数据量是几十个键,没有分页的意义)
|
||||||
|
* · 没有 eventual consistency —— 文件是强一致的,比 KV 更严格
|
||||||
|
* · 没有 TTL / metadata(certstore 没用这两样)
|
||||||
|
* · 没有写入频率限制(KV 免费版 1000 写/天;本地文件没有,这点反而更好)
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const encSeg = (s) =>
|
||||||
|
encodeURIComponent(String(s)).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
||||||
|
const decSeg = (s) => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(s);
|
||||||
|
} catch {
|
||||||
|
return s; // 文件名被人手工改坏了也不至于整个 list 崩掉
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export class FileKV {
|
||||||
|
constructor(dir) {
|
||||||
|
this.dir = String(dir);
|
||||||
|
fs.mkdirSync(this.dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 键 → 绝对文件路径 */
|
||||||
|
filePathFor(key) {
|
||||||
|
const segs = String(key).split(':').map(encSeg);
|
||||||
|
segs[segs.length - 1] += '.kv';
|
||||||
|
return path.join(this.dir, ...segs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绝对文件路径 → 键 */
|
||||||
|
#keyFor(absPath) {
|
||||||
|
const rel = path.relative(this.dir, absPath);
|
||||||
|
const parts = rel.split(path.sep);
|
||||||
|
parts[parts.length - 1] = parts[parts.length - 1].replace(/\.kv$/, '');
|
||||||
|
return parts.map(decSeg).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key, type = 'text') {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(this.filePathFor(key), 'utf8');
|
||||||
|
} catch {
|
||||||
|
return null; // KV 语义:不存在返回 null
|
||||||
|
}
|
||||||
|
if (type === 'json') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put(key, value) {
|
||||||
|
const p = this.filePathFor(key);
|
||||||
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||||
|
// ★ 先写临时文件再 rename:避免「写到一半进程被杀」留下半截密文,
|
||||||
|
// 那会让 openJson 抛错、看起来像「密钥换了」。rename 在同一文件系统上是原子的。
|
||||||
|
const tmp = `${p}.tmp-${process.pid}`;
|
||||||
|
fs.writeFileSync(tmp, String(value));
|
||||||
|
fs.renameSync(tmp, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(this.filePathFor(key));
|
||||||
|
} catch {
|
||||||
|
/* 不存在就算了 —— 与 KV 的 delete 语义一致(幂等) */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async list({ prefix = '', cursor } = {}) {
|
||||||
|
void cursor; // 见头注释:不做分页
|
||||||
|
const names = [];
|
||||||
|
const walk = (d) => {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(d, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const full = path.join(d, e.name);
|
||||||
|
if (e.isDirectory()) walk(full);
|
||||||
|
else if (e.name.endsWith('.kv')) names.push(this.#keyFor(full));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(this.dir);
|
||||||
|
return {
|
||||||
|
keys: names.filter((k) => k.startsWith(prefix)).sort().map((name) => ({ name })),
|
||||||
|
list_complete: true,
|
||||||
|
cursor: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
/**
|
||||||
|
* cn-certkeeper —— 国内机上的证书自动续期服务。
|
||||||
|
*
|
||||||
|
* 为什么要有它(2026-10-06 定):
|
||||||
|
* Workers 免费版 CPU 硬顶 10 ms,付费版 $5/月(≈¥36)。实测一次完整签发
|
||||||
|
* 的密码学工作量约 3~4 ms(本地 Node),乘上「Workers 比本地慢」的系数后
|
||||||
|
* 正好擦着 10 ms 的上限跑 —— 与其花每月 36 块去买「不擦边」,不如把签发
|
||||||
|
* 这一坨放到**本来就在跑的国内机**上(那边 CPU 不受限,Docker 也现成)。
|
||||||
|
* 于是:Worker 只留探针 / UI / 环境自检(轻量,免费版绰绰有余),
|
||||||
|
* 签发 + 部署整条链路搬到这台机器。
|
||||||
|
*
|
||||||
|
* ★ 业务代码一行没改:直接复用 blog-admin 编译出来的 lib/*.js
|
||||||
|
* (`npm run selftest:ssl:build` 的产物)。那套代码只用
|
||||||
|
* crypto / fetch / btoa / atob / Request / Response / TextEncoder,
|
||||||
|
* 全是 Node 20+ 的全局 API;存储那层用 kv-file.mjs 补了个文件版 KVNamespace。
|
||||||
|
*
|
||||||
|
* 职责:
|
||||||
|
* · 每天定时(默认 04:10)跑一次续期检查 —— 先探针查剩余天数,
|
||||||
|
* ≤ RENEW_BEFORE_DAYS(30) 才真去签发,签完自动部署到多吉云 / 1Panel
|
||||||
|
* · 带鉴权的 HTTP 接口,供人工触发与查看状态
|
||||||
|
* · 探针走**本机 node:tls**(国内机是真 Node,getPeerCertificate 可用),
|
||||||
|
* 所以能拿到线上证书的真实到期日 —— 这一步在 Worker 上做不到
|
||||||
|
* (Workers 的 node:tls 是桩函数,实测抛 "getPeerCertificate is not implemented")
|
||||||
|
*/
|
||||||
|
import http from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { FileKV } from './kv-file.mjs';
|
||||||
|
|
||||||
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const LIB_DIR = path.resolve(HERE, '../lib');
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const lib = (name) => require(path.join(LIB_DIR, `${name}.js`));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 配置
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || '/data';
|
||||||
|
const PORT = Number(process.env.PORT || 8019);
|
||||||
|
const HOST = process.env.HOST || '127.0.0.1';
|
||||||
|
const AUTH_TOKEN = String(process.env.AUTH_TOKEN || '').trim();
|
||||||
|
const TOKEN_SECRET = String(process.env.TOKEN_SECRET || '').trim();
|
||||||
|
const RENEW_HOUR = Number(process.env.RENEW_HOUR ?? 4);
|
||||||
|
const RENEW_MINUTE = Number(process.env.RENEW_MINUTE ?? 10);
|
||||||
|
const RUN_ON_START = String(process.env.RUN_ON_START ?? 'check').toLowerCase(); // check | renew | off
|
||||||
|
const PROBE_TIMEOUT_MS = Number(process.env.PROBE_TIMEOUT_MS || 8000);
|
||||||
|
|
||||||
|
if (!AUTH_TOKEN) {
|
||||||
|
console.error('[cn-certkeeper] 缺少 AUTH_TOKEN —— 拒绝以无鉴权状态启动');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!TOKEN_SECRET) {
|
||||||
|
console.error('[cn-certkeeper] 缺少 TOKEN_SECRET —— 保险箱解不开凭据,签发必然失败');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 加载 lib
|
||||||
|
let renewAll;
|
||||||
|
let RENEW_BEFORE_DAYS;
|
||||||
|
let loadConfig;
|
||||||
|
let loadLog;
|
||||||
|
let probeTls;
|
||||||
|
let daysLeft;
|
||||||
|
let parsePemInfo;
|
||||||
|
try {
|
||||||
|
({ renewAll, RENEW_BEFORE_DAYS } = lib('certissue'));
|
||||||
|
({ loadConfig, loadLog } = lib('certstore'));
|
||||||
|
({ probeTls, daysLeft, parsePemInfo } = lib('certprobe'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[cn-certkeeper] 加载 lib 失败:', e instanceof Error ? e.message : e);
|
||||||
|
console.error(' —— 确认 lib/ 目录已随镜像一起打进(编译产物来自 `npm run selftest:ssl:build`)');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- env(对齐 Worker 的 Env)
|
||||||
|
const kv = new FileKV(DATA_DIR);
|
||||||
|
const env = {
|
||||||
|
RSS_KV: kv,
|
||||||
|
TOKEN_SECRET,
|
||||||
|
// ★ 留空 → probeTls 跳过「调国内机 editor-api」那一跳,直接走本机 node:tls。
|
||||||
|
// 这台机器就是「国内机」本身,没有理由再绕一次 HTTP。
|
||||||
|
EDITOR_API_BASE: '',
|
||||||
|
EDITOR_TOKEN: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 工具
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
/** 按**本地时区**格式化 —— 容器里设了 TZ=Asia/Shanghai,所以打出来就是北京时间。
|
||||||
|
* ★ 别用 toISOString():那是 UTC,日志写着「04:10 续期」而人看到的是 12:10,对不上。 */
|
||||||
|
const fmt = (d) =>
|
||||||
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
const ts = () => fmt(new Date());
|
||||||
|
const log = (...a) => console.log(`[${ts()}] [cn-certkeeper]`, ...a);
|
||||||
|
const j = (obj) => JSON.stringify(obj, null, 1);
|
||||||
|
|
||||||
|
/** 只读检查:逐域名握手看真实剩余天数,**不签发、不写 DNS、不改站点** */
|
||||||
|
async function checkAll() {
|
||||||
|
const cfg = await loadConfig(env);
|
||||||
|
const out = [];
|
||||||
|
for (const d of cfg.domains) {
|
||||||
|
if (d.disabled) {
|
||||||
|
out.push({ name: d.name, skipped: true, reason: '域名已停用' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const live = await probeTls(host, PROBE_TIMEOUT_MS);
|
||||||
|
const left = daysLeft(live.notAfter);
|
||||||
|
out.push({
|
||||||
|
name: d.name,
|
||||||
|
host,
|
||||||
|
ok: !!live.ok,
|
||||||
|
probeMs: Date.now() - t0,
|
||||||
|
notAfter: live.notAfter ? new Date(live.notAfter).toISOString() : null,
|
||||||
|
daysLeft: left,
|
||||||
|
issuer: live.issuer || null,
|
||||||
|
subject: live.subject || null,
|
||||||
|
tlsVersion: live.tlsVersion || null,
|
||||||
|
needRenew: left === null ? true : left <= RENEW_BEFORE_DAYS,
|
||||||
|
error: live.error || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 凭据预检(**只读**):确认每条凭据真的能调通对面的 API。
|
||||||
|
*
|
||||||
|
* 为什么必须有:配错了凭据,症状是「等到续期那天才发现签不出来」——
|
||||||
|
* 而那时候线上证书可能已经只剩几天。这里把它提前到「随时可查」。
|
||||||
|
* 对应 Worker 侧的 `/ssl/selfcheck`,但那边受 Workers 运行时限制
|
||||||
|
* (拿不到证书正文、CF token 缺 DNS 权限),这边的检查更完整。
|
||||||
|
*
|
||||||
|
* 全程不做任何写操作:CA 只读目录、DNS 只 list、部署目标只 ping。
|
||||||
|
*/
|
||||||
|
async function preflight() {
|
||||||
|
const { AcmeClient } = lib('acme');
|
||||||
|
const { getAccess, listAccess } = lib('certstore');
|
||||||
|
const { makeDnsProvider } = lib('dnsprovider');
|
||||||
|
const { makeDeployer } = lib('deployer');
|
||||||
|
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
const cfg = await loadConfig(env);
|
||||||
|
|
||||||
|
// ---- ① CA:目录可达性 + 是否强制 EAB ----
|
||||||
|
const all = await listAccess(env);
|
||||||
|
for (const item of all) {
|
||||||
|
if (item.type !== 'acme-eab') continue;
|
||||||
|
const rec = await getAccess(env, item.name);
|
||||||
|
const url = String(rec?.directoryUrl || '');
|
||||||
|
try {
|
||||||
|
if (!url) throw new Error('缺少 directoryUrl');
|
||||||
|
const c = new AcmeClient(url, { jwk: {}, kid: '' });
|
||||||
|
const needEab = await c.externalAccountRequired();
|
||||||
|
const kid = String(rec?.eabKid || '');
|
||||||
|
out.push({
|
||||||
|
kind: 'ca',
|
||||||
|
name: item.name,
|
||||||
|
ok: true,
|
||||||
|
detail: `目录可达(${url.replace(/^https?:\/\//, '').split('/')[0]});EAB ${
|
||||||
|
needEab ? `必需,kid=${kid.slice(0, 8)}…` : '非必需'
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
out.push({ kind: 'ca', name: item.name, ok: false, detail: `${errText(e)}(${url || '未配 URL'})` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ② DNS:只 list 一条 _acme-challenge ----
|
||||||
|
for (const d of cfg.domains) {
|
||||||
|
const host = d.san.find((s) => !s.startsWith('*.')) || d.name;
|
||||||
|
const rec = await getAccess(env, d.dns);
|
||||||
|
if (!rec) {
|
||||||
|
out.push({ kind: 'dns', name: d.dns, ok: false, detail: '凭据不存在' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p = makeDnsProvider(rec);
|
||||||
|
const vals = await p.listTxt(`_acme-challenge.${host}`);
|
||||||
|
out.push({ kind: 'dns', name: `${d.dns} → ${host}`, ok: true, detail: `可读,现存 TXT ${vals.length} 条` });
|
||||||
|
} catch (e) {
|
||||||
|
const m = errText(e);
|
||||||
|
// DNSPod 在「这个子域下没有 TXT」时回 ResourceNotFound.NoDataOfRecord ——
|
||||||
|
// 这是**健康**信号(上次签完清干净了),不是错误。
|
||||||
|
const healthy = /NoDataOfRecord/i.test(m);
|
||||||
|
out.push({
|
||||||
|
kind: 'dns',
|
||||||
|
name: `${d.dns} → ${host}`,
|
||||||
|
ok: healthy,
|
||||||
|
detail: healthy ? '可读,该名字下无现存 TXT(正常)' : m,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ③ 部署目标:只 ping ----
|
||||||
|
const targets = new Set();
|
||||||
|
for (const d of cfg.domains) for (const t of d.deploy || []) targets.add(t);
|
||||||
|
for (const target of targets) {
|
||||||
|
const credName = target === '1panel' ? '1panel-cn' : target;
|
||||||
|
const rec = await getAccess(env, credName);
|
||||||
|
if (!rec) {
|
||||||
|
out.push({ kind: 'deploy', name: credName, ok: false, detail: '凭据不存在' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dp = makeDeployer(rec);
|
||||||
|
if (typeof dp.ping === 'function') {
|
||||||
|
// ★ ping 的约定是「返回 {ok,error,hint,detail} 而**不抛错**」。
|
||||||
|
// 第一版直接 String(r) 打成了 `[object Object]`,更糟的是把 r.ok=false
|
||||||
|
// 也记成通过 —— 那这个预检就等于白做。必须读 r.ok。
|
||||||
|
const r = await dp.ping();
|
||||||
|
const detail = r.ok
|
||||||
|
? r.detail || 'API 可达'
|
||||||
|
: `${r.error || '未知错误'}${r.hint ? ' —— ' + r.hint : ''}`;
|
||||||
|
out.push({ kind: 'deploy', name: credName, ok: !!r.ok, detail });
|
||||||
|
} else {
|
||||||
|
out.push({ kind: 'deploy', name: credName, ok: true, detail: '(该适配器未实现 ping,已跳过)' });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
out.push({ kind: 'deploy', name: credName, ok: false, detail: errText(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真跑一次续期(内部分支:探针→签发→部署) */
|
||||||
|
async function runRenew(opts = {}) {
|
||||||
|
const started = Date.now();
|
||||||
|
log(`开始续期检查(by=${opts.by || 'cron'}${opts.force ? ' force' : ''}${opts.noDeploy ? ' noDeploy' : ''})`);
|
||||||
|
const results = await renewAll(env, { by: opts.by || 'cn-certkeeper', force: opts.force, noDeploy: opts.noDeploy });
|
||||||
|
for (const r of results) {
|
||||||
|
const tag = r.skipped ? '跳过' : r.ok ? '成功' : '失败';
|
||||||
|
log(` ${r.domain}: ${tag} — ${r.reason}`);
|
||||||
|
}
|
||||||
|
const ms = Date.now() - started;
|
||||||
|
log(`续期检查结束,用时 ${(ms / 1000).toFixed(1)}s`);
|
||||||
|
return { results, ms };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 并发闸
|
||||||
|
let busy = null; // Promise | null
|
||||||
|
|
||||||
|
function withLock(name, fn) {
|
||||||
|
if (busy) return Promise.reject(new Error(`已有任务在执行中(${busy.name}),请稍后再试`));
|
||||||
|
const p = (async () => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
busy = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
p.name = name;
|
||||||
|
busy = p;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- HTTP
|
||||||
|
function readBody(req, limit = 64 * 1024) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let n = 0;
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (c) => {
|
||||||
|
n += c.length;
|
||||||
|
if (n > limit) {
|
||||||
|
reject(new Error('请求体过大'));
|
||||||
|
req.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
chunks.push(c);
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
const s = Buffer.concat(chunks).toString('utf8').trim();
|
||||||
|
if (!s) return resolve({});
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(s));
|
||||||
|
} catch {
|
||||||
|
reject(new Error('请求体不是合法 JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function authed(req) {
|
||||||
|
const h = req.headers['x-auth-token'];
|
||||||
|
if (h && h === AUTH_TOKEN) return true;
|
||||||
|
const a = String(req.headers.authorization || '');
|
||||||
|
if (a.startsWith('Bearer ') && a.slice(7) === AUTH_TOKEN) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res, code, obj) {
|
||||||
|
const body = j(obj);
|
||||||
|
res.writeHead(code, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://localhost');
|
||||||
|
const p = url.pathname.replace(/\/+$/, '') || '/';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// health 免鉴权(探活/监控用,不吐任何敏感信息)
|
||||||
|
if (p === '/health' && req.method === 'GET') {
|
||||||
|
let domains = null;
|
||||||
|
try {
|
||||||
|
domains = (await loadConfig(env)).domains.length;
|
||||||
|
} catch {
|
||||||
|
/* 配置还没建好也要能探活 */
|
||||||
|
}
|
||||||
|
return send(res, 200, { ok: true, service: 'cn-certkeeper', dataDir: DATA_DIR, domains, busy: !!busy });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authed(req)) return send(res, 401, { ok: false, error: '未授权(需要 X-Auth-Token 或 Bearer)' });
|
||||||
|
|
||||||
|
// 只读:查每个域名的真实剩余天数
|
||||||
|
if (p === '/status' && req.method === 'GET') {
|
||||||
|
const domains = await checkAll();
|
||||||
|
return send(res, 200, { ok: true, renewBeforeDays: RENEW_BEFORE_DAYS, domains });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行续期
|
||||||
|
if (p === '/renew' && req.method === 'POST') {
|
||||||
|
const body = await readBody(req);
|
||||||
|
const out = await withLock('renew', () => runRenew({ by: 'http', force: !!body.force, noDeploy: !!body.noDeploy }));
|
||||||
|
return send(res, 200, { ok: true, ...out });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只要探针判断(不签发)
|
||||||
|
if (p === '/renew-check' && req.method === 'GET') {
|
||||||
|
const domains = await checkAll();
|
||||||
|
const need = domains.filter((d) => d.needRenew && !d.skipped);
|
||||||
|
return send(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
renewBeforeDays: RENEW_BEFORE_DAYS,
|
||||||
|
needRenew: need.map((d) => d.name),
|
||||||
|
domains,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 凭据预检(只读):CA 目录 / DNS 只读列举 / 部署目标 ping
|
||||||
|
if (p === '/preflight' && req.method === 'GET') {
|
||||||
|
const items = await preflight();
|
||||||
|
const bad = items.filter((x) => !x.ok);
|
||||||
|
return send(res, 200, { ok: bad.length === 0, passed: items.length - bad.length, total: items.length, items });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行日志
|
||||||
|
if (p === '/log' && req.method === 'GET') {
|
||||||
|
const limit = Math.min(Number(url.searchParams.get('limit') || 50), 200);
|
||||||
|
const entries = (await loadLog(env)).slice(0, limit);
|
||||||
|
return send(res, 200, { ok: true, count: entries.length, entries });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数据目录概览(不返回任何凭据内容)
|
||||||
|
if (p === '/data' && req.method === 'GET') {
|
||||||
|
const names = (await kv.list({ prefix: 'certkeeper:access:' })).keys.map((k) =>
|
||||||
|
k.name.replace('certkeeper:access:', ''),
|
||||||
|
);
|
||||||
|
const certs = (await kv.list({ prefix: 'certkeeper:cert:' })).keys.map((k) =>
|
||||||
|
k.name.replace('certkeeper:cert:', ''),
|
||||||
|
);
|
||||||
|
let cfg = null;
|
||||||
|
try {
|
||||||
|
const c = await loadConfig(env);
|
||||||
|
cfg = { version: c.version, domains: c.domains.map((d) => d.name), notifyTo: c.notify.emails };
|
||||||
|
} catch (e) {
|
||||||
|
cfg = { error: e instanceof Error ? e.message : String(e) };
|
||||||
|
}
|
||||||
|
return send(res, 200, { ok: true, dataDir: DATA_DIR, access: names, certs, config: cfg });
|
||||||
|
}
|
||||||
|
|
||||||
|
return send(res, 404, { ok: false, error: `没有这个接口:${req.method} ${p}` });
|
||||||
|
} catch (e) {
|
||||||
|
log('请求出错:', e instanceof Error ? e.message : e);
|
||||||
|
return send(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 定时
|
||||||
|
function scheduleDaily(hour, minute, fn) {
|
||||||
|
const arm = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const next = new Date(now);
|
||||||
|
next.setHours(hour, minute, 0, 0);
|
||||||
|
if (next <= now) next.setDate(next.getDate() + 1);
|
||||||
|
const delay = next.getTime() - now.getTime();
|
||||||
|
log(`下次自动续期:${fmt(next)}(${(delay / 3600000).toFixed(1)} 小时后)`);
|
||||||
|
// setTimeout 的上限是 2^31-1 ms ≈ 24.8 天,一天一次不会碰到
|
||||||
|
setTimeout(() => {
|
||||||
|
withLock('cron', () => fn()).catch((e) => log('定时任务出错:', e instanceof Error ? e.message : e));
|
||||||
|
arm(); // 跑完再排下一次(不用 setInterval:避免上一次没跑完就叠上下一次)
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
arm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 启动
|
||||||
|
process.on('unhandledRejection', (e) => log('未处理的 Promise 拒绝(已忽略,不退出进程):', e));
|
||||||
|
process.on('uncaughtException', (e) => log('未捕获异常(已忽略,不退出进程):', e));
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
log(`已启动,监听 http://${HOST}:${PORT}`);
|
||||||
|
log(`数据目录 ${DATA_DIR};续期阈值 ${RENEW_BEFORE_DAYS} 天;每日 ${RENEW_HOUR}:${String(RENEW_MINUTE).padStart(2, '0')}`);
|
||||||
|
|
||||||
|
// 启动自检:先确认数据目录读得出、凭据解得开 —— 配错了现在就要吼,别等到凌晨
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await loadConfig(env);
|
||||||
|
log(`配置就绪:${cfg.domains.length} 组域名 → ${cfg.domains.map((d) => d.name).join(', ')}`);
|
||||||
|
const names = (await kv.list({ prefix: 'certkeeper:access:' })).keys.map((k) =>
|
||||||
|
k.name.replace('certkeeper:access:', ''),
|
||||||
|
);
|
||||||
|
log(`凭据 ${names.length} 条:${names.join(', ')}`);
|
||||||
|
|
||||||
|
if (RUN_ON_START !== 'off') {
|
||||||
|
const st = await checkAll();
|
||||||
|
for (const d of st) {
|
||||||
|
if (d.skipped) {
|
||||||
|
log(` ${d.name}: 已停用`);
|
||||||
|
} else if (d.ok) {
|
||||||
|
log(` ${d.name}: 线上证书 ${d.daysLeft} 天后到期(${d.notAfter},${d.issuer || '?'})`);
|
||||||
|
} else {
|
||||||
|
log(` ${d.name}: 探测失败 — ${d.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (RUN_ON_START === 'renew') {
|
||||||
|
await withLock('onstart', () => runRenew({ by: 'onstart' })).catch((e) => log('启动续期出错:', e.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleDaily(RENEW_HOUR, RENEW_MINUTE, () => runRenew({ by: 'cron' }));
|
||||||
|
} catch (e) {
|
||||||
|
log('★ 启动自检失败:', e instanceof Error ? e.message : e);
|
||||||
|
log(' 如果是「配置不是合法 JSON」或「找不到 config」,先跑一次导出脚本:');
|
||||||
|
log(' node tools/export-certkeeper-data.mjs --out <数据目录>');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 优雅退出:容器 stop 时别留下半个临时文件
|
||||||
|
for (const sig of ['SIGTERM', 'SIGINT']) {
|
||||||
|
process.on(sig, () => {
|
||||||
|
log(`收到 ${sig},退出`);
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
setTimeout(() => process.exit(0), 3000).unref();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# cn-dns-helper 镜像 —— 零 npm 依赖,所以没有 package.json / 锁文件 / npm ci
|
||||||
|
#
|
||||||
|
# 作用:把「在 Cloudflare 上增删 DNS TXT 记录」这件小事的执行权留在国内机。
|
||||||
|
# 原因见 src/server.mjs 顶部注释:Worker 侧的 CF 凭据没有 DNS 权限,
|
||||||
|
# 而国内机 certimate 里有一条有完整权限的 CF token。
|
||||||
|
#
|
||||||
|
# ★ apk 源换阿里云:国内机器直连 dl-cdn.alpinelinux.org 慢到不可用。
|
||||||
|
# 这里其实一个包都不装(node 内置模块够用),保留这段只为将来加包方便。
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
ARG APK_MIRROR=mirrors.aliyun.com
|
||||||
|
RUN if [ -n "$APK_MIRROR" ]; then \
|
||||||
|
sed -i "s#dl-cdn.alpinelinux.org#$APK_MIRROR#g" /etc/apk/repositories; \
|
||||||
|
fi \
|
||||||
|
&& apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
EXPOSE 8018
|
||||||
|
|
||||||
|
# 没配 DNS_HELPER_TOKEN 时 server.mjs 会自己 process.exit(1),容器随即退出 ——
|
||||||
|
# 刻意的:宁可起不来,也不能以无鉴权状态对公网服务。
|
||||||
|
CMD ["node", "src/server.mjs"]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
services:
|
||||||
|
cn-dns-helper:
|
||||||
|
build: /srv/cn-dns-helper
|
||||||
|
image: cn-dns-helper:local
|
||||||
|
container_name: cn-dns-helper
|
||||||
|
restart: unless-stopped
|
||||||
|
# ★ 只绑本机回环:外部经 1Panel/openresty 反代进来,容器端口不直接暴露
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8018:8018"
|
||||||
|
environment:
|
||||||
|
# CF_API_TOKEN:有 Zone/DNS 权限的 Cloudflare token(从 certimate 迁出)
|
||||||
|
CF_API_TOKEN: ${CF_API_TOKEN:?}
|
||||||
|
# DNS_HELPER_TOKEN:Worker 侧调用本服务的共享密钥,两边必须一致
|
||||||
|
DNS_HELPER_TOKEN: ${DNS_HELPER_TOKEN:?}
|
||||||
|
BIND_HOST: 0.0.0.0
|
||||||
|
BIND_PORT: "8018"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8018/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/**
|
||||||
|
* cn-dns-helper —— 跑在国内机上的「DNS-01 助手」,用 Docker 管理。
|
||||||
|
*
|
||||||
|
* 为什么需要它(而不是让 Cloudflare Worker 直接改 DNS):
|
||||||
|
* 1. Worker 里那份 cloudflare 凭据是 Workers/KV/D1 专用的(没有 Zone/DNS 权限),
|
||||||
|
* 拿它做 DNS-01 会被 CF 回 403。
|
||||||
|
* 2. 国内机上的 certimate 容器里本来就有一条**有完整 DNS 权限**的 CF token
|
||||||
|
* (能列 zone、能增删 dns_records)—— 既然机器已在、凭据已在,
|
||||||
|
* 就把「写 TXT 记录」这件事留在国内机做,Worker 只管调它。
|
||||||
|
* 3. 顺带好处:Worker 侧不用再持有任何 DNS 域名的写权限凭据,权限面更小。
|
||||||
|
*
|
||||||
|
* 契约(与 blog-admin/src/lib/dnsremoted.ts 严格对应):
|
||||||
|
* POST /dns/txt { zone, name, value } -> 创建 TXT,回 { id }
|
||||||
|
* POST /dns/del { zone, name, value? } -> 删除匹配的 TXT,回 { deleted }
|
||||||
|
* GET /dns/list?zone=&name= -> 列出 TXT -> { records: [{id, name, value}] }
|
||||||
|
* GET /health -> { ok: true, ts }
|
||||||
|
*
|
||||||
|
* 鉴权:Header `X-Auth-Token: <DNS_HELPER_TOKEN>`,与 editor-api 同思路 ——
|
||||||
|
* 没配 token 直接 process.exit(1),宁可起不来也不无鉴权对公网服务。
|
||||||
|
*
|
||||||
|
* 零 npm 依赖,只用 node 内置模块。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'node:http';
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const PORT = Number(process.env.BIND_PORT || 8018);
|
||||||
|
const HOST = process.env.BIND_HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
// ★ 两个 token 角色完全不同,绝不能混用(曾经踩过:拿 HELPER_TOKEN 去调 CF,
|
||||||
|
// 结果 CF 回 9109 Invalid access token,排查了很久):
|
||||||
|
// · CF_TOKEN —— 调 Cloudflare API 的凭据,必须带 Zone/DNS 权限
|
||||||
|
// · HELPER_TOKEN —— 本服务的**入站鉴权**密钥,Worker 用它证明「是我在调」
|
||||||
|
const CF_TOKEN = (process.env.CF_API_TOKEN || '').trim();
|
||||||
|
const HELPER_TOKEN = (process.env.DNS_HELPER_TOKEN || '').trim();
|
||||||
|
const CF_API = 'https://api.cloudflare.com/client/v4';
|
||||||
|
|
||||||
|
// zone 名 -> zone id 的缓存(避免每次都列 zone)
|
||||||
|
const zoneIdCache = new Map();
|
||||||
|
|
||||||
|
if (!HELPER_TOKEN) {
|
||||||
|
console.error('[cn-dns-helper] 缺少 DNS_HELPER_TOKEN,拒绝以无鉴权状态启动');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (!CF_TOKEN) {
|
||||||
|
console.error('[cn-dns-helper] 缺少 CF_API_TOKEN,无法访问 Cloudflare');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const log = (...a) => console.log(new Date().toISOString(), ...a);
|
||||||
|
|
||||||
|
function safeEqual(a, b) {
|
||||||
|
const ba = Buffer.from(String(a));
|
||||||
|
const bb = Buffer.from(String(b));
|
||||||
|
if (ba.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ba, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function json(res, code, obj) {
|
||||||
|
const body = JSON.stringify(obj);
|
||||||
|
res.writeHead(code, {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Content-Length': Buffer.byteLength(body),
|
||||||
|
});
|
||||||
|
res.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBody(req, limit = 32 * 1024) {
|
||||||
|
const chunks = [];
|
||||||
|
let size = 0;
|
||||||
|
for await (const c of req) {
|
||||||
|
size += c.length;
|
||||||
|
if (size > limit) throw new Error('请求体过大');
|
||||||
|
chunks.push(c);
|
||||||
|
}
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
return raw ? JSON.parse(raw) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取 zone id:优先缓存,未命中则查(用 token 的 zone 列表权限) */
|
||||||
|
async function getZoneId(zone) {
|
||||||
|
const z = String(zone || '').trim().toLowerCase();
|
||||||
|
if (!z) throw new Error('缺少 zone');
|
||||||
|
if (zoneIdCache.has(z)) return zoneIdCache.get(z);
|
||||||
|
|
||||||
|
const r = await fetch(`${CF_API}/zones?name=${encodeURIComponent(z)}`, {
|
||||||
|
headers: { Authorization: `Bearer ${CF_TOKEN}` },
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.success) {
|
||||||
|
throw new Error(`列 zone 失败: ${JSON.stringify(j.errors || j)}`);
|
||||||
|
}
|
||||||
|
let id = (j.result || [])[0]?.id;
|
||||||
|
|
||||||
|
// 退路:token 可能不带 zone:list 但能直接操作某 zone。
|
||||||
|
// 这里不做猜测,直接报错让人去看 —— 报错比瞎猜安全。
|
||||||
|
if (!id) {
|
||||||
|
throw new Error(`token 看不到 zone「${z}」(可能缺 Zone:Read,或该 zone 不在授权范围)`);
|
||||||
|
}
|
||||||
|
zoneIdCache.set(z, id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 找完全匹配的记录(type=TXT + name + value) */
|
||||||
|
async function findTxt(zoneId, name, value) {
|
||||||
|
const url = `${CF_API}/zones/${zoneId}/dns_records?type=TXT&name=${encodeURIComponent(name)}&per_page=100`;
|
||||||
|
const r = await fetch(url, { headers: { Authorization: `Bearer ${CF_TOKEN}` } });
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.success) throw new Error(`列 TXT 失败: ${JSON.stringify(j.errors || j)}`);
|
||||||
|
const all = j.result || [];
|
||||||
|
if (value == null) return all;
|
||||||
|
return all.filter((x) => String(x.content).replace(/^"|"$/g, '') === String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTxt(zone, name, value) {
|
||||||
|
const zoneId = await getZoneId(zone);
|
||||||
|
// 幂等:已存在同值记录就不重复建(ACM E 重试时很常见)
|
||||||
|
const exist = await findTxt(zoneId, name, value);
|
||||||
|
if (exist.length > 0) {
|
||||||
|
log('TXT 已存在,跳过创建', name);
|
||||||
|
return { id: exist[0].id, existed: true };
|
||||||
|
}
|
||||||
|
const r = await fetch(`${CF_API}/zones/${zoneId}/dns_records`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${CF_TOKEN}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ type: 'TXT', name, content: value, ttl: 60 }),
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (!j.success) throw new Error(`创建 TXT 失败: ${JSON.stringify(j.errors || j)}`);
|
||||||
|
log('已创建 TXT', name);
|
||||||
|
return { id: j.result.id, existed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function delTxt(zone, name, value) {
|
||||||
|
const zoneId = await getZoneId(zone);
|
||||||
|
const hits = await findTxt(zoneId, name, value);
|
||||||
|
let deleted = 0;
|
||||||
|
for (const h of hits) {
|
||||||
|
const r = await fetch(`${CF_API}/zones/${zoneId}/dns_records/${h.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${CF_TOKEN}` },
|
||||||
|
});
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.success) deleted += 1;
|
||||||
|
}
|
||||||
|
log('已删除 TXT', name, '条数=', deleted);
|
||||||
|
return { deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url, 'http://localhost');
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
// 健康检查放行(1Panel / docker healthcheck 用)
|
||||||
|
if (path === '/health') {
|
||||||
|
return json(res, 200, { ok: true, ts: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 鉴权:常量时间比较,避免时序侧信道
|
||||||
|
const got = req.headers['x-auth-token'] || '';
|
||||||
|
if (!safeEqual(got, HELPER_TOKEN)) {
|
||||||
|
log('鉴权失败', req.method, path, 'from', req.socket.remoteAddress);
|
||||||
|
return json(res, 401, { error: 'unauthorized' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (req.method === 'POST' && path === '/dns/txt') {
|
||||||
|
const b = await readBody(req);
|
||||||
|
const out = await addTxt(b.zone, b.name, b.value);
|
||||||
|
return json(res, 200, out);
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && path === '/dns/del') {
|
||||||
|
const b = await readBody(req);
|
||||||
|
const out = await delTxt(b.zone, b.name, b.value);
|
||||||
|
return json(res, 200, out);
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && path === '/dns/list') {
|
||||||
|
const zone = url.searchParams.get('zone');
|
||||||
|
const name = url.searchParams.get('name');
|
||||||
|
const zoneId = await getZoneId(zone);
|
||||||
|
const recs = await findTxt(zoneId, name, null);
|
||||||
|
return json(res, 200, {
|
||||||
|
records: recs.map((x) => ({
|
||||||
|
id: x.id,
|
||||||
|
name: x.name,
|
||||||
|
value: String(x.content).replace(/^"|"$/g, ''),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'not found' });
|
||||||
|
} catch (e) {
|
||||||
|
log('处理出错', path, e.message);
|
||||||
|
return json(res, 500, { error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
log(`cn-dns-helper 已启动 http://${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
+136
-2
@@ -404,13 +404,17 @@ certimate 每个 workflow 挂 cron,**每天无条件重跑整条流水线**。
|
|||||||
本项目改为:**先探针查证书剩余天数,低于 `RENEW_BEFORE_DAYS`(30 天)才真正签发**。
|
本项目改为:**先探针查证书剩余天数,低于 `RENEW_BEFORE_DAYS`(30 天)才真正签发**。
|
||||||
好处:省 CA 限速额度(Let's Encrypt / LiteSSL 都有周限额)、DNS 改动更少、日志只有真实动作才有记录。
|
好处:省 CA 限速额度(Let's Encrypt / LiteSSL 都有周限额)、DNS 改动更少、日志只有真实动作才有记录。
|
||||||
|
|
||||||
### 8.3 ★ 免费版跑不了签发(重要结论)
|
### 8.3 免费版 CPU 限制(★ 结论已在 §9 被实测推翻,保留原文供对照)
|
||||||
|
|
||||||
| 项 | 免费版 | 付费版($5/月) |
|
| 项 | 免费版 | 付费版($5/月) |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Worker CPU | **硬顶 10ms** | 默认 30s,可调至 5min |
|
| Worker CPU | **硬顶 10ms** | 默认 30s,可调至 5min |
|
||||||
| `[limits] cpu_ms` 配置 | ★ **直接拒绝部署**(code 100328) | 支持 |
|
| `[limits] cpu_ms` 配置 | ★ **直接拒绝部署**(code 100328) | 支持 |
|
||||||
| ACME 签发 | ❌ 跑不起来 | ✅ |
|
| ACME 签发 | ⚠️ 擦边(见 §9.2 实测) | ✅ 稳 |
|
||||||
|
|
||||||
|
> ★★ **更正(2026-10-06 晚)**:这里原先写「❌ 跑不起来」是**按文档推断、未实测**得出的,
|
||||||
|
> 属于误判。实测密码学开销只有 0.69~3.3ms,是「擦边」而不是「必然爆」。
|
||||||
|
> 最终决策与实测数据见 **§9**。
|
||||||
|
|
||||||
实测报错:
|
实测报错:
|
||||||
```
|
```
|
||||||
@@ -457,3 +461,133 @@ HTTP 请求 duration 本身**无硬限制**,签发的耗时主要花在 `sleep
|
|||||||
`GET /zones` 回 200 + 空数组、`dns_records` 直连 403 → **`200181.xyz` 无法走 DNS-01**。
|
`GET /zones` 回 200 + 空数组、`dns_records` 直连 403 → **`200181.xyz` 无法走 DNS-01**。
|
||||||
需到 Cloudflare 重新生成含 `Zone → DNS → Edit` 且覆盖 `200181.xyz` 的 token。
|
需到 Cloudflare 重新生成含 `Zone → DNS → Edit` 且覆盖 `200181.xyz` 的 token。
|
||||||
- **真实端到端签发验证**(需 Paid plan + 用户批准):会真写 DNS TXT、消耗一次 CA 配额、重绑站点。
|
- **真实端到端签发验证**(需 Paid plan + 用户批准):会真写 DNS TXT、消耗一次 CA 配额、重绑站点。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、★ 架构定案:签发链路搬到国内机(2026-10-06 晚)
|
||||||
|
|
||||||
|
### 9.1 决策:B + C 组合,拒绝 CF 付费
|
||||||
|
|
||||||
|
用户拍板:**不买 CF Paid($5/月 ≈ ¥36/月)**,改走组合方案:
|
||||||
|
|
||||||
|
| 方案 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| **B** | Worker 留在**免费版**,代码优化(缓存 CryptoKey,把 CPU 压进 10ms) |
|
||||||
|
| **C** | **「签发 + 部署」整条链路搬到国内机的 Docker 容器**里 |
|
||||||
|
|
||||||
|
理由:¥36/月 已经比这台本来就在跑的国内机(腾讯云广州)贵,且国内机本来就常驻、
|
||||||
|
Docker 管理成本几乎为零,没必要为一道 10ms 的紧箍咒额外付费。
|
||||||
|
|
||||||
|
> 附带收益:国内机能**直接访问 1Panel API 与各站点探针**,绕开了 Worker 最大的两个坑
|
||||||
|
> ——「CF 出口 IP 拿不到 1Panel 白名单」与「Worker 无 TCP 探针」。
|
||||||
|
> 实测国内机探针能直接读到证书正文(Worker 做不到):`usj.cc` 62 天 / `t-t.live` 83 天 /
|
||||||
|
> `200181.xyz` 62 天。
|
||||||
|
|
||||||
|
### 9.2 ★ 实测数据(推翻 8.3 的旧结论)
|
||||||
|
|
||||||
|
本机 Win32 上 `process.cpuUsage()` 采样粒度太粗(200ms 忙循环只报 46ms),且
|
||||||
|
`crypto.subtle` 走线程池不计入主线程 —— **必须改用「大循环 + `process.hrtime.bigint()` 墙钟」**。
|
||||||
|
|
||||||
|
| 操作 | 耗时 |
|
||||||
|
|---|---|
|
||||||
|
| EC P-256 keygen | 83 µs |
|
||||||
|
| ECDSA sign + SHA256 | 41 µs |
|
||||||
|
| SHA-256 digest | 3.2 µs |
|
||||||
|
| `importKey('jwk')` | **126 µs** |
|
||||||
|
| `sign`(已缓存 CryptoKey) | **84 µs** |
|
||||||
|
| `importKey` + `sign`(未缓存) | **252 µs** |
|
||||||
|
|
||||||
|
一次签发约 10~12 次 JWS:**优化前 ≈3.3ms → 优化后 ≈1.4ms**(乘 2~3 倍保守系数:
|
||||||
|
6.6~10ms vs 2.8~4.2ms)。免费版 10ms 是「能压进去但没余量」。
|
||||||
|
|
||||||
|
### 9.3 优化:acme.ts 缓存 CryptoKey
|
||||||
|
|
||||||
|
`src/lib/acme.ts` 原实现**每次 JWS 都 `importKey`**。改为缓存 Promise:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private signingKey: Promise<CryptoKey> | null = null;
|
||||||
|
|
||||||
|
private importSigningKey(): Promise<CryptoKey> {
|
||||||
|
if (!this.signingKey) {
|
||||||
|
this.signingKey = (crypto.subtle.importKey(
|
||||||
|
'jwk', this.account.jwk, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign'],
|
||||||
|
) as Promise<CryptoKey>).catch((e) => { this.signingKey = null; throw e; });
|
||||||
|
}
|
||||||
|
return this.signingKey; // 存 Promise,并发时只 import 一次
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 存 **Promise** 而非 CryptoKey:并发调用时只真正 import 一次;
|
||||||
|
> 失败清缓存避免永久 reject。实测:单次签发 importKey **12 → 1 次**。
|
||||||
|
|
||||||
|
### 9.4 新增部署单元 `deploy/cn-certkeeper/`(Docker 管理)
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `Dockerfile` | `node:22-alpine` + 阿里云 apk 源 + `ca-certificates tzdata`;`COPY lib/` + `COPY src/` |
|
||||||
|
| `docker-compose.yml` | 只绑 **`127.0.0.1:8019`**;`TZ=Asia/Shanghai`;`HOST=0.0.0.0`(容器内必须,否则端口映射进不来) |
|
||||||
|
| `src/kv-file.mjs` | **文件系统版 KVNamespace**(照 KV 接口面:`get/put/delete/list`),键位 `a:b:c` → `<dir>/a/b/c.kv`;`put` 先写 `.tmp-<pid>` 再 `renameSync`(原子,防半截密文) |
|
||||||
|
| `src/serve.mjs` | 服务本体:`withLock()` 并发闸 + `scheduleDaily()` 自递归 setTimeout + 带鉴权 HTTP 接口 |
|
||||||
|
| `lib/*` | 直接复用 `blog-admin/src/lib/` 编译产物(acme/deployer/dnsprovider/certissue/certvault…) |
|
||||||
|
|
||||||
|
**接口**:`/health`(免鉴权) `/status` `/renew`(POST) `/renew-check` `/preflight` `/log` `/data`
|
||||||
|
|
||||||
|
**关键设计**:
|
||||||
|
- **同一把 `TOKEN_SECRET`** → certvault 的 AES-GCM 密文两边可互解,Worker 与国内机共享 KV 数据
|
||||||
|
- `acme.ts` **只用 Node 20+ 全局 API**(`crypto`/`fetch`/`btoa`/`Request`/`Response`/`TextEncoder`)
|
||||||
|
→ **可直接跑在 Node,无需改代码**
|
||||||
|
- `preflight` 逐目标体检,**必须读 `ping()` 返回的 `{ok,error,hint,detail}` 对象**,
|
||||||
|
不能当字符串(否则显示 `[object Object]`,更糟的是把 `ok:false` 记成通过)
|
||||||
|
|
||||||
|
### 9.5 部署与验收记录
|
||||||
|
|
||||||
|
- 镜像 `cn-certkeeper:local`(241MB),容器 `Up (healthy)`,`127.0.0.1:8019`
|
||||||
|
- 数据目录 8 个 `.kv` 就位(7 凭据 + 1 config)
|
||||||
|
- **容器内 preflight 7/7 全过**:litessl/zerossl 目录可达 + EAB 正确 · tencent-usj/tencent-tt 可读 ·
|
||||||
|
cloudflare→200181.xyz 可读 · dogecloud 3 个 CDN 域名 · **1panel-cn API 可达**(容器访问 1Panel 未被白名单挡)
|
||||||
|
- **本地端到端 mock 测试 22/22 全过**(FileKV + 凭据解密 + ACME 注册 + 签发 + DNSPod 写/删 TXT + 落库 + 日志)
|
||||||
|
- 定时:`RENEW_HOUR=4` / `RENEW_MINUTE=10`(对齐原 Worker cron `10 4 * * *`)
|
||||||
|
|
||||||
|
### 9.6 ★ 清理 1Panel 过期垃圾证书(2026-10-06)
|
||||||
|
|
||||||
|
删掉 6 条 certimate 早期遗留的过期证书(**删除前已导出全量记录含私钥**到
|
||||||
|
`secrets-backup/1panel-ssl-backup-<ts>.json`,可回滚):
|
||||||
|
|
||||||
|
| id | 域名 | 到期 |
|
||||||
|
|---|---|---|
|
||||||
|
| 4 | 200181.xyz | 2026-05-24 |
|
||||||
|
| 5 | \*.usj.cc | 2026-05-11 |
|
||||||
|
| 6 | \*.usj.cc | 2026-07-12 |
|
||||||
|
| 7 | \*.200181.xyz | 2026-07-24 |
|
||||||
|
| 8 | \*.usj.cc | 2026-09-11 |
|
||||||
|
| 9 | 200181.xyz | 2026-09-24 |
|
||||||
|
|
||||||
|
**保留(在用)**:`id 11 usj.cc`(5 站)· `id 10 200181.xyz`(ssh.200181.xyz)· `id 2 *.t-t.live`(5 站)。
|
||||||
|
结果:**证书库 9 条 → 3 条**,所有站点 HTTPS 正常。
|
||||||
|
|
||||||
|
> 判据用**证书记录自带的 `websites` 字段**(1Panel 自己算的引用关系),
|
||||||
|
> 比遍历 `/websites/{id}/https` 更权威。删除接口 `POST /websites/ssl/del {"ids":[...]}`。
|
||||||
|
>
|
||||||
|
> ⚠️ 3 个手工建的 nginx vhost(`dnsapi.usj.cc` / `writeapi.usj.cc` / `vaultwarden`)
|
||||||
|
> **不归 1Panel 站点管理**,证书是**文件拷贝**(指纹与 id 11 一致),与证书库记录解耦。
|
||||||
|
|
||||||
|
### 9.7 ★ 未决冲突:t-t.live 的「两套管理」
|
||||||
|
|
||||||
|
- 1Panel 证书库 `id 2 *.t-t.live` 记录的是**旧证书**(到期 2026-10-09)
|
||||||
|
- 但对外 **实际服务**的是 certd 直接写进 nginx 的**新证书**(Let's Encrypt,到期 2026-12-27)
|
||||||
|
- → certd **绕过 1Panel 证书库**改 nginx。若本项目也部署 t-t.live 的 1panel 目标,
|
||||||
|
会把 1Panel 库里的旧证书物化到站点 `ssl/`,**覆盖掉 certd 的新证书**。
|
||||||
|
|
||||||
|
**必须二选一**:
|
||||||
|
1. 停掉 certd,本项目接管 t-t.live 全部(含 1Panel + 腾讯云 EO)
|
||||||
|
2. 本项目**放弃 t-t.live 的 1panel 部署目标**,只留 usj.cc / 200181.xyz
|
||||||
|
|
||||||
|
### 9.8 其它待决
|
||||||
|
|
||||||
|
- **Worker 的证书续期 cron(`10 4 * * *`)是否停掉**:现在国内机已接管签发,
|
||||||
|
两边同时跑会**重复签发**(浪费 CA 配额)+ 争抢部署。建议停掉 Worker 的这条 cron,
|
||||||
|
Worker 只留只读监控/后台。
|
||||||
|
- **`usj.cc` 的 SAN 会从单名升级成通配**(线上是 `CN=usj.cc`,本项目配的是 `usj.cc;*.usj.cc`)
|
||||||
|
—— 需确认是否接受。
|
||||||
|
- **`dnsapi.usj.cc` 上 HTTPS**:nginx 反代已配好(HTTP 通),证书未上。
|
||||||
|
当前 `cn-certkeeper` 只绑 `127.0.0.1:8019`,暂未走反代。
|
||||||
Reference in new issue
Block a user