托管容器有了 IPv6 之后,客户问得最多的一句话不是”带宽多少”,而是”这个地址我记不住,能不能给我个域名”。
一句话听着简单,落地要解决三件事:域名在谁家的 DNS、IP 从哪儿取、变了之后谁去改。这就是狸花云这一版上的 DDNS 托管,代码在 server/ddns.mjs,两百多行,但四家服务商能凑出四种完全不同的签名方式。
一、先定边界:只管”记录”,不管”解析”
DDNS 托管刻意做得很窄——不托管 DNS 解析本身,只做”把某个域名指向某个 IP,并在 IP 变化时改掉它”。
这意味着域名必须先在服务商那边建好、NS 已经生效、Zone 属于用户自己。狸花云这边只保存一把 API 密钥和一条记录绑定关系。这样设计的好处是:用户随时可以拔掉,解析不会因为我们停机而消失。
二、两种数据源:控制台自己的 IP,还是某台容器的 IP
这是整个功能里最容易被忽略、但决定它有用没用的一层。
| 数据源 | 取值方式 | 典型用途 |
|---|---|---|
public(本机) |
依次请求 ipify / ipinfo / ipip / ifconfig.me,取到第一个合法 IPv4;AAAA 记录走 api64.ipify + icanhazip 取 IPv6 | 用户把自己的宽带、自己的 VPS 接进来 |
container(指定 vmid) |
从 GUEST_CACHE 里读该容器的 ipv6;缓存缺失时兜底用 MAC 推 EUI-64 |
给狸花云托管的容器配一个固定域名 |
container 这条路径是重点。容器里的 IPv6 是 SLAAC 出来的,前缀来自节点网段。取不到缓存时,我们自己算:
function macToEui64(mac) {
const parts = String(mac || '').toUpperCase().replace(/-/g, ':').split(':')
if (parts.length !== 6) return ''
const b0 = (parseInt(parts[0], 16) ^ 0x02).toString(16).padStart(2, '0') // 翻转 U/L 位
return [b0, parts[1].toLowerCase(), parts[2].toLowerCase(), 'ff', 'fe',
parts[3].toLowerCase(), parts[4].toLowerCase(), parts[5].toLowerCase()].join('')
}
第一字节异或 0x02(翻转本地/全局位),中间插 ff:fe——这就是 EUI-64 的全部秘密。前缀则从控制台容器自己的 /64 反推,缓存 5 分钟,也可以在配置里写死 network.ipv6Prefix 覆盖。
结果:即使容器一个字节都没上报,只要 MAC 能查到,域名就能算出正确的 AAAA 值。(当然,容器得是 running,否则直接报错,不去猜。)
三、四家服务商,四种打法
| 服务商 | 鉴权 | 签名算法 | 查记录方式 | 最大坑点 |
|---|---|---|---|---|
| Cloudflare | API Token(Bearer) | 无,靠 Token | /zones?name= 逐级剥离域名找 Zone |
proxied 必须显式写 false,否则记录被橙云代理,DDNS 失去意义 |
| 阿里云 DNS | AccessKey ID/Secret | RPC 风格 HMAC-SHA1,GET&%2F& |
DescribeDomainRecords + RRKeyWord |
Timestamp 必须是秒级 UTC ISO,.sssZ 要正则剥掉,否则签名一直对不上 |
| 腾讯云 DNSPod | SecretId/SecretKey | TC3-HMAC-SHA256 四段派生 | DescribeRecordList |
canonicalRequest 里 signed headers 必须全小写,host 头必须显式塞进去 |
| 雨云 DNS | x-api-key | 无 | 端点不公开,只能试探 | 见下 |
阿里云那套签名是最容易翻车的一段:
const stringToSign = 'GET&' + encodeURIComponent('/') + '&' + encodeURIComponent(qs)
const sig = crypto.createHmac('sha1', secret + '&').update(stringToSign).digest('base64')
// 注意:secret 后面要拼一个 &,这是 RPC 签名的固定格式
雨云则是另一种性质的坑:它的 DNS API 文档不完整,域名列表端点到底叫 /dns/domain/ 还是 /dns/domains/ 说不准。处理方式是多端点探测——挨个试,哪个返回结构像就用哪个;test() 里遇到 404 不判定失败,而是提示”API Key 已提交(未验证账户接口)”。这不是优雅,但比让用户对着一个红叉猜要强。
四、upsert 的三态语义
四条路径最后都收敛到同一个 upsert(cfg, {domain, type, value, ttl}),返回值必带三态之一:
unchanged—— 查到记录且值完全一致,一个请求都不发直接返回;updated—— 有记录但值不同,PATCH/PUT,返回old与new;created—— 没有记录,新建。
unchanged 短路这件事看着小,实际是省钱:Cloudflare 免费版有 API 调用配额,阿里云对修改操作也有限流。2 分钟一轮、几十条记录滚下来,不做短路的话每天白白打出去上万次写请求。
五、同步环:一个定时器伺候所有用户
不是每条记录一个定时器,而是一个 2 分钟的 tick 扫全表:
_ddnsTimer = setInterval(async () => {
if (_ddnsBusy) return // 防重入:上一轮没跑完就跳过,不排队
_ddnsBusy = true
try {
let ip4 = '', ip6 = ''
try { ip4 = await DDNS.getPublicIPv4() } catch {}
try { ip6 = await DDNS.getPublicIPv6() } catch {} // 公网 IP 一轮只取一次,全用户复用
for (const uid of Object.keys(all)) {
for (const r of all[uid].records) {
if (r.enabled === false) continue
if (r.lastIP === ip) continue // 值没变,跳过
// ... 单条 try/catch,失败只写 lastError,不中断循环
}
}
} finally { _ddnsBusy = false }
}, 120000)
_ddnsTimer.unref()
三个细节值得单独说:
1. 公网 IP 一轮只取一次。 public 源的用户不管有多少条记录,共用同一次探测结果。否则一百条记录就是一百次 ipify 请求,自己把自己限流。
2. 单条失败隔离。 阿里云欠费、Cloudflare Token 过期,都只污染那一条记录的 lastError,其他服务商的记录照常同步。一次全网挂掉的原因,往往就是循环里没有 try/catch。
3. unref()。 定时器不阻止进程退出。这个坑踩过——带 setInterval 又没 unref 的时候,Ctrl+C 之后进程挂着不走,部署脚本以为没停干净。
六、密钥不进响应
用户填的 Token、AccessKey 全部经 vault.encrypt() 落盘,/api/ddns/overview 只返回字段名和一个固定的掩码字符串:
masked: Object.fromEntries(Object.keys(p.cfg || {}).map((k) => [k, 'REDACTED'])),
明文只在真正调 API 的那一行 vault.decrypt(v) 里出现一次,出函数即丢。这样即使有人拿到数据库文件,没有 master.key 也解不开——和 VLESS 链接是同一套主密钥。
七、还没做的
- 只支持 A / AAAA,CNAME 和泛解析没做;
- TTL 写死 300/600,没给用户开口子;
- 失败没有退避重试,是每 2 分钟硬撞一次(对限流不友好,但可预测);
- 批量操作没有,一条一条点。
顺手也个提醒自己一句:docs/CHANGELOG.md 还停在 v7.1.3,7.10 到 7.20 这一大段压根没补。功能跑得比文档快,是好事也是债——回头补上。
DDNS 这东西技术上没什么深奥的,难的全在”别人的 API 各有各的脾气”。写十个功能九个是业务逻辑,剩下那一个是专门用来跟签名算法较劲的。