免费公共中转站(ntfy.sh)—— 不租服务器的外网通道
什么时候用
场景长这样:A 设备和 B 设备要同步数据,但它们互相够不着。
典型是手机在 4G/5G(运营商内网、没有任何可被反连的地址)+ 电脑在家用宽带(NAT/CGNAT/动态 IP)。
先明确一件事:这是网络本身的限制,不是代码 bug。
两端都不可能有固定可达地址,唯一出路是两端都主动去连一个第三方公共点。
然后按这个顺序排除:
- 有公网 IP 的机器吗? → 自己写中继(TLS 长连接 + 帧协议),最可控。
- 没有,但能接受装客户端? → 现成内网穿透工具。
- 上面都不行 / 只想最快跑通? → 用免费的公共消息中转站。这就是本 skill。
一、选型:先实测,别信文档
用 curl 挨个打一遍,只看四件事:要不要注册 / 要不要 Key / 能不能覆盖写 / 有没有大小限。
2026-09 的实测结论(可以直接当黑名单用):
| 站点 | 结果 |
|---|---|
| ntfy.sh | 零注册、零 Key,主题名自己起(天然零配对);但 2026-09-21 实测有整段(约一小时)不通的时段(当天两次探测还见过它时而 200 时而 TCP 超时)—— 别单独用,也别当默认站,本项目已把它从内置默认列表删掉,见第十二节 |
| ntfy.envs.net | 可用。2026-09-21 实测:发布 / 增量拉取 / JSON 流 / 大正文转附件四项全通,/v1/health 约 1.1 s |
| ntfy.mzte.de | 可用。同上四项全通,/v1/health 约 1.0 s |
| ntfy.tedomum.net | TCP 能连上,但发布被 RST —— "端口通"不等于"能用",必须真发一条才算数 |
| jsonblob.com | 403 |
| paste.rs | 不支持 PUT 覆盖(404) |
| kvdb.io | 要 email |
| jsonstorage.net | 要 API Key |
| 0x0.st | 已停上传 |
探站别只打 /v1/health(它只证明"这个站活着")。四条都要真跑一遍:
发布 / ?poll=1&since= 拉取 / /json 流 / 大正文转附件 ——
ntfy.tedomum.net 就是死在这条上的。
为什么 ntfy 特别适合这个场景:主题名就是"频道",两端填同一个名字就能通,
不需要任何配对/握手/注册流程 —— 这正好对上"用户自己抄一个码到另一台设备"的交互。
二、ntfy 的三种用法(选错就会踩限流)
# 1) 发布:正文就是消息内容
curl -d "内容" https://ntfy.sh/mytopic
# 2) 增量拉取(轮询用)
curl "https://ntfy.sh/mytopic/json?poll=1&since=1758345600"
# 3) 订阅流(实时用,NDJSON,每 45 秒一条 keepalive 事件)
curl -N "https://ntfy.sh/mytopic/json"
必须记住的四个坑
poll=1不带since返回空。 只写since=all才给全量。
要做增量就老老实实传上次那条消息的 unix 时间戳。- 同 IP 平均 5 秒只允许 1 次请求,突发上限 60 次,超了返 HTTP 429。
实测:900ms 一轮的轮询跑到第 17 轮就开始稳定 429。
⇒ 长连接订阅是正解,轮询只当兜底(比如每 3 次订阅失败兜一次)。 - 正文 4KB 上限,超了服务端自动把内容转成附件,消息体变成
{"event":"message","message":"att","attachment":{"url":"..."}}。
发布时带Filename: xxx.bin头。90KB / 240KB 都实测往返一致。
⇒ 两端都要实现"小载荷走正文、大载荷走附件",用户无感。 - 公共实例的
/v1/stats、/v1/stats?topic=...能列出主题名。
⇒ 主题名绝不能等于频道码,必须哈希。见下一节。
三、主题名与密钥的派生(安全模型)
用户手上的那串东西叫频道码(至少 12 位,越长越安全)。它同时决定两件事:
上行主题名 = "dkn-" + hex(SHA256("topic|up|" + channel))[0:8]
下行主题名 = "dkn-" + hex(SHA256("topic|dn|" + channel))[0:8]
加密密钥 = SHA256("key|" + channel) // 32 字节
- 有前缀(
topic|/key|)是必须的:否则同一个频道码在上面三处会派生出相关的值。 - 主题名只取前 8 字节:够用,且短。
- 上/下行主题必须分开:否则两端各自发出的消息会被自己收到,来回打架。
- 频道码本身就是密码,靠"用户自己抄"完成分发。别做成明文主题名。
四、端到端加密(两端必须逐字节一致)
载荷结构:AES-256-GCM,nonce(12) ‖ 密文 ‖ tag(16),整个 blob 再 Base64 成一个字符串。
业务字段直接塞在明文的 JSON 里:
{"v":1,"e":false,"p":false,"n":"<唯一 tag>","t":"正文","ts":1758345600}
e= 收到后要不要回车;p= 要不要往当前窗口粘贴;n= 唯一 tag,用来"认领"自己发的那条。- JSON 序列化不要把中文转成反斜杠 u 四位十六进制那种转义形式。
C# 用UnsafeRelaxedJsonEscaping,Kotlin/Java 侧同理 ——
否则一个汉字占 6 个字符,4KB 上限被吃掉一大半。
两端对齐怎么保证(最重要的一条经验)
光靠"我这边加密自己能解开"是测不出跨端兼容性的。
必须在自检台上放几条用另一端算出来的固定基准值:
断言:Crypt.topic("dkntest-...", "up") == "dkn-f12567bfe149aac8"
断言:Crypt.topic("dkntest-...", "dn") == "dkn-5d9eee68b5286a2b"
任何一边偷偷改了派生规则,这两条立刻炸。
基准值要从真正跑通了对端的那个实现里取,不能手算。
五、踩过的真 bug(照着查,省几小时)
-
二进制不能直接当正文发。
ntfy 正文只接受文本。两端约定的密文编码是 Base64 文本,
发送端必须先Base64(blob).toByteArray(ASCII)再写出去。
直接塞原始密文字节,接收端FromBase64String必然失败,
而且失败得毫无线索(只表现为"一条都解不开")。 -
订阅方向搞反是重灾区。
电脑端订阅上行主题(手机发上来的),手机端读下行主题(电脑发下来的)。
反了以后症状是"发出去成功、对方永远收不到",日志里一切正常。 -
"认领"自己的消息。
只靠since增量会把上一条消息当成本次结果。
发的时候带一个唯一 tag,收的时候比对,不匹配就跳过。 -
本机剪贴板监听会跟写入抢。
监听线程 250ms 读一次,会让SetDataObject抛"假失败"(其实写进去了)。
写入要做 重试 3 次 + 读回核对。 -
GCM 解密的判长要用
<不是<=。
blob.size < 12 + 16才算非法;写成<=会让"空明文"解不开。
六、自检台该怎么写
跑真连公共站的跨端联调(跳过 = 假绿,比失败更危险):
- ① 电脑端加密 → 手机端解密
- ② 手机端加密 → 电脑端解密 ← 这一条最容易暴露 base64 / 方向问题
- ③ 大字(> 4KB,走附件通道)双向一字不差
- ④ 别人用别的密钥发的消息,本端解不开(认证标签拦下,不是"解出来是乱码")
- ⑤ 主题名里不含频道码本身
联调用的外部 exe / 脚本路径要按 user.dir 拼绝对路径,
用相对路径会因为工作目录不同而被静默跳过。加一句 println 把"跳过了"明确打出来。
每个联调频道用一次性随机码("dknjvm" + System.currentTimeMillis()),跑完就作废。
七、给用户怎么说(一句话版)
> 能,用免费的公共中转站,免注册、免 Key。
> 电脑端生成一串频道码 → 复制给手机 → 两端填同一个。
> 内容是 AES-256-GCM 端到端加密的,中转站只看到密文;
> 频道码就是密码,别外传。
> 公共站单个会时通时不通,所以两端各挂几个、同一份内容一起发(见第十二节)——
> 单个站挂掉不影响你。
> 两个已知限制:超过 4KB 自动走附件;中转站限速,所以用的是长连接。
八、和"自建内网穿透"的关系
两条路不冲突,默认可以并行:局域网通道照常工作(谁快谁先用上),
云端通道只在"远端"场景兜底。默认别做成二选一 —— 用户在家时并不想手动切。
自建中继的适用条件是"有一台有公网 IP 的机器",那是更可控的方案(TLS + 指纹固定 + 预共享密钥);
而免费公共中转的价值是 零运维、零成本、五分钟能跑通。
九、如果用户明确要"只用云端中转":该删什么
有用户会直接说「把局域网那套去掉,只用云端」。这时候别只加个开关把局域网关掉,
要把整条路整块删干净 —— 留着的死代码(端口常量、菜单项、权限、说明文字)
比缺功能更坑,用户下次会问"怎么还有 5000 端口的代码"。
按下面清单逐项过一遍(两端都要):
| 类别 | 要删的东西 |
|---|---|
| 监听 | 服务端监听 0.0.0.0:5000、手机端的 8080 接收服务 |
| 接口 | GET /ping、POST /sendtext、POST /sendclip、回传 POST http://{对方IP}:8080/receiveclip |
| 对端地址 | "记住对方 IP"的文件 / 菜单项 / 输入框 / 持久化 key |
| 发现 | 扫同网段、端口探测、连不上自动搜对端(这类文件改名 .unused 留着,不要真删) |
| 防火墙 | 生成 netsh 命令的菜单项 + 常量(改成"不再需要放行"的说明) |
| 手机权限 | ACCESS_WIFI_STATE / ACCESS_NETWORK_STATE 只服务局域网直连,删掉;顺带 uses-feature android.hardware.wifi 也消失 |
| 依赖 | 电脑端 .csproj 从 Microsoft.NET.Sdk.Web 换回 Microsoft.NET.Sdk;删掉 using Microsoft.AspNetCore.* |
| 文案 | 托盘菜单 / 状态条 / 启动气泡 / 使用说明里的 IP、端口、防火墙字样 |
| 自检项 | 反过来写:原来是"5000 在监听吗",现在是"5000 不应该有监听(有的话说明旧版没退干净)" |
几个实操要点:
- 删源码文件用改名
.unused,不要真删。构建脚本一般是find … -name '*.kt',
自动就排除了,随时能找回。 - 精确字符串替换 + 命中数必须为 1,否则报错退出(脚本化,别手改大文件)。
改完立刻反查残留:grep -n "8080\|5000\|receiveclip\|startServer\|sendclip\|sendtext"。 - 反查 dex 用 Python,别用
strings(Git Bash 里常常没有strings):
zipfile.ZipFile(apk).read('classes.dex').count(b'关键词'),
再把re.findall(rb'Lcom/你的包/[A-Za-z0-9_]+;', dex)打出来核对类名清单。 - 纯逻辑自检台(脱离平台的那种)看不到平台层的缺失,
所以减法改造的最后一步必须是真编译 / 真打包,不能只跑自检。 - 没有频道码就等于整个程序什么都不做,所以第一次运行要自动生成频道码:
写盘 + 写日志 + 放进剪贴板 + 弹个非模态气泡(别弹模态对话框)。
气泡内容就是手机要填的那一行host/频道码。 - 安全上顺带更好:删掉监听端口之后,"无鉴权的开放端口"这个隐患一起没了。
十、频道码"按当天日期自动生成"(免手填,2026-09-21 落地为 v2.6 / v5.9)
用户常会接着提这个需求:「根据当天日期生成一个独一无二的频道码,日期不变它就不变,
程序启动时自动设定好;原来手动设定和随机生成的功能也保留」。
这是把"频道码分发"这个人成本去掉的最省事做法 —— 两端各算各的,谁也不用抄给谁。
派生规则(和主题名一样,必须带域前缀)
频道码 = "dknd-" + hex(SHA256("dkn-daily-channel|v1|" + yyyy-MM-dd))[0:12] // 共 29 字符
- 前缀固定成
dknd-,和随机的 32 位十六进制码在肉眼上就能区分
(用户一眼看出"这串是自动的",出问题时也好描述)。 - 把
|v1|放进哈希输入里是为了以后改算法不撞车。 - 日期必须是公历 yyyy-MM-dd、用不随地区变的文化设置:
C# 用DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Kotlin/Java 用SimpleDateFormat("yyyy-MM-dd", Locale.US)。
⚠️ 泰国等默认非公历的地区,Locale.getDefault()会给你佛历年份(+543 年),
两端就对不上了,而且症状是"日期看着都一样,就是不通"。
两端怎么落
| 做法 | |
|---|---|
| 电脑端 | 配置里加 auto=1;启动时 EnsureChannel() 算出当天码 → 写盘 → 放剪贴板 → 弹气泡。先定码再 Reload(),否则会白连一次昨天/上一条的主题(日志里会出现两次订阅) |
| 手机端 | 勾选框「按日期自动」(默认勾上);取值时现算、不落盘(dailyChannel()),免得跨天还读着旧的 |
| 手动模式 | 电脑端 auto=0、手机端取消勾选。切手动时把"当前算出来的那串"存进原来的字段,用户看着不突兀;日期变化不再覆盖它。两端都要有切回自动的入口 |
| 跨天 | 电脑端 30 秒轮询比对日期;手机端前台心跳每 3 秒顺手比一次,后台靠云端长连接的循环兜底 |
最容易漏的一条:把"频道码变了"加进长连接的断开条件
跨天时如果只在 UI 层把新码算出来、却不主动断开旧连接,长连接会一直听着昨天的主题 ——
表面一点错误都没有,就是不收消息(比报错难查得多)。
必须让订阅的 stopped 条件包含"当前地址 ≠ 建立连接时的地址":
Cloud.stream(addr) { !cloudRunning || Prefs.cloudAddr(this) != lastAddr }
电脑端同理(Reload() 里比对旧主题)。兜底周期就是 ntfy 的 45 秒 keepalive。
验证方式(照抄这一套)
- 写死几个跨端对拍的基准值:今天、明天、跨年(如
2026-09-21/2026-09-22/2027-01-01)
的期望码,两边各断言一次。任何一边改了规则立刻炸。 - 纯 JVM 自检台里给日期留一个
override开关(object Daily { var override: String? }),
这样能一次跑完"同一天不变 / 跨天不同 / 跨月跨年不同 / 跨天密钥解不开"这些断言。 - 真跑对端的可执行文件对拍:电脑端加一个 CLI
--daily-channel [日期]只打印不做事,
手机端自检台用ProcessBuilder调它比对;这样"两端算法一致"是机器证出来的。 - 收尾补一步:程序真跑一次,看日志里有没有
按日期自动设定频道码(程序启动):2026-09-21 → dknd-xxxx,以及订阅只发生一次。
必须如实告知的安全代价
派生规则写在客户端里,只要知道日期就能算出频道码 ⇒
自动模式的安全性低于"用户自设的长随机码"。
要在代码注释 / UI 提示 / 使用说明 / README 四处都写清楚,
而不是只在内部嘀咕一句。想更安全就引导用户切手动模式填一串长的随机码。
自检台调外部 exe 的两个死坑
Process.waitFor()没超时会永久吊死。探测用的旧版 exe 不认识新参数 → 它不打印也不退出,
走的可能是"起托盘进程"分支,整个自检台卡几分钟。
⇒ 一律waitFor(25, SECONDS)+ 超时destroyForcibly();
输出不符合预期格式就返回 null 并打印"这个 exe 不支持某参数(多半是旧版本)",
明确跳过,不要静默。- 候选路径要优先最新的产物(本项目
build/CloudClip-Win.exe排最前),
否则bin/Release/net8.0-windows/里那个旧 exe 会被先选中,测的是老代码。 - 探测完记得
tasklist | grep <exe名>看一眼有没有被拉起来的残留进程,有就清掉。
十一、两端互问式"连通性测试"(2026-09-21 落地为电脑端 v2.6 / 手机端 v6.0)
场景:用户看着一条通道,界面上永远是同一个死样子(圆点红、内容不动),
但原因可能是三件完全不同的事(这一端没网 / 两端频道码不一致 / 对端根本没在跑)。
"发一条试试看"只能给出"不行",给不出"卡在哪一段"。
本节的做法是让两端各自以对方的名义说话,一次性把每一段都测出来。
地雷一:自己发、自己读 = 自言自语
用自己的身份发、再到自己该读的那个主题里找 —— 一定能找到,
于是报告"通了",但这条链路其实一步都没走出去。
⇒ 必须以对端的身份说话:这一端发出去的,要落在对端读的那个主题里。
落法(设 A=本端身份、B=对端身份,两端各有一个"读的主题"):
| 谁 | 用什么身份发 | 落到哪个主题 | 证明什么 |
|---|---|---|---|
| 甲 | 乙的身份 | 乙读的那个主题 | 甲能把东西送出去且乙侧会看到(甲→乙) |
| 乙 | 甲的身份 | 甲读的那个主题 | 乙能把东西送回来(乙→甲) |
自检台必须为这条单独写一节,断言四件事:① 用对端身份发得出去;
② 它落在对端的主题里;③ 用本端身份读的确实是本端那个主题
(所以不能拿它自测);④ 另一个主题里没有这条(证明身份真的换了)。
第 ④ 条最容易漏 —— 少了它,"身份没生效"的 bug 是测不出来的。
地雷二:认领要靠密文,不能靠名字
tag(如 hello-<rev> / pong-<rev>)是明文塞进载荷的:谁都能写一个一样的,
拿它当"这就是对端那条"的证据是假的。认加密之后的密文:
AES-GCM 每次都带随机 nonce ⇒ 密文逐字节相同 ⟺ 同一密钥 + 同一段明文。
两端各把自己那条的密文交给对方去比对(本项目叫 hx / ox),
"逐字节相同"才是铁证。
配套地雷(一定会踩):发送方必须把已经算好的那份密文原样发出去。
- Kotlin:publishTo(..., presetBlob: ByteArray? = null),发送用
presetBlob ?: Crypt.encrypt(...);测里那句 val blob = Crypt.encrypt(...)
一旦被重新执行,凭证就和主题里那条不同了,比对永远不命中。
- 自检台加一条实证断言:同明文加两次必须得到不同密文
(这条本身也说明"凭证只能复用、不能重算")。
地雷三:轮询时把自己发的那条剔掉
主题是追加式的,本进程刚发出去的探测还在里面。
如果认领逻辑留了"宽松分支"(比如"tag 前缀对得上就算"),
就会把自己的呼叫当成对端的回复 ⇒ 给出假"通"。
⇒ 按消息 id 去重(seen / HashSet<string>),并且认领逻辑先严后宽、
严格命中就立即 break。
地雷四:读原始密文要改数据结构
如果轮询函数只返回"解密后的正文"(t 字段),密文根本拿不到,上面这套全废。
⇒ 给消息记录多带一个字段(Blob),在解密成功的同时把
Convert.ToBase64String(blob) 一起带出去。
限流:连读两个主题,第二个必然 429
「同 IP 平均 5 秒 1 次」是按订阅/轮询请求算的。
一轮里先读主题①再读主题② ⇒ 第二次就 HTTP 429
(正文 {"code":42901,...},日志里表现为"HTTP fail + 0 条")。
注意:?poll=1 的长轮询不算(它就是"待到有消息",不是轮询),
别把 --cloud-selftest 里的 poll=1 当成同样的东西。
⇒ 轮询间隔 ≥2.5 秒;并且别把"读不到"当成"对端没回" ——
先看日志里是不是 HTTP fail,那可能纯粹是自己把额度用光了。
分工:谁有资格下结论
- 发起端只负责把探测发出去,不判"成功/失败"(它看不到对端到底收到没有)。
- 能同时看到两侧的那一端才出结论(本项目是手机:它既读自己的、也读电脑的)。
- 另一端(电脑)只报它自己看到的那一半,另一段老实写
"请看手机上的显示"。两端各说一套、用户不知道该信谁,比没有测试更糟。
交付形态(本项目)
- 电脑端写一份结果文件(桌面
cloudclip-win-cir.txt,找不到桌面退到
文档\手机目录转存\、再退到文档\),里面含一行CIRJSON {…};
手机端点按钮时先试着读它,读不到就自己跑同机上的 exe
(开发场景:手机与电脑同机,ProcessBuilder("…/CloudClip-Win.exe","--cir-json"))。 - 手机把结论 +
reportJSON 再交回电脑端(--cir-report "<json>")补一段。 - 注意两端
report的字段名必须逐个对齐(本项目用极短名v/ht/hx/ox/el),
并且在两侧的测试里都断言这几个名字 —— 一边改名、另一边还按老名字解析,
症状是"功能静默失效、但两边自检都绿"。
排查开关(强烈建议加)
--cir-report 这种"读一圈再判断"的命令,失败时只有一句"没找到记录",
根本分不清是主题是空的还是全解不开。加一个环境变量开关
(本项目 TRAYAPI_TRACE=1)把每轮、每条(tag / 是否命中 / 密文长度)打出来 ——
本节的三个地雷全是靠它才定位到的。
⚠️ 这个开关必须是进程级的全局静态初始化,不能只在某个 CLI 分支里赋值:
托盘常驻进程和 --cir-* 是两条路,只在后者里打开的话,
"手机发过来到底有没有到"在托盘模式下一点痕迹都没有。
另外:读到一行不是 NDJSON 时要显式记日志,
否则"消息没来"和"来了但没认出来"这两件相反的事会混成同一个静默。
十二、别把通道押在一个公共站上:多站冗余(2026-09-21 落地为电脑端 v2.8 / 手机端 v6.1)
定性:公共实例的可用性是"按小时"变的
同一台机器、同一天实测:
| 目标 | 18:21 | 19:06 ~ 19:40 | 20:1x |
|---|---|---|---|
https://ntfy.sh/v1/health |
✔ | ✘ TCP 超时 | ✔ 200 |
https://baidu.com |
✔ | ✔ | ✔ |
https://ntfy.envs.net/v1/health |
— | ✔ 1117 ms | ✔ |
https://ntfy.mzte.de/v1/health |
— | ✔ 992 ms | ✔ |
用户机器上的托盘日志把这一小时记成了 29 条一模一样的
订阅断开:HttpRequestException … (ntfy.sh:443),N 秒后重连
(退避从 1 秒涨到 30 秒)。这就是"连不上"的全部真相 —— 代码一行没错。
关键在于:这个现象和"本机没网"在界面上长得一模一样。
两端只连一个站的时候,站一挂 ⇒ 两端同时红 ⇒ 用户只会说"连不上",永远查不出卡在哪。
所以修法不是"换个更靠谱的站"(没有这种站),而是不押在任何单个站上。
设计:发送端发多份,接收端多路
┌── ntfy.envs.net ──┐
手机 --并行发同一份密文─┤ ├─ 长连接订阅 ── 电脑
└── ntfy.mzte.de ───┘
| 环节 | 做法 | 理由 |
|---|---|---|
| 发送 | 并行发到所有可达站(同一份密文发 N 份) | 挑一个发 = 两端必须挑到同一个;手机走 4G、电脑走宽带,封锁策略不一样 ⇒ 各挑各的 = "两边都显示正常但永远收不到对方",最难查的故障。发多份不需要两端商量 |
| 接收(电脑/常驻端) | 每个可达站各开一条长连接,Task.WaitAll 一组 |
谁先到算谁的;某条断了只重连它自己 |
| 接收(手机/移动端) | 挨个站试,第一个连上的就用 | 移动端吃电、可能在流量上;"能碰上"已由发送侧发多份保证 |
| 拉取 / 轮询 | 挨个站都拉一遍再合并 | 内容可能只存在于某一个站上 |
| 去重 | 按密文去重,不能按消息 id | 每个实例给同一条消息生成自己的 id:同一份 blob 发到几个站就是几个不同 id。而密文只加密一次、发多份 ⇒ 逐字节相同,正好当键。AES-GCM 每次新随机 nonce,所以"用户真的复制了两次相同文字"不会被误吞 |
| 站数 | 2 个就够,硬上限写死 2(MaxFanout = 2) |
别再挂第 3 个:只有两个站同时挂才会不通,第 3 个带来的是"每轮多一个请求 + 常驻端多一条长连接",可用性边际增益≈ 0。参见下面「站数别贪多」 |
可达性探测:并行 + 缓存 + "探不到就当能用"
- 打
/v1/health(很轻,返回{"healthy":true})。 - 必须并行:串行几个站 × 5 秒超时 = 界面卡十几秒。
- 缓存 ~45 秒(
Forget()给"配置变了 / 全挂了"用)。 - 一个都探不到时返回全部配置的站:探测失败 ≠ 发送一定失败
(探测走的是 HEAD/GET,发送是 PUT/POST,中间设备可能放一个拦一个)。
fallback 要默认开(这是个升级陷阱)
老版本的配置里写着 server=https://ntfy.sh。如果严格"只听配置",
升级完还是只连那个已经不通的站 —— 这次修了等于没修。
所以:server= 后面自动跟上内置备用站,用一个 fallback=1(默认开)控制。
server= 支持逗号 / 分号 / 空格 / 换行分隔,也支持 # 注释 ——
⚠️ 注释必须在切分之前剥掉,否则 # 这是注释 会被拆成两段、
第二段被补成 https://这是注释(这个 bug 真的写进去过,靠自检才发现)。
两个一定会踩的坑
- "只填频道码"不能让 host 列表变空。
解析abcdefghijkl这种裸频道码时若返回空 hosts,而
publish/fetch/ping全是"遍历 hosts",结果就是
配置看着填好了、按发送永远不动,报错还会骗人地说"频道码没填或太短"。
⇒ 裸频道码 = 自动挂上默认主站(+备用站)。 HttpClient要拆成两个。
一个带超时(30 s,发送/轮询用,防止某个站卡住把整轮发送永远挂住),
一个超时设InfiniteTimeSpan(只给长连接订阅用,靠CancellationToken收尾)。
排障开关:--relay-probe
它把两件以前长得一模一样的事分开:
中转站体检:你填了 1 个,内置备用站 已开启,实际会用 2 个。
· https://ntfy.envs.net
· https://ntfy.mzte.de
逐个探测 /v1/health(每个最多等 5 秒):
✔ https://ntfy.envs.net 828 ms
✔ https://ntfy.mzte.de 2014 ms
—— 2/2 个可达,够用。
全部不通 = 本机网络/被墙;只有一个不通 = 那个实例挂了。
站数别贪多:2 个就够(3 个是纯浪费)
多站冗余是治「某个公共实例挂了整条通道就全死」,两个站就治住了 ——
只有它们同时挂才会不通,这个概率远低于单站。第 3 个站带来的不是可用性,而是:
- 每轮发送多一个请求(含它失败时的 30 秒超时预算);
- 常驻端多一条长连接、多一个重连循环;
- 每次可达性探测多一份 5 秒超时预算。
更坑的是:如果那个站恰好在你这条网络里从来不通,它就是“默认带一个注定失败的站” ——
本项目 2026-09-21 就是这个情形(ntfy.sh 443 整段不通),
于是 v2.11 把它从内置列表删掉,回车到两个站(MaxFanout = 2)。
判断标准很简单:你自己跑 --relay-probe,全部达的就留下,长期红的就删。
删掉不等于封杀 —— 用户想加回来,在配置里手填一行就行(手填的站永远排第一)。
⚠️ 两端的默认站列表和 MaxFanout 必须一致 —— 不一致就是
“我发到 A、你只听 B”这种最难查的故障。客户端代码注释里要直接点名另一端的符号名。
自测要走生产那条路(否则自测在吓唬人)
改为多站之后,自测如果还只往主站发,主站被限流(HTTP 429)就报"失败",
而实际上另外两个站早收下了、对端照样能收到。
⇒ 自测也并行发到所有站,并逐个打出行结果:
发小载荷 134 字节密文 → 2/2 个中转站收到
✔ https://ntfy.envs.net
✔ https://ntfy.mzte.de
验收要点:--cloud-selftest 的"小载荷 / 大载荷(走附件)"两项都要看到
N/N 个中转站收到 且取回解密一字不差;托盘实机日志里要同时出现
「同时订阅 N 个中转站」+ N 条「已连上」,发一条后能看到
「同一份内容已经从别的中转站收到过了,跳过重复的一份」× N-1(去重生效的正面证据)。
十三、诊断消息必须跟内容分流(2026-09-21 落地为电脑端 v2.9 / 手机端 v6.2)
症状(用户截图原样)
连通性测试 · 手机回报(20:18:59)
20:25:17 电脑→手机 已写入手机剪贴板 ← 测试消息把人家正在用的内容冲掉了
连通性测试 · 手机回报(20:18:59)
20:25:20 电脑→手机 已写入手机剪贴板 ← 同一条,记了三遍
用户原话:「连通性测试和普通的消息我希望能够区分开来……它不要占用这个剪贴板」。
为什么会必然发生
连通性测试的消息和真内容走的是同一条通道、同一套载荷格式,
接收端只判断"这是不是本频道的消息" —— 是就照常「写剪贴板 + 弹通知」。
于是三个后果同时出现:
- 占剪贴板:测试话把用户剪贴板里的东西冲掉。电脑端也一样 ——
跑一次--cir,托盘就会把「连通性测试 · 电脑呼叫手机(…)」写进 Windows 剪贴板
(在跑着托盘的时候实测抓到过:剪贴板里躺着 20:41:32 那句)。
注意"冒充对端身份发"不是保险:那条消息打到的是对端读的主题,
而本机(或另一台端)可能正好也订阅着它 —— 自己发的测试会打到自己身上。 - 看着像真消息:日志里是正常气泡 + 方向标,用户根本分不出。
- 同一条记三遍:见第十二节的"按密文去重",按消息 id 去重等于没去重。
改法:一个标记 + 三层识别 + 三种命运
- 载荷加标记:
{"v":1,...,"d":true,"t":"连通性测试 · …","n":"hello-xxxx"}。
两端都发、两端都认。 - 三层识别(
Diag.isTest/IsTestMessage),从硬到软:
d标记 → tag 前缀(hello-/ping-/pong-/cir-)→ 正文前缀(「连通性测试」只认开头)。
后两层是为了"只升级了一端"也不回到老毛病。只认开头这条别省:
用户真复制一篇讲"连通性测试"的文章,正文不会以它开头。 - 三种命运(收件分流的唯一判据,界面/服务只照做,别在两处各写一套):
- 自己刚发出去的那条(echo)→ 直接丢,连日志都不记(记了只是噪音);
- 测试消息 → 只记一行日志:不写剪贴板、不弹通知、不注入按键;
- 真内容 → 老行为。 - 界面也跟着分流:把"谁配显示成气泡"收口成一个函数
(本项目Source.isBubble:只有真的收发内容才是气泡,
系统消息/诊断消息是无底色小灰字)。以前界面自己判断"是不是系统消息",
加一类日志就漏一处 —— 这次的连通性测试就是那么漏成气泡的。
判据写成白名单("是内容才给气泡")比黑名单("是系统消息就不给")抗新增。
用户能按的那个按钮要加冷却
15 秒足够。一轮测试要往云端发 1~2 条、还要在两个主题上各拉好几轮,
连点几下就是在公共站上刷「同 IP 平均 5 秒 1 次」的额度,而且把日志刷满。
按早了就提示"还差 N 秒"(cooldownLeft() 写成纯函数,自测直接喂时刻断言)。
验证套路(可复现的 A/B)
不要只查代码,要真跑 + 读剪贴板前后比对:
# 纯 ctypes 读写剪贴板(⚠️ 64 位下必须给 Win32 函数标 argtypes/restype,
# 不标就段错误):
# save -> 记下当前剪贴板原文,并**原样写回**(内容没变,监听端不会当成新内容推一遍)
# check -> 与记下的原文逐字比对
流程:启动托盘(带 trace)→ 等「已连上」→ 等首轮回填落定 → save →
跑一次 --cir → 等 8 秒 → check。期望:剪贴板一字未变,
日志里只有「收到一条连通性测试消息(只记日志,不写剪贴板)」,且没有
只写剪贴板 / 已发送 Ctrl+V。旧版跑同一条命令后剪贴板里会躺着测试话 —— 这就是对照。
顺带一提:日志文案会跟着通道跑
本项目里手机的日志文本(如「云端订阅断开(第 1 次):…」)是当内容发出去的
(用户点了气泡复制,就被剪贴板监听推走了)。排查时看到"电脑收到了手机的报错文本"
不要慌,那不是 bug,是用户复制过它。真要更稳,可以让界面上的"点气泡复制"
顺手打上 JustSent 标记,免得多推一条。
十四、性能调优清单(2026-09-21 两端实测,照抄即可)
用户提「看看有哪些地方可以调优」时,先按这张单子过一遍 —— 每一条都是真花过代价的,
不是理论上的"可能更省"。
① 状态显示别另开一条轮询(最大的一笔)
反面教材:手机界面为了让顶上那个圆点变绿,每 3 秒 GET 一次中转站 ——
20 次/分钟,费电、费流量、还占着公共站的限流额度。更糟的是它测的是
"另一个请求通不通",跟"收消息的那条长连接活着吗"不是一回事:绿点不保证收得到东西。
正确做法:长连接本身就是最准的探针。
- 订阅函数加一个 onUp 回调(HTTP 200 那一刻回调一次);
- 服务侧维护 cloudUp / cloudUpAt / cloudDownAt 三个 volatile;
- 界面每 1 秒纯本地读变量重画(1 秒一次不花钱),断开头几秒给个宽限(正常换网会闪断);
- 掉线时把长连接自己报的错(超时 / 域名解析不了 / HTTP 4xx)当理由贴出来,比"心跳失败"具体得多。
顺带白捡一条能力:服务没在跑也看得出来。老的"另发一个探测请求"永远测不出这个 ——
服务死了,探测照样通,圆点绿着,人却收不到东西。
② 多站操作并行,但只读的必须"全等齐再合并"
发送(fanOut)只要一个成功就能收工;拉取/探测(fanAll)必须每个站都问到 ——
"这条内容在不在这个站上"是互相独立的事实。但顺序上一定要并行:
串行几个站、每个超时 3~8 秒,最坏就是逐个相加,连通性测试里好几个这种循环,
串起来能把一次测试拖到十几秒。并行后最坏 ≈ 一个超时。
③ Windows 剪贴板监听:先读序号,变了才读内容
Thread.Sleep(250) + Clipboard.ContainsText() + Clipboard.GetText() 是常见写法,
但它一秒四趟打开剪贴板、把整段内容读成新字符串:
[DllImport("user32.dll")] private static extern uint GetClipboardSequenceNumber();
var cur = GetClipboardSequenceNumber(); // 不打开剪贴板、不分配字符串
if (cur != 0 && cur == seq) continue; // 没变就跳过(99% 的循环走这条)
seq = cur;
// 变了才去读
剪贴板是全局独占资源,一秒开合四趟还会跟别的程序抢(它们那边会弹"剪贴板被占用")。
拿不到序号(0)时退回老行为"每次都读"——功能优先,别为了省电变成聋子。
④ 常驻程序的日志要轮转
File.AppendAllText 只会越写越大:一天几十条 × 常年不管 = 几百 MB,
真出问题想翻的时候反而打不开。到 2MB 就 File.Move(path, path + ".1", overwrite: true)
(旧的 .1 直接丢),目录里最多两份。
实测:放一个 2.48 MB 的旧日志 → 启动后新日志 1025 字节、.1 保留 2600000 字节。
⑤ 探测/重连要"地板"
订阅循环每断一次都会 force 探一轮中转站;网络不好时就成了"断一次探一轮",
每个站各 5 秒超时地打 —— 探测本身把网络搞得更差。给 force 也加一个最小间隔(8 秒),
间隔内先用上一次的结论。
⑥ 别在 getCount() / getView() 里取数据
getCount() 里写 data = repo.all()(复制整表)是个经典坑:ListView 一帧要调好几次。
快照只在"数据真变了"的回调里取一次,其余方法都读缓存;行内 view 用 view.tag 存个 Holder。
⑦ 用户界面上的"测试"不要弹框
点一次弹一层说明框,挡住用户正在看的日志;而结论本来就是要写进日志的。
落成灰字日志 + 一句 toast 就够,点开日志能看全文、能复制。
(这一条是用户体验,不是性能,但用户提"优化"时通常指的就是它。)
十五、同一段内容被两条路各发了一次:记账要记在"动作那一刻"
症状(用户截图原话:「第一次发送消息给电脑的时候,气泡里面有两段文字是相同的内容」):
发送端日志里同一条内容出现两遍,中间隔几秒;接收端也真收到两遍。
怎么定性(一步到位,别猜):去看接收端日志里那条载荷的标志位。
这个项目的载荷带 e(要不要补回车)/ p(要不要粘贴),于是:
[22:04:15] 收到手机发来的 17 字:我已经很久没有玩狼人杀了 有点怀念
[22:04:16] 已发送 Ctrl+V + 回车 ← e=true p=true → 来自「显式发送」按钮
[22:04:18] 收到手机发来的 17 字:我已经很久没有玩狼人杀了 有点怀念
[22:04:18] 只写剪贴板(没要求粘贴) ← e=false p=false → 来自「剪贴板自动同步」
两条、标志不同 = 两条路各发了一次(不是重传、不是多站冗余去重失败 ——
多站冗余那份密文逐字节相同,早就被按密文去重挡掉了;这两条的密文不同,
因为它们是两次独立加密,nonce 不同)。
根因:发送端有一个"这段文字刚发出去,剪贴板监听别再推一遍"的记账,
但它写在网络回调里(发送成功后才记),而一次发送的往返实测 1~4 秒 ——
0.7 秒一轮的剪贴板监听只要在这几秒里探到同一段文字,就抢在前面又推一遍。
用户在发之前十有八九"先复制、再粘进输入框",所以这个撞车几乎必然发生,
而且只在第一句话上发生(后面几句是直接打的,剪贴板里没有)。
修法(两条一起上):
- 记账挪到"动作发起"那一刻,别等回调:按钮点击里、所有早退分支之后立刻 mark。
kotlin val text = etCmd.text.toString().trim() ... JustSent.mark(text) // ← 就在这儿,不要挪进 publishAsync 的回调 Cloud.publishAsync(cloud, text, enter) { r -> ... } - 日志去重窗口要盖住一次网络往返。这条最反直觉:去重窗口看起来是"两条日志之间
的时间差",但日志是发送完成才写的 —— 两条路各写一次的时间差 = 各自往返之差,
可以到 3~4 秒。按"两条日志几乎同时到"设 3 秒 = 必然漏。设 8 秒(≈ 最坏往返 + 余量),
并跟"刚发出去"那个窗口对齐。
别做的事:
- 别让显式发送去让位给自动同步。自动同步只写剪贴板(p=false),
显式发送会粘贴+回车 —— 两者不可互换,压掉显式那条等于用户按了发送却没打进窗口。
要压就压自动那条。
- 别用"同一秒""1.5 秒内"这种假设当窗口值,理由同上(写日志的时刻 ≠ 动作的时刻)。
怎么写断言(纯 JVM 自检台里,两样都要有):
// 源码级:mark 的字节位置必须在 publish 之前
val iMark = mainSrc.indexOf("JustSent.mark(text)")
val iPub = mainSrc.indexOf("Cloud.publishAsync(cloud, text, enter)")
check("按下发送就记下「这段刚发出去」", iMark in 1 until iPub)
// 行为级:照抄用户实测的时间差(4 秒),它必须仍然算重复
Source.phoneToPc(文本, "已发到云端", Dir.VOICE, 10_000L)
eq("4 秒后同内容 → 不再记第二条", Source.phoneToPc(文本, "已发到云端", Dir.TO_PC, 14_000L), null)