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:
zqlit committed 2026-10-06 18:28:55 +08:00
1 parent a3bc10c68c
commit 1427c47dcf
15 files changed
+1387 -52

No files matched your search

+38
View File
@@ -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"]
+36
View File
@@ -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
+116
View File
@@ -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,
};
}
}
+454
View File
@@ -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();
});
}
+27
View File
@@ -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"]
+22
View File
@@ -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
+199
View File
@@ -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}`);
});