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
@@ -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}`);
|
||||
});
|
||||
Reference in new issue
Block a user