#!/usr/bin/env bash
# =====================================================================
#  Matrix 轻量一键安装脚本 · tuwunel 版(通用版 t1.26)
#  Matrix one-command installer · tuwunel edition (Universal t1.26)
#  t1.26:【第二层·设备绑定】"我的服务器只认 VeilX" —— 员工拿账号密码去登 Element 也用不了。
#         机制:客户端登录后用【手机安全芯片里不可导出的 P-256 私钥】对自己的 access token
#         签名并绑定(POST /oprf/device/bind);Caddy 对每个 client API 请求做 forward_auth
#         查 /oprf/device/check,没绑过硬件设备的 token 一律 403。Element 能登录拿 token,
#         但它没有那把芯片密钥、也不会说这套协议,于是 sync/send 全被拦。
#         为什么绑 token 而不是逐请求签名:聊天流量在 matrix-rust-sdk 内部的 HTTP 栈里,
#         无法逐请求注入头;而 token 本就在每个请求头上 —— 效果等价,SDK 一行不用改。
#         ⚠️【最容易自伤的开关】DEVICE_BIND=0(默认关)|1(观察)|strict(强制)。
#         开 strict 而员工手机还没升级 = 全员立刻断线,故:①默认关 ②`sudo tuwunel device-bind`
#         切换时要手输 ENFORCE 确认 ③后台"设备绑定"页先看着每个成员都出现再切。
#         后台新增【设备绑定】页:谁绑了、密钥来源(安全隔区/StrongBox/TEE)、当前档位、解绑
#         (解绑=立即断线且换客户端也回不来)。账号被远程销毁时其绑定一并作废。
#         天花板(诚实记录):员工在自己已绑定的手机上 root 后作弊,签名照样合法 —— 那是
#         "合法设备的越权使用",靠远程冻结/销毁与审计兜底,不是本机制能防的。
#  t1.25:【运维闭环】四件套,全部装好即生效、零配置:
#         ① 一键恢复/迁移:`sudo tuwunel restore <备份文件>`(菜单 r)。自动试 .backup-key
#            再问口令、校验域名一致(Matrix 不能换域名)、旧数据保留 *.pre-restore-*、
#            起服后跑就绪检查。迁移=新机同域名装一遍再 restore。备份从此有了另一半。
#         ② update 验签:`sudo tuwunel update` 下载脚本后先验【厂商 Ed25519 签名】
#            (<脚本URL>.sig),验不过拒绝更新 —— 发布渠道被攻破也塞不进恶意脚本。
#            发布方每版跑 license-issuer/sign-release.sh 出 .sig 一起上传。
#            破例:TUWUNEL_UPDATE_INSECURE=1 显式跳过(自担风险)。
#         ③ 告警进 IM:cron 里默默失败的事没人看日志 —— 现在自动注册 @veilx-alert
#            机器人、建私密房间邀请 @admin,把【证书续不上/备份失败/服务掉线/磁盘≥90%】
#            推到管理员天天在用的 VeilX 里。用 VeilX 运维 VeilX。
#         ④ 看门狗:cron 5 分钟打一次 /versions,掉线先 docker compose up -d 自愈,
#            拉不起来才告警;状态翻转才发消息,不刷屏。
#         另:授权证书从 90 天缩到 30 天(吊销一台恶意服务器最坏只等 30 天),
#            续期检查从周改日(剩 <14 天续,签发服务短暂宕机次日即重试);
#            就绪检查加双栈兜底(IPv6-only 服务器不再被 curl -4 误报);备份顺带打包 veilx-wk。
#  t1.24:【服务器授权证书】(协议见仓库 VEILX-LOCK-SPEC.md)VeilX 客户端从本版起【只连
#         厂商签过字的服务器】:部署收尾自动向厂商签发服务申请 Ed25519 证书(签发前
#         它会回访体检:联邦关、OPRF 在、拦 Element),托管在
#         /.well-known/veilx/license.json,客户端用内置厂商公钥验签+验域名+验有效期,
#         验不过硬拒绝。没证书的服务器(手搭的 Synapse、魔改脚本的部署)VeilX 一律不连。
#         默认签 90 天,cron 每周自查、剩 30 天内自动续(重新体检)。新增子命令
#         `sudo tuwunel license`(菜单 k 项):看状态/手动续期。环境变量:
#         VEILX_ISSUER=签发服务地址(默认 license.veilx.example 占位,需改成你的)。
#         申请失败只告警不打断安装——服务器照常跑,但 VeilX 连不上,续上即恢复。
#         另:安装向导不再问【用哪个后台】——一律 VeilX 自研(与 t1.22 孤岛哲学一致,
#         Ketesa 坏页多、问了只会选错);极个别破例仍可 ADMIN_UI=ketesa 显式覆盖,不弹交互。
#  t1.23:【VeilX 自研管理后台】管理后台新增选择:VeilX 自研(默认)/ Ketesa。自研后台是
#         纯静态 SPA(内嵌进本脚本,Caddy file_server 托管,不另起容器),只调 tuwunel 真正
#         支持的接口——用户/邀请码/房间(含发布到公开目录)都能用,不像 Ketesa 有一堆坏页。
#         并接入 VeilX 专属能力:【设备安全】远程冻结/销毁(走 OPRF 服务新增的 /oprf/admin/*
#         管理员路由,服务端校验管理员身份,对任意账号操作、无需停容器)。ADMIN_UI=veilx|ketesa。
#  t1.22:【默认孤岛·不再逐项问】隔离与加固写死,安装向导从 8 问精简到 3 问。
#         固定(不再询问):注册=令牌、联邦=关、只面向VeilX、不装Element Web、
#         隐私加固=开、OPRF=开。VeilX 本就是"自建、只服务本团队"的孤岛,这些是产品
#         定义而非可选项,选错任何一个都会破坏保密模型。仅保留 3 个纯功能问题:
#         【通话 / 管理后台 / 文件上限】。极个别破例仍可用环境变量显式覆盖(如
#         ENABLE_FEDERATION=1),但绝不弹交互;重跑配置一律回到孤岛默认。
#  t1.21:【团队通讯录】两件事一起放开,让团队【用户+房间】都可被发现:
#         ① show_all_local_users_in_user_directory=true —— 任何成员都能搜到本服全部用户
#            (默认只显示"同过房/公开房里"的人,新同事谁都搜不到)。
#         ② lockdown_public_room_directory=false —— 普通成员建的公开房也能进目录被搜到
#            (原隐私加固块把它锁成"仅管理员可发布",于是成员建的公开房永远搜不到;
#            且该块还把 ① 又设回 false,把团队通讯录整个抵消掉 —— 现已从隐私块移除,
#            两个开关统一在 [global] 按团队模型设置)。私密房间不受影响,外服由客户端域名过滤挡住。
#         老服务器:`sudo tuwunel` 菜单重跑配置(或手动改 tuwunel.toml 后重启容器)即可生效;
#         已经建出来的公开房是"未发布"状态,需重新发布一次(重建,或在客户端里把它设为公开)。
#  t1.20:【只面向 VeilX 客户端】新增【选项 8/8】(默认开,VEILX_ONLY=1|0|strict)。开启后
#         ①不部署 Element Web ②关掉 Element X 自助注册(原生 OIDC) ③Caddy 拒绝
#         User-Agent 不含 VeilX 的 /_matrix/client/* 与 /_synapse/admin/*。
#         动机:阅后即焚等策略是【由客户端执行】的,队里有一个人用 Element,
#         他的旧消息就永远不会被清掉,房间策略对他形同虚设。
#         ⚠️ 这是【门槛】不是【保证】:UA 是客户端自述字符串,伪造成本约等于零,
#         已登录的会话也不受影响。真要强制只能上应用完整性证明,而那与本项目
#         "能在去 Google 化手机上跑"冲突。别对外宣称成安全边界。
#         刻意保留的放行:/_matrix/client/versions(规范公开,且安装器就绪检查、
#         菜单在线检查、客户端加速验证都在打它)、/_matrix/federation/* 与
#         /_matrix/key/*(否则开联邦会整个断掉)。
#         管理后台**不在 matrix 主机上开任何例外**:Ketesa 改成同源走 admin 主机,
#         在那里单独放行它真正调用的十来个端点(login/whoami/profile/publicRooms/
#         directory/media/_synapse/admin/*)——这份清单里没有 /sync、没有
#         /rooms/*/messages、没有 /send,所以把 Element 指过去也同步不了、发不出。
#         (早先草案在 matrix 主机放行 `Origin: https://admin.<域名>`,那是个人人可用的
#          口子:Origin 同样是自述头,admin 子域又是公开 DNS,加个请求头即可全绕。)
#  t1.19:【远程停用】服务器可远程让员工手机①冻结=加密上锁(可撤销)②销毁=永久不可解。
#         指令用服务器的 Ed25519 私钥签名(/data/signing.key 首次启动自动生成,0600),
#         客户端在启用最高档时固定公钥,**中间人无法伪造销毁指令去毁掉任意员工数据**。
#         新增: GET /oprf/pubkey(公开)、POST /oprf/status(签名裁决 ok/frozen/killed)、
#         `--admin-reap <天>` 长期未签到自动【冻结】(配 cron;可撤销,≥7 天才受理)。
#         要不可逆销毁得显式用 `--admin-reap-destroy <天>`。
#         限速 12→30 次/小时(客户端后台立即零化后解锁更频繁,12 会误伤正常使用)。
#         修复: 应急处置里的"吊销登录会话"原用 `tuwunel --execute`,但服务器运行时
#         RocksDB 被占锁必然失败;tuwunel 只能在【管理员房间】发 !admin 命令,
#         现改为打印确切命令让运维执行,不再假装已完成。
#  t1.18:【修复 OPRF 卡死/502】t1.17 的成员管理少了 docker compose run 的 -T,在 SSH 里
#         会挂起、占着数据库锁,导致主容器崩溃循环、全队最高档手机被锁在外面。本版:
#         ①run 一律加 -T + trap 兜底 + 管理后强制验证服务复活;②容器打不开锁时不再 panic
#         死循环,而是等锁释放最多 30s;③新增 `sudo tuwunel oprf-repair`(菜单第 3 项)
#         一键强杀卡死容器+干净重启(绝不碰数据库文件);④管理前先清理残留的 -run- 容器。
#  t1.17:【VeilX 加固:服务器辅助 PIN(OPRF)】新增可选组件,安装时【选项 7/8】默认开,
#         装好后菜单 o) 项或 `sudo tuwunel oprf` 管理(状态/开关/销毁某成员密钥/日志),
#         也可 `sudo tuwunel enable-oprf|disable-oprf`。作用:VeilX 手机解锁必须问这台服务器,
#         于是【失窃且离线的手机永远无法爆破 PIN】,设备丢失或员工离职时可远程销毁其密钥让手机永久打不开。
#         实现:docker 服务(oprf/,多阶段编译 Rust,不在宿主机装工具链),Caddy 把
#         matrix.域名/oprf/ 反代到它——复用现有证书,不需新域名/DNS。它看不到任何人的 PIN。
#         容错:OPRF 是唯一需本地编译的服务,故在主 up -d 前单独 build,失败自动退回未开启
#         并摘掉编排/路由,保证聊天服务照常可用。
#  t1.16:【装好后可随时切换界面语言】此前语言只在首次安装时问一次、之后无处可改。现新增
#         菜单项 L 与 `sudo tuwunel lang`:在 English / 简体中文 间切换,写入 .env(UI_LANG=)持久化,
#         菜单立即用新语言重绘。(老服务器需先把副本更新到本版,见 t1.15 补救。)
#  t1.15:【修复:重跑本地脚本不刷新已装副本】菜单刷新副本用的是 cd 之后的相对 $0,会判存在失败而
#         跳过复制 —— 导致 `bash tuwunel.sh` 重跑后,菜单显示新功能但按下去仍调用 /opt 里的旧副本
#         (典型:选 a『改后台网址』却弹出没有 a 的旧菜单)。改用 cd 前解析好的绝对路径 $SELF_SRC。
#         已装旧版补救:`sudo tuwunel update`(联网拉最新),或 `sudo cp ~/你的.sh /opt/tuwunel/tuwunel-installer.sh`。
#  t1.14:【后台网址可自定义】管理面板子域名不再写死 admin.,可改成 console./manage. 等(ADMIN_SUB=,
#         或新装向导里选)。老服务器更新脚本后,`sudo tuwunel admin-url`(或菜单 a 项)即可改:
#         交互问新子域→提醒先加 DNS→复用 config 重生成 Caddyfile 并重启(只改这一项,数据/账号不动)。
#         改后记得去域名商加  <新子域>.你的域名  的 A 记录(旧的 admin. 记录可删)。
#  t1.13:【双语界面 / Bilingual UI】整个脚本 UI 现支持 English + 简体中文,单源实现:
#         启动时询问语言(Language / 语言: [1] English [2] 中文),或安装时加 LANG_UI=en 直接英文;
#         选择存入 .env(UI_LANG=),菜单/改配置/子命令沿用同一语言;默认(非交互/未选)仍为中文,
#         现有中文体验完全不变。安装向导、DNS/端口指引、进度、部署成功卡、CREDENTIALS.txt、管理菜单、
#         adduser/uninstall/update/备份/隐私/涂销等全部用户可见界面均已双语(内部日志与生成的配置注释保留中文)。
#  t1.12: 安装时新增【是否用了 Cloudflare/CDN】询问(或 CDN=1):选是则放宽 DNS 预检
#         (不再要求解析到本机,避免橙云代理下 DNS 指向 CDN 而卡在预检),并打印 CDN 专属
#         提醒(matrix/媒体主机须走灰云、大文件被 CDN 100MB 上限掐死、关 Bot Fight、Bypass 缓存)。
#  t1.11: 新增【可选·自动定时加密备份】(默认关):`sudo tuwunel autobackup` 开启后,cron
#         每周(或每天)自动做 AES-256 加密备份、自动轮转(留最近 N 个)、满盘自动跳过;
#         密钥存 .backup-key(仅 root),开启时醒目提示务必抄走(否则备份永久打不开)。
#  t1.10:【保密加固/机密最小化】(1) 修复关键缺口:强制新房间默认 E2EE
#        (encryption_enabled_by_default_for_room_type="all";此前不写=客户端没主动加密时消息明文入库);
#        (2) 备份改【AES-256 加密】(可选口令;此前裸 tar.gz 含明文密码是最糟暴露面);
#        (3) 新增 `sudo tuwunel forget-secrets`(菜单 s):涂销磁盘上的明文管理员密码/邀请码 + fstrim。
#  t1.9: 新增【隐私加固 / 元数据最小化】(默认开,PRIVACY=0 可关):真实客户端 IP 不入库
#        (ip_source=connect_info,tuwunel 建设备时无开关可关地记 IP,这是唯一缓解);
#        修复两个危险默认:撤回消息原文默认再留 60 天、管理房留操作流水;关在线状态/输入提示;
#        收紧资料与房间目录暴露面。新增 `sudo tuwunel privacy`(菜单 p):看【删不掉什么】、
#        查加固状态、清容器日志。配置改动带自动回滚(键不被本版接受则还原,不会把服务搞挂)。
#  t1.8: 新增【Element X 手机 App 自助注册】(默认开):开启 tuwunel 内置 OIDC(不用另装 MAS),
#        Element X 就能在一个 App 内注册+登录。注册仍走 UIAA、强制邀请码,不绕过(官方确认);
#        安全前提=本脚本不添加任何上游 IdP。老服务器补开:`sudo tuwunel enable-elementx`;
#        关闭:`disable-elementx`(关后 Element X 仅能密码登录、注册改走网页/管理员建号)。
#  t1.7: 新增【自托管 Web 管理后台 Ketesa】(synapse-admin 官方支持的成熟面板):
#        tuwunel v1.8.1+ 已实现 Synapse 管理 API,面板放 admin.你的域名,可图形化
#        管理用户/房间/媒体/邀请码。tuwunel 全局自带 CORS(源码确认),故 Caddy 不再需
#        要加 CORS(加了反而冲突)。Ketesa 亦为非 root(sws 用户/8080),已同样处理端口与权限。
#        · 老服务器补装:`sudo tuwunel enable-admin` 或菜单第 4 项(只开后台,不动其它)。
#        · "举报事件/被举报用户"两页:tuwunel 未实现该端点,Caddy 空桩返回空列表避免红报错
#          (真举报以消息形式进 admin 房间,不进这两个 API)。
#        · 上线前自动 `caddy validate`,语法错则跳过 caddy 重启,保住老配置不中断整站。
#  t1.6: 修复网页客户端 502(第二处根因)—— element-web 以非 root nginx 运行,
#        而配置文件因 umask 077 是 600(仅 root 可读),容器读不了 /app/config.json 而崩溃重启;
#        改为 chmod 644(此文件是公开的客户端配置,无机密)。
#  t1.5: 修复网页客户端 502 —— 新版 element-web 是非 root nginx,绑不了 80 端口;
#        改为监听 8080(ELEMENT_WEB_PORT=8080)、Caddy 转发到 element-web:8080。
#  t1.4: 新增【自更新】:老部署想拿新功能,`sudo tuwunel update` 一条命令从 GitHub 拉最新脚本
#        并自动应用(数据/账号不动);或重跑一键安装命令也会刷新本地脚本+全局命令。
#  t1.3: 新增可选【自托管 Element Web 网页客户端】(默认开):成员打开 https://你的域名
#        就能直接注册/登录/聊天,不用去 element.io、不用装 App;锁定到你的服务器、可白标。
#  t1.2: 装完注册全局命令 `sudo tuwunel`(开菜单/加人不用记路径、不用带域名);
#        支持 GitHub 一条命令安装(bash <(curl ...));命令行不带域名时向导交互询问。
#  t1.1: 装完【自动建好管理员并打印账号密码】;adduser 一条命令建号并设密码
#        (走 Matrix 注册接口+令牌,不碰 --execute/DB 锁)—— CLI 便利已与 Synapse 版持平。
#
#  为什么是 tuwunel(不是 Synapse / Dendrite):
#    · Rust 单二进制、内置 RocksDB —— 【免装 PostgreSQL】,内存 ~64–256MB
#      量级(Synapse 是 1–4GB),真·省资源,中型公司也扛得住 500 人。
#    · conduwuit 的官方继任者,由全职团队维护,已被瑞士政府用于面向公民的
#      生产部署;单进程无需 worker。
#    · 【发大文件/大图/长视频】:max_request_size 用字节整数,可设到几 GB。
#      (注:Matrix 协议无分片续传,做不到"完全像 Telegram";E2EE 房大文件
#       需客户端整体加密,超大文件较吃客户端内存 —— 这是协议限制,非本脚本。)
#    · 原生支持 Element Call(通话,可选,默认关)。
#    · 【成熟 Web 管理后台】:tuwunel v1.8.1+ 已实现 Synapse 管理 API,本脚本可选装
#      Ketesa(synapse-admin 官方支持的成熟图形面板),图形化管理用户/邀请码/房间/媒体。
#  代价(务必知晓):tuwunel 的 Synapse 管理 API 较新(2026-07 起),覆盖约 69/100 端点
#    (核心用户/房间/媒体/邀请码全通;举报/限速等边角页面不可用);CLI 与管理员房间命令永久兜底。
#
#  组件: Caddy(自动HTTPS) + tuwunel  (+ 可选 Element Web 网页客户端 / Ketesa 管理后台 / LiveKit 通话)
#  客户端: Element X / Element Web / 任意 Matrix 客户端
#
#  用法:
#   ▶ 一条命令安装(GitHub 托管,把 URL 换成你仓库的原始地址):
#      bash <(curl -fsSL https://raw.githubusercontent.com/你/仓库/main/tuwunel-installer.sh)
#     (以 root 或前面加 sudo;向导会【交互询问域名】,命令行不用带域名)
#   ▶ 或先下载再跑:
#      curl -fsSL <上面URL> -o tuwunel-installer.sh && sudo bash tuwunel-installer.sh
#   ▶ 直接带域名(懒得等向导): sudo bash tuwunel-installer.sh mychat.org
#
#   装完后会注册一个全局命令,以后就这么用(不用记路径、不用再带域名):
#      sudo tuwunel            # 打开中文管理菜单
#      sudo tuwunel adduser    # 加成员(一条命令建号并设密码)
#      sudo tuwunel update        # 从 GitHub 拉最新脚本并应用新功能(数据不动)
#      sudo tuwunel enable-admin    # 【老服务器补装 Web 管理后台 Ketesa】(只开后台,不动其它)
#      sudo tuwunel admin-url       # 【改后台网址】admin. → 别的子域(如 console. ;需先加对应 DNS)
#      sudo tuwunel lang            # 【切换界面语言】English / 简体中文(存 .env,持久生效)
#      sudo tuwunel enable-elementx # 【开 Element X 手机自助注册】(原生OIDC;disable-elementx 关)
#      sudo tuwunel cf-cert         # 【全橙云】粘贴 CF Origin 证书(橙云下 Caddy 签不出证书时用)
#                                     #   cf-cert status 看到期  /  cf-cert off 关掉回自动 HTTPS
#      sudo tuwunel privacy        # 隐私/元数据:看能删什么、查加固状态、清日志
#      sudo tuwunel forget-secrets  # 无痕清理:涂销磁盘上的明文密码/邀请码
#      sudo tuwunel autobackup     # 可选:开启每周自动加密备份(含轮转/满盘跳过)
#      sudo tuwunel config          # 改配置    sudo tuwunel uninstall  # 卸载
#   (curl|bash 管道模式想让菜单/adduser 可用,设 TUWUNEL_INSTALLER_URL=<上面URL> 让它自取副本)
#
#  前提: DNS A 记录已指向本服务器公网 IP:
#    你的域名.com   matrix.你的域名.com   (开后台再加 admin. ;开通话再加 livekit.  matrix-rtc.)
#
#  可选环境变量:
#    INSTALL_DIR=/opt/tuwunel   安装目录(默认)
#    ACME_EMAIL=you@x.com       证书通知邮箱(默认 admin@域名)
#    SKIP_DNS_CHECK=1           跳过 DNS 预检
#    REG_MODE=token|open        注册方式(默认 token=需令牌)
#    ENABLE_FEDERATION=1|0      联邦(默认 0=关闭,纯私密孤岛)
#    ENABLE_CALLS=1|0           语音视频通话(默认 0=关闭)
#    ENABLE_ADMIN=1|0           Web 管理后台 Ketesa(默认 1=开;放 admin.域名,需加 DNS)
#    ADMIN_SUB=veilx            后台子域名(默认:VeilX 自研=veilx、Ketesa=admin;可设 console 等,需加对应 DNS)
#    ADMIN_UI=veilx|ketesa      用哪个管理后台(默认 veilx=自研;ketesa=通用 synapse-admin)
#    CDN=1|0                    服务器前是否有 Cloudflare/CDN 代理(默认 0;放宽 DNS 预检)
#    CF_ORIGIN=1|0              全橙云:用 CF Origin 证书替代自动 HTTPS(默认 0)
#                               橙云下 Caddy 的 ACME 必失败(TLS-ALPN-01 被 CF 终止、
#                               HTTP-01 在 Full(Strict) 下死锁),故须自带证书。
#                               代价:上传上限 100MB、通话不可用、VeilX 代理不可用。
#    MAX_UPLOAD=4G              单文件上限(默认 4G;支持 K/M/G,内部转字节)
#    VEILX_ONLY=1|0|strict      只面向 VeilX 客户端(默认 1)。开启时:
#                               ① 不部署 Element Web ② 关掉 Element X 自助注册(原生 OIDC)
#                               ③ Caddy 只放行 User-Agent 含 VeilX 的 /_matrix/client/*
#                                  与 /_synapse/admin/*(/versions 永远放行)
#                               strict = 连管理后台的例外也不留,并自动关掉 Ketesa
#                               ⚠️ 这是【门槛】不是【保证】:UA 是客户端自述字符串,
#                                  改个 UA 即可绕过。真要强制只能上应用完整性证明
#                                  (Play Integrity/App Attest),而那与本项目的去 Google 化冲突。
#
#  ★ server_name(你的域名)一旦部署【不可更改】,改了必须清库重来 —— tuwunel 硬限制。
#  脚本可安全重复运行:已完成的部署只做重启;半途失败会自动续装。
#  Required Notice: Copyright (c) 2026 VeilXofficial
# =====================================================================
set -euo pipefail

INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
# 备份【必须】默认放在安装目录之外 —— 卸载是 rm -rf $INSTALL_DIR,
# 备份若躺在里面,"先备份再卸载"这个最合理的操作序列会把备份一起删光。
DEFAULT_BACKUP_DIR="/root/tuwunel-backups"
MARKER="由 tuwunel-installer.sh 生成"
TUWUNEL_IMAGE="ghcr.io/matrix-construct/tuwunel:latest"
# 自更新用的脚本原始地址(Lockmere 官网;可用 TUWUNEL_UPDATE_URL 覆盖为镜像)。
# 发版纪律:每次上传新脚本必须连同 .sig 一起(license-issuer/sign-release.sh 生成),
# 否则全网 `sudo tuwunel update` 会因验签取不到签名而拒绝更新。
REPO_RAW="${TUWUNEL_UPDATE_URL:-https://lockmere.io/veilx/install.sh}"

# (重新)安装全局命令 `tuwunel` 指向已装好的脚本副本
install_launcher() {
  [ -f "$1" ] && [ -d /usr/local/bin ] || return 0
  printf '#!/usr/bin/env bash\nexec bash %s "$@"\n' "$1" > /usr/local/bin/tuwunel 2>/dev/null \
    && chmod +x /usr/local/bin/tuwunel 2>/dev/null || true
}

# ---- 终端配色(仅真终端启用;重定向/NO_COLOR 时自动关闭)----
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
  E=$'\033'
  C_RESET="${E}[0m"; C_B="${E}[1m"; C_DIM="${E}[2m"
  C_CYAN="${E}[1;36m"; C_GREEN="${E}[1;32m"; C_YELLOW="${E}[1;33m"
  C_RED="${E}[1;31m";  C_BLUE="${E}[1;34m"
else
  C_RESET=""; C_B=""; C_DIM=""; C_CYAN=""; C_GREEN=""; C_YELLOW=""; C_RED=""; C_BLUE=""
fi
bold() { printf '\n%s==> %s%s\n' "$C_B$C_CYAN" "$1" "$C_RESET"; }
warn() { printf '%s[!] %s%s\n' "$C_YELLOW" "$1" "$C_RESET"; }
die()  { printf '%s[✗] %s%s\n' "$C_RED" "$1" "$C_RESET"; exit 1; }
ok()   { printf '%s✓ %s%s\n' "$C_GREEN" "$1" "$C_RESET"; }

has_tty() { [ -t 0 ] || { [ -e /dev/tty ] && (exec </dev/tty) 2>/dev/null; }; }
env_saved() { grep -E "^$1=" "$INSTALL_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- || true; }
press_enter() {
  if [ -t 0 ]; then read -rp "$1" REPLY || true
  elif [ -e /dev/tty ]; then read -rp "$1" REPLY < /dev/tty 2>/dev/null || true
  fi
}
ask_opt() { REPLY=""
  if [ -t 0 ]; then read -rp "$1" REPLY || true
  elif [ -e /dev/tty ]; then read -rp "$1" REPLY < /dev/tty 2>/dev/null || true; fi
  [ -n "$REPLY" ] || REPLY="$2"
}

# ---- 双语 UI / Bilingual UI(English + 简体中文;单源。LANG_UI=en 预设,或启动时选择)----
UI_LANG="${LANG_UI:-$(env_saved UI_LANG)}"
case "$UI_LANG" in
  en|zh) : ;;
  *) case "${LC_ALL:-}${LANG:-}" in *[Zz][Hh]*) UI_LANG=zh;; *) UI_LANG="";; esac ;;   # 未设定:locale 含 zh→中文,否则留空待询问
esac
# L "English text" "中文文本" —— 按 UI_LANG 输出;UI_LANG!=en 一律走中文(安全默认)
L(){ if [ "$UI_LANG" = "en" ]; then printf '%s' "$1"; else printf '%s' "$2"; fi; }

# ---- 把 4G / 4.5G / 500M / 800MB / 1T 之类转成字节整数(tuwunel max_request_size 用字节)----
# 注意:必须容忍小数。bash 的 $(( )) 不支持小数,4.5G 会抛算术语法错误,
# 在 set -e 下会让整个安装当场中止 —— 故一律走 awk 算,并对非法/<=0 回落默认 4G。
to_bytes() {
  local v n mult b
  v="$(echo "$1" | tr 'a-z' 'A-Z' | tr -d '[:space:]')"; v="${v%B}"; v="${v%I}"  # 4GB / 4GiB → 4G
  case "$v" in
    *T) n="${v%T}"; mult=1099511627776 ;;
    *G) n="${v%G}"; mult=1073741824 ;;
    *M) n="${v%M}"; mult=1048576 ;;
    *K) n="${v%K}"; mult=1024 ;;
    *)  n="$v";     mult=1 ;;          # 纯数字=字节
  esac
  # 只接受非负数字(至多一个小数点);负号、字母、多个点一律非法
  case "$n" in ''|*[!0-9.]*|*.*.*) echo 4294967296; return ;; esac
  b="$(awk -v n="$n" -v m="$mult" 'BEGIN{ printf "%.0f", n*m }' 2>/dev/null)"
  case "$b" in ''|*[!0-9]*) echo 4294967296; return ;; esac
  # 0 / 溢出到超出 bash 整数范围时也回落默认(0 会让所有上传失败)
  if [ "$b" -gt 0 ] 2>/dev/null; then echo "$b"; else echo 4294967296; fi
}
# 上限写法是否合法(供交互处重问用)。to_bytes 对非法值静默回落默认,
# 那在交互里会变成"用户以为设了 0G,其实是 4G",所以校验要独立且严格。
size_ok() {
  local v n
  v="$(echo "$1" | tr 'a-z' 'A-Z' | tr -d '[:space:]')"; v="${v%B}"; v="${v%I}"
  case "$v" in *T|*G|*M|*K) n="${v%?}" ;; *) n="$v" ;; esac
  case "$n" in ''|*[!0-9.]*|*.*.*) return 1 ;; esac
  awk -v n="$n" 'BEGIN{ exit !(n+0 > 0) }' 2>/dev/null   # 必须 > 0
}
human() {  # 字节 → 人类可读
  local b="$1"
  # 老部署的 .env 里可能没有 MAX_UPLOAD_BYTES,空值会让 [ -ge ] 抛两行英文错误刷在菜单上
  case "$b" in ''|*[!0-9]*) printf '%s' "$(L "not set" 未设置)"; return ;; esac
  # 用 %.10g 而不是整数除法:设了 4.5G 就该显示 4.5G,不能截断成 4G
  if   [ "$b" -ge 1099511627776 ]; then awk -v b="$b" 'BEGIN{printf "%.10gT", b/1099511627776}'
  elif [ "$b" -ge 1073741824 ];    then awk -v b="$b" 'BEGIN{printf "%.10gG", b/1073741824}'
  elif [ "$b" -ge 1048576 ];       then awk -v b="$b" 'BEGIN{printf "%.10gM", b/1048576}'
  else printf '%s%s' "$b" "$(L B 字节)"; fi
}

# ---- 通过 Matrix 注册接口 + 注册令牌建号(引擎无关,不用 --execute/不碰 DB 锁)----
# 用法: register_user <用户名> <密码> <homeserver_url> <注册令牌>
# 返回 0=成功;首个建成的账号因 grant_admin_to_first_user=true 自动成为管理员
# JSON 字符串转义。密码是用户手输的,含 " 或 \ 或制表符时直接拼进 JSON 会生成非法请求体,
# 服务器解析失败 → 注册失败,而报错只会说"用户名已存在/令牌失效",把人引向完全错误的方向。
json_esc() {
  local s="$1"
  s="${s//\\/\\\\}"        # 反斜杠必须最先转,否则会把后面转出来的反斜杠再转一遍
  s="${s//\"/\\\"}"
  s="${s//$'\t'/\\t}"
  s="${s//$'\n'/\\n}"
  s="${s//$'\r'/\\r}"
  printf '%s' "$s"
}
# 安装器自己的 User-Agent。**必须含 "VeilX"**:开了 VEILX_ONLY 之后,Caddy 只放行
# UA 含 VeilX 的 /_matrix/client/*,而建号走的正是 /_matrix/client/v3/register ——
# 不带这个,新装时【自动创建管理员】和 `sudo tuwunel adduser` 都会被自己的拦截 403。
INSTALLER_UA="VeilX-Installer/1.0"
register_user() {
  local u="$1" p="$2" hs="$3" tok="$4" r sess eu ep et es
  eu="$(json_esc "$u")"; ep="$(json_esc "$p")"; et="$(json_esc "$tok")"
  # UIAA 第一步:拿 session
  r="$(curl -4 -sS --max-time 15 -A "$INSTALLER_UA" -X POST "$hs/_matrix/client/v3/register" \
        -H 'Content-Type: application/json' \
        -d "{\"username\":\"$eu\",\"password\":\"$ep\",\"inhibit_login\":true}" 2>/dev/null || true)"
  case "$r" in *'"user_id"'*) return 0 ;; esac   # 万一无需 UIAA 直接成功
  sess="$(printf '%s' "$r" | grep -o '"session"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  [ -n "$sess" ] || return 1
  es="$(json_esc "$sess")"
  # 第二步:带注册令牌
  r="$(curl -4 -sS --max-time 15 -A "$INSTALLER_UA" -X POST "$hs/_matrix/client/v3/register" \
        -H 'Content-Type: application/json' \
        -d "{\"username\":\"$eu\",\"password\":\"$ep\",\"inhibit_login\":true,\"auth\":{\"type\":\"m.login.registration_token\",\"token\":\"$et\",\"session\":\"$es\"}}" 2>/dev/null || true)"
  case "$r" in
    *'"user_id"'*) return 0 ;;
    *'m.login.dummy'*)   # 部分实现在令牌后还要 dummy 阶段
      r="$(curl -4 -sS --max-time 15 -A "$INSTALLER_UA" -X POST "$hs/_matrix/client/v3/register" \
            -H 'Content-Type: application/json' \
            -d "{\"username\":\"$eu\",\"password\":\"$ep\",\"inhibit_login\":true,\"auth\":{\"type\":\"m.login.dummy\",\"session\":\"$es\"}}" 2>/dev/null || true)"
      case "$r" in *'"user_id"'*) return 0 ;; *) return 1 ;; esac ;;
    *) return 1 ;;
  esac
}
# 校验用户名(仅小写字母数字与 . _ - =),防注入
uname_ok(){ echo "$1" | grep -Eq '^[a-z0-9._=-]+$'; }

# ---- 服务器授权证书(t1.24,协议见仓库 VEILX-LOCK-SPEC.md)----------------------
# VeilX 客户端只连持有厂商 Ed25519 签名证书的服务器。部署收尾自动向厂商签发服务申请;
# 签发服务签字前会**回访体检**本机(联邦关、OPRF 在、拦 Element),所以必须等服务就绪后再申请。
# 厂商公钥(与 iOS/Android 内置的同一把;换钥=全网证书作废,除非私钥泄露别动它)
VENDOR_PUBKEY="IjKa1Jyq95GcVYhTDk9d7AVcgQTX+HyTHeDLRrGRxpI="
# 厂商签发服务地址(Lockmere 官方)。issuer 部署见仓库 license-issuer/README.md。
VEILX_ISSUER="${VEILX_ISSUER:-https://license.lockmere.io}"

# 通用厂商验签:$1=数据文件,$2=base64 签名(字符串)。0=通过,1=不过,2=本机 openssl 不支持。
# license 本地自检与 update 脚本签名校验共用(同一把厂商公钥=同一个信任根)。
vendor_verify_raw() {
  local data="$1" sigb64="$2" d rc
  openssl pkeyutl -help 2>&1 | grep -q rawin || return 2
  d="$(mktemp -d)" || return 1
  # 32 字节原始公钥 → SPKI DER(固定 12 字节头)→ PEM
  { printf '\x30\x2a\x30\x05\x06\x03\x2b\x65\x70\x03\x21\x00'
    printf '%s' "$VENDOR_PUBKEY" | base64 -d; } > "$d/pub.der" 2>/dev/null
  openssl pkey -pubin -inform DER -in "$d/pub.der" -out "$d/pub.pem" 2>/dev/null || { rm -rf "$d"; return 1; }
  printf '%s' "$sigb64" | base64 -d > "$d/sig" 2>/dev/null
  openssl pkeyutl -verify -pubin -inkey "$d/pub.pem" -rawin -in "$data" -sigfile "$d/sig" >/dev/null 2>&1
  rc=$?; rm -rf "$d"; return $rc
}

# 本地验签(防拿到坏文件就上架):$1=license.json 路径。openssl 不支持 ed25519 时跳过
# (只是少一道本地自检;客户端侧照样会验)。0=通过/跳过,1=验签失败。
license_verify_local() {
  local f="$1" dom na fed iss sig msg rc
  dom="$(grep -o '"domain"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  na="$(grep -o '"not_after"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  fed="$(grep -o '"fed"[[:space:]]*:[[:space:]]*[0-9]*' "$f" | head -1 | grep -o '[0-9]*$')"
  iss="$(grep -o '"issuer"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  sig="$(grep -o '"sig"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  [ -n "$dom" ] && [ -n "$na" ] && [ -n "$sig" ] || return 1
  msg="$(mktemp)" || return 1
  printf 'veilx-license-v1\n%s\n%s\n%s\n%s' "$dom" "$na" "${fed:-0}" "$iss" > "$msg"
  vendor_verify_raw "$msg" "$sig"; rc=$?
  rm -f "$msg"
  case "$rc" in
    2) warn "$(L "Local openssl lacks ed25519 — skipping local check (clients still verify)." "本机 openssl 不支持 ed25519 —— 跳过本地自检(客户端仍会验签)。")"; return 0 ;;
    *) return $rc ;;
  esac
}

# 申请/续期证书:向签发服务报上本机 matrix 主机名,拿回来先本地验签再落盘。
# $1=matrix 主机名。0=已装好,1=失败(只告警,不打断——服务器照常跑,续上即恢复)。
license_fetch() {
  local host="$1" tmp r err
  case "$VEILX_ISSUER" in *veilx.example*) warn "$(L "VEILX_ISSUER is still the placeholder — set it to your issuer URL (VEILX_ISSUER=https://license.yourdomain)." "VEILX_ISSUER 还是占位值 —— 请设成你的签发服务地址(VEILX_ISSUER=https://license.你的域名)。")"; return 1;; esac
  tmp="$(mktemp)" || return 1
  r="$(curl -sS --max-time 60 -A "$INSTALLER_UA" -X POST "$VEILX_ISSUER/issue" \
        -H 'Content-Type: application/json' \
        -d "{\"host\":\"$(json_esc "$host")\"}" -o "$tmp" -w '%{http_code}' 2>/dev/null || true)"
  if [ "$r" != "200" ]; then
    err="$(grep -o '"error"[[:space:]]*:[[:space:]]*"[^"]*"' "$tmp" 2>/dev/null | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
    warn "$(L "License request failed (HTTP ${r:-?}): ${err:-issuer unreachable}" "证书申请失败(HTTP ${r:-?}):${err:-签发服务不可达}")"
    rm -f "$tmp"; return 1
  fi
  if ! license_verify_local "$tmp"; then
    warn "$(L "Issuer returned a license that fails local verification — NOT installing it." "签发服务返回的证书本地验签不过 —— 不予安装。")"
    rm -f "$tmp"; return 1
  fi
  mkdir -p "$INSTALL_DIR/veilx-wk"
  mv -f "$tmp" "$INSTALL_DIR/veilx-wk/license.json"; chmod 644 "$INSTALL_DIR/veilx-wk/license.json"
  ok "$(L "VeilX server license installed (valid until $(grep -o '"not_after"[^,}]*' "$INSTALL_DIR/veilx-wk/license.json" | grep -o '[0-9-]*' | tail -1))." "VeilX 服务器授权证书已装好(有效期至 $(grep -o '"not_after"[^,}]*' "$INSTALL_DIR/veilx-wk/license.json" | grep -o '[0-9-]*' | tail -1))。")"
  return 0
}

# 剩余天数;证书缺失/读不出打印 -1。GNU date(服务器都是 Linux)。
license_days_left() {
  local f="$INSTALL_DIR/veilx-wk/license.json" na
  [ -f "$f" ] || { echo "-1"; return; }
  na="$(grep -o '"not_after"[[:space:]]*:[[:space:]]*"[^"]*"' "$f" | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  [ -n "$na" ] || { echo "-1"; return; }
  echo $(( ( $(date -u -d "$na" +%s 2>/dev/null || echo 0) - $(date -u +%s) ) / 86400 ))
}

# 每日 cron:剩 <14 天(或缺失)自动续(t1.25:证书缩到 30 天以加速吊销,故检查也从周改日;
# 签发服务恰好宕机时次日就重试,不再浪费整周宽限期)。写成独立小脚本,不依赖调用时的环境。
license_cron_install() {
  [ -d /etc/cron.daily ] || return 0
  rm -f /etc/cron.weekly/tuwunel-license 2>/dev/null || true   # 清掉 t1.24 的周检旧版
  cat > /etc/cron.daily/tuwunel-license <<CRON
#!/bin/sh
# VeilX 服务器授权证书自动续期(由 tuwunel-installer 安装)
VEILX_ISSUER="$VEILX_ISSUER" INSTALL_DIR="$INSTALL_DIR" bash "$INSTALL_DIR/tuwunel-installer.sh" license-renew >/dev/null 2>&1
CRON
  chmod +x /etc/cron.daily/tuwunel-license 2>/dev/null || true
}

# ---- 运维告警:推送到管理员的 Matrix 房间(t1.25)---------------------------------
# 哲学:cron 里默默失败的事(证书续不上/备份失败/服务掉线/磁盘快满)没人看日志,
# 必须推到管理员每天在用的 IM 里 —— 用 VeilX 运维 VeilX。实现:首次告警时自动注册
# 一个 @veilx-alert 机器人账号,建一个私密房间邀请 @admin,之后所有告警发进去。
# 凭据(密码/token/房间号)存 .env(0600)。任何一步失败只写 alerts.log,绝不影响调用方。
alert_admin() {   # $1=消息文本
  local msg="$1" d tok room_enc txn
  { echo "[$(date '+%F %T')] $msg" >> "$INSTALL_DIR/alerts.log"
    tail -n 300 "$INSTALL_DIR/alerts.log" > "$INSTALL_DIR/alerts.log.tmp" && mv -f "$INSTALL_DIR/alerts.log.tmp" "$INSTALL_DIR/alerts.log"; } 2>/dev/null || true
  d="$(env_saved MATRIX_DOMAIN)"; [ -n "$d" ] || return 0
  tok="$(env_saved ALERT_TOKEN)"; ALERT_ROOM="$(env_saved ALERT_ROOM)"
  if [ -z "$tok" ] || [ -z "$ALERT_ROOM" ]; then alert_setup || return 0; tok="$(env_saved ALERT_TOKEN)"; ALERT_ROOM="$(env_saved ALERT_ROOM)"; fi
  [ -n "$tok" ] && [ -n "$ALERT_ROOM" ] || return 0
  room_enc="$(printf '%s' "$ALERT_ROOM" | sed 's/!/%21/g;s/:/%3A/g')"
  txn="$(date +%s%N 2>/dev/null || date +%s)"
  curl -fsS --max-time 10 -A "$INSTALLER_UA" -X PUT \
    "https://matrix.$d/_matrix/client/v3/rooms/$room_enc/send/m.room.message/vxalert$txn" \
    -H "Authorization: Bearer $tok" -H 'Content-Type: application/json' \
    -d "{\"msgtype\":\"m.text\",\"body\":\"$(json_esc "$msg")\"}" >/dev/null 2>&1 || true
}

alert_setup() {   # 幂等:建机器人账号 + 告警房间(邀请 @admin),凭据写 .env
  local d rtk hs ap r tok roomid
  d="$(env_saved MATRIX_DOMAIN)"; rtk="$(env_saved REG_TOKEN)"
  [ -n "$d" ] && [ -n "$rtk" ] || return 1
  hs="https://matrix.$d"
  ap="$(env_saved ALERT_PASS)"
  if [ -z "$ap" ]; then
    ap="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | cut -c1-20)"
    register_user "veilx-alert" "$ap" "$hs" "$rtk" || return 1
    printf 'ALERT_PASS=%s\n' "$ap" >> "$INSTALL_DIR/.env"
  fi
  r="$(curl -fsS --max-time 15 -A "$INSTALLER_UA" -X POST "$hs/_matrix/client/v3/login" \
        -H 'Content-Type: application/json' \
        -d "{\"type\":\"m.login.password\",\"identifier\":{\"type\":\"m.id.user\",\"user\":\"veilx-alert\"},\"password\":\"$(json_esc "$ap")\"}" 2>/dev/null || true)"
  tok="$(printf '%s' "$r" | grep -o '"access_token"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  [ -n "$tok" ] || return 1
  r="$(curl -fsS --max-time 15 -A "$INSTALLER_UA" -X POST "$hs/_matrix/client/v3/createRoom" \
        -H "Authorization: Bearer $tok" -H 'Content-Type: application/json' \
        -d "{\"name\":\"VeilX $(L "Ops alerts" "运维告警")\",\"preset\":\"trusted_private_chat\",\"is_direct\":false,\"invite\":[\"@admin:$d\"]}" 2>/dev/null || true)"
  roomid="$(printf '%s' "$r" | grep -o '"room_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')"
  [ -n "$roomid" ] || return 1
  printf 'ALERT_TOKEN=%s\nALERT_ROOM=%s\n' "$tok" "$roomid" >> "$INSTALL_DIR/.env"
  return 0
}

# ---- 看门狗:5 分钟一次,掉线自动拉起,状态翻转才告警(不刷屏)(t1.25)----------------
watchdog_run() {
  cd "$INSTALL_DIR" 2>/dev/null || return 0
  local d url st pct dst
  d="$(env_saved MATRIX_DOMAIN)"; [ -n "$d" ] || return 0
  url="https://matrix.$d/_matrix/client/versions"; st="$INSTALL_DIR/.watchdog_state"
  if curl -fsS --max-time 6 -A "$INSTALLER_UA" "$url" >/dev/null 2>&1 \
     || curl -4 -fsS --max-time 6 -A "$INSTALLER_UA" "$url" >/dev/null 2>&1; then
    [ "$(cat "$st" 2>/dev/null)" = "down" ] && alert_admin "✅ $(L "Service is back online." "服务已恢复在线。")"
    echo ok > "$st" 2>/dev/null || true
  else
    # 已处于 down 且已告警 → 静默,等恢复时报平安
    [ "$(cat "$st" 2>/dev/null)" = "down" ] && return 0
    docker compose up -d >/dev/null 2>&1 || true
    sleep 20
    if curl -fsS --max-time 6 -A "$INSTALLER_UA" "$url" >/dev/null 2>&1; then
      alert_admin "⚠️ $(L "Service was down; watchdog restarted it and it recovered." "服务曾掉线,看门狗已自动拉起并恢复。")"
      echo ok > "$st" 2>/dev/null || true
    else
      alert_admin "🔴 $(L "Service is DOWN and auto-restart failed. SSH in and check: cd $INSTALL_DIR && docker compose logs --tail 40" "服务不在线,自动拉起失败 —— 请登录服务器排查: cd $INSTALL_DIR && docker compose logs --tail 40")"
      echo down > "$st" 2>/dev/null || true
    fi
  fi
  # 磁盘 ≥90% 告警(状态翻转才发一次,并顺手跑一次 Docker 冗余回收)
  pct="$(df -P . 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5);print $5}')"; dst="$INSTALL_DIR/.watchdog_disk"
  if [ "${pct:-0}" -ge 90 ] 2>/dev/null; then
    if [ "$(cat "$dst" 2>/dev/null)" != "hi" ]; then
      alert_admin "🔴 $(L "Disk ${pct}% full — pruning Docker leftovers; please clean up or expand soon." "磁盘已用 ${pct}% —— 已自动回收 Docker 冗余;请尽快清理或扩容。")"
      echo hi > "$dst" 2>/dev/null || true; disk_guard
    fi
  else echo ok > "$dst" 2>/dev/null || true; fi
}

watchdog_cron_install() {
  [ -d /etc/cron.d ] || return 0
  cat > /etc/cron.d/tuwunel-watchdog <<CRON
# VeilX 看门狗:5 分钟一次,掉线自动拉起 + 告警到管理员房间(由 tuwunel-installer 安装)
*/5 * * * * root INSTALL_DIR="$INSTALL_DIR" bash "$INSTALL_DIR/tuwunel-installer.sh" watchdog >/dev/null 2>&1
CRON
  chmod 644 /etc/cron.d/tuwunel-watchdog 2>/dev/null || true
}

# ---- 磁盘守卫 / 清理(只回收 Docker 冗余,不删用户文件)----
menu_cleanup() {
  cd "$INSTALL_DIR"
  local before after
  echo ""; echo "$(L "── Disk / data usage ──" "── 磁盘 / 数据 用量 ──")"
  printf '  %s: %s    %s: %s\n' "$(L "Install dir total" 安装目录合计)" "$(du -sh . 2>/dev/null | cut -f1)" "$(L "Disk free" 磁盘剩余)" "$(df -h . 2>/dev/null | awk 'NR==2{print $4}')"
  printf '  %s: %s\n' "$(L "  └ database+media (RocksDB)" "  └ 数据库+媒体(RocksDB)")" "$(du -sh data/tuwunel 2>/dev/null | cut -f1)"
  echo ""; echo "$(L "Safe cleanup (reclaims only redundant Docker images/cache, touches no user files)…" "安全清理(仅回收 Docker 冗余镜像/缓存,不动你任何用户的文件)…")"
  before="$(df -P . 2>/dev/null | awk 'NR==2{print $4}')"
  docker image prune -f >/dev/null 2>&1 || true
  docker builder prune -f >/dev/null 2>&1 || true
  after="$(df -P . 2>/dev/null | awk 'NR==2{print $4}')"
  if [ -n "$before" ] && [ -n "$after" ] && [ "$after" -gt "$before" ] 2>/dev/null; then
    ok "$(L "Cleanup done, freed ~$(( (after-before)/1024 )) MB (disk free $(df -h . 2>/dev/null | awk 'NR==2{print $4}'))." "清理完成,释放约 $(( (after-before)/1024 )) MB(磁盘剩余 $(df -h . 2>/dev/null | awk 'NR==2{print $4}'))。")"
  else ok "$(L "Cleanup done (nothing redundant, disk free $(df -h . 2>/dev/null | awk 'NR==2{print $4}'))." "清理完成(暂无冗余,磁盘剩余 $(df -h . 2>/dev/null | awk 'NR==2{print $4}'))。")"; fi
  echo ""
  warn "$(L "With big files enabled, chat media grows data/tuwunel fast — watch disk. tuwunel has no S3 offload; media is on the local disk." "大文件放开后,聊天媒体会让 data/tuwunel 快速增长,注意磁盘余量。tuwunel 无 S3 卸载,媒体存本地盘。")"
}
disk_guard() {
  cd "$INSTALL_DIR" 2>/dev/null || return 0
  local pct log; log="$INSTALL_DIR/diskguard.log"
  pct="$(df . 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5); print $5}')"; pct="${pct:-0}"
  [ "$pct" -ge 80 ] 2>/dev/null || return 0
  { echo "[$(date '+%F %T')] 磁盘已用 ${pct}% ≥ 80%,回收 Docker 冗余…"
    docker image prune -f 2>&1 | tail -n1
    df -h . | awk 'NR==2{print "  回收后:剩余 "$4",已用 "$5}'
  } >> "$log" 2>&1
  tail -n 200 "$log" > "$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log" 2>/dev/null || true
}

menu_status() {
  cd "$INSTALL_DIR"
  local d asub; d="$(env_saved MATRIX_DOMAIN)"; asub="$(env_saved ADMIN_SUB)"; asub="${asub:-admin}"
  echo ""; echo "$(L "── Current config ──" "── 当前配置 ──")"
  local vonly vonly_txt; vonly="$(env_saved VEILX_ONLY)"
  case "$vonly" in
    0|"")   vonly_txt="$(L "off (any Matrix client)" "关(任何 Matrix 客户端)")" ;;
    strict) vonly_txt="$(L "STRICT (no admin-panel exception)" "严格(不留后台例外)")" ;;
    *)      vonly_txt="$(L "on (non-VeilX clients refused)" "开(拒绝非 VeilX 客户端)")" ;;
  esac
  echo "  $(L "VeilX-only" 仅VeilX): $vonly_txt"
  echo "  $(L Hardening 加固): $([ "$(env_saved ENABLE_OPRF)" = "1" ] && L "server-assisted PIN ON" "服务器辅助PIN 已开" || L "off" 未开)"
  echo "  $(L Domain 域名): ${d:-$(L unknown 未知)}   $(L Reg 注册): $(env_saved REG_MODE)   $(L Federation 联邦): $([ "$(env_saved ENABLE_FEDERATION)" = "1" ] && L on 开 || L off 关)   $(L Calls 通话): $([ "$(env_saved ENABLE_CALLS)" = "1" ] && L on 开 || L off 关)   $(L Web 网页): $([ "$(env_saved ENABLE_WEB)" = "1" ] && L on 开 || L off 关)   $(L Admin 后台): $([ "$(env_saved ENABLE_ADMIN)" = "1" ] && L on 开 || L off 关)   $(L Big-files 大文件): $(human "$(env_saved MAX_UPLOAD_BYTES)")"
  [ "$(env_saved ENABLE_WEB)" = "1" ] && echo "  $(L "Web client:" "网页客户端:") https://${d}$(L "(members register/log in in a browser)" "(成员浏览器直接注册/登录)")"
  [ "$(env_saved ENABLE_ADMIN)" = "1" ] && echo "  $(L "Admin panel:" "管理后台: ") https://${asub}.${d}$(L "(admin user/password login, graphical management)" "(管理员账号密码登录,图形化管理)")"
  [ -f /etc/cron.d/tuwunel-backup ] && echo "  $(L "Auto backup:" "自动备份: ") $(L On 已开启)($(grep -oE '#.*' /etc/cron.d/tuwunel-backup 2>/dev/null))" || echo "  $(L "Auto backup:" "自动备份: ") $(L "Off (enable with sudo tuwunel autobackup)" "未开启(sudo tuwunel autobackup 可开)")"
  [ "$(env_saved USE_CDN)" = "1" ] && echo "  $(L "CDN mode:" "CDN 模式:") $(L "flagged (DNS pre-check relaxed; matrix/media must be grey-cloud)" "已标记(DNS 预检放宽;matrix/媒体须灰云)")"
  echo "  $(L "Phone signup (Element X):" "手机App注册(Element X):") $([ "$(env_saved ENABLE_ELEMENTX)" = "1" ] && L "on (native OIDC, invite token still required)" "开(原生OIDC,注册仍需邀请码)" || L "off (Element X password login only; register via web / admin)" "关(Element X 仅密码登录;注册走网页/管理员建号)")"
  echo "$(L "── Container status ──" "── 容器状态 ──")"; docker compose ps 2>/dev/null || true
  echo "$(L "── Resource usage ──" "── 资源占用 ──")"
  local pct; pct="$(df . 2>/dev/null | awk 'NR==2{gsub(/%/,"",$5);print $5}')"; pct="${pct:-0}"
  printf '  %s: %s   %s: %s   ' "$(L "Data (db+media)" "数据(库+媒体)")" "$(du -sh data/tuwunel 2>/dev/null | cut -f1)" "$(L "Disk free" 磁盘剩余)" "$(df -h . 2>/dev/null | awk 'NR==2{print $4}')"
  if [ "$pct" -ge 90 ] 2>/dev/null; then printf '%s%s%s\n' "$C_RED" "$(L "Disk used $pct% — high, use menu 6 to clean" "磁盘已用 $pct%,偏高,菜单选 6 清理")" "$C_RESET"
  else printf '%s%s%s\n' "$C_GREEN" "$(L "Disk used $pct%" "磁盘已用 $pct%")" "$C_RESET"; fi
  free -h 2>/dev/null | awk 'NR<=2{print "  "$0}' || true
  echo "$(L "── Online check ──" "── 在线检查 ──")"
  if curl -4 -fsS --max-time 8 "https://matrix.${d}/_matrix/client/versions" >/dev/null 2>&1; then
    ok "$(L "https://matrix.${d} OK (valid cert, service online)" "https://matrix.${d} 正常(证书有效,服务在线)")"
  else warn "$(L "matrix.${d} not reachable yet — debug: docker compose logs --tail 30" "matrix.${d} 暂不可访问 —— 排查: docker compose logs --tail 30")"; fi
  echo "  $(L Credentials: 凭据:) $INSTALL_DIR/CREDENTIALS.txt"
}
menu_privacy() {
  cd "$INSTALL_DIR" 2>/dev/null || { warn "$(L "Deployment directory not found" "未找到部署目录")"; return; }
  printf '%s\n' "$(L '
── Privacy / metadata: first, what CANNOT be deleted (no false comfort) ──
  End-to-end encryption protects "content"; the following is structural metadata the
  server needs to run — deleting it breaks the server:
    · Room membership (who is in which room, who invited whom) — kept even if history is purged
    · Event graph & timestamps (each message id / ordering / send time)
    · Device list and the timing of E2EE key uploads
    · Account existence, creation time, display name/avatar
  Beyond the server''s reach: local copies on members'' phones/computers, the Apple/Google
  push servers relaying notifications, and your own backups (which contain what you just purged).
  ⚠️ tuwunel has no "auto-destruct messages" feature (MSC1763 not implemented) — do not promise the server auto-deletes messages.' '
── 隐私 / 元数据:先说【删不掉的】(不给你假安全感)──
  端到端加密保护的是"内容";下面这些是服务器运行必须的结构性元数据,删了就无法工作:
    · 房间成员关系(谁在哪个房间、谁邀请谁)—— 清历史也会保留状态事件
    · 事件图与时间戳(每条消息的 id/前后关系/发送时刻)
    · 设备清单与 E2EE 密钥的上传时序
    · 账号存在性、创建时间、昵称/头像
  服务器管不到的地方:成员手机/电脑上的本地副本、推送经过的苹果/谷歌服务器、
  以及你自己的备份包(备份里含有你刚清掉的数据)。
  ⚠️ tuwunel 没有"消息定时自动销毁"功能(未实现 MSC1763),别对外承诺服务器会自动销毁消息。')"
  echo ""
  echo "$(L "── Current hardening status (reads tuwunel.toml) ──" "── 当前加固状态(读 tuwunel.toml)──")"
  _pk(){ if grep -qE "^$1[[:space:]]*=" tuwunel.toml 2>/dev/null; then echo "  ✔ $2"; else echo "  ✘ $2 $(L "(not enabled)" "(未启用)")"; fi; }
  _pk ip_source                 "$(L "real client IP not stored (ip_source=connect_info)" "真实客户端 IP 不入库(ip_source=connect_info)")"
  _pk save_unredacted_events    "$(L "redaction is a true delete (no 60-day original kept)" "撤回即真删(不再保留 60 天原文)")"
  _pk allow_local_presence      "$(L "presence not recorded" 不记录在线状态)"
  _pk require_auth_for_profile_requests "$(L "profile requires auth" 资料需鉴权)"
  _pk admin_room_notices        "$(L "admin room keeps no action log" 管理房不留操作流水)"
  echo "$(L "  (not enabled? run: PRIVACY=1 sudo -E tuwunel config)" "  (未启用? 执行: PRIVACY=1 sudo -E tuwunel config)")"
  echo ""
  echo "$(L "── Cleanable: container logs (may hold a little IP/request trace) ──" "── 可清理的:容器日志(可能含少量 IP/请求痕迹)──")"
  local tot=0 pth
  for c in $(docker compose ps -q 2>/dev/null); do
    pth="$(docker inspect --format='{{.LogPath}}' "$c" 2>/dev/null)"
    [ -n "$pth" ] && [ -f "$pth" ] && tot=$((tot + $(stat -c%s "$pth" 2>/dev/null || echo 0)))
  done
  echo "$(L "  Current container logs total: $(( tot / 1024 )) KB" "  当前容器日志合计: $(( tot / 1024 )) KB")"
  printf "%s" "$(L "  Clear all container logs? [y/N]: " "  清空全部容器日志? [y/N]: ")"
  local R=""; if [ -t 0 ]; then read -r R || R=""; else read -r R < /dev/tty 2>/dev/null || R=""; fi
  case "$R" in
    y|Y)
      for c in $(docker compose ps -q 2>/dev/null); do
        pth="$(docker inspect --format='{{.LogPath}}' "$c" 2>/dev/null)"
        [ -n "$pth" ] && [ -f "$pth" ] && truncate -s 0 "$pth" 2>/dev/null || true
      done
      ok "$(L "Container logs cleared." "容器日志已清空。")" ;;
    *) echo "$(L "  Skipped." "  已跳过。")" ;;
  esac
  echo ""
  echo "$(L "  Note: RocksDB deletes are tombstone-based; space and residual bytes are only truly reclaimed after compaction." "  提示:RocksDB 的删除是「墓碑式」,空间与残留字节要等压缩(compaction)后才真正回收。")"
  echo "$(L "  The only reliable way to destroy data: destroy/reinstall the server disk, and handle your backups." "  彻底销毁数据的唯一可靠方式:销毁/重装服务器磁盘,并处理好备份包。")"
}

menu_forget_secrets() {
  cd "$INSTALL_DIR" 2>/dev/null || { warn "$(L "Deployment directory not found" "未找到部署目录")"; return; }
  local WIPE; WIPE="$(L 'SECURELY WIPED — see your password manager' '已安全销毁 —— 见密码管理器')"
  if [ ! -f CREDENTIALS.txt ]; then ok "$(L "CREDENTIALS.txt does not exist (maybe already wiped)." "CREDENTIALS.txt 不存在(可能已涂销)。")"; else
    if grep -qE 'WIPED|安全销毁' CREDENTIALS.txt 2>/dev/null; then ok "$(L "Credentials file already wiped — no plaintext password/token." "凭据文件已涂销过,无明文密码/令牌。")"; else
      echo "$(L "CREDENTIALS.txt holds the PLAINTEXT admin password + invite token. They are useless at runtime (the password is hashed in the DB, shown here only for you)," "CREDENTIALS.txt 里有【明文管理员密码 + 邀请码】,运行时无用(密码已哈希入库,仅显示用),")"
      echo "$(L "but they are the highest-value target for anyone who gets physical access to the disk after a disk image. This wipes those two lines; the file stays (as a deployment marker)." "却是拿到磁盘镜像的人\"一击拿走\"的最高价值目标。此操作会把这两行涂掉,文件保留(作部署标记)。")"
      echo "$(L "Copy the admin password / invite token into your password manager FIRST!" "务必先把管理员密码/邀请码抄进密码管理器!")"
      printf "%s" "$(L "Confirm wipe? type yes: " "确认涂销? 输入 yes: ")"; local R=""; if [ -t 0 ]; then read -r R || R=""; else read -r R < /dev/tty 2>/dev/null || R=""; fi
      if [ "$R" = "yes" ]; then
        umask 077
        sed -E "s/^(Admin password:|管理员密码:).*/\\1  ($WIPE)/; s/^(Registration token:|注册令牌:).*/\\1  ($WIPE)/" CREDENTIALS.txt > CREDENTIALS.txt.new 2>/dev/null \
          && cat CREDENTIALS.txt.new > CREDENTIALS.txt && shred -u CREDENTIALS.txt.new 2>/dev/null
        chmod 600 CREDENTIALS.txt 2>/dev/null || true
        ok "$(L "Wiped the password and invite token from CREDENTIALS.txt." "已涂销 CREDENTIALS.txt 里的密码与邀请码。")"
      else echo "$(L "Cancelled." "已取消。")"; return; fi
    fi
  fi
  # 邀请码仍在 tuwunel.toml(令牌注册开着就必须在);彻底移除需改用管理员建号
  if grep -q '^registration_token' tuwunel.toml 2>/dev/null; then
    echo ""
    echo "$(L "Note: the invite token also lives in tuwunel.toml (required at runtime while token registration is on, cannot be removed)." "提示:邀请码同样存在 tuwunel.toml(令牌注册开着时运行必需,无法移除)。")"
    echo "$(L "To take the invite token off disk entirely → turn off self-registration and create accounts as admin:" "要把邀请码也从磁盘彻底拿掉 → 关闭自助注册、改由管理员建号:")"
    echo "$(L "   turn off allow_registration, then create accounts only with sudo tuwunel adduser" "   REG_MODE=admin_only 暂不支持;可执行  allow_registration 关闭后仅用 sudo tuwunel adduser 建号")"
  fi
  echo ""; echo "$(L "==> Running fstrim (hints the SSD to reclaim freed blocks; not a guaranteed wipe)…" "==> 触发 fstrim(提示 SSD 回收已释放块;非保证擦除)…")"
  fstrim -av 2>/dev/null || fstrim / 2>/dev/null || warn "$(L "fstrim unavailable (virtual disk may not support it)" "fstrim 不可用(虚拟盘可能不支持)")"
  echo "$(L "  Note: deletion on SSD/VPS is not a guaranteed physical wipe; the only reliable destruction is LUKS crypto-erase or destroying the disk." "  注:SSD/VPS 上删除不保证物理擦除;彻底销毁只能靠 LUKS 加密擦除或销毁磁盘。")"
}

# 设备应急处置:设备丢失/失窃/员工离职时对该账号的密钥做处置。
# 分层的关键:【冻结】可撤销,是刚发现异常那几小时该做的事(还不知道设备能不能找回);
# 【销毁】不可撤销,确认拿不回来了再用。只有这两级分开,管理员才敢在第一时间动手。
menu_oprf_members() {
  cd "$INSTALL_DIR" 2>/dev/null || return
  local D; D="$(env_saved MATRIX_DOMAIN)"
  # 处置时可顺带吊销该账号的 Matrix 登录会话:否则拿到手机的人若赶上【已解锁+联网】
  # 状态,仍能用这个账号继续收发消息、看群里在说什么。
  # tuwunel is administered from its ADMIN ROOM, not the CLI: `tuwunel --execute`
  # cannot run while the server holds the RocksDB lock, so the old attempt here
  # always failed. Deactivating is the operator's most urgent action (it removes
  # the member from every room, which rotates the Megolm keys so the compromised
  # account stops receiving new messages), so spell out the exact command rather
  # than pretend to have done it.
  _revoke_sessions() {
    local acct="$1"
    printf '\n%s\n' "$(L "  ACTION REQUIRED — in the admin room of your VeilX/Element client, send:" "  需要你手动执行 —— 在客户端的【管理员房间】里发送:")"
    printf '      %s\n' "!admin users deactivate $acct"
    printf '%s\n' "$(L "  This removes them from every room and rotates the encryption keys, so the compromised account can no longer read new messages or impersonate them." "  这会把该账号踢出所有房间并轮换加密密钥,失控的账号从此读不到新消息、也无法冒充该员工发言。")"
    return 0
  }
  # sled 是单进程独占的:任何管理操作都必须先停服务容器,否则打不开数据库。
  # 停机时间约 1-2 秒;期间成员解锁会失败(会自动重试)。
  # 关键:必须带 -T(不分配伪终端),否则在 SSH/脚本里 `run` 会挂起等待终端、
  # 一直占着数据库锁,导致随后启动的主容器打不开库而崩溃重启(502)。
  # 用 trap 兜底:无论中途出什么错,一定把主容器重新拉起来。
  _oprf_admin() {
    local out rc
    docker compose stop oprf >/dev/null 2>&1
    # Clean up any leftover one-off admin container that could still hold the lock.
    docker ps -aq --filter name=oprf-run 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1
    trap 'docker compose up -d oprf >/dev/null 2>&1' RETURN
    out="$(docker compose run --rm -T oprf "$@" 2>&1)"; rc=$?
    docker compose up -d oprf >/dev/null 2>&1
    trap - RETURN
    # Never leave the service down: if it didn't come back healthy, force a clean
    # restart so highest-tier phones aren't locked out.
    sleep 2
    if ! docker compose ps --status running -q oprf 2>/dev/null | grep -q .; then
      warn "$(L "OPRF service didn't come back — forcing a clean restart…" "OPRF 服务没能自动恢复 —— 正在强制干净重启…")"
      oprf_repair
    fi
    printf '%s' "$out"
    return $rc
  }
  while :; do
    printf '\n%s\n' "$(L "── Device incident response ──" "── 设备应急处置 ──")"
    printf '%s\n' "$(L '
  Device lost, stolen, or an employee leaving? FREEZE first — it blocks that phone
  from unlocking but keeps the key, so you can undo it if the device turns up. Only
  DESTROY once you are sure: that is permanent, and even the correct PIN will never
  open it again.' '
  设备丢失/失窃,或员工离职?先【冻结】—— 挡住那台手机解锁,但保住密钥,设备找回可撤销。
  确认拿不回来了再【销毁】——不可逆,之后连正确的 PIN 也永远打不开。')"
    echo ""
    echo "  1) $(L "Device status (who is enrolled; keys are never shown)" "查看设备状态(谁启用了;不显示任何密钥)")"
    echo "  2) $(L "FREEZE an account (reversible — use this first)" "【冻结】某账号(可撤销 —— 第一时间用这个)")"
    echo "  3) $(L "Release an account (undo freeze)" "【解冻】某账号(撤销冻结)")"
    echo "  4) $(L "DESTROY an account's key (permanent)" "【销毁】某账号的密钥(永久,不可逆)")"
    echo "  5) $(L "DESTROY EVERY ENROLLED DEVICE (org-wide incident)" "【全员销毁】(全组织级安全事件)")"
    echo "  0) $(L Back 返回)"
    local R=""; if [ -t 0 ]; then read -rp "$(L "Select: " "请选择: ")" R || return; else read -rp "$(L "Select: " "请选择: ")" R < /dev/tty 2>/dev/null || return; fi
    local acct="" c=""
    case "$R" in
      1)
        echo ""
        printf '  %-34s %-12s %-10s %s\n' "$(L ACCOUNT 账号)" "$(L STATE 状态)" "$(L "USED/HR" 本小时)" "$(L "LAST USE" 最近使用)"
        _oprf_admin --admin-list 2>/dev/null | grep -E '^@' | while IFS=$'\t' read -r a s c2 ls; do
          local st when
          case "$s" in
            destroyed) st="$(L destroyed 已销毁)";;
            frozen)    st="$(L frozen 已冻结)";;
            *)         st="$(L ok 正常)";;
          esac
          if [ "${ls:-0}" = "0" ]; then when="—"; else when="$(date -d "@$ls" '+%m-%d %H:%M' 2>/dev/null || echo "$ls")"; fi
          printf '  %-34s %-12s %-10s %s\n' "$a" "$st" "${c2:-0}" "$when"
        done
        echo ""
        echo "$(L "  (empty = nobody has enabled the highest level yet)" "  (空 = 还没有员工启用最高级别)")" ;;
      2|3|4)
        read -rp "$(L "Full user id (e.g. @li:$D): " "完整用户 id(如 @li:$D): ")" acct || continue
        [ -n "$acct" ] || continue
        case "$R" in
          2) # 必须确认真的生效:紧急时刻误报"已冻结"比失败更危险。
             if _oprf_admin --admin-freeze "$acct" | grep -q '^ok'; then
               ok "$(L "FROZEN: $acct can no longer unlock. Reversible — use 'Release' when safe." "已冻结:$acct 暂时无法解锁。可撤销 —— 人安全后用【解冻】。")"
               printf "%s" "$(L "  Also revoke their Matrix login sessions? [y/N]: " "  是否同时吊销他的 Matrix 登录会话? [y/N]: ")"
               read -r c || c=""
               case "$c" in y|Y) _revoke_sessions "$acct";; esac
             else
               warn "$(L "NOT frozen — no such account, or it was already destroyed. Check the status list." "未能冻结 —— 没有这个账号,或已被销毁。请查看成员状态。")"
             fi ;;
          3) if _oprf_admin --admin-unfreeze "$acct" | grep -q '^ok'; then
               ok "$(L "Released: $acct can unlock again." "已解冻:$acct 可以正常解锁了。")"
             else
               warn "$(L "Failed — no such account, or it was already destroyed." "失败 —— 没有这个账号,或已被销毁。")"
             fi ;;
          4) warn "$(L "PERMANENT: even the correct PIN will never open $acct's phone again." "不可逆:之后连正确的 PIN 也永远打不开 $acct 的手机。")"
             read -rp "$(L "Type yes to confirm: " "输入 yes 确认: ")" c || continue
             [ "$c" = yes ] || { echo "$(L Cancelled 已取消)"; continue; }
             if _oprf_admin --admin-kill "$acct" | grep -q '^killed'; then
               ok "$(L "Destroyed. $acct's phones can never be unlocked." "已销毁。$acct 的手机再也无法解锁。")"
             else warn "$(L "Failed — check: docker compose logs oprf" "失败 —— 请查: docker compose logs oprf")"; fi
             printf "%s" "$(L "  Also revoke their Matrix login sessions? [Y/n]: " "  是否同时吊销他的 Matrix 登录会话? [Y/n]: ")"
             read -r c || c=""
             case "$c" in n|N) :;; *) _revoke_sessions "$acct";; esac ;;
        esac ;;
      5)
        warn "$(L "This destroys EVERY member's key. Every VeilX phone on this server becomes permanently unopenable. There is no undo." "这会销毁【所有成员】的密钥。本服务器上每一台 VeilX 手机都将永久打不开。无法撤销。")"
        read -rp "$(L "Type DESTROY ALL to confirm: " "输入 DESTROY ALL 确认: ")" c || continue
        [ "$c" = "DESTROY ALL" ] || { echo "$(L Cancelled 已取消)"; continue; }
        _oprf_admin --admin-kill-all | grep -E '^killed' || true
        ok "$(L "All member keys destroyed." "全部成员密钥已销毁。")" ;;
      0|"") return ;;
    esac
    press_enter "
$(L "Press Enter to continue… " "按回车继续… ")"
  done
}

# VeilX 加固(服务器辅助 PIN):状态 / 开关 / 远程销毁某账号的密钥。
menu_oprf() {
  cd "$INSTALL_DIR" 2>/dev/null || { warn "$(L "Deployment directory not found" "未找到部署目录")"; return; }
  local D; D="$(env_saved MATRIX_DOMAIN)"
  while :; do
    local on running
    oprf_enabled && on=1 || on=0
    running=$(docker compose ps --status running -q oprf 2>/dev/null | grep -c . || echo 0)
    printf '\n%s\n' "$(L "── VeilX hardening: server-assisted PIN ──" "── VeilX 加固:服务器辅助 PIN ──")"
    printf '%s\n' "$(L '
  What it does: VeilX phones must ask THIS server to unlock. So a lost or stolen
  phone that is OFFLINE can never be brute-forced, and you can destroy the key of a
  device you cannot get back, making it permanently unopenable. It never sees any PIN.' '
  作用:VeilX 手机解锁时必须问【这台服务器】。于是失窃且【离线】的手机永远无法爆破
  PIN;设备拿不回来时你可销毁其密钥,让那台手机永久打不开。它全程看不到任何人的 PIN。')"
    echo ""
    echo "  $(L Status 状态): $([ "$on" = 1 ] && L "ON" "已开启" || L "OFF" "未开启")   $(L Container 容器): $([ "$running" -gt 0 ] && L running 运行中 || L "not running" 未运行)"
    [ "$on" = 1 ] && echo "  $(L "Client endpoint (the app finds this automatically)" "客户端端点(App 会自动找到)"): https://matrix.$D/oprf/"
    echo ""
    if [ "$on" = 1 ]; then
      echo "  1) $(L "DEVICE INCIDENT RESPONSE (lost / stolen / offboarding: freeze / release / destroy)" "【设备应急处置】(丢失 / 失窃 / 离职:冻结 / 解冻 / 销毁)")"
      echo "  2) $(L "Logs" 查看日志)"
      echo "  3) $(L "RESTART / REPAIR service (if phones can't unlock / 502)" "重启 / 修复服务(手机解不了锁 / 502 时)")"
      echo "  4) $(L "Turn OFF (phones fall back to PIN + hardware only)" "关闭(手机退回仅 PIN + 硬件加密)")"
    else
      echo "  1) $(L "Turn ON" 开启)"
    fi
    echo "  0) $(L Back 返回)"
    local R=""; if [ -t 0 ]; then read -rp "$(L "Select: " "请选择: ")" R || return; else read -rp "$(L "Select: " "请选择: ")" R < /dev/tty 2>/dev/null || return; fi
    case "$R" in
      1) if [ "$on" = 1 ]; then menu_oprf_members
         else
           [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" enable-oprf || warn "$(L "script copy missing" "缺少脚本副本")"
           return
         fi ;;
      2) [ "$on" = 1 ] && { docker compose logs --tail 50 oprf 2>/dev/null || true; } ;;
      3) [ "$on" = 1 ] && oprf_repair ;;
      4) [ "$on" = 1 ] || continue
         [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" disable-oprf || warn "$(L "script copy missing" "缺少脚本副本")"
         return ;;
      0|"") return ;;
    esac
  done
}

# ---- 自动定时加密备份(可选;配置编进 cron 行,不污染 .env)----
backup_run() {   # 非交互,供 cron 调用;密钥读 .backup-key,目录/保留数从环境变量取
  cd "$INSTALL_DIR" 2>/dev/null || return 1
  local keyf dir keep ts f raw free log
  log="$INSTALL_DIR/backup.log"; keyf="$INSTALL_DIR/.backup-key"
  [ -f "$keyf" ] || { echo "[$(date '+%F %T')] $(L "no backup key (.backup-key), skipped." "无备份密钥(.backup-key),跳过。")" >> "$log"; return 1; }
  dir="${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}"; keep="${BACKUP_KEEP:-2}"
  mkdir -p "$dir" 2>/dev/null; chmod 700 "$dir" 2>/dev/null || true
  raw="$(du -sk data/tuwunel 2>/dev/null | cut -f1)"; raw="${raw:-0}"
  free="$(df -Pk "$dir" 2>/dev/null | awk 'NR==2{print $4}')"; free="${free:-0}"
  if [ "$free" -lt "$raw" ] 2>/dev/null; then
    echo "[$(date '+%F %T')] $(L "backup skipped: not enough free space (need ≈${raw}K, have ${free}K) — avoids filling the disk. Clean up / lower the keep count / use an external disk." "跳过备份:剩余空间不足(需≈${raw}K,剩${free}K),避免撑爆盘。请清理/调低保留数/改存外部盘。")" >> "$log"
    alert_admin "⚠️ $(L "Weekly backup SKIPPED: not enough free disk space. Clean up or use an external disk." "本周自动备份被跳过:磁盘剩余空间不足。请清理或改存外部盘。")"
    return 1
  fi
  ts="$(date +%F-%H%M%S)"; f="$dir/tuwunel-backup-$ts.tar.gz.enc"
  docker compose stop tuwunel >/dev/null 2>&1 || true
  # 【必须一起备份 data/oprf】最高档(服务器辅助 PIN)的每账号密钥 k 只存在这里 ——
  # 丢了它,所有最高档用户的本地消息库【永远打不开】,备份里的聊天记录也救不回来
  # (库是用 PIN+k 派生的密钥加的);signing.key 也在里面,丢了远程停用的签名就验不过。
  BK_PATHS=".env tuwunel.toml data/tuwunel"
  [ -d data/oprf ] && BK_PATHS="$BK_PATHS data/oprf"
  [ -d veilx-wk ] && BK_PATHS="$BK_PATHS veilx-wk"   # 授权证书:可重新申请,但带上省一步
  ( umask 077; tar czf - $BK_PATHS 2>/dev/null \
      | BKPW="$(cat "$keyf")" openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:BKPW -out "$f" 2>/dev/null ) || true
  docker compose start tuwunel >/dev/null 2>&1 || docker compose up -d >/dev/null 2>&1 || true
  chmod 600 "$f" 2>/dev/null || true
  if [ -s "$f" ]; then
    echo "[$(date '+%F %T')] $(L "backup done:" "备份完成:") $f ($(du -h "$f" 2>/dev/null | cut -f1))" >> "$log"
    ls -1t "$dir"/tuwunel-backup-*.tar.gz.enc 2>/dev/null | tail -n +$((keep+1)) | while read -r old; do rm -f "$old"; done
  else
    rm -f "$f" 2>/dev/null; echo "[$(date '+%F %T')] $(L "backup failed (empty file)." "备份失败(空文件)。")" >> "$log"
    alert_admin "🔴 $(L "Weekly backup FAILED (empty archive). Check $INSTALL_DIR/backup.log" "本周自动备份失败(空文件)。请查看 $INSTALL_DIR/backup.log")"
  fi
  tail -n 200 "$log" > "$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log" 2>/dev/null || true
}

menu_autobackup() {
  cd "$INSTALL_DIR" 2>/dev/null || { warn "$(L "Deployment directory not found" "未找到部署目录")"; return; }
  local keyf cronf dir keep freq cronline self C R
  keyf="$INSTALL_DIR/.backup-key"; cronf="/etc/cron.d/tuwunel-backup"
  self="${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}"
  echo ""; echo "$(L "── Scheduled encrypted backup ──" "── 自动定时加密备份 ──")"
  if [ -f "$cronf" ]; then ok "$(L "Current: ON" "当前:已开启")"; grep -oE '#.*' "$cronf" 2>/dev/null | sed 's/^/     /'
  else echo "$(L "  Current: off" "  当前:未开启")"; fi
  echo "$(L "  1) Enable / reset    2) Disable    3) Back up once now    0) Back" "  1) 开启 / 重设    2) 关闭    3) 立即备份一次    0) 返回")"
  C=""; if [ -t 0 ]; then read -rp "$(L "  Select: " "  选择: ")" C || C=""; else read -rp "$(L "  Select: " "  选择: ")" C < /dev/tty 2>/dev/null || C=""; fi
  case "$C" in
    2) rm -f "$cronf" 2>/dev/null; ok "$(L "Auto backup disabled (existing backup files kept)." "已关闭自动备份(已生成的备份文件保留)。")"; return ;;
    3) [ -f "$cronf" ] || { warn "$(L "Not enabled yet — can't back up now. Choose 1 to enable first." "尚未开启,无法立即备份。先选 1 开启。")"; return; }
       echo "$(L "  Backing up (tuwunel pauses briefly)…" "  正在备份(会短暂停一下 tuwunel)…")"
       # 按引号取值,含空格的目录才能完整还原(与上面写 cron 行的引号写法配套)
       BACKUP_DIR="$(sed -n 's/.*BACKUP_DIR="\([^"]*\)".*/\1/p' "$cronf" | head -1)" \
       BACKUP_KEEP="$(sed -n 's/.*BACKUP_KEEP=\([0-9]*\).*/\1/p' "$cronf" | head -1)" backup_run
       tail -n1 "$INSTALL_DIR/backup.log" 2>/dev/null; return ;;
    1) : ;;
    *) return ;;
  esac
  printf "%s" "$(L "  Backup directory [Enter=$DEFAULT_BACKUP_DIR; can be a mounted external disk / object-store path]: " "  备份存放目录 [回车=$DEFAULT_BACKUP_DIR;可填挂载的外部盘/对象存储路径]: ")"
  if [ -t 0 ]; then read -r dir || dir=""; else read -r dir < /dev/tty 2>/dev/null || dir=""; fi
  dir="${dir:-$DEFAULT_BACKUP_DIR}"
  # 路径落在安装目录里 = 卸载时被 rm -rf 一起删掉,这里必须拦住
  case "$dir" in
    "$INSTALL_DIR"|"$INSTALL_DIR"/*)
      warn "$(L "'$dir' is inside the install dir — it would be wiped on uninstall, making the backup pointless. Using $DEFAULT_BACKUP_DIR instead." "『$dir』在安装目录里面 —— 卸载时会被一起删光,备份就白做了。已改用 $DEFAULT_BACKUP_DIR。")"
      dir="$DEFAULT_BACKUP_DIR" ;;
  esac
  printf "%s" "$(L "  Keep how many most-recent [Enter=2]: " "  保留最近几个 [回车=2]: ")"; if [ -t 0 ]; then read -r keep || keep=""; else read -r keep < /dev/tty 2>/dev/null || keep=""; fi
  echo "$keep" | grep -qE '^[0-9]+$' || keep=2
  printf "%s" "$(L "  Frequency [1=weekly (recommended) 2=daily, Enter=1]: " "  频率 [1=每周(推荐) 2=每天,回车=1]: ")"; if [ -t 0 ]; then read -r freq || freq=""; else read -r freq < /dev/tty 2>/dev/null || freq=""; fi
  case "$freq" in 2) cronline="0 4 * * *"; freq="$(L "daily 04:00" 每天04:00)";; *) cronline="0 4 * * 0"; freq="$(L "Sunday 04:00" 每周日04:00)";; esac
  mkdir -p "$dir" 2>/dev/null; chmod 700 "$dir" 2>/dev/null || true
  [ -f "$keyf" ] || { ( umask 077; openssl rand -hex 32 > "$keyf" ); chmod 600 "$keyf"; }
  # 路径必须加引号:含空格的目录(如 /mnt/my backups)会把 cron 命令行切断,
  # 结果是 cron 每次都 "command not found",自动备份一次都不会产生 —— 且用户毫无察觉。
  printf '%s root INSTALL_DIR="%s" BACKUP_DIR="%s" BACKUP_KEEP=%s bash "%s" backup-run >/dev/null 2>&1  #%s %s\n' \
    "$cronline" "$INSTALL_DIR" "$dir" "$keep" "$self" "$freq" "$(L "keep $keep, store $dir" "保留${keep}个 存 $dir")" > "$cronf"
  chmod 644 "$cronf" 2>/dev/null || true
  ok "$(L "Enabled: $freq encrypted backup → $dir (keeps last $keep, auto-skips if disk full)." "已开启:$freq 自动加密备份 → $dir(保留最近 $keep 个,满盘自动跳过)。")"
  echo ""
  printf '  %s%s%s%s\n' "$C_B" "$C_YELLOW" "$(L "⚠️ CRITICAL: below is the backup encryption key — copy it into your password manager NOW!" "⚠️ 极重要:下面是备份加密密钥,现在就抄进密码管理器!")" "$C_RESET"
  echo "  ──────────────────────────────────────────────"
  echo "    $(cat "$keyf")"
  echo "  ──────────────────────────────────────────────"
  printf '%s\n' "$(L "  · Without this key, EVERY backup is permanently unopenable. It exists only on this server —
    if the server is gone and you didn't copy it, all backups are lost. Store it elsewhere now.
  · The local backups dir disappears with the server. Copy the .enc files off regularly:
      scp root@SERVER_IP:$dir/'*.enc' ~/     (or point the dir at a mounted external disk / object store)
  · Decrypt: openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass pass:KEY -in backup.enc | tar xzf - -C targetdir" "  · 没有这把密钥,任何备份都【永久打不开】。它现在只存在这台服务器上——
    服务器要是没了、你又没抄下,备份全废。务必现在存到别处。
  · 本机 backups 目录会随服务器一起消失。请把 .enc 定期复制到别处:
      scp root@服务器IP:$dir/'*.enc' ~/     (或把目录设成挂载的外部盘/对象存储)
  · 恢复解密: openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass pass:密钥 -in 备份.enc | tar xzf - -C 目标目录")"
  echo ""
  printf "%s" "$(L "  Run one backup now to verify? [Y/n]: " "  现在立即跑一次备份验证? [Y/n]: ")"; R=""; if [ -t 0 ]; then read -r R || R=""; else read -r R < /dev/tty 2>/dev/null || R=""; fi
  case "$R" in n|N) : ;; *) echo "$(L "  Backing up (tuwunel pauses briefly)…" "  备份中(会短暂停一下 tuwunel)…")"; BACKUP_DIR="$dir" BACKUP_KEEP="$keep" backup_run; tail -n1 "$INSTALL_DIR/backup.log" 2>/dev/null ;; esac
}

menu_backup() {
  cd "$INSTALL_DIR"; local ts f pw pw2 bdir; ts="$(date +%F-%H%M%S)"
  # 存到安装目录【之外】,否则卸载时 rm -rf 会把刚做好的备份一起删掉
  bdir="${BACKUP_DIR:-$DEFAULT_BACKUP_DIR}"
  mkdir -p "$bdir" 2>/dev/null; chmod 700 "$bdir" 2>/dev/null || true
  f="$bdir/tuwunel-backup-$ts.tar.gz"
  umask 077
  echo "$(L "Backup contains PLAINTEXT secrets (invite token, config, the whole database, all media). Encrypting before storing/transferring is strongly recommended." "备份含【明文机密】(邀请码、配置、整个数据库、全部媒体)。强烈建议加密后再存/传。")"
  echo "$(L "Set an encryption passphrase (AES-256). Blank = no encryption (at your own risk)." "设一个加密口令(AES-256)。留空=不加密(风险自负)。")"
  printf "%s" "$(L "  Passphrase: " "  加密口令: ")"; pw=""; if [ -t 0 ]; then read -rs pw || pw=""; else read -rs pw < /dev/tty 2>/dev/null || pw=""; fi; echo
  if [ -n "$pw" ]; then
    printf "%s" "$(L "  Again: " "  再输一次: ")"; pw2=""; if [ -t 0 ]; then read -rs pw2 || pw2=""; else read -rs pw2 < /dev/tty 2>/dev/null || pw2=""; fi; echo
    [ "$pw" = "$pw2" ] || { warn "$(L "Passphrases don't match, cancelled." "两次口令不一致,已取消。")"; return; }
    f="$f.enc"
  else
    warn "$(L "Unencrypted! The backup holds the plaintext admin password/invite token — never send it anywhere untrusted." "未加密!备份里有明文管理员密码/邀请码,切勿传到不可信位置。")"
  fi
  echo "$(L "==> Stopping the service to back up RocksDB consistently (packing a live DB may corrupt it)…" "==> 停止服务以一致地备份 RocksDB(库不停止直接打包可能损坏)…")"
  docker compose stop tuwunel >/dev/null 2>&1 || true
  # data/oprf 同上:最高档的每账号密钥 k 与 signing.key 都在里面,不备份=最高档用户数据永久锁死。
  BK_PATHS=".env CREDENTIALS.txt tuwunel.toml data/tuwunel"
  [ -d data/oprf ] && BK_PATHS="$BK_PATHS data/oprf"
  [ -d veilx-wk ] && BK_PATHS="$BK_PATHS veilx-wk"   # 授权证书:可重新申请,但带上省一步
  if [ -n "$pw" ]; then
    # 口令走环境变量(不进 argv/ps),openssl 从 stdin 读 tar 流并加密
    tar czf - $BK_PATHS 2>/dev/null \
      | BKPW="$pw" openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:BKPW -out "$f" 2>/dev/null || true
  else
    tar czf "$f" $BK_PATHS 2>/dev/null || true
  fi
  docker compose start tuwunel >/dev/null 2>&1 || docker compose up -d >/dev/null 2>&1 || true
  chmod 600 "$f" 2>/dev/null || true
  if [ -s "$f" ]; then ok "$(L "Backup done: $f($(du -h "$f" | cut -f1))" "备份完成: $f($(du -h "$f" | cut -f1))")"
    echo "$(L "  (outside the install dir; uninstall won't delete it)" "  (存在安装目录之外,卸载不会删掉它)")"
    if [ -n "$pw" ]; then
      echo "$(L "  ⚠️ Lose the passphrase = backup unopenable forever; store it in your password manager." "  ⚠️ 口令丢了 = 备份永久打不开,请存进密码管理器。")"
      echo "$(L "  Decrypt: openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass pass:YOURPASS -in \"$(basename "$f")\" | tar xzf - -C targetdir" "  恢复解密: openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass pass:你的口令 -in \"$(basename "$f")\" | tar xzf - -C 目标目录")"
    fi
    echo "$(L "  Download to your machine: scp root@$(env_saved PUBLIC_IP | grep . || L YOUR_SERVER_IP 你的服务器IP):$f ~/Desktop/" "  下载到本机: scp root@$(env_saved PUBLIC_IP | grep . || echo 你的服务器IP):$f ~/Desktop/")"
  else warn "$(L "Backup failed" 备份失败)"; fi
}

# 非 root 自动提权
if [ "$(id -u)" -ne 0 ]; then
  if [ -f "${0:-}" ] && command -v sudo >/dev/null 2>&1; then exec sudo -E bash "$0" "$@"; fi
fi
command -v apt-get >/dev/null 2>&1 || die "$(L "This script supports Ubuntu / Debian only. Choose Ubuntu 22.04/24.04 for your server." "本脚本仅支持 Ubuntu / Debian。买服务器请选 Ubuntu 22.04/24.04")"
SELF_SRC=""; [ -f "${0:-}" ] && SELF_SRC="$(cd "$(dirname -- "$0")" && pwd)/$(basename -- "$0")"

# =====================================================================
#  VeilX 专用代理(VLESS + XTLS-Vision + REALITY)
#  ------------------------------------------------------------------
#  为什么这台机器特别适合做 REALITY:REALITY 需要"借"一个真实网站的 TLS
#  握手来伪装,而本机【本来就在跑真实的 Matrix/Element 站点 + 真实证书】。
#  于是 dest 直接指向本机的 Caddy —— 探测者打过来看到的是【货真价实】的
#  站点和证书,IP↔域名↔证书三者完全自洽(比借 microsoft.com 更难被识破)。
#
#  端口布局(开启后):
#    :80/tcp   → Caddy      (保留!ACME HTTP-01 续证靠它)
#    :443/tcp  → Xray       ├─ 合法 VeilX 客户端 → freedom 出网
#                           └─ 其他任何人/主动探测 → caddy:443 → 真实站点
#    :443/udp  → 不发布      (否则浏览器走 HTTP/3 会绕过 Xray)
#
#  客户端凭据存 xray/clients.tsv,config.json 每次由它重新生成(不依赖 jq)。
# =====================================================================
XRAY_IMAGE="${XRAY_IMAGE:-ghcr.io/xtls/xray-core:latest}"

px_dir()      { echo "$INSTALL_DIR/xray"; }
px_clients()  { echo "$INSTALL_DIR/xray/clients.tsv"; }
px_enabled()  { [ "$(env_saved ENABLE_PROXY)" = "1" ]; }
oprf_enabled(){ [ "$(env_saved ENABLE_OPRF)" = "1" ]; }

# 强杀所有卡住的 oprf 容器(释放 sled 锁)并起一个干净的。绝不碰数据库文件,只动容器。
# 用于:管理操作后没自动恢复、或容器崩溃循环时的一键自救。
oprf_repair() {
  cd "$INSTALL_DIR" 2>/dev/null || return 1
  docker compose stop oprf >/dev/null 2>&1
  docker ps -aq --filter name=oprf 2>/dev/null | xargs -r docker rm -f >/dev/null 2>&1
  docker compose up -d oprf >/dev/null 2>&1
  # CRITICAL: rm+recreate gives oprf a NEW container IP; Caddy caches the old one
  # and keeps returning 502. Restart caddy so it re-resolves the upstream.
  docker compose restart caddy >/dev/null 2>&1
  sleep 4
  if docker compose ps --status running -q oprf 2>/dev/null | grep -q .; then
    ok "$(L "OPRF service is running again (Caddy re-pointed)." "OPRF 服务已恢复运行(Caddy 已重新指向)。")"
  else
    warn "$(L "Still down — check: docker compose logs --tail 20 oprf" "仍未恢复 —— 请查: docker compose logs --tail 20 oprf")"
  fi
}

# ---- VeilX 加固:服务器辅助 PIN(OPRF)---------------------------------------
# 在 $INSTALL_DIR/oprf 写出 Dockerfile + Rust 源码(由 compose 的 build: 编译)。
# 协议 ristretto255 2HashDH:客户端发盲化点 B=r·P,服务端回 E=k·B,客户端解盲得 k·P。
# 服务端全程看不到 PIN;k 只存在本机 sled 库里,永不下发。
oprf_write_files() {
  mkdir -p oprf/src data/oprf
  chown -R 10001:10001 data/oprf 2>/dev/null || true
  cat > oprf/Dockerfile <<'EOF'
FROM rust:1-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends build-essential pkg-config \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY Cargo.toml ./
COPY src ./src
RUN cargo build --release

FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
    && rm -rf /var/lib/apt/lists/*
COPY --from=build /src/target/release/veilx-oprf-guard /usr/local/bin/veilx-oprf-guard
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/veilx-oprf-guard"]
EOF
  cat > oprf/Cargo.toml <<'EOF'
[package]
name = "veilx-oprf-guard"
version = "0.1.0"
edition = "2021"
description = "Server half of VeilX's server-assisted PIN (ristretto255 OPRF), with per-account rate limiting, reversible freeze, and signed remote-deactivation verdicts."

[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
curve25519-dalek = { version = "4", features = ["rand_core"] }
rand_core = { version = "0.6", features = ["getrandom"] }
sled = "0.34"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
# Token verification against the homeserver (whoami). rustls to avoid OpenSSL.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# Signs the remote-deactivation verdicts the client verifies against a pinned key.
ed25519-dalek = { version = "2", features = ["rand_core"] }
# Layer 2 device binding: verifies P-256 ECDSA signatures made by the phone's
# Secure Enclave / StrongBox key (P-256 is the common denominator both支持).
p256 = { version = "0.13", default-features = false, features = ["ecdsa", "std"] }
# ^ P-256 是两平台安全芯片都支持的公共下界(Secure Enclave 不支持 Ed25519)。
sha2 = "0.10"

[profile.release]
strip = true
lto = true
EOF
  cat > oprf/src/main.rs <<'EOF'
//! VeilX OPRF guard — server half of the server-assisted PIN.
//!
//! Holds a per-account ristretto255 secret `k` and evaluates `E = k · B` on the
//! client's blinded point `B`. It never sees the PIN (only the blinded point) and
//! never returns `k`. Because unlocking needs this evaluation, a lost OFFLINE
//! device cannot brute-force the PIN — and this server can refuse (remote
//! deactivation), plus it rate-limits so even online guessing is hopeless.
//!
//! HTTP (JSON):
//!   POST /oprf/eval    { "account", "blinded" }  Authorization: Bearer <matrix token>
//!         -> { "evaluated" }        429 rate-limited/frozen · 410 destroyed
//!   POST /oprf/kill    { "account" }             Authorization: Bearer <matrix token>
//!         -> 204. NOTE: authenticated **as the account owner** — this is the
//!         "I lost my phone, kill it from my other device" path. An operator
//!         acting on someone else's account uses the local `--admin-*` commands
//!         below, which is why they exist.
//!   GET  /oprf/pubkey  -> { "pubkey" }  public; the client pins it at enrollment
//!   POST /oprf/status  { "account" }             Authorization: Bearer <matrix token>
//!         -> { "state": ok|frozen|killed, "ts", "sig" }, Ed25519-signed over
//!         "account:state:ts" so a hostile network can't forge a verdict.
//!
//! Device binding (layer 2 — "only VeilX can use this server"). The phone signs
//! its own access token with a key that lives in the Secure Enclave / StrongBox
//! and cannot be exported, so Element — which has no such key and does not speak
//! this protocol — can log in but cannot bind, and every later request is refused:
//!   POST /oprf/device/bind  { account, device_id, pubkey, sig, backing }
//!         Authorization: Bearer <matrix token>   -> 204 · 401 · 400
//!   GET  /oprf/device/check Authorization: Bearer <matrix token>
//!         -> 204 bound (or enforcement off) · 403 unbound
//!         Caddy forward_auth calls this for every /_matrix/client/* request.
//!   GET  /oprf/admin/devices              -> [{account,device_id,backing,bound_at,last_seen}]
//!   POST /oprf/admin/device/unbind { token_hash } -> 204  (instant sign-out)
//!
//! DEVICE_BIND=0|1|strict gates enforcement (default 0 = off). NEVER switch to
//! strict before every client ships binding support — it locks out everyone.
//!
//! Operator surface (server-admin only; drives the VeilX admin console). The
//! bearer token is checked against the homeserver's admin API, so only an
//! operator — not the account owner — can act on someone else's account. These
//! run against the RUNNING process (no container stop, unlike the CLI below):
//!   GET  /oprf/admin/list                        -> [{account,state,count,last_seen}]
//!   POST /oprf/admin/freeze   { "account" }      -> 204  (reversible hold)
//!   POST /oprf/admin/unfreeze { "account" }      -> 204
//!   POST /oprf/admin/kill     { "account" }      -> 204  (PERMANENT destroy)
//!
//! Local admin (run by the installer, never exposed over HTTP — deliberately has
//! no way to print `k`):
//!   --admin-list · --admin-freeze <acct> · --admin-unfreeze <acct>
//!   --admin-kill <acct> · --admin-kill-all
//!   --admin-reap <days>          冻结长期未签到的账号（可撤销，默认行为）
//!   --admin-reap-destroy <days>  同上但永久销毁（不可逆，需显式选择）
//!
//! Config via env: BIND, DB, HOMESERVER, RATE_LIMIT, RATE_WINDOW_SECS,
//! SIGNING_KEY (defaults to `signing.key` next to the database).
//!
//! ⚠️ 这个文件是**单一事实来源**。两个安装器内嵌的副本由
//!    `veilx_matrix_ocs/scripts/sync-oprf.py` 从这里生成 —— 改完请跑一次 --write。
use axum::{extract::State, http::StatusCode, routing::{get, post}, Json, Router};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use curve25519_dalek::{ristretto::CompressedRistretto, scalar::Scalar};
// `verify_strict` 是 VerifyingKey 的**固有方法**,不需要 Verifier trait ——
// 多导一个会把 p256 那行的同名 trait 挤成"未使用"(两者是 signature crate 的同一个
// trait),看着像哪里写错了。
use ed25519_dalek::{Signature as Ed25519Signature, Signer, SigningKey, VerifyingKey};
use p256::ecdsa::{signature::Verifier as _, Signature as P256Sig, VerifyingKey as P256Key};
use rand_core::OsRng;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{sync::Arc, time::{SystemTime, UNIX_EPOCH}};

#[derive(Clone)]
struct App {
    db: sled::Db,
    http: reqwest::Client,
    homeserver: String,
    rate_limit: u32,
    rate_window: u64,
    // Ed25519 key the client pins at enrollment; every deactivation status response
    // is signed with it, so a man-in-the-middle cannot forge a "destroy" verdict.
    signing_key: SigningKey,
    /// Device-binding enforcement (layer 2). Off by default — see `BindMode`.
    bind_mode: BindMode,
    /// Token-hash -> Bound. A separate tree so it never collides with account keys
    /// and can be cleared wholesale without touching any `k`.
    devices: sled::Tree,
    /// 第三层:联邦只对 VeilX 服务器开放。false=标准 Matrix 联邦(谁都能连)。
    fed_guard: bool,
    /// 厂商 Ed25519 公钥 —— 与客户端 pin 的是**同一把**。签发方私钥只在厂商手里,
    /// 所以"能出示对本域名签发的证书"这件事伪造不了。
    vendor_pubkey: Option<VerifyingKey>,
    /// 对端域名 -> (判定, 到期时间戳)。联邦请求很密,不能每条都去拉一次证书。
    fed_cache: sled::Tree,
}

/// How hard `/oprf/device/check` bites. Switching to `Strict` before every client
/// ships binding support locks out the whole team, hence the deliberate default.
#[derive(Clone, Copy, PartialEq)]
enum BindMode { Off, Observe, Strict }

impl BindMode {
    fn parse(s: &str) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "strict" | "2" => BindMode::Strict,
            "1" | "observe" => BindMode::Observe,
            _ => BindMode::Off,
        }
    }
}

/// One bound (device, token) pair. Keyed by the token's SHA-256 — the plaintext
/// token is never stored, so a stolen database yields no sessions.
#[derive(Serialize, Deserialize)]
struct Bound {
    account: String,
    device_id: String,
    /// X9.62 uncompressed P-256 public key, base64 (audit/debug only).
    pubkey: String,
    /// secure_enclave | strongbox | tee | software — recorded, never enforced.
    backing: String,
    bound_at: u64,
    #[serde(default)]
    last_seen: u64,
}
#[derive(Serialize, Deserialize)]
struct Record {
    k: [u8; 32],
    window_start: u64,
    count: u32,
    killed: bool,
    /// Reversible hold: refuses unlocks but keeps k, so an incident that turns out
    /// fine can be undone. `killed` destroys k and can never be undone.
    #[serde(default)]
    frozen: bool,
    /// Unix time of the last successful evaluation (for the admin status list).
    #[serde(default)]
    last_seen: u64,
}
#[derive(Deserialize)] struct EvalReq { account: String, blinded: String }
#[derive(Serialize)]   struct EvalResp { evaluated: String }
#[derive(Deserialize)] struct KillReq { account: String }

fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() }

/// The bearer token must belong to `account` — verified against the homeserver.
async fn verify(app: &App, headers: &axum::http::HeaderMap, account: &str) -> bool {
    let Some(tok) = headers.get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok()).and_then(|s| s.strip_prefix("Bearer ")) else { return false };
    let url = format!("{}/_matrix/client/v3/account/whoami", app.homeserver.trim_end_matches('/'));
    let Ok(resp) = app.http.get(&url).bearer_auth(tok).send().await else { return false };
    if !resp.status().is_success() { return false }
    let Ok(body) = resp.json::<serde_json::Value>().await else { return false };
    body.get("user_id").and_then(|v| v.as_str()) == Some(account)
}

async fn eval(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<EvalReq>)
    -> Result<Json<EvalResp>, StatusCode> {
    if !verify(&app, &headers, &req.account).await { return Err(StatusCode::UNAUTHORIZED) }
    let raw = B64.decode(req.blinded.as_bytes()).map_err(|_| StatusCode::BAD_REQUEST)?;
    let point = CompressedRistretto::from_slice(&raw).map_err(|_| StatusCode::BAD_REQUEST)?
        .decompress().ok_or(StatusCode::BAD_REQUEST)?;
    let key = req.account.as_bytes();
    let mut rec: Record = match app.db.get(key).ok().flatten() {
        Some(b) => serde_json::from_slice(&b).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
        None => Record { k: Scalar::random(&mut OsRng).to_bytes(), window_start: now(), count: 0,
                         killed: false, frozen: false, last_seen: 0 },
    };
    if rec.killed { return Err(StatusCode::GONE) }
    // Frozen: same refusal as rate-limited, so a phone in the wrong hands just
    // looks "try again later" rather than announcing that the account was frozen.
    if rec.frozen { return Err(StatusCode::TOO_MANY_REQUESTS) }
    let t = now();
    if t.saturating_sub(rec.window_start) >= app.rate_window { rec.window_start = t; rec.count = 0 }
    if rec.count >= app.rate_limit {
        let _ = app.db.insert(key, serde_json::to_vec(&rec).unwrap());
        return Err(StatusCode::TOO_MANY_REQUESTS);
    }
    rec.count += 1;
    rec.last_seen = t;
    let k: Scalar = Option::from(Scalar::from_canonical_bytes(rec.k)).ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
    let evaluated = (point * k).compress().to_bytes();
    app.db.insert(key, serde_json::to_vec(&rec).unwrap()).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let _ = app.db.flush_async().await;
    Ok(Json(EvalResp { evaluated: B64.encode(evaluated) }))
}

/// Destroy this account's k — its devices become permanently unopenable.
async fn kill(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<KillReq>) -> StatusCode {
    if !verify(&app, &headers, &req.account).await { return StatusCode::UNAUTHORIZED }
    let rec = Record { k: [0u8; 32], window_start: now(), count: 0, killed: true, frozen: false, last_seen: 0 };
    let _ = app.db.insert(req.account.as_bytes(), serde_json::to_vec(&rec).unwrap());
    let _ = app.db.flush_async().await;
    StatusCode::NO_CONTENT
}

#[derive(Serialize)] struct PubkeyResp { pubkey: String }
#[derive(Deserialize)] struct StatusReq { account: String }
#[derive(Serialize)] struct StatusResp { state: String, ts: u64, sig: String }

/// The client pins this at enrollment; public, so no auth. It is only a *verify*
/// key — the private half never leaves the server.
async fn pubkey(State(app): State<Arc<App>>) -> Json<PubkeyResp> {
    Json(PubkeyResp { pubkey: B64.encode(app.signing_key.verifying_key().to_bytes()) })
}

/// Deactivation status: the phone polls this. The verdict is SIGNED over
/// "account:state:ts", so a hostile network cannot forge "killed" to destroy an
/// employee's data, nor forge "ok" that the client would trust over a real kill
/// (the client only ACTS on killed/frozen, and only when the signature checks).
///  - ok:     do nothing
///  - frozen: encrypt-and-lock now (reversible)
///  - killed: destroy now (permanent)
async fn status(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<StatusReq>)
    -> Result<Json<StatusResp>, StatusCode> {
    if !verify(&app, &headers, &req.account).await { return Err(StatusCode::UNAUTHORIZED) }
    let rec: Option<Record> = app.db.get(req.account.as_bytes()).ok().flatten()
        .and_then(|b| serde_json::from_slice(&b).ok());
    let state = match rec {
        Some(r) if r.killed => "killed",
        Some(r) if r.frozen => "frozen",
        _ => "ok",
    };
    let ts = now();
    let msg = format!("{}:{}:{}", req.account, state, ts);
    let sig = app.signing_key.sign(msg.as_bytes());
    Ok(Json(StatusResp { state: state.to_string(), ts, sig: B64.encode(sig.to_bytes()) }))
}

// ---- Device binding (layer 2) ----

#[derive(Deserialize)]
struct BindReq {
    account: String,
    device_id: String,
    pubkey: String,
    sig: String,
    #[serde(default)]
    backing: String,
}

/// Lowercase hex SHA-256 of the bearer token — the only form we ever persist.
fn token_hash(tok: &str) -> String {
    let d = Sha256::digest(tok.as_bytes());
    d.iter().map(|b| format!("{b:02x}")).collect()
}

fn bearer(headers: &axum::http::HeaderMap) -> Option<String> {
    headers.get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.strip_prefix("Bearer "))
        .map(|s| s.to_string())
}

/// Bind this access token to a hardware key. The signature proves the caller holds
/// a private key that cannot leave the phone's secure element; the bearer check
/// proves the token is really this account's. Element can do the second but never
/// the first, which is the whole point of layer 2.
async fn device_bind(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<BindReq>)
    -> StatusCode {
    let Some(tok) = bearer(&headers) else { return StatusCode::UNAUTHORIZED };
    if !verify(&app, &headers, &req.account).await { return StatusCode::UNAUTHORIZED }
    let th = token_hash(&tok);
    // Idempotent: re-binding the same token is a no-op, so a client may call this
    // on every launch without special-casing "already bound".
    if app.devices.get(th.as_bytes()).ok().flatten().is_some() { return StatusCode::NO_CONTENT }
    let (Ok(pk_raw), Ok(sig_raw)) = (B64.decode(req.pubkey.as_bytes()), B64.decode(req.sig.as_bytes()))
        else { return StatusCode::BAD_REQUEST };
    let Ok(vk) = P256Key::from_sec1_bytes(&pk_raw) else { return StatusCode::BAD_REQUEST };
    // Both platforms hand us DER-encoded ECDSA signatures.
    let Ok(sig) = P256Sig::from_der(&sig_raw) else { return StatusCode::BAD_REQUEST };
    let msg = format!("veilx-device-bind-v1\n{}\n{}\n{}", req.account, req.device_id, th);
    if vk.verify(msg.as_bytes(), &sig).is_err() { return StatusCode::BAD_REQUEST }
    let b = Bound {
        account: req.account, device_id: req.device_id, pubkey: req.pubkey,
        backing: if req.backing.is_empty() { "unknown".into() } else { req.backing },
        bound_at: now(), last_seen: now(),
    };
    let _ = app.devices.insert(th.as_bytes(), serde_json::to_vec(&b).unwrap());
    let _ = app.devices.flush_async().await;
    StatusCode::NO_CONTENT
}

/// Caddy forward_auth target: is the token on this request bound to a hardware
/// device? Called on every /_matrix/client/* request, so it must stay cheap —
/// one sled lookup, no homeserver round-trip.
///
/// `last_seen` is refreshed at most once a minute: without that throttle a busy
/// /sync loop would rewrite the same row constantly for no operational gain.
/// 第三层 · 联邦守卫 —— Caddy 对每个 `/_matrix/federation/*` 请求 forward_auth 到这里。
///
/// ## 要解决的问题
/// Matrix 的联邦是**全网开放**的:一旦 `allow_federation = true`,任何一台 Matrix
/// 服务器都能和本站互通,于是 matrix.org 上的 Element 用户可以直接私聊 VeilX 用户。
/// 而产品要的是"**VeilX 服务器之间**可以互通,别家客户端一律进不来" ——
/// 中间这一档在原生 Matrix 里不存在,要自己加。
///
/// ## 判据:对端也得持有厂商签发的证书
/// 复用第一层那套(客户端连服务器时验的就是它):去拉对端的
/// `https://<域名>/.well-known/veilx/license.json`,用**同一把**厂商公钥验签。
///  - 另一台跑本脚本部署的 VeilX 服务器 → 有证书 → 放行
///  - matrix.org / 任何普通 Matrix 服务器 → 签不出来 → 拒绝
///
/// 伪造不了:厂商私钥只在签发服务上;冒充域名也没用 —— 攻击者得能在**那个域名**
/// 上放出一份**对该域名签发**的证书(`domain` 字段进签名输入)。
///
/// 证书里的 `fed` 字段由厂商签发时决定这台服务器允不允许联邦 —— 这条能力可以按
/// 客户档位/合规要求发放,而不只是本机的一个开关。
async fn fed_check(State(app): State<Arc<App>>, headers: axum::http::HeaderMap) -> StatusCode {
    if !app.fed_guard { return StatusCode::NO_CONTENT }
    let Some(origin) = fed_origin(&headers) else {
        // 联邦请求必须带 X-Matrix 授权头。没有 = 不是合法的联邦调用。
        return StatusCode::FORBIDDEN
    };
    // 缓存:联邦流量很密,不能每条都去拉一次证书。放行缓存 1 小时,拒绝缓存 5 分钟
    // (拒绝的缓存短一些,好让对方续期后较快恢复)。
    let t = now();
    if let Ok(Some(raw)) = app.fed_cache.get(origin.as_bytes()) {
        if let Ok((ok, until)) = serde_json::from_slice::<(bool, u64)>(&raw) {
            if t < until { return if ok { StatusCode::NO_CONTENT } else { StatusCode::FORBIDDEN } }
        }
    }
    let ok = fed_licensed(&app, &origin).await;
    let ttl = if ok { 3600 } else { 300 };
    if let Ok(v) = serde_json::to_vec(&(ok, t + ttl)) {
        let _ = app.fed_cache.insert(origin.as_bytes(), v);
    }
    if ok { StatusCode::NO_CONTENT } else { StatusCode::FORBIDDEN }
}

/// 从 `Authorization: X-Matrix origin="a.org",destination=...,key=...,sig=...`
/// 里取出对端服务器名。规范允许带引号也允许不带。
fn fed_origin(headers: &axum::http::HeaderMap) -> Option<String> {
    let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
    let rest = raw.strip_prefix("X-Matrix ")?;
    for part in rest.split(',') {
        if let Some(v) = part.trim().strip_prefix("origin=") {
            let v = v.trim().trim_matches('"');
            if v.is_empty() { return None }
            // 只接受域名字符,挡住把 URL/路径塞进来的花招。
            if !v.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == ':') {
                return None
            }
            return Some(v.to_string())
        }
    }
    None
}

/// 拉对端证书并验厂商签名。任何一步不成立都算"没证书"。
async fn fed_licensed(app: &App, origin: &str) -> bool {
    let Some(vk) = app.vendor_pubkey.as_ref() else { return false };
    let host = origin.split(':').next().unwrap_or(origin);
    let url = format!("https://{origin}/.well-known/veilx/license.json");
    let Ok(resp) = app.http.get(&url)
        .timeout(std::time::Duration::from_secs(8))
        .header("User-Agent", "VeilX-Fed/1.0")
        .send().await else { return false };
    if !resp.status().is_success() { return false }
    let Ok(v) = resp.json::<serde_json::Value>().await else { return false };
    let g = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
    let (domain, not_after, issuer, sig_b64) = (g("domain"), g("not_after"), g("issuer"), g("sig"));
    // fed 在证书里是数字,序列化成字符串比较(与签发端的签名输入一致)。
    let fed = match v.get("fed") { Some(x) => x.to_string().trim_matches('"').to_string(), None => "0".into() };
    // 证书必须是**签给这个域名**的,否则任何人都能转贴别人的证书。
    if domain != host { return false }
    // 这台服务器被签发时就没获准联邦。
    if fed != "1" { return false }
    // not_after 是 YYYY-MM-DD 零填充,字典序即时间序;当天仍有效。
    if not_after.as_str() < today_utc().as_str() { return false }
    // 签名输入必须与签发端逐字节一致(规范 §3)。
    let msg = format!("veilx-license-v1\n{domain}\n{not_after}\n{fed}\n{issuer}");
    let Ok(sig_raw) = B64.decode(sig_b64.as_bytes()) else { return false };
    let Ok(sig_arr) = <[u8; 64]>::try_from(sig_raw.as_slice()) else { return false };
    vk.verify_strict(msg.as_bytes(), &Ed25519Signature::from_bytes(&sig_arr)).is_ok()
}

/// 今天的 UTC 日期,YYYY-MM-DD。只为和证书里的 not_after 做字典序比较,
/// 不引入 chrono 这种大依赖。
fn today_utc() -> String {
    let mut d = (now() / 86400) as i64;
    let mut y = 1970i64;
    loop {
        let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
        let len = if leap { 366 } else { 365 };
        if d < len { break }
        d -= len; y += 1;
    }
    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
    let ml = [31i64, if leap {29} else {28}, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    let mut m = 0usize;
    while d >= ml[m] { d -= ml[m]; m += 1; }
    format!("{:04}-{:02}-{:02}", y, m + 1, d + 1)
}

async fn device_check(State(app): State<Arc<App>>, headers: axum::http::HeaderMap) -> StatusCode {
    if app.bind_mode != BindMode::Strict { return StatusCode::NO_CONTENT }
    let Some(tok) = bearer(&headers) else { return StatusCode::FORBIDDEN };
    let th = token_hash(&tok);
    match app.devices.get(th.as_bytes()).ok().flatten() {
        Some(raw) => {
            if let Ok(mut b) = serde_json::from_slice::<Bound>(&raw) {
                let t = now();
                if t.saturating_sub(b.last_seen) >= 60 {
                    b.last_seen = t;
                    let _ = app.devices.insert(th.as_bytes(), serde_json::to_vec(&b).unwrap());
                }
            }
            StatusCode::NO_CONTENT
        }
        None => StatusCode::FORBIDDEN,
    }
}

#[derive(Serialize)]
struct AdminDevice {
    account: String, device_id: String, backing: String,
    bound_at: u64, last_seen: u64,
    /// Handle for unbinding; it IS the token hash, which is not a credential.
    token_hash: String,
}
#[derive(Deserialize)] struct UnbindReq { token_hash: String }
#[derive(Serialize)] struct BindModeResp { mode: String }

/// Every bound device, for the admin console. No key material, no tokens.
async fn admin_devices(State(app): State<Arc<App>>, headers: axum::http::HeaderMap)
    -> Result<Json<Vec<AdminDevice>>, StatusCode> {
    if !verify_admin(&app, &headers).await { return Err(StatusCode::UNAUTHORIZED) }
    let mut out = Vec::new();
    for kv in app.devices.iter() {
        let Ok((k, v)) = kv else { continue };
        let Ok(b) = serde_json::from_slice::<Bound>(&v) else { continue };
        out.push(AdminDevice {
            account: b.account, device_id: b.device_id, backing: b.backing,
            bound_at: b.bound_at, last_seen: b.last_seen,
            token_hash: String::from_utf8_lossy(&k).to_string(),
        });
    }
    Ok(Json(out))
}

/// Drop one binding. Under strict mode this is an instant, irreversible sign-out:
/// that token can never pass the gate again, and no other client can take over the
/// session, because binding needs the phone's non-exportable key.
async fn admin_device_unbind(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<UnbindReq>)
    -> StatusCode {
    if !verify_admin(&app, &headers).await { return StatusCode::UNAUTHORIZED }
    match app.devices.remove(req.token_hash.as_bytes()) {
        Ok(Some(_)) => { let _ = app.devices.flush_async().await; StatusCode::NO_CONTENT }
        Ok(None) => StatusCode::NOT_FOUND,
        Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
    }
}

/// Current enforcement tier, so the console can show it (and warn before strict).
async fn admin_bind_mode(State(app): State<Arc<App>>, headers: axum::http::HeaderMap)
    -> Result<Json<BindModeResp>, StatusCode> {
    if !verify_admin(&app, &headers).await { return Err(StatusCode::UNAUTHORIZED) }
    Ok(Json(BindModeResp { mode: match app.bind_mode {
        BindMode::Strict => "strict", BindMode::Observe => "observe", BindMode::Off => "off",
    }.to_string() }))
}

// ---- Operator (server-admin) HTTP surface ----
// Mirrors the `--admin-*` CLI, but authenticated as a **server admin** and run
// against the live process, so the VeilX admin console can freeze/kill/list any
// account with no downtime (the CLI needs the container stopped — sled is
// single-writer). Reachable at `/oprf/admin/*`.
#[derive(Deserialize)] struct AdminReq { account: String }
#[derive(Serialize)]   struct AdminAccount { account: String, state: String, count: u32, last_seen: u64 }

/// The bearer token must belong to a **server admin** — verified by calling an
/// admin-only homeserver endpoint with it (200 = admin, 403 = not). This reuses
/// tuwunel's own admin gate instead of keeping a second list of who's an operator.
async fn verify_admin(app: &App, headers: &axum::http::HeaderMap) -> bool {
    let Some(tok) = headers.get(axum::http::header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok()).and_then(|s| s.strip_prefix("Bearer ")) else { return false };
    let url = format!("{}/_synapse/admin/v2/users?from=0&limit=1&guests=false",
                      app.homeserver.trim_end_matches('/'));
    match app.http.get(&url).bearer_auth(tok).send().await {
        Ok(resp) => resp.status().is_success(),
        Err(_) => false,
    }
}

/// account + state + eval count + last_seen — never any key material.
async fn admin_list(State(app): State<Arc<App>>, headers: axum::http::HeaderMap)
    -> Result<Json<Vec<AdminAccount>>, StatusCode> {
    if !verify_admin(&app, &headers).await { return Err(StatusCode::UNAUTHORIZED) }
    let mut out = Vec::new();
    for kv in app.db.iter() {
        let Ok((k, v)) = kv else { continue };
        let Ok(r) = serde_json::from_slice::<Record>(&v) else { continue };
        let state = if r.killed { "destroyed" } else if r.frozen { "frozen" } else { "ok" };
        out.push(AdminAccount {
            account: String::from_utf8_lossy(&k).to_string(),
            state: state.to_string(), count: r.count, last_seen: r.last_seen,
        });
    }
    Ok(Json(out))
}

/// Reversible hold on someone else's account (keeps k). CONFLICT if already destroyed.
async fn admin_freeze(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<AdminReq>) -> StatusCode {
    set_frozen(&app, &headers, &req.account, true).await
}
async fn admin_unfreeze(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<AdminReq>) -> StatusCode {
    set_frozen(&app, &headers, &req.account, false).await
}
async fn set_frozen(app: &Arc<App>, headers: &axum::http::HeaderMap, account: &str, freeze: bool) -> StatusCode {
    if !verify_admin(app, headers).await { return StatusCode::UNAUTHORIZED }
    match app.db.get(account.as_bytes()).ok().flatten()
        .and_then(|b| serde_json::from_slice::<Record>(&b).ok()) {
        Some(mut r) if !r.killed => {
            r.frozen = freeze;
            let _ = app.db.insert(account.as_bytes(), serde_json::to_vec(&r).unwrap());
            let _ = app.db.flush_async().await;
            StatusCode::NO_CONTENT
        }
        Some(_) => StatusCode::CONFLICT,   // already destroyed — nothing to (un)freeze
        None => StatusCode::NOT_FOUND,
    }
}

/// Destroy someone else's k — permanent, irreversible. Operator-only.
async fn admin_kill(State(app): State<Arc<App>>, headers: axum::http::HeaderMap, Json(req): Json<AdminReq>) -> StatusCode {
    if !verify_admin(&app, &headers).await { return StatusCode::UNAUTHORIZED }
    let r = Record { k: [0u8; 32], window_start: now(), count: 0, killed: true, frozen: false, last_seen: 0 };
    let _ = app.db.insert(req.account.as_bytes(), serde_json::to_vec(&r).unwrap());
    let _ = app.db.flush_async().await;
    drop_bindings(&app, &req.account).await;
    StatusCode::NO_CONTENT
}

/// Destroying an account also revokes its device bindings (spec §9.6): under
/// strict mode that stops the phone dead at the gateway, without waiting for it
/// to poll /oprf/status and act on the verdict itself.
async fn drop_bindings(app: &Arc<App>, account: &str) {
    let doomed: Vec<Vec<u8>> = app.devices.iter().filter_map(|kv| kv.ok())
        .filter(|(_, v)| serde_json::from_slice::<Bound>(v)
            .map(|b| b.account == account).unwrap_or(false))
        .map(|(k, _)| k.to_vec()).collect();
    for k in doomed { let _ = app.devices.remove(k); }
    let _ = app.devices.flush_async().await;
}

/// Load the Ed25519 signing key from `path`, generating+persisting it on first run.
/// Kept next to the sled db (0600, in the same LUKS-backed volume).
fn load_or_make_key(path: &str) -> SigningKey {
    if let Ok(bytes) = std::fs::read(path) {
        if let Ok(arr) = <[u8; 32]>::try_from(bytes.as_slice()) {
            return SigningKey::from_bytes(&arr);
        }
    }
    let key = SigningKey::generate(&mut OsRng);
    let _ = std::fs::write(path, key.to_bytes());
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
    }
    key
}

/// Local admin operations (run by the installer, never exposed over HTTP).
/// Deliberately has no way to print k: the key must never leave the database.
fn admin(db: &sled::Db, args: &[String]) {
    let load = |a: &str| -> Option<Record> {
        db.get(a.as_bytes()).ok().flatten().and_then(|b| serde_json::from_slice(&b).ok())
    };
    let save = |a: &str, r: &Record| {
        let _ = db.insert(a.as_bytes(), serde_json::to_vec(r).unwrap());
        let _ = db.flush();
    };
    match args[1].as_str() {
        // account<TAB>state<TAB>used/limit<TAB>last_seen — no key material.
        "--admin-list" => {
            for kv in db.iter() {
                let Ok((k, v)) = kv else { continue };
                let acct = String::from_utf8_lossy(&k).to_string();
                let Ok(r) = serde_json::from_slice::<Record>(&v) else { continue };
                let state = if r.killed { "destroyed" } else if r.frozen { "frozen" } else { "ok" };
                println!("{}\t{}\t{}\t{}", acct, state, r.count, r.last_seen);
            }
        }
        "--admin-freeze" | "--admin-unfreeze" => {
            let freeze = args[1] == "--admin-freeze";
            match load(&args[2]) {
                Some(mut r) if !r.killed => { r.frozen = freeze; save(&args[2], &r); eprintln!("ok"); }
                Some(_) => eprintln!("already destroyed"),
                None => eprintln!("no such account"),
            }
        }
        "--admin-kill" => {
            let r = Record { k: [0u8; 32], window_start: now(), count: 0, killed: true, frozen: false, last_seen: 0 };
            save(&args[2], &r);
            eprintln!("killed {}", args[2]);
        }
        // Org-wide destruction, for a company-level security incident.
        "--admin-kill-all" => {
            let mut n = 0;
            let accts: Vec<String> = db.iter().filter_map(|kv| kv.ok())
                .map(|(k, _)| String::from_utf8_lossy(&k).to_string()).collect();
            for a in accts {
                let r = Record { k: [0u8; 32], window_start: now(), count: 0, killed: true, frozen: false, last_seen: 0 };
                save(&a, &r); n += 1;
            }
            eprintln!("killed {n}");
        }
        // Idle reaper: act on accounts not seen for N days. Meant to run from cron,
        // so a device that has been out of contact far longer than any normal absence
        // is handled automatically, without an operator having to be online.
        //
        // ⚠️ **FREEZES by default, does not destroy.** An unattended cron job that
        // permanently destroys data is the wrong default: parental leave, a long
        // holiday, a hospital stay or a spare phone in a drawer all look exactly like
        // a lost device from here. Freezing is reversible (`--admin-unfreeze`), costs
        // the person one call to IT, and still blocks the device from unlocking —
        // which is the whole point. `--admin-reap-destroy` keeps the irreversible
        // behaviour for whoever deliberately wants it.
        //
        // The <7d floor exists for the same reason: a week is shorter than any normal
        // absence, and anything below that is almost certainly a misconfigured cron.
        "--admin-reap" | "--admin-reap-destroy" => {
            let destroy = args[1] == "--admin-reap-destroy";
            let days: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
            if days == 0 {
                eprintln!("usage: --admin-reap <days>            (freeze, reversible)");
                eprintln!("       --admin-reap-destroy <days>    (destroy, PERMANENT)");
                return;
            }
            if days < 7 {
                eprintln!("refusing: <7 days is shorter than a normal absence \
                           (holiday / leave / spare device) — pick a longer window");
                return;
            }
            let cutoff = now().saturating_sub(days * 86400);
            let mut n = 0;
            let victims: Vec<(String, Record)> = db.iter().filter_map(|kv| kv.ok())
                .filter_map(|(k, v)| {
                    let r: Record = serde_json::from_slice(&v).ok()?;
                    // Only act on enrolled, still-alive accounts with a real last_seen.
                    // Already-frozen ones are skipped so a daily cron stays quiet.
                    if !r.killed && !r.frozen && r.last_seen != 0 && r.last_seen < cutoff {
                        Some((String::from_utf8_lossy(&k).to_string(), r))
                    } else { None }
                }).collect();
            for (a, mut r) in victims {
                if destroy {
                    r = Record { k: [0u8; 32], window_start: now(), count: 0,
                                 killed: true, frozen: false, last_seen: 0 };
                } else {
                    r.frozen = true;   // keep k — this must stay undoable
                }
                save(&a, &r); n += 1;
                eprintln!("{} {a}", if destroy { "destroyed" } else { "froze" });
            }
            eprintln!("{} {n} account(s) idle > {days}d",
                      if destroy { "destroyed" } else { "froze" });
        }
        _ => eprintln!("unknown admin command"),
    }
}

#[tokio::main]
async fn main() {
    let args: Vec<String> = std::env::args().collect();
    let env = |k: &str, d: &str| std::env::var(k).unwrap_or_else(|_| d.to_string());
    let db_path = env("DB", "./oprf.db");
    // Don't panic-loop if an admin one-off container is briefly holding the lock:
    // wait for it to release (up to ~30s) instead of crashing and restarting.
    // A crash-loop here locks out EVERY highest-tier phone, so recover gracefully.
    let db = {
        let mut opened = None;
        for _ in 0..60 {
            match sled::open(&db_path) {
                Ok(d) => { opened = Some(d); break; }
                Err(_) => std::thread::sleep(std::time::Duration::from_millis(500)),
            }
        }
        opened.expect("open db (lock still held after 30s)")
    };
    // Local admin operations, run by the installer (see `admin`).
    if args.len() >= 2 && args[1].starts_with("--admin-") {
        admin(&db, &args);
        return;
    }
    // 默认与数据库同目录：docker 下是 /data/signing.key，独立部署是 <DIR>/data/signing.key。
    // 两种部署因此都不必单独配这一项（仍可用 SIGNING_KEY 覆盖）。
    let default_signing = std::path::Path::new(&db_path)
        .parent()
        .map(|p| p.join("signing.key"))
        .unwrap_or_else(|| std::path::PathBuf::from("signing.key"));
    let signing_key = load_or_make_key(&env("SIGNING_KEY", &default_signing.to_string_lossy()));
    // Layer 2. Default OFF on purpose: enabling strict before every phone runs a
    // binding-capable build would lock the entire team out of the server.
    let bind_mode = BindMode::parse(&env("DEVICE_BIND", "0"));
    // 第三层:联邦只对持证(VeilX)服务器开放。默认**关** —— 和第二层同一个理由:
    // 开着而对端还没证书 = 联邦整个断掉。由 `sudo tuwunel fed-guard` 切换。
    let fed_guard = matches!(env("FED_GUARD", "0").as_str(), "1" | "strict");
    let vendor_pubkey = B64.decode(env("VENDOR_PUBKEY", "").as_bytes()).ok()
        .and_then(|b| <[u8; 32]>::try_from(b.as_slice()).ok())
        .and_then(|a| VerifyingKey::from_bytes(&a).ok());
    if fed_guard && vendor_pubkey.is_none() {
        // 宁可明说也不要"看起来开着、其实全放行"。
        eprintln!("veilx-oprf-guard: FED_GUARD=1 但 VENDOR_PUBKEY 缺失/无效 —— 联邦将全部拒绝");
    }
    let devices = db.open_tree("devices").expect("open devices tree");
    let fed_cache = db.open_tree("fedcache").expect("open fedcache tree");
    let app = Arc::new(App {
        db, http: reqwest::Client::new(),
        homeserver: env("HOMESERVER", "https://localhost"),
        rate_limit: env("RATE_LIMIT", "30").parse().unwrap_or(30),
        rate_window: env("RATE_WINDOW_SECS", "3600").parse().unwrap_or(3600),
        signing_key, bind_mode, devices, fed_guard, vendor_pubkey, fed_cache,
    });
    let bind = env("BIND", "127.0.0.1:8787");
    let router = Router::new()
        .route("/oprf/eval", post(eval))
        .route("/oprf/kill", post(kill))
        .route("/oprf/pubkey", get(pubkey))
        .route("/oprf/status", post(status))
        .route("/oprf/device/bind", post(device_bind))
        .route("/oprf/device/check", get(device_check))
        .route("/oprf/fed/check", get(fed_check))
        .route("/oprf/admin/list", get(admin_list))
        .route("/oprf/admin/freeze", post(admin_freeze))
        .route("/oprf/admin/unfreeze", post(admin_unfreeze))
        .route("/oprf/admin/kill", post(admin_kill))
        .route("/oprf/admin/devices", get(admin_devices))
        .route("/oprf/admin/device/unbind", post(admin_device_unbind))
        .route("/oprf/admin/bind-mode", get(admin_bind_mode))
        .with_state(app);
    let listener = tokio::net::TcpListener::bind(&bind).await.expect("bind");
    eprintln!("veilx-oprf-guard listening on {bind} (device-bind: {}, fed-guard: {})",
              match bind_mode { BindMode::Strict => "strict", BindMode::Observe => "observe", BindMode::Off => "off" },
              if fed_guard { "on" } else { "off" });
    axum::serve(listener, router).await.unwrap();
}
EOF
}

# 在 .env 里写/改一个键(不重写整个文件,供子命令单独调用)
# 用 awk 而非 sed:值里可能含 | & \ 等 sed 替换串的元字符(域名列表、dest 的
# 冒号、随手输入的竖线),用 sed 会被解释甚至让脚本在 set -e 下直接中断。
px_env_set() {
  local k="$1" v="$2" f="$INSTALL_DIR/.env" tmp
  [ -f "$f" ] || return 1
  tmp="$f.tmp$$"
  KEY="$k" VAL="$v" awk '
    BEGIN { k=ENVIRON["KEY"]; v=ENVIRON["VAL"]; done=0 }
    index($0, k "=") == 1 { if (!done) { print k "=" v; done=1 } ; next }
    { print }
    END { if (!done) print k "=" v }
  ' "$f" > "$tmp" && mv "$tmp" "$f" || { rm -f "$tmp"; return 1; }
  chmod 600 "$f" 2>/dev/null || true
}

# 借用 xray 镜像本身生成密钥,不往宿主机装任何东西
px_xray_run() { docker run --rm "$XRAY_IMAGE" "$@" 2>/dev/null; }

# 由 clients.tsv 重新生成 xray/config.json
# ---- 可调参数(存 .env,均有安全默认值) ----
# PROXY_PORT   监听端口。443 时接管 Caddy 的 443(伪装最好);其它端口则不动 Caddy。
# PROXY_DEST   REALITY 借用的目标。留空=本机真实站点(caddy:443,推荐)
# PROXY_SNI    serverNames,逗号分隔。留空=本机域名 + matrix 子域
# PROXY_FLOW   xtls-rprx-vision(默认) 或 空
# PROXY_FP     uTLS 指纹:chrome(默认)/firefox/safari/edge/ios/android/random
px_port() { local v; v="$(env_saved PROXY_PORT)"; case "$v" in ''|*[!0-9]*) echo 443;; *) echo "$v";; esac; }
px_flow() { local v; v="$(env_saved PROXY_FLOW)"; [ -n "$v" ] && { [ "$v" = "none" ] && echo "" || echo "$v"; } || echo "xtls-rprx-vision"; }
px_fp()   { local v; v="$(env_saved PROXY_FP)";   [ -n "$v" ] && echo "$v" || echo "chrome"; }
px_dest() { local v; v="$(env_saved PROXY_DEST)"; [ -n "$v" ] && echo "$v" || echo "caddy:443"; }
# serverNames:自定义优先,否则本机域名 + matrix 子域
px_sni_list() {
  local v dom; v="$(env_saved PROXY_SNI)"; dom="$(env_saved MATRIX_DOMAIN)"
  [ -n "$v" ] && echo "$v" || echo "$dom,matrix.$dom"
}
# 客户端连接时用的 SNI(取 serverNames 第一个)
px_sni_primary() { px_sni_list | cut -d, -f1; }

px_write_config() {
  local d; d="$(px_dir)"
  local dom pbk_priv sids port flow dest
  dom="$(env_saved MATRIX_DOMAIN)"
  pbk_priv="$(cat "$d/private.key" 2>/dev/null)"
  sids="$(cat "$d/shortids.txt" 2>/dev/null | tr '\n' ' ')"
  [ -n "$dom" ] && [ -n "$pbk_priv" ] || return 1
  port="$(px_port)"; flow="$(px_flow)"; dest="$(px_dest)"

  # clients 数组。
  # 两个必须去重的点:
  #  · email 必须唯一 —— 重复会让 xray 直接拒绝启动
  #    ("failed to initiate user > proxy/vless: User X already exists")
  #    所以在备注后面缀上 UUID 前 8 位,既唯一又能看出是谁。
  #  · UUID 本身也去重,防止同一台设备被加了两次。
  local cl="" uuid label rest first=1 flowfield="" seen=""
  [ -n "$flow" ] && flowfield="\"flow\":\"$flow\","
  while IFS="$(printf '\t')" read -r uuid label rest; do
    [ -n "$uuid" ] || continue
    case "$uuid" in \#*) continue;; esac
    case " $seen " in *" $uuid "*) continue;; esac   # 同一 UUID 只保留一次
    seen="$seen $uuid"
    [ $first -eq 1 ] || cl="$cl,"
    cl="$cl{\"id\":\"$uuid\",$flowfield\"email\":\"$(json_esc "${label:-client}-${uuid%%-*}")\"}"
    first=0
  done < "$(px_clients)"

  # shortIds 数组
  local si="" s; first=1
  for s in $sids; do
    [ $first -eq 1 ] || si="$si,"
    si="$si\"$s\""; first=0
  done
  [ -n "$si" ] || si='""'

  # serverNames 数组
  local sn="" n; first=1
  for n in $(px_sni_list | tr ',' ' '); do
    [ -n "$n" ] || continue
    [ $first -eq 1 ] || sn="$sn,"
    sn="$sn\"$(json_esc "$n")\""; first=0
  done
  [ -n "$sn" ] || sn="\"$dom\""

  cat > "$d/config.json" <<EOF
{
  "log": { "loglevel": "warning" },
  "inbounds": [
    {
      "tag": "veilx-reality",
      "listen": "0.0.0.0",
      "port": $port,
      "protocol": "vless",
      "settings": { "clients": [$cl], "decryption": "none" },
      "streamSettings": {
        "network": "tcp",
        "security": "reality",
        "realitySettings": {
          "show": false,
          "dest": "$(json_esc "$dest")",
          "xver": 0,
          "serverNames": [$sn],
          "privateKey": "$(json_esc "$pbk_priv")",
          "shortIds": [$si]
        }
      },
      "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"] }
    }
  ],
  "outbounds": [
    { "tag": "direct", "protocol": "freedom" },
    { "tag": "block",  "protocol": "blackhole" }
  ]
}
EOF
  # 官方 xray 镜像以非 root(uid 65532)运行,600 的 root 属主文件它读不了,
  # 容器会直接起不来。所以配置文件必须可被容器读取(644);私钥的保护靠
  # 外层目录 700(px_enable 里设置),宿主机上其它用户仍进不来。
  chmod 644 "$d/config.json" 2>/dev/null || true
}

# 生成 veilx:// 链接(标准 REALITY 参数,客户端内部还原成标准 vless://)
px_link() {
  local uuid="$1" label="${2:-VeilX}" d dom pbk sid host port flow fp sni extra
  d="$(px_dir)"; dom="$(env_saved MATRIX_DOMAIN)"
  pbk="$(cat "$d/public.key" 2>/dev/null)"
  sid="$(head -1 "$d/shortids.txt" 2>/dev/null)"
  host="$(env_saved PROXY_HOST)"; [ -n "$host" ] || host="$dom"
  port="$(px_port)"; flow="$(px_flow)"; fp="$(px_fp)"; sni="$(px_sni_primary)"
  extra=""; [ -n "$flow" ] && extra="&flow=$flow"
  printf 'veilx://proxy?proto=reality&server=%s&port=%s&id=%s&pbk=%s&sid=%s&sni=%s&fp=%s%s&type=tcp&name=%s&v=1' \
    "$host" "$port" "$uuid" "$pbk" "$sid" "$sni" "$fp" "$extra" "$(px_urlenc "$label")"
}

# 百分号编码。必须逐【字节】处理:printf "'$c" 对 >127 的字节在 bash 里
# 是有符号的,中文会被编成 %FFFFFFFFFFFFFFE6 这种垃圾。这里先用 od 拿到
# 十六进制字节,再按 ASCII 码值判断是否需要转义,与语言环境无关。
px_urlenc() {
  local s="$1" out="" h d
  for h in $(printf '%s' "$s" | od -An -tx1 -v | tr -s ' \n' ' '); do
    [ -n "$h" ] || continue
    d=$((16#$h))
    if { [ "$d" -ge 48 ] && [ "$d" -le 57 ]; } \
      || { [ "$d" -ge 65 ] && [ "$d" -le 90 ]; } \
      || { [ "$d" -ge 97 ] && [ "$d" -le 122 ]; } \
      || [ "$d" -eq 45 ] || [ "$d" -eq 46 ] || [ "$d" -eq 95 ] || [ "$d" -eq 126 ]; then
      out="$out$(printf "\\$(printf '%03o' "$d")")"
    else
      out="$out$(printf '%%%02X' "$d")"
    fi
  done
  printf '%s' "$out"
}

px_qr() {
  command -v qrencode >/dev/null 2>&1 || {
    echo "$(L "  (installing qrencode for the QR code…)" "  (正在安装 qrencode 以显示二维码…)")"
    DEBIAN_FRONTEND=noninteractive apt-get install -y -qq qrencode >/dev/null 2>&1 || true
  }
  command -v qrencode >/dev/null 2>&1 && qrencode -t ANSIUTF8 -m 1 "$1" 2>/dev/null || true
}

px_print_client() {
  local uuid="$1" label="$2" link
  link="$(px_link "$uuid" "$label")"
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== VeilX proxy link ==" "== VeilX 代理链接 ==")" "$C_RESET"
  printf '  %s: %s%s%s\n' "$(L Label 备注)" "$C_B" "$label" "$C_RESET"
  printf '\n%s\n\n' "$link"
  px_qr "$link"
  echo "$(L "  Scan it in VeilX (Settings → Proxy → Scan), or tap the link on the phone." "  在 VeilX 里扫码添加(设置 → 代理 → 扫码),或在手机上直接点这个链接。")"
}

# 读一行输入,答案打到 stdout(供 R="$(px_ask ...)" 捕获)。
# 注意:提示语必须走 stderr / dev/tty —— bash 的 `read -rp` 把提示写 stderr,
# 之前那种 `read -rp "..." R </dev/tty 2>/dev/null` 会把提示一起吞掉,
# 用户只看到光标卡住,完全不知道脚本在等什么。
px_ask() {
  local prompt="$1" ans=""
  if [ -t 0 ]; then
    # 交互终端:提示走 stderr(stdout 要留给返回值)
    printf '%s' "$prompt" >&2
    IFS= read -r ans || ans=""
  elif [ -e /dev/tty ] && (exec </dev/tty) 2>/dev/null; then
    # stdin 被重定向了(如 bash <(curl ...)),但仍有控制终端
    printf '%s' "$prompt" > /dev/tty 2>/dev/null || true
    IFS= read -r ans < /dev/tty || ans=""
  else
    # 完全非交互(管道/自动化):从 stdin 读一行,读不到就返回空走默认值
    printf '%s' "$prompt" >&2
    IFS= read -r ans || ans=""
  fi
  printf '%s' "$ans"
}

# 验证一个站点能不能当伪装目标。
# 这一步很重要:同样是知名大站,能不能用差别极大(实测 www.apple.com /
# dl.google.com / www.irs.gov 可用,而 www.microsoft.com 不可用),而且选错
# 之后客户端只会显示"超时",完全看不出原因。要求:TLS1.3 + H2 + 证书匹配。
# 粗筛,不是保证。REALITY 对 dest 的真实要求是:TLS1.3 + 用 X25519 密钥交换
# 且不触发 HelloRetryRequest + 支持 H2。这里用 openssl 尽量逼近(-groups X25519
# 是关键),但仍有站点能过粗筛却用不了 —— 实测 www.microsoft.com 就是 TLS1.3+H2
# 齐全却依然握手不成。所以默认走下面那份【实测验证过】的清单,自定义输入只挡
# 明显不合格的(比如只会 301 跳转的 irs.gov)。
px_check_dest() {
  local host="${1%%:*}" out="" TO=""
  # timeout(1) 是 GNU coreutils 的,Ubuntu 有、别的系统未必 —— 没有就不加,
  # 否则整条命令会因 "command not found" 而被误判成"站点不可用"。
  command -v timeout >/dev/null 2>&1 && TO="timeout 12"
  if command -v openssl >/dev/null 2>&1; then
    out="$(echo | $TO openssl s_client -connect "$host:443" -servername "$host" \
            -tls1_3 -groups X25519 -alpn h2 2>&1)"
    # openssl 版本太老不认这些参数时,别误判成"不可用"
    if echo "$out" | grep -qiE "unknown option|unrecognized|Usage"; then out=""; fi
    if [ -n "$out" ]; then
      echo "$out" | grep -q "TLSv1.3" || return 1
      echo "$out" | grep -qi "ALPN protocol: h2" || return 1
      return 0
    fi
  fi
  # 退路:至少确认它是个 HTTP/2 的正常站点,而不是跳转页
  command -v curl >/dev/null 2>&1 || return 0
  local v; v="$(curl -sI --max-time 12 -o /dev/null -w '%{http_version} %{http_code}' "https://$host/" 2>/dev/null)"
  case "$v" in 2\ 200|2\ 30*) return 0 ;; *) return 1 ;; esac
}

# 放行代理端口。80/443 一般默认开着,但 8443 这种非标端口几乎总是被挡:
# 既可能是机器上的 ufw/firewalld,也可能是云厂商控制台里的安全组 —— 后者
# 我们改不了,只能明确告诉用户去开,否则手机永远连不上而且毫无提示。
px_open_firewall() {
  local port="$1" opened=""
  if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -qi "^Status: active"; then
    ufw allow "$port"/tcp >/dev/null 2>&1 && opened="ufw"
  elif command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state >/dev/null 2>&1; then
    firewall-cmd --permanent --add-port="$port"/tcp >/dev/null 2>&1 \
      && firewall-cmd --reload >/dev/null 2>&1 && opened="firewalld"
  fi
  [ -n "$opened" ] && ok "$(L "Opened port $port in the local firewall ($opened)." "已在本机防火墙放行 $port 端口($opened)。")"

  # 本机放行了不代表外网能进 —— 云厂商的安全组在机器之外。
  if [ "$port" != "443" ] && [ "$port" != "80" ]; then
    echo
    warn "$(L "IMPORTANT: port $port must also be allowed in your VPS provider's firewall / security group." "重要:$port 端口还必须在你的 VPS 服务商控制台(防火墙/安全组)里放行。")"
    echo "$(L "  Ports 80 and 443 are usually open by default; $port almost never is." "  80 和 443 通常默认开放,$port 基本上一定是关的。")"
    echo "$(L "  Without that, your phone will simply time out with no useful error." "  不放行的话,手机只会一直超时,而且看不出原因。")"
  fi
}

# 运行状态自检。用大白话说结论,不堆术语 —— 用户要的是
# 「现在到底能不能用」,而不是一堆容器名和端口号。
px_status() {
  cd "$INSTALL_DIR" 2>/dev/null || return 1
  local dom port running
  dom="$(env_saved MATRIX_DOMAIN)"; port="$(px_port)"
  printf '\n%s%s%s\n\n' "$C_B$C_CYAN" "$(L "== Status ==" "== 运行状态 ==")" "$C_RESET"

  # 1) 代理本身
  if docker compose ps --status running -q xray 2>/dev/null | grep -q .; then
    ok "$(L "Proxy is running (port $port)" "代理正在运行(端口 $port)")"
    running=1
  else
    warn "$(L "Proxy is NOT running" "代理没有在运行")"
    echo "   $(L "See why:" "查看原因:") docker compose logs --tail 30 xray"
    running=0
  fi

  # 2) 你的 Matrix 网站 —— 用户最关心的其实是这个
  local code
  code="$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 "https://$dom/" 2>/dev/null)"
  case "$code" in
    000|"") warn "$(L "Your Matrix site did NOT respond!" "你的 Matrix 网站没有响应!")"
            if [ "$port" = "443" ]; then
              echo "   $(L "The proxy shares port 443 with your site. Roll back with:" "代理和网站共用 443 端口。回滚:")"
              echo "   sudo tuwunel proxy disable"
            fi ;;
    *)      ok "$(L "Your Matrix site is up (HTTP $code)" "你的 Matrix 网站正常(HTTP $code)")" ;;
  esac

  # 3) 端口是否真的从外网进得来 —— 容器在跑 ≠ 手机连得上。
  #    绝大多数"连不上"其实卡在云厂商安全组,而不是软件本身。
  if [ "$running" = "1" ]; then
    local ip reach
    ip="$(env_saved PUBLIC_IP)"
    [ -n "$ip" ] || ip="$(curl -s --max-time 8 https://api.ipify.org 2>/dev/null)"
    if [ -n "$ip" ] && command -v curl >/dev/null 2>&1; then
      # 从本机绕到公网 IP 打自己的端口:能通说明外部也能通。
      if timeout 10 bash -c "</dev/tcp/$ip/$port" 2>/dev/null; then
        ok "$(L "Port $port is reachable from outside." "$port 端口从外部可以连通。")"
      else
        warn "$(L "Port $port is NOT reachable from outside — this is why phones can't connect." "$port 端口从外部【连不通】—— 手机连不上就是因为这个。")"
        echo "   $(L "Open it in your VPS provider's firewall / security group." "请到 VPS 服务商控制台的防火墙/安全组里放行它。")"
        echo "   $(L "Or switch to port 443 (menu → 9 → Advanced), which is usually already open." "或者改用 443 端口(菜单 → 9 高级设置),那个通常已经开着。")"
      fi
    fi
  fi

  # 4) 设备数
  local n=0
  [ -f "$(px_clients)" ] && n="$(grep -c . "$(px_clients)" 2>/dev/null || echo 0)"
  echo "   $(L "Devices added:" "已添加设备:") $n"
  [ "$n" = "0" ] && echo "   $(L "Add one from the menu to get a QR code for your phone." "在菜单里添加一台,就能拿到手机扫的二维码。")"
  echo
}

# ---------------------------------------------------------------------
# 一键向导:面向不懂网络的用户。
# 只问一个真正需要人决定的问题(用哪个端口),其余全部自动:
# 密钥、UUID、shortId、借用目标(本机真站)、SNI、flow、uTLS 都用最优默认值。
# ---------------------------------------------------------------------
px_wizard() {
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — deploy the Matrix server first" "找不到 $INSTALL_DIR —— 请先部署 Matrix 服务器")"
  cd "$INSTALL_DIR"
  local dom; dom="$(env_saved MATRIX_DOMAIN)"
  [ -n "$dom" ] || die "$(L "Can't read your domain from .env" "读不到 .env 里的域名")"

  cat <<EOF

┌────────────────────────────────────────────────────────┐
│  $(L "Set up the VeilX proxy — guided" "VeilX 专用代理 · 一键向导")
└────────────────────────────────────────────────────────┘

$(L "This lets your phone reach this server even where it is blocked." "这能让你的手机在被封锁的网络里也能连上这台服务器。")
$(L "It disguises itself as your own website — a probe sees the real site." "它会伪装成你自己的网站 —— 别人探测时看到的就是你真实的站点。")

$(L "Everything is automatic (keys, IDs, camouflage target)." "密钥、账号、伪装目标等全部自动生成,你不用懂。")
$(L "You only need to answer ONE question:" "你只需要回答一个问题:")

EOF

  cat <<EOF
$(L "Which port should the proxy use?" "代理用哪个端口?")

  1) $(L "8443  — SAFE. Your Matrix site is NOT touched at all." "8443  —— 【安全】完全不动你的 Matrix 网站。")
     $(L "         Recommended for the first try." "         第一次用推荐选这个。")

  2) $(L "443   — BEST disguise, but your site briefly restarts and" "443   —— 【伪装最好】但你的网站会短暂重启,")
     $(L "         shares the port with the proxy." "         而且要和代理共用端口。")
     $(L "         Only pick this once 8443 already works." "         建议先用 8443 跑通了再换成这个。")

EOF
  local R; R="$(px_ask "$(L "Select [1-2, default 1]: " "请选择 [1-2,直接回车=1]: ")")"
  case "$R" in
    2) px_env_set PROXY_PORT 443
       echo; warn "$(L "Your website will restart briefly. If anything goes wrong, run: sudo tuwunel proxy disable" "你的网站会短暂重启。万一出问题,执行: sudo tuwunel proxy disable")" ;;
    *) px_env_set PROXY_PORT 8443
       echo; ok "$(L "Using 8443 — your Matrix site will not be affected." "使用 8443 —— 你的 Matrix 网站不受任何影响。")" ;;
  esac

  # ---- 伪装成哪个网站 ----
  cat <<EOF

$(L "The proxy disguises itself as some real HTTPS website." "代理会把自己伪装成某个真实的 HTTPS 网站。")
$(L "Anyone probing your server just sees that website." "别人探测你的服务器时,看到的就是那个网站。")

  1) $(L "A well-known public site (no domain of your own needed)" "借用一个知名网站(不需要你自己的域名)")
     $(L "         Keeps the proxy unrelated to your Matrix server." "         好处:代理和你的 Matrix 服务器互不相关。")

  2) $(L "This server's own Matrix site" "用这台服务器自己的 Matrix 网站")
     $(L "         Strongest consistency, but ties the proxy to your domain." "         一致性最强,但会把代理和你的域名绑在一起。")

EOF
  local C; C="$(px_ask "$(L "Select [1-2, default 1]: " "请选择 [1-2,直接回车=1]: ")")"
  if [ "$C" = "2" ]; then
    px_env_set PROXY_DEST "caddy:443"
    px_env_set PROXY_SNI  ""
    px_env_set PROXY_HOST ""
    ok "$(L "Disguising as your own Matrix site." "将伪装成你自己的 Matrix 站点。")"
  else
    # 逐个验证,选第一个真正可用的 —— 不能用的站会让客户端只报超时。
    local site="" cand
    echo "$(L "  Checking which sites work as a disguise…" "  正在检测哪些站点可用作伪装…")"
    for cand in www.apple.com dl.google.com www.irs.gov addons.mozilla.org www.cloudflare.com; do
      if px_check_dest "$cand"; then site="$cand"; echo "    ✓ $cand"; break; else echo "    ✗ $cand"; fi
    done
    if [ -z "$site" ]; then
      warn "$(L "No candidate worked — falling back to this server's own site." "没有可用的候选 —— 退回使用本机站点。")"
      px_env_set PROXY_DEST "caddy:443"; px_env_set PROXY_SNI ""; px_env_set PROXY_HOST ""
    else
      px_env_set PROXY_DEST "$site:443"
      px_env_set PROXY_SNI  "$site"
      # 不用域名了 —— 客户端直接连 IP。
      local myip; myip="$(env_saved PUBLIC_IP)"
      [ -n "$myip" ] || myip="$(curl -s --max-time 8 https://api.ipify.org 2>/dev/null)"
      [ -n "$myip" ] && px_env_set PROXY_HOST "$myip"
      ok "$(L "Disguising as $site — your domain is not involved." "将伪装成 $site —— 不涉及你的域名。")"
    fi
  fi

  px_env_set PROXY_FLOW "xtls-rprx-vision"
  px_env_set PROXY_FP   "chrome"

  echo
  echo "$(L "Setting up… (first run downloads the proxy image, a minute or two)" "正在部署…(首次会下载代理镜像,需要一两分钟)")"
  px_enable
}

# 交互式设置:端口 / 目标网站 / SNI / flow / uTLS 指纹(专家用)
px_config() {
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"
  cd "$INSTALL_DIR"
  local dom; dom="$(env_saved MATRIX_DOMAIN)"
  local R

  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== VeilX proxy settings ==" "== VeilX 专用代理设置 ==")" "$C_RESET"
  printf '  %s: %s\n' "$(L "Port" 端口)"       "$(px_port)"
  printf '  %s: %s\n' "$(L "Borrowed site" 借用目标)" "$(px_dest)"
  printf '  %s: %s\n' "$(L "SNI" SNI)"          "$(px_sni_list)"
  printf '  %s: %s\n' "$(L "flow" flow)"        "$(px_flow | sed 's/^$/(none)/')"
  printf '  %s: %s\n' "$(L "uTLS" uTLS)"        "$(px_fp)"
  echo

  # ---- 端口 ----
  echo "$(L "Listening port. 443 gives the best camouflage (Xray takes 443, Caddy keeps 80 and stays internal)." "监听端口。443 伪装最好(Xray 接管 443,Caddy 保留 80 并转内网)。")"
  echo "$(L "Any other port leaves Caddy's 443 untouched — less invasive, but a non-443 proxy port is more conspicuous." "填其它端口则完全不动 Caddy 的 443 —— 侵入性最小,但非 443 的代理端口更显眼。")"
  R="$(px_ask "$(L "Port [$(px_port)]: " "端口 [$(px_port)]: ")")"
  if [ -n "$R" ]; then
    case "$R" in ''|*[!0-9]*) warn "$(L "Not a number, keeping current." "不是数字,保持原值。")";;
      *) [ "$R" -ge 1 ] && [ "$R" -le 65535 ] && px_env_set PROXY_PORT "$R" || warn "$(L "Out of range." "超出范围。")";; esac
  fi

  # ---- 借用目标 ----
  echo
  echo "$(L "Which real site should the proxy disguise itself as? Probes that aren't VeilX clients get forwarded there." "代理伪装成哪个真实网站?非 VeilX 客户端的探测会被转发过去。")"
  echo "  1) $(L "This server's own Matrix/Element site (recommended — the IP really does host it)" "本机真实的 Matrix/Element 站点(推荐 —— 这个 IP 确实托管它)")"
  echo "  2) $(L "An external site (e.g. www.microsoft.com:443)" "外部站点(如 www.microsoft.com:443)")"
  R="$(px_ask "$(L "Select [1-2, blank=keep]: " "请选择 [1-2,留空=不改]: ")")"
  case "$R" in
    1) px_env_set PROXY_DEST "caddy:443"; px_env_set PROXY_SNI ""
       ok "$(L "Using the local site." "已设为本机站点。")" ;;
    2) local DHOST=""
       DHOST="$(px_ask "$(L "  Target host:port (e.g. www.microsoft.com:443): " "  目标 主机:端口(如 www.microsoft.com:443): ")")"
       if [ -n "$DHOST" ]; then
         case "$DHOST" in *:*) :;; *) DHOST="$DHOST:443";; esac
         echo "$(L "  Checking $DHOST …" "  正在检测 $DHOST …")"
         if px_check_dest "$DHOST"; then
           px_env_set PROXY_DEST "$DHOST"
           px_env_set PROXY_SNI "${DHOST%%:*}"
           ok "$(L "Borrowing $DHOST" "已改为借用 $DHOST")"
         else
           warn "$(L "$DHOST can't be used as a disguise (needs TLS 1.3 + HTTP/2). Keeping the current setting." "$DHOST 不能用作伪装目标(需要 TLS 1.3 + HTTP/2)。保持原设置。")"
           echo "$(L "  Known-good: www.apple.com, dl.google.com, www.irs.gov, addons.mozilla.org" "  已验证可用: www.apple.com、dl.google.com、www.irs.gov、addons.mozilla.org")"
         fi
       fi ;;
  esac

  # ---- 自定义 SNI ----
  echo
  R="$(px_ask "$(L "serverNames (comma-separated, blank=keep) [$(px_sni_list)]: " "serverNames(逗号分隔,留空=不改)[$(px_sni_list)]: ")")"
  [ -n "$R" ] && px_env_set PROXY_SNI "$R"

  # ---- flow ----
  echo
  echo "$(L "flow: xtls-rprx-vision is the standard and recommended; 'none' disables it." "flow:xtls-rprx-vision 是标准且推荐;填 none 表示不用。")"
  R="$(px_ask "$(L "flow [$(px_flow | sed 's/^$/none/')]: " "flow [$(px_flow | sed 's/^$/none/')]: ")")"
  case "$R" in
    "") : ;;
    none|NONE) px_env_set PROXY_FLOW "none" ;;
    xtls-rprx-vision) px_env_set PROXY_FLOW "xtls-rprx-vision" ;;
    *) warn "$(L "Unknown flow, keeping current." "未知 flow,保持原值。")" ;;
  esac

  # ---- uTLS ----
  echo
  echo "$(L "uTLS fingerprint. Keep 'chrome' unless you know why: a rare fingerprint is a SMALLER crowd to hide in, not a safer one." "uTLS 指纹。没有特别理由就用 chrome —— 冷门指纹意味着可藏身的人群更小,不是更安全。")"
  R="$(px_ask "$(L "uTLS (chrome/firefox/safari/edge/ios/android/random) [$(px_fp)]: " "uTLS(chrome/firefox/safari/edge/ios/android/random)[$(px_fp)]: ")")"
  case "$R" in
    "") : ;;
    chrome|firefox|safari|edge|ios|android|random|randomized) px_env_set PROXY_FP "$R" ;;
    *) warn "$(L "Unknown fingerprint, keeping current." "未知指纹,保持原值。")" ;;
  esac

  echo
  if px_enabled; then
    echo "$(L "Applying…" "正在应用…")"
    px_write_config || die "$(L "Failed to write xray config" "写入 xray 配置失败")"
    px_apply_compose || die "$(L "compose update refused — settings saved but NOT applied; nothing was changed on disk." "compose 更新被拒绝 —— 设置已保存但【未应用】,磁盘上的编排文件没有改动。")"
    docker compose up -d --remove-orphans >/dev/null 2>&1 || true
    docker compose restart xray >/dev/null 2>&1 || true
    sleep 3
    # 必须确认 xray 真起来了:端口是 443 时 Caddy 已经让出 443,
    # 若 xray 崩溃循环则整站 HTTPS 都没人应答。
    if docker compose ps --status running -q xray 2>/dev/null | grep -q .; then
      ok "$(L "Applied. Re-issue client links so they carry the new settings: sudo tuwunel proxy list" "已应用。请重新分发客户端链接以带上新设置:sudo tuwunel proxy list")"
    else
      warn "$(L "xray did NOT come up with the new settings." "xray 用新设置【没能启动】。")"
      echo "  docker compose logs --tail 30 xray"
      if [ "$(px_port)" = "443" ]; then
        warn "$(L "Caddy has already released :443 — your site is DOWN until this is fixed. Roll back now with: sudo tuwunel proxy disable" "Caddy 已经让出 :443 —— 在修好之前你的站点是【下线】状态。立即回滚:sudo tuwunel proxy disable")"
      fi
    fi
  else
    ok "$(L "Saved. They take effect when you run: sudo tuwunel proxy enable" "已保存。执行 sudo tuwunel proxy enable 时生效。")"
  fi
}

px_add_client() {
  local label="${1:-}"
  [ -f "$(px_clients)" ] || die "$(L "Proxy not enabled yet — run: sudo tuwunel proxy enable" "尚未开启代理 —— 请先执行: sudo tuwunel proxy enable")"
  if [ -z "$label" ]; then
    local p; p="$(L "Label for this device (e.g. my-phone): " "这台设备的备注(如 my-phone): ")"
    if [ -t 0 ]; then read -rp "$p" label || true
    elif [ -e /dev/tty ]; then read -rp "$p" label < /dev/tty || true; fi
  fi
  label="$(printf '%s' "${label:-device}" | tr -d '\t\n')"
  # 备注重名会让列表分不清谁是谁(email 唯一性由 px_write_config 保证),
  # 这里自动加序号,体验上更清楚。
  if [ -f "$(px_clients)" ] && cut -f2 "$(px_clients)" 2>/dev/null | grep -qxF "$label"; then
    local _i=2
    while cut -f2 "$(px_clients)" 2>/dev/null | grep -qxF "$label-$_i"; do _i=$((_i+1)); done
    label="$label-$_i"
  fi
  # 允许指定 UUID(第二个参数或交互输入);留空则自动生成
  local uuid="${2:-}"
  if [ -z "$uuid" ] && [ -t 0 ]; then
    uuid="$(px_ask "$(L "UUID (blank = generate): " "UUID(留空=自动生成): ")")"
  fi
  uuid="$(printf '%s' "$uuid" | tr -d '[:space:]')"
  if [ -n "$uuid" ]; then
    echo "$uuid" | grep -Eqi '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' \
      || die "$(L "Not a valid UUID" "UUID 格式不合法")"
    grep -q "^$uuid	" "$(px_clients)" 2>/dev/null && die "$(L "That UUID is already in use" "该 UUID 已存在")"
  else
    uuid="$(px_xray_run uuid)"
    [ -n "$uuid" ] || uuid="$(cat /proc/sys/kernel/random/uuid 2>/dev/null)"
  fi
  [ -n "$uuid" ] || die "$(L "Failed to generate a UUID" "生成 UUID 失败")"
  printf '%s\t%s\t%s\n' "$uuid" "$label" "$(date +%F)" >> "$(px_clients)"
  px_write_config || die "$(L "Failed to write xray config" "写入 xray 配置失败")"
  ( cd "$INSTALL_DIR" && docker compose up -d xray >/dev/null 2>&1 && docker compose restart xray >/dev/null 2>&1 ) || true
  ok "$(L "Client added." "客户端已添加。")"
  px_print_client "$uuid" "$label"
}

px_list() {
  local f; f="$(px_clients)"
  [ -f "$f" ] || die "$(L "Proxy not enabled yet." "尚未开启代理。")"
  printf '\n%s\n' "$(L "== VeilX proxy clients ==" "== VeilX 代理客户端 ==")"
  local uuid label date_ n=0
  while IFS="$(printf '\t')" read -r uuid label date_; do
    [ -n "$uuid" ] || continue
    n=$((n+1))
    printf '  %s) %-20s %s  %s\n' "$n" "${label:-device}" "${date_:-}" "${uuid%%-*}…"
  done < "$f"
  [ "$n" -gt 0 ] || echo "$(L "  (none yet — sudo tuwunel proxy add)" "  (还没有 —— sudo tuwunel proxy add)")"
  echo
}

px_enable() {
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — deploy the server first" "找不到 $INSTALL_DIR —— 请先部署服务器")"
  cd "$INSTALL_DIR"
  local dom; dom="$(env_saved MATRIX_DOMAIN)"
  [ -n "$dom" ] || die "$(L "Can't read the domain from .env" "读不到 .env 里的域名")"
  local d; d="$(px_dir)"; mkdir -p "$d"; chmod 700 "$d"

  # 已开启:不当作 no-op —— compose 可能在一次 reconfigure 后丢了 xray 段,
  # 这时用户正是靠再跑一次 enable 来修复的。重新注入并起服务。
  if px_enabled && [ -f "$d/config.json" ]; then
    echo "$(L "Proxy already enabled — re-applying config and compose…" "代理已开启 —— 重新应用配置与编排…")"
    px_write_config || warn "$(L "Failed to write xray config" "写入 xray 配置失败")"
    if px_apply_compose; then
      docker compose up -d --remove-orphans >/dev/null 2>&1 || true
      sleep 2
      docker compose ps --status running -q xray 2>/dev/null | grep -q . \
        && ok "$(L "Proxy is running." "代理运行中。")" \
        || warn "$(L "xray is not running: docker compose logs --tail 30 xray" "xray 未在运行: docker compose logs --tail 30 xray")"
    else
      warn "$(L "compose update refused — see the message above." "compose 更新被拒绝 —— 见上方提示。")"
    fi
    px_list; return 0
  fi

  bold "$(L "Setting up the VeilX dedicated proxy" "开启 VeilX 专用代理")"
  if [ "$(px_port)" = "443" ]; then
    echo "$(L "  Xray will take over :443; Caddy keeps :80 (for cert renewal) and stays reachable internally." "  Xray 将接管 :443;Caddy 保留 :80(续证用)并继续在内网提供服务。")"
  else
    echo "$(L "  Xray will listen on :$(px_port); Caddy keeps :80 and :443 untouched." "  Xray 将监听 :$(px_port);Caddy 的 :80 和 :443 完全不动。")"
  fi
  echo "$(L "  Probes that aren't VeilX clients get your REAL Matrix site — that's the camouflage." "  非 VeilX 客户端的探测会看到你【真实的】Matrix 站点 —— 这就是伪装。")"

  docker image inspect "$XRAY_IMAGE" >/dev/null 2>&1 || {
    echo "$(L "  Pulling Xray image…" "  正在拉取 Xray 镜像…")"
    docker pull -q "$XRAY_IMAGE" >/dev/null 2>&1 || die "$(L "Failed to pull $XRAY_IMAGE" "拉取 $XRAY_IMAGE 失败")"
  }

  # 密钥对 + shortIds(仅在首次生成)
  if [ ! -f "$d/private.key" ]; then
    local kp priv pub
    kp="$(px_xray_run x25519)"
    priv="$(printf '%s\n' "$kp" | grep -iE 'private' | sed 's/.*: *//' | tr -d '\r')"
    pub="$(printf '%s\n' "$kp"  | grep -iE 'public'  | sed 's/.*: *//' | tr -d '\r')"
    [ -n "$priv" ] && [ -n "$pub" ] || die "$(L "Failed to generate proxy keys" "生成代理密钥失败")"
    printf '%s\n' "$priv" > "$d/private.key"
    printf '%s\n' "$pub"  > "$d/public.key"
    chmod 600 "$d/private.key" "$d/public.key"
  fi
  [ -f "$d/shortids.txt" ] || { openssl rand -hex 4 > "$d/shortids.txt"; chmod 600 "$d/shortids.txt"; }
  [ -f "$(px_clients)" ] || { : > "$(px_clients)"; chmod 600 "$(px_clients)"; }

  px_env_set ENABLE_PROXY 1
  px_write_config || die "$(L "Failed to write xray config" "写入 xray 配置失败")"
  px_apply_compose || die "$(L "Failed to update docker-compose.yml" "更新 docker-compose.yml 失败")"

  echo "$(L "  Restarting services…" "  正在重启服务…")"
  docker compose up -d --remove-orphans >/dev/null 2>&1 || warn "$(L "compose up reported an error — check: docker compose ps" "compose up 报错 —— 请查: docker compose ps")"
  sleep 3
  if ! docker compose ps --status running -q xray 2>/dev/null | grep -q .; then
    # 起不来时直接把日志摆出来 —— 不懂网络的人不该被要求自己去敲 docker 命令。
    # 也【不】发二维码:给一个连不上的节点只会让人白折腾半天。
    echo
    warn "$(L "The proxy did NOT start. Nothing was handed out — your Matrix site is untouched." "代理没能启动。没有生成任何节点 —— 你的 Matrix 网站不受影响。")"
    echo
    echo "$(L "── Log (last 20 lines) ──" "── 日志(最后 20 行)──")"
    docker compose logs --tail 20 --no-log-prefix xray 2>&1 | sed 's/^/  /' || true
    echo
    echo "$(L "Send the lines above for help. To undo:  sudo tuwunel proxy disable" "把上面的内容发给我排查。撤销:  sudo tuwunel proxy disable")"
    return 1
  fi

  ok "$(L "VeilX proxy is running on :$(px_port)." "VeilX 代理已在 :$(px_port) 运行。")"
  px_open_firewall "$(px_port)"
  # 端口非 443 时,curl https://域名 打的是 Caddy 而不是代理 —— 那样"验证通过"
  # 是假象,所以要显式带上代理端口。
  if [ "$(px_port)" = "443" ]; then
    echo "$(L "  Verify camouflage from outside:  curl -I https://$dom" "  从外部验证伪装:  curl -I https://$dom")"
  else
    echo "$(L "  Verify camouflage from outside:  curl -I --resolve $dom:$(px_port):<server-ip> https://$dom:$(px_port)" "  从外部验证伪装:  curl -I --resolve $dom:$(px_port):<服务器IP> https://$dom:$(px_port)")"
  fi
  echo "$(L "  Roll back any time:              sudo tuwunel proxy disable" "  随时回滚:                        sudo tuwunel proxy disable")"
  # 直接把第一台设备的链接+二维码打出来:用户装完就能扫,
  # 不用再回菜单找「添加设备」。第二个参数留空=自动生成 UUID。
  # 只有在一个客户端都没有时才自动创建 —— 否则重跑向导会不断堆叠设备。
  if [ -s "$(px_clients)" ]; then
    echo
    ok "$(L "Existing devices kept — nothing new was created." "已保留原有设备 —— 没有新建。")"
    px_list
  else
    px_add_client "$(L "my phone" "我的手机")" ""
  fi
  cat <<EOF

$(L "── What to do next ──" "── 接下来怎么做 ──")
$(L "1. Open VeilX on your phone" "1. 手机上打开 VeilX")
$(L "2. Menu → 网络代理 → 扫码添加 VeilX 代理" "2. 侧边菜单 → 网络代理 → 扫码添加 VeilX 代理")
$(L "3. Scan the QR code above, then turn on the 代理 switch" "3. 扫上面的二维码,然后打开「代理」开关")

$(L "Add more devices:  sudo tuwunel proxy add" "再加设备:  sudo tuwunel proxy add")
$(L "Check status:      sudo tuwunel proxy" "查看状态:  sudo tuwunel proxy")
EOF
}

px_disable() {
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"
  cd "$INSTALL_DIR"
  local pport; pport="$(px_port)"
  # 先停容器,再改 compose —— 反过来的话服务已从 compose 里消失,
  # stop/rm 就找不到它了,容器会残留并继续占着端口。
  docker compose stop xray >/dev/null 2>&1 || true
  docker compose rm -f xray >/dev/null 2>&1 || true
  px_env_set ENABLE_PROXY 0
  px_apply_compose || warn "$(L "Failed to restore docker-compose.yml — check the .bak-preproxy backup" "还原 docker-compose.yml 失败 —— 请查 .bak-preproxy 备份")"
  docker compose up -d --remove-orphans >/dev/null 2>&1 || true
  sleep 3
  if [ "$pport" = "443" ]; then
    ok "$(L ":443 has been handed back to Caddy. Client credentials are kept (re-enable any time)." ":443 已还给 Caddy。客户端凭据保留(随时可再开启)。")"
  else
    ok "$(L "Proxy stopped (:$pport released). Client credentials are kept." "代理已停止(:$pport 已释放)。客户端凭据保留。")"
  fi
  echo "$(L "  Verify the site is up:  curl -I https://$(env_saved MATRIX_DOMAIN)" "  确认站点正常:  curl -I https://$(env_saved MATRIX_DOMAIN)")"
}

# 按 ENABLE_PROXY 改写 docker-compose.yml:caddy 端口 + xray 服务
# 改动前自动留一份 .bak-preproxy(只留第一次的,即"未开代理时"的原样)
# 全程在副本上改,校验通过才落盘 —— 关键:开代理时会先把 443 从 Caddy 拿走,
# 若此时注入 xray 失败却仍写回,宿主机 443 就没人监听 = 整站直接下线。
px_apply_compose() {
  local f="$INSTALL_DIR/docker-compose.yml"
  [ -f "$f" ] || return 1
  [ -f "$f.bak-preproxy" ] || cp -a "$f" "$f.bak-preproxy"

  # 不用 `trap ... RETURN`:RETURN 触发时函数的 local 变量已经不在作用域里,
  # 在 set -u 下会炸成 "work: unbound variable"。改成每个出口显式清理。
  local work="$f.veilxwork$$" tmp="$f.veilxtmp$$"
  px_cleanup_tmp() { rm -f "$work" "$tmp" 2>/dev/null || true; }
  cp -a "$f" "$work" || { px_cleanup_tmp; return 1; }

  # 先移除脚本上次注入的 xray 段(幂等)
  sed -i '/# >>> veilx-proxy >>>/,/# <<< veilx-proxy <<</d' "$work" || { px_cleanup_tmp; return 1; }

  if px_enabled; then
    local pport; pport="$(px_port)"
    if [ "$pport" = "443" ]; then
      # Caddy 让出宿主机 443(容器内仍监听 443,Xray 通过内网 caddy:443 转发)
      sed -i 's|^\( *\)ports: \["80:80", "443:443", "443:443/udp"\]|\1ports: ["80:80"]   # veilx-proxy: 443 交给 xray|' "$work" || { px_cleanup_tmp; return 1; }
    else
      # 非 443:完全不动 Caddy,若之前被改过则还原
      sed -i 's|^\( *\)ports: \["80:80"\] *# veilx-proxy.*|\1ports: ["80:80", "443:443", "443:443/udp"]|' "$work" || { px_cleanup_tmp; return 1; }
    fi
    # 在 networks: 段之前插入 xray 服务
    awk -v pport="$pport" -v img="$XRAY_IMAGE" '
      /^networks:/ && !done {
        print "# >>> veilx-proxy >>>"
        print "  xray:"
        print "    image: " img
        print "    restart: unless-stopped"
        # 不用 *log 锚点:万一目标 compose 没定义它,YAML 解析会整体失败,
        # 连带把 Matrix 的所有容器一起弄down。这里写死等价配置,自包含。
        print "    logging:"
        print "      driver: json-file"
        print "      options: { max-size: \"10m\", max-file: \"3\" }"
        print "    security_opt: [\"no-new-privileges:true\"]"
        print "    depends_on: [caddy]"
        print "    ports: [\"" pport ":" pport "/tcp\"]"
        print "    volumes:"
        print "      - ./xray/config.json:/etc/xray/config.json:ro"
        print "    command: [\"run\", \"-c\", \"/etc/xray/config.json\"]"
        print "    mem_limit: 96m"
        print "    networks: [internal]"
        print ""
        print "# <<< veilx-proxy <<<"
        done=1
      }
      { print }
    ' "$work" > "$tmp" && mv "$tmp" "$work" || { px_cleanup_tmp; return 1; }

    # 校验:xray 段确实注入了,且必须有人监听宿主机 443(Caddy 或 xray),
    # 否则拒绝写回 —— 宁可不开代理,也不能把整站打没。
    grep -q '# >>> veilx-proxy >>>' "$work" || {
      warn "$(L "xray block was not injected — refusing to write docker-compose.yml" "xray 段未能注入 —— 拒绝写回 docker-compose.yml")"; px_cleanup_tmp; return 1; }
    if ! grep -qE '"443:443"|"443:443/tcp"' "$work"; then
      warn "$(L "Nothing would listen on host :443 — refusing to write docker-compose.yml" "没有任何服务会监听宿主机 :443 —— 拒绝写回 docker-compose.yml")"
      px_cleanup_tmp; return 1
    fi
  else
    # 还原 caddy 端口
    sed -i 's|^\( *\)ports: \["80:80"\] *# veilx-proxy.*|\1ports: ["80:80", "443:443", "443:443/udp"]|' "$work" || { px_cleanup_tmp; return 1; }
  fi

  cat "$work" > "$f" || { px_cleanup_tmp; return 1; }
  px_cleanup_tmp
  return 0
}

menu_proxy() {
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"
  cd "$INSTALL_DIR"
  while :; do
    # 未部署时只给一个入口:一键向导。菜单里堆满 SNI/flow/uTLS 这种词
    # 只会让不懂网络的人不敢按,也更容易点错把站点搞挂。
    if ! px_enabled; then
      cat <<EOF

┌────────────────────────────────────────────────────────┐
│  $(L "VeilX dedicated proxy" "VeilX 专用代理")   [$(L "not set up" "尚未部署")]
└────────────────────────────────────────────────────────┘
  $(L "Lets your phone connect even on a restricted or unstable network," "让你的手机在受限或不稳定的网络下也能连上这台服务器,")
  $(L "by disguising the connection as your own website." "方法是把连接伪装成你自己的网站。")

  1) $(L "Set it up now (guided, one question)" "一键部署(向导,只问一个问题)")
  0) $(L Back 返回)
EOF
      local c0; c0="$(px_ask "$(L "Select [0-1]: " "请选择 [0-1]: ")")"
      case "$c0" in
        1) px_wizard ;;
        *) return 0 ;;
      esac
      press_enter
      continue
    fi

    local st port
    st="$(L "ON" "运行中")"; port="$(px_port)"
    cat <<EOF

┌────────────────────────────────────────────────────────┐
│  $(L "VeilX dedicated proxy" "VeilX 专用代理")   [$st · $(L "port" 端口) $port]
└────────────────────────────────────────────────────────┘
  1) $(L "Add a device → get its link + QR code" "添加设备 → 得到链接和二维码")
  2) $(L "My devices" "我的设备列表")
  3) $(L "Show a device's link/QR again" "重新显示某台设备的链接/二维码")
  4) $(L "Check status" "检查运行状态")
  5) $(L "Turn it off" "关闭代理")
  9) $(L "Advanced settings (only if you know what you're doing)" "高级设置(不懂就别动)")
  0) $(L Back 返回)
EOF
    local c; c="$(px_ask "$(L "Select: " "请选择: ")")"
    case "$c" in
      1) px_add_client ;;
      2) px_list ;;
      3) px_list
         local n="" i=0 uuid label rest
         n="$(px_ask "$(L "Which number? " "第几个? ")")"
         while IFS="$(printf '\t')" read -r uuid label rest; do
           [ -n "$uuid" ] || continue; i=$((i+1))
           [ "$i" = "$n" ] && { px_print_client "$uuid" "${label:-device}"; break; }
         done < "$(px_clients)" ;;
      4) px_status ;;
      5) px_disable ;;
      9) px_config ;;
      0|"") return 0 ;;
      *) warn "$(L "Invalid choice" "无效选择")" ;;
    esac
    press_enter
  done
}

# ---------------------------------------------------------------------
# 子命令
# ---------------------------------------------------------------------
if [ "${1:-}" = "diskguard" ]; then disk_guard; exit 0; fi
if [ "${1:-}" = "cleanup" ]; then [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"; menu_cleanup; exit 0; fi
if [ "${1:-}" = "backup-run" ]; then INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; backup_run; exit 0; fi
if [ "${1:-}" = "watchdog" ]; then INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; watchdog_run; exit 0; fi

# 子命令: fed-guard —— 第三层联邦守卫。
# 只和**持有厂商证书**的 VeilX 服务器联邦。判据与客户端连服务器时用的是同一张证书、
# 同一把厂商公钥(见守卫里的 fed_check)。默认关:开着而对端没证书 = 联邦整个断掉。
if [ "${1:-}" = "fed-guard" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"
  cd "$INSTALL_DIR"
  CUR="$(env_saved FED_GUARD)"; CUR="${CUR:-0}"
  CURFED="$(env_saved ENABLE_FEDERATION)"; CURFED="${CURFED:-0}"
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== Layer 3 · federation guard ==" "== 第三层 · 联邦守卫 ==")" "$C_RESET"
  echo "$(L "Federation: " "联邦: ")$([ "$CURFED" = "1" ] && L "on" "已开启" || L "off" "已关闭")"
  echo "$(L "Guard: " "守卫: ")$([ "$CUR" = "1" ] \
    && L "on — only licensed VeilX servers may federate" "开启 —— 只有持证的 VeilX 服务器能联邦" \
    || L "off — standard Matrix federation (anyone)" "关闭 —— 标准 Matrix 联邦(谁都能连)")"
  echo ""
  echo "$(L "  [0] off — standard Matrix federation: ANY homeserver can reach your users,
        including Element users on matrix.org.
  [1] on  — only servers holding a vendor-signed VeilX licence may federate.
        Other VeilX deployments interoperate; the rest of Matrix cannot." \
"  [0] 关闭 —— 标准 Matrix 联邦:任何一台 Matrix 服务器都能触达你的用户,
        包括 matrix.org 上的 Element 用户。
  [1] 开启 —— 只有持厂商签发证书的服务器能联邦。
        别人部署的 VeilX 之间互通,其余 Matrix 一律进不来。")"
  NF=""
  PROMPT_NF="$(L "→ [0/1, Enter=keep]: " "→ [0/1,回车=不改]: ")"
  if [ -t 0 ]; then read -rp "$PROMPT_NF" NF || true
  elif [ -e /dev/tty ]; then read -rp "$PROMPT_NF" NF < /dev/tty || true
  else die "$(L "fed-guard needs an interactive terminal" "fed-guard 需要交互终端")"; fi
  case "$NF" in
    0) NEWF=0 ;;
    1) NEWF=1 ;;
    "") echo "$(L "Unchanged." "未改动。")"; exit 0 ;;
    *) die "$(L "Invalid choice" "无效选择")" ;;
  esac
  if [ "$NEWF" = "1" ]; then
    [ "$(env_saved ENABLE_OPRF)" = "1" ] || die "$(L "the guard runs inside the OPRF service (sudo tuwunel enable-oprf first)" "守卫跑在 OPRF 服务里,请先 sudo tuwunel enable-oprf")"
    [ "$CURFED" = "1" ] || warn "$(L "Federation is currently OFF, so this changes nothing yet. Turn federation on in: sudo tuwunel → change config." \
"当前联邦是关闭的,所以这一项暂时不起作用。要开联邦: sudo tuwunel → 修改配置。")"
    warn "$(L "Your peers must ALSO hold a vendor-signed licence with fed=1. A VeilX deployment whose licence says fed=0 will be refused — check the other side before switching." \
"对端也必须持有厂商签发、且 fed=1 的证书。证书里 fed=0 的 VeilX 部署会被拒 —— 切换前先确认对方那一侧。")"
  fi
  if grep -q '^FED_GUARD=' .env 2>/dev/null; then
    sed -i "s|^FED_GUARD=.*|FED_GUARD=$NEWF|" .env
  else printf 'FED_GUARD=%s\n' "$NEWF" >> .env; fi
  echo "$(L "==> Regenerating config and restarting…" "==> 重新生成配置并重启…")"
  FED_GUARD="$NEWF" exec bash "${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}" config
fi

# 子命令: device-bind —— 第二层设备绑定档位(t1.26)。规范 VEILX-LOCK-SPEC.md §9.5。
# 这是全脚本最容易自伤的开关:开 strict 而客户端没升级 = 全员立刻断线,所以必须
# 手动输入确认词,且默认答案永远是"否"。
if [ "${1:-}" = "device-bind" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"
  cd "$INSTALL_DIR"
  CUR="$(env_saved DEVICE_BIND)"; CUR="${CUR:-0}"
  case "$CUR" in
    strict) CUR_TXT="$(L "strict — unbound clients are REFUSED" "strict —— 未绑定的客户端一律拒绝")" ;;
    1)      CUR_TXT="$(L "observe — not blocking, just recording" "观察 —— 不拦截,只记录")" ;;
    *)      CUR_TXT="$(L "off — not enforced (default)" "关闭 —— 不强制(默认)")" ;;
  esac
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== Layer 2 · device binding ==" "== 第二层 · 设备绑定 ==")" "$C_RESET"
  echo "$(L "Current: " "当前档位: ")$CUR_TXT"
  echo ""
  echo "$(L "  [0] off      — record bindings only, block nobody (safe default)
  [1] observe  — still blocks nobody; use this to confirm every phone has upgraded
  [2] strict   — only phones that bound a hardware key may use the server" \
"  [0] 关闭   —— 只记录绑定,不拦截任何人(安全默认)
  [1] 观察   —— 同样不拦截;用它确认全员手机都已升级
  [2] strict —— 只有用硬件密钥绑定过的手机才能使用服务器")"
  NB=""
  PROMPT_NB="$(L "→ [0/1/2, Enter=keep]: " "→ [0/1/2,回车=不改]: ")"
  if [ -t 0 ]; then read -rp "$PROMPT_NB" NB || true
  elif [ -e /dev/tty ]; then read -rp "$PROMPT_NB" NB < /dev/tty || true
  else die "$(L "device-bind needs an interactive terminal" "device-bind 需要交互终端")"; fi
  case "$NB" in
    0) NEW=0 ;;
    1) NEW=1 ;;
    2) NEW=strict ;;
    "") echo "$(L "Unchanged." "未改动。")"; exit 0 ;;
    *) die "$(L "Invalid choice" "无效选择")" ;;
  esac
  if [ "$NEW" = "strict" ]; then
    [ "$(env_saved ENABLE_OPRF)" = "1" ] || die "$(L "strict needs the OPRF service (sudo tuwunel enable-oprf first)" "strict 依赖 OPRF 服务,请先 sudo tuwunel enable-oprf")"
    warn "$(L "STOP AND READ. From the moment this takes effect, any phone that has not bound a hardware key is CUT OFF — it cannot sync or send. That includes YOUR phone if its VeilX build predates device binding. Roll out the new app to everyone first, watch the admin console's device list until every member appears, and only then switch." \
"停下来读完。生效那一刻起,凡是没有用硬件密钥绑定过的手机【立刻断线】—— 收不到也发不出。如果你自己手机上的 VeilX 还是旧版,同样会被挡在外面。请先把新版 App 发给所有人,在管理后台的设备列表里看到每个成员都出现之后,再切换。")"
    CONFIRM=""
    PROMPT_C="$(L "Type ENFORCE to continue: " "输入 ENFORCE 继续: ")"
    if [ -t 0 ]; then read -rp "$PROMPT_C" CONFIRM || true
    elif [ -e /dev/tty ]; then read -rp "$PROMPT_C" CONFIRM < /dev/tty || true; fi
    [ "$CONFIRM" = "ENFORCE" ] || { echo "$(L "Cancelled, nothing changed." "已取消,未改动。")"; exit 0; }
  fi
  if grep -q '^DEVICE_BIND=' .env 2>/dev/null; then
    sed -i "s|^DEVICE_BIND=.*|DEVICE_BIND=$NEW|" .env
  else printf 'DEVICE_BIND=%s\n' "$NEW" >> .env; fi
  echo "$(L "==> Regenerating config and restarting…" "==> 重新生成配置并重启…")"
  DEVICE_BIND="$NEW" exec bash "${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}" config
fi

# 子命令: restore —— 一键恢复/迁移(t1.25)。备份的另一半:出事那天不该让人手工对目录。
# 用法: sudo tuwunel restore <备份文件>。迁移=新机器先跑一遍安装(同一域名),再 restore。
if [ "${1:-}" = "restore" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  BF="${2:-}"
  [ -n "$BF" ] || die "$(L "Usage: sudo tuwunel restore <backup file (.tar.gz or .tar.gz.enc)>" "用法: sudo tuwunel restore <备份文件(.tar.gz 或 .tar.gz.enc)>")"
  [ -f "$BF" ] || die "$(L "File not found: $BF" "找不到文件: $BF")"
  [ -f "$INSTALL_DIR/docker-compose.yml" ] || die "$(L "No deployment on this host yet — run the installer first (SAME domain), then restore." "本机还没部署 —— 请先用【同一个域名】跑一遍安装,装好后再 restore。")"
  cd "$INSTALL_DIR"
  RTMP="$(mktemp -d)"; trap 'rm -rf "$RTMP"' EXIT
  echo "$(L "==> Unpacking the backup…" "==> 正在解开备份…")"
  case "$BF" in
    *.enc)
      RPW=""
      # 先试自动备份的钥匙(.backup-key),开不了再问口令(手动备份用的是口令)
      if [ -f .backup-key ] && BKPW="$(cat .backup-key)" openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:BKPW -in "$BF" 2>/dev/null | tar xzf - -C "$RTMP" 2>/dev/null; then
        :
      else
        rm -rf "$RTMP"; mkdir -p "$RTMP"
        PROMPT_RPW="$(L "  Backup passphrase: " "  备份口令: ")"
        if [ -t 0 ]; then read -rsp "$PROMPT_RPW" RPW; echo
        elif [ -e /dev/tty ]; then read -rsp "$PROMPT_RPW" RPW < /dev/tty; echo
        else die "$(L "restore needs an interactive terminal for the passphrase" "restore 需要交互终端输入口令")"; fi
        BKPW="$RPW" openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:BKPW -in "$BF" 2>/dev/null | tar xzf - -C "$RTMP" 2>/dev/null \
          || die "$(L "Decrypt/unpack failed — wrong passphrase or corrupted file." "解密/解包失败 —— 口令不对或文件已损坏。")"
      fi ;;
    *) tar xzf "$BF" -C "$RTMP" 2>/dev/null || die "$(L "Unpack failed — corrupted file?" "解包失败 —— 文件损坏?")" ;;
  esac
  [ -d "$RTMP/data/tuwunel" ] || die "$(L "No data/tuwunel inside — not a backup made by this script?" "备份里没有 data/tuwunel —— 不是本脚本做的备份?")"
  # 域名必须一致:Matrix 改域名=全员身份/签名作废,不存在"恢复顺便换域名"这种操作。
  BDOM="$(sed -n 's/^MATRIX_DOMAIN=//p' "$RTMP/.env" 2>/dev/null | head -1)"
  CDOM="$(env_saved MATRIX_DOMAIN)"
  [ -n "$BDOM" ] || die "$(L "Can't read the domain from the backup's .env" "从备份的 .env 里读不到域名")"
  [ "$BDOM" = "$CDOM" ] || die "$(L "Domain mismatch: backup=$BDOM, this host=$CDOM. A Matrix server cannot change domain — install with the backup's domain first." "域名不一致:备份=$BDOM,本机=$CDOM。Matrix 服务器不能换域名 —— 请先用备份里的域名重装。")"
  RTS="$(date +%F-%H%M%S)"
  echo "$(L "About to REPLACE current data (old data is kept at data/*.pre-restore-$RTS, delete it yourself once verified)." "即将【替换】当前数据(旧数据保留在 data/*.pre-restore-$RTS,确认无误后自行删除)。")"
  if [ -t 0 ]; then read -rp "$(L "  Continue? [y/N]: " "  继续吗?[y/N]: ")" RGO || exit 1
  elif [ -e /dev/tty ]; then read -rp "$(L "  Continue? [y/N]: " "  继续吗?[y/N]: ")" RGO < /dev/tty || exit 1
  else RGO=y; fi   # 非交互(自动化恢复演练)默认继续
  case "$RGO" in y|Y) :;; *) echo "$(L "Cancelled, nothing changed." "已取消,未改动任何东西。")"; exit 0;; esac
  echo "$(L "==> Stopping services…" "==> 停止服务…")"
  docker compose stop >/dev/null 2>&1 || true
  mv data/tuwunel "data/tuwunel.pre-restore-$RTS" 2>/dev/null || true
  cp -a "$RTMP/data/tuwunel" data/tuwunel
  if [ -d "$RTMP/data/oprf" ]; then
    [ -d data/oprf ] && mv data/oprf "data/oprf.pre-restore-$RTS" 2>/dev/null || true
    cp -a "$RTMP/data/oprf" data/oprf
  fi
  cp -a "$RTMP/tuwunel.toml" tuwunel.toml 2>/dev/null || true
  [ -d "$RTMP/veilx-wk" ] && cp -a "$RTMP/veilx-wk/." veilx-wk/ 2>/dev/null || true
  # .env 按键合并:备份里的值(注册令牌等,须与恢复的库匹配)覆盖本机同名键;
  # 本机新增的键(如 VEILX_ISSUER/告警凭据)保留。整体覆盖会丢新键,整体保留会令牌错配。
  if [ -f "$RTMP/.env" ]; then
    while IFS= read -r kv; do
      case "$kv" in ''|'#'*) continue;; esac
      k="${kv%%=*}"
      if grep -q "^$k=" .env 2>/dev/null; then
        sed -i "s|^$k=.*|$(printf '%s' "$kv" | sed 's/[&|]/\\&/g')|" .env
      else printf '%s\n' "$kv" >> .env; fi
    done < "$RTMP/.env"
  fi
  echo "$(L "==> Starting services…" "==> 启动服务…")"
  docker compose up -d >/dev/null 2>&1 || true
  RD="$(env_saved MATRIX_DOMAIN)"; ROK=0
  for i in $(seq 1 24); do
    { curl -fsS --max-time 4 "https://matrix.$RD/_matrix/client/versions" >/dev/null 2>&1 \
      || curl -4 -fsS --max-time 4 "https://matrix.$RD/_matrix/client/versions" >/dev/null 2>&1; } && { ROK=1; break; }
    sleep 5
  done
  if [ "$ROK" = 1 ]; then
    ok "$(L "Restore complete — service is online. Old data kept at data/*.pre-restore-$RTS." "恢复完成 —— 服务已在线。旧数据保留在 data/*.pre-restore-$RTS。")"
    alert_admin "✅ $(L "Server restored from backup $(basename "$BF") and back online." "已从备份 $(basename "$BF") 恢复并重新上线。")"
    [ "$(license_days_left)" -lt 14 ] && warn "$(L "License missing/short — run: sudo tuwunel license" "授权证书缺失/临期 —— 请执行: sudo tuwunel license")"
  else
    warn "$(L "Restored but not online yet. Check: cd $INSTALL_DIR && docker compose logs --tail 40" "已恢复但服务未就绪。请看: cd $INSTALL_DIR && docker compose logs --tail 40")"
  fi
  exit 0
fi
if [ "${1:-}" = "autobackup" ]; then INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; SELF_BIN="${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}"; menu_autobackup; exit 0; fi

# 子命令: update —— 从 GitHub 拉最新脚本,替换本地副本+全局命令,再自动应用新功能(不动数据)
if [ "${1:-}" = "update" ]; then
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — finish the deployment first" "找不到 $INSTALL_DIR,先完成部署")"
  SELF_DST="$INSTALL_DIR/tuwunel-installer.sh"; tmp="$(mktemp)"
  bold "$(L "Updating script: pulling latest from $REPO_RAW…" "更新脚本:从 $REPO_RAW 拉取最新…")"
  if ! curl -fsSL "$REPO_RAW" -o "$tmp" 2>/dev/null; then
    rm -f "$tmp"; die "$(L "Download failed (network/blocked?). Via a mirror: TUWUNEL_UPDATE_URL=<mirror URL> sudo -E tuwunel update" "下载失败(网络/被墙?)。国内可: TUWUNEL_UPDATE_URL=<加速镜像地址> sudo -E tuwunel update")"
  fi
  # 安全校验 ①(t1.25):厂商 Ed25519 签名 —— 发布渠道(GitHub/网站/镜像)被攻破也塞不进
  # 恶意脚本:攻击者没有厂商私钥,签不出 .sig。与服务器授权证书同一个信任根。
  # 发布方每次发新版要跑 license-issuer/sign-release.sh 生成同名 .sig 一起上传。
  if [ "${TUWUNEL_UPDATE_INSECURE:-0}" = "1" ]; then
    warn "$(L "TUWUNEL_UPDATE_INSECURE=1 — skipping signature verification (NOT recommended)." "TUWUNEL_UPDATE_INSECURE=1 —— 跳过签名校验(不建议)。")"
  else
    tmp_sig="$(mktemp)"
    curl -fsSL "$REPO_RAW.sig" -o "$tmp_sig" 2>/dev/null \
      || { rm -f "$tmp" "$tmp_sig"; die "$(L "Signature download failed ($REPO_RAW.sig). The publisher must upload a .sig (license-issuer/sign-release.sh). To bypass at your own risk: TUWUNEL_UPDATE_INSECURE=1 sudo -E tuwunel update" "签名下载失败($REPO_RAW.sig)。发布方需用 license-issuer/sign-release.sh 生成 .sig 一起上传。自担风险可跳过: TUWUNEL_UPDATE_INSECURE=1 sudo -E tuwunel update")"; }
    vendor_verify_raw "$tmp" "$(cat "$tmp_sig")"; VRC=$?
    rm -f "$tmp_sig"
    case "$VRC" in
      0) ok "$(L "Signature verified (vendor key)." "签名校验通过(厂商密钥)。")" ;;
      2) rm -f "$tmp"; die "$(L "Local openssl lacks ed25519 — can't verify the update signature. Bypass at your own risk: TUWUNEL_UPDATE_INSECURE=1" "本机 openssl 不支持 ed25519,无法校验更新签名。自担风险可跳过: TUWUNEL_UPDATE_INSECURE=1")" ;;
      *) rm -f "$tmp"; die "$(L "SIGNATURE VERIFICATION FAILED — the downloaded script is NOT signed by the vendor (tampered mirror / compromised channel?). Nothing changed." "【签名校验失败】—— 下载的脚本不是厂商签发的(镜像被篡改/发布渠道被攻破?)。未改动任何东西。")" ;;
    esac
  fi
  # 安全校验 ②:必须是本脚本(含标识)且语法正确,才替换
  if grep -q "$MARKER" "$tmp" && bash -n "$tmp" 2>/dev/null; then
    cp -f "$tmp" "$SELF_DST" 2>/dev/null && chmod +x "$SELF_DST" 2>/dev/null || true
    install_launcher "$SELF_DST"; rm -f "$tmp"
    NEWV="$(grep -m1 -E '通用版 t|Universal t' "$SELF_DST" | grep -oE 't[0-9]+\.[0-9]+' || L unknown 未知)"
    ok "$(L "Script updated to $NEWV." "脚本已更新到 $NEWV。")"
    echo "$(L "==> Applying new config (asks a few options; data/accounts untouched)…" "==> 应用新配置(会问你几个选项,数据/账号一律不动)…")"
    exec bash "$SELF_DST" config
  else
    rm -f "$tmp"; die "$(L "The downloaded file failed validation (error page / tampered mirror?), aborted, nothing changed." "下载到的文件校验不通过(可能是错误页/被镜像篡改),已放弃,未改动任何东西。")"
  fi
fi

if [ "${1:-}" = "adduser" ]; then
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — finish the deployment first" "找不到 $INSTALL_DIR,先完成部署")"
  cd "$INSTALL_DIR"
  D="$(env_saved MATRIX_DOMAIN)"; TOK="$(env_saved REG_TOKEN)"; HS="https://matrix.$D"
  [ -n "$D" ] && [ -n "$TOK" ] || die "$(L "Can't read domain/registration token — deployment may be incomplete" "读不到域名/注册令牌,部署可能未完成")"
  docker compose ps --status running -q tuwunel 2>/dev/null | grep -q . || die "$(L "tuwunel not running: cd $INSTALL_DIR && docker compose up -d" "tuwunel 未运行: cd $INSTALL_DIR && docker compose up -d")"
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== Add a team member (create account + set password) ==" "== 添加团队成员(直接建好账号并设密码)==")" "$C_RESET"
  NU=""
  PROMPT_U="$(L "New member username (lowercase alphanumeric, e.g. lisi): " "新成员用户名(小写字母数字,如 lisi): ")"
  if [ -t 0 ]; then read -rp "$PROMPT_U" NU || exit 1
  elif [ -e /dev/tty ]; then read -rp "$PROMPT_U" NU < /dev/tty || exit 1
  else die "$(L "adduser needs an interactive terminal: sudo bash tuwunel-installer.sh adduser" "adduser 需交互终端: sudo bash tuwunel-installer.sh adduser")"; fi
  NU="$(echo "$NU" | tr 'A-Z' 'a-z' | tr -d '[:space:]')"
  uname_ok "$NU" || die "$(L "Invalid username (only lowercase alphanumeric and . _ - =)" "用户名不合法(只允许小写字母数字与 . _ - =)")"
  NP=""
  PROMPT_P="$(L "Password (blank = auto-generate a strong one): " "密码(留空=自动生成强密码): ")"
  if [ -t 0 ]; then read -rp "$PROMPT_P" NP || true
  elif [ -e /dev/tty ]; then read -rp "$PROMPT_P" NP < /dev/tty || true; fi
  [ -n "$NP" ] || NP="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | cut -c1-16)"
  echo "$(L "==> Creating @$NU:$D …" "==> 正在创建 @$NU:$D …")"
  if register_user "$NU" "$NP" "$HS" "$TOK"; then
    ok "$(L "Member created." "成员已创建。")"
    printf '   %s: %s%s%s   %s: %s%s%s\n' "$(L User 账号)" "$C_B" "$NU" "$C_RESET" "$(L Password 密码)" "$C_B$C_GREEN" "$NP" "$C_RESET"
    echo "$(L "   Have them log in with Element: server = $D, using the above." "   让 TA 用 Element 登录:服务器填 $D,用上面账号密码。")"
  else
    warn "$(L "Creation failed. Possibly: username exists, token invalid, or service not ready." "创建失败。可能:该用户名已存在、令牌失效、或服务未就绪。")"
    echo "$(L "   Fallback: send [server $D + registration token $TOK] to the member to self-register;" "   兜底方案:把 [服务器 $D + 注册令牌 $TOK] 发给成员自助注册;")"
    echo "$(L "   or send !admin help in the admin room for commands." "   或在管理员房间发 !admin help 查看命令。")"
  fi
  exit 0
fi

if [ "${1:-}" = "uninstall" ]; then
  [ -t 0 ] || die "$(L "uninstall must run in an interactive terminal (prevents accidents)" "uninstall 必须在交互终端里执行(防误删)")"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found, nothing to uninstall" "没找到 $INSTALL_DIR,无需卸载")"
  INSTALL_DIR="$(readlink -f -- "$INSTALL_DIR" 2>/dev/null || echo "$INSTALL_DIR")"
  case "$INSTALL_DIR" in ""|/|/root|/home|/usr|/etc|/var|/bin|/boot|/lib*|/opt|/srv|/sys|/proc|/dev)
      die "$(L "Refusing to delete dangerous path [$INSTALL_DIR]" "拒绝删除危险路径 [$INSTALL_DIR]")" ;; esac
  grep -q "$MARKER" "$INSTALL_DIR/tuwunel.toml" 2>/dev/null || die "$(L "[$INSTALL_DIR] doesn't look like a directory deployed by this script, refusing to delete." "[$INSTALL_DIR] 不像本脚本部署的目录,拒绝删除。")"
  UN_DOMAIN="$(env_saved MATRIX_DOMAIN)"
  cat <<EOF

┌──────────────────────────────────────────────────────────┐
│  $(L "⚠️  Completely uninstall tuwunel server" "⚠️  彻底卸载 tuwunel 服务器")${UN_DOMAIN:+($UN_DOMAIN)}
└──────────────────────────────────────────────────────────┘
$(L "Will permanently delete (unrecoverable!): all chat history, media, accounts, database, certs, config ($INSTALL_DIR)." "将永久删除(无法恢复!):全部聊天记录、媒体、账号、数据库、证书、配置($INSTALL_DIR)。")

$(L "Automatically KEPT: backups in $DEFAULT_BACKUP_DIR (uninstall doesn't touch it;" "会自动【保留】:$DEFAULT_BACKUP_DIR 里的备份(卸载不碰它;")
$(L "if the install dir still has old backups, they're moved there first)." "若安装目录里还有旧版留下的备份,下面会先搬过去再删)。")

$(L "No backup? Press Ctrl+C now → re-run sudo tuwunel → menu item 5 'Back up now'," "没备份的话:现在按 Ctrl+C 退出 → 重跑 sudo tuwunel → 菜单第 5 项『立即备份』,")
$(L "the backup goes to $DEFAULT_BACKUP_DIR and won't be deleted on uninstall." "备份会存到 $DEFAULT_BACKUP_DIR,卸载时不会被删。")
EOF
  read -rp "$(L "Confirm #1: type yes to continue: " "第 1 次确认:输入 yes 继续: ")" R1 || exit 1
  [ "$R1" = "yes" ] || { echo "$(L "Cancelled." "已取消。")"; exit 0; }
  if [ -n "$UN_DOMAIN" ]; then read -rp "$(L "Confirm #2: type your domain [$UN_DOMAIN]: " "第 2 次确认:输入你的域名【$UN_DOMAIN】: ")" R2 || exit 1
    [ "$R2" = "$UN_DOMAIN" ] || { echo "$(L "No match, cancelled." "不匹配,已取消。")"; exit 0; }
  else read -rp "$(L "Confirm #2: type DELETE in caps: " "第 2 次确认:输入大写 DELETE: ")" R2 || exit 1
    [ "$R2" = "DELETE" ] || { echo "$(L "No match, cancelled." "不匹配,已取消。")"; exit 0; }; fi
  # 抢救安装目录里的历史备份(旧版本的备份就存在这儿,rm -rf 会连它一起删)
  if ls -1 "$INSTALL_DIR"/tuwunel-backup-*.tar.gz* >/dev/null 2>&1 \
     || ls -1 "$INSTALL_DIR"/backups/tuwunel-backup-* >/dev/null 2>&1; then
    mkdir -p "$DEFAULT_BACKUP_DIR" 2>/dev/null; chmod 700 "$DEFAULT_BACKUP_DIR" 2>/dev/null || true
    mv -f "$INSTALL_DIR"/tuwunel-backup-*.tar.gz* "$DEFAULT_BACKUP_DIR"/ 2>/dev/null || true
    mv -f "$INSTALL_DIR"/backups/tuwunel-backup-* "$DEFAULT_BACKUP_DIR"/ 2>/dev/null || true
    ok "$(L "Moved old backups from the install dir to $DEFAULT_BACKUP_DIR (won't be deleted)" "已把安装目录里的旧备份移到 $DEFAULT_BACKUP_DIR(不会被删除)")"
  fi
  ( cd "$INSTALL_DIR" && docker compose down --remove-orphans ) 2>/dev/null || true
  rm -rf "$INSTALL_DIR"
  ok "$(L "Uninstall complete. $INSTALL_DIR deleted." "卸载完成。$INSTALL_DIR 已删除。")"
  if ls -1 "$DEFAULT_BACKUP_DIR"/tuwunel-backup-* >/dev/null 2>&1; then
    echo "$(L "   Backups remain in: $DEFAULT_BACKUP_DIR" "   备份仍保留在: $DEFAULT_BACKUP_DIR")"
    echo "$(L "   Decrypt a backup: openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass pass:YOURPASS -in backup.enc | tar xzf - -C targetdir" "   解开备份: openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass pass:你的口令 -in 备份.enc | tar xzf - -C 目标目录")"
    echo "$(L "   (unencrypted backup: tar xzf backup.tar.gz -C targetdir)" "   (未加密的备份直接 tar xzf 备份.tar.gz -C 目标目录)")"
  fi
  exit 0
fi


# =====================================================================
# CF Origin 证书(全橙云模式)
# ---------------------------------------------------------------------
# 橙云下 Caddy 自动 HTTPS 一定失败,两条验证方式全被 CF 挡住:
#   · TLS-ALPN-01:CF 在边缘终止 TLS,acme-tls/1 这个 ALPN 传不到源站 —— 必失败
#   · HTTP-01    :Full(Strict) 下是死锁 —— CF 回源要求源站already有有效证书,
#                  而你正是因为没有证书才在申请
# 解法:用 CF 后台签的 Origin Certificate(免费/15 年/只有 CF 认它),Caddy 用
# tls 指令直接加载,全程不碰 ACME。
# 关键设计:证书路径 + 开关(CF_ORIGIN)都存进 .env,Caddyfile 的生成逻辑读它 ——
# 所以以后 tuwunel config / update / 重跑安装 重新生成 Caddyfile 时会自动带上
# tls 指令,不会像"手动改 Caddyfile"那样被覆盖掉。
# =====================================================================
CF_DIR_NAME="cf-origin"

# 读取用户粘贴的一段 PEM:忽略 BEGIN 之前的杂物,读到 END 行为止。
# 容忍从网页/Windows 复制来的 CRLF(否则 openssl 会报莫名其妙的解析错)。
cf_read_pem() {
  local line out=""
  while IFS= read -r line; do
    line="${line%$'\r'}"
    if [ -z "$out" ]; then
      case "$line" in *-----BEGIN*) out="$line"$'\n';; *) continue;; esac
    else
      out="$out$line"$'\n'
      case "$line" in *-----END*) break;; esac
    fi
  done
  printf '%s' "$out"
}

# 取证书的 SAN,输出形如 DNS:example.com,DNS:*.example.com
# 不用 `openssl x509 -ext`(那是 OpenSSL 3 专属,LibreSSL/旧版会报 unknown option
# 并静默返回空 → SAN 检查会把好证书误判成坏的)。-text 的格式各实现一致。
cf_san_of() {
  openssl x509 -in "$1" -noout -text 2>/dev/null \
    | awk '/Subject Alternative Name/{getline; gsub(/[ \t]/,""); print; exit}'
}

# 校验:能解析 / 没过期 / 证书与私钥配对 / SAN 同时覆盖根域名和 *.域名。
# 任何一条不过就拒绝落盘 —— 宁可不装,也不能让 Caddy 抱着一张废证书起不来。
cf_validate() {
  local crt="$1" key="$2" dom="$3" pc pk san
  openssl x509 -in "$crt" -noout >/dev/null 2>&1 || { warn "$(L "Certificate does not parse — did you paste the whole PEM block?" "证书解析失败 —— 整段 PEM 都粘上了吗?")"; return 1; }
  openssl pkey -in "$key" -noout >/dev/null 2>&1 || { warn "$(L "Private key does not parse" "私钥解析失败")"; return 1; }
  openssl x509 -in "$crt" -noout -checkend 0 >/dev/null 2>&1 || { warn "$(L "Certificate has already expired" "证书已经过期了")"; return 1; }
  pc="$(openssl x509 -in "$crt" -noout -pubkey 2>/dev/null | openssl md5 2>/dev/null)"
  pk="$(openssl pkey -in "$key" -pubout 2>/dev/null | openssl md5 2>/dev/null)"
  [ -n "$pc" ] && [ "$pc" = "$pk" ] || { warn "$(L "Certificate and private key are not a pair" "证书和私钥不是一对(粘串了?)")"; return 1; }
  san="$(cf_san_of "$crt")"
  case "$san" in *"DNS:$dom"*) :;; *) warn "$(L "Certificate SAN does not cover $dom (needed for the root domain)" "证书 SAN 不含 $dom(根域名要用)")"; return 1;; esac
  case "$san" in *"DNS:*.$dom"*) :;; *) warn "$(L "Certificate SAN does not cover *.$dom (needed for matrix./admin.)" "证书 SAN 不含 *.$dom(matrix. / admin. 等子域要用)")"; return 1;; esac
  return 0
}

# 交互收集 + 校验 + 落盘。$1=域名 $2=目标目录。成功 0 / 失败 1(失败时不动任何现有文件)。
cf_collect_and_store() {
  local dom="$1" dir="$2" crt key tmpd
  command -v openssl >/dev/null 2>&1 || { warn "$(L "openssl not found" "找不到 openssl")"; return 1; }
  has_tty || { warn "$(L "Pasting a certificate needs an interactive terminal" "粘贴证书需要交互终端")"; return 1; }

  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "CF Origin Certificate" "CF Origin 证书")" "$C_RESET"
  cat <<EOF
$(L "Get one from the Cloudflare dashboard (free, 15 years, only CF trusts it):" "去 Cloudflare 后台签一张(免费,15 年有效,只有 CF 认它):")
  1. SSL/TLS → Origin Server → Create Certificate
  2. $(L "Hostnames: keep the default" "主机名保持默认") ${C_GREEN}$dom, *.$dom${C_RESET}
  3. $(L "Private key type: RSA or ECC, format PEM. You get TWO blocks." "私钥类型 RSA 或 ECC 均可,格式 PEM。会给你【两段】。")
  4. ${C_YELLOW}$(L "The private key is shown ONCE — copy it before closing the page." "私钥只显示这一次,关掉页面就再也拿不到,先复制好。")${C_RESET}

$(L "Also confirm in the dashboard:" "另外后台还要确认:")
  · SSL/TLS → $(L "encryption mode" "加密模式") = ${C_GREEN}Full (Strict)${C_RESET}
  · Security → Bot Fight Mode = ${C_GREEN}Off${C_RESET}  $(L "(it 403s Matrix clients)" "(否则会 403 掉 Matrix 客户端)")
  · Rules → Cache Rules → /_matrix/* $(L and 和) /.well-known/matrix/* = ${C_GREEN}Bypass cache${C_RESET}
EOF
  press_enter "$(L "Ready? Press Enter, then paste the CERTIFICATE… " "准备好了按回车,然后粘贴【证书】… ")"

  echo ""
  echo "$(L "Paste the CERTIFICATE (from -----BEGIN CERTIFICATE----- to -----END CERTIFICATE-----):" "粘贴【证书】(从 -----BEGIN CERTIFICATE----- 到 -----END CERTIFICATE-----):")"
  if [ -t 0 ]; then crt="$(cf_read_pem)"; else crt="$(cf_read_pem < /dev/tty)"; fi
  echo ""
  echo "$(L "Paste the PRIVATE KEY (from -----BEGIN ... PRIVATE KEY----- to -----END ... PRIVATE KEY-----):" "粘贴【私钥】(从 -----BEGIN ... PRIVATE KEY----- 到 -----END ... PRIVATE KEY-----):")"
  if [ -t 0 ]; then key="$(cf_read_pem)"; else key="$(cf_read_pem < /dev/tty)"; fi

  case "$crt" in *CERTIFICATE*) :;; *) warn "$(L "That was not a certificate" "粘进来的不是证书")"; return 1;; esac
  case "$key" in *PRIVATE*KEY*) :;; *) warn "$(L "That was not a private key" "粘进来的不是私钥")"; return 1;; esac
  case "$key" in *CERTIFICATE*) warn "$(L "You pasted the certificate twice — the second block must be the PRIVATE KEY" "两段都粘成证书了 —— 第二段要粘【私钥】")"; return 1;; esac

  tmpd="$dir.tmp.$$"
  ( umask 077; mkdir -p "$tmpd" ) || { warn "$(L "Cannot create $tmpd" "无法创建 $tmpd")"; return 1; }
  printf '%s' "$crt" > "$tmpd/origin.pem"
  printf '%s' "$key" > "$tmpd/origin.key"

  if ! cf_validate "$tmpd/origin.pem" "$tmpd/origin.key" "$dom"; then
    rm -rf "$tmpd"
    warn "$(L "Certificate rejected — nothing on disk was changed." "证书未通过校验 —— 磁盘上什么都没改。")"
    return 1
  fi

  ( umask 077; mkdir -p "$dir" ) || { rm -rf "$tmpd"; return 1; }
  mv -f "$tmpd/origin.pem" "$dir/origin.pem" && mv -f "$tmpd/origin.key" "$dir/origin.key" || { rm -rf "$tmpd"; return 1; }
  rm -rf "$tmpd"
  chmod 700 "$dir" 2>/dev/null || true
  chmod 644 "$dir/origin.pem" 2>/dev/null || true
  chmod 600 "$dir/origin.key" 2>/dev/null || true
  ok "$(L "Certificate validated and saved to $dir" "证书已通过校验,保存在 $dir")"
  return 0
}

# 打印当前证书状态(SAN / 到期时间)。没装返回 1。
cf_cert_status() {
  local dir="${INSTALL_DIR:-/opt/tuwunel}/$CF_DIR_NAME" exp san
  if [ ! -s "$dir/origin.pem" ] || [ ! -s "$dir/origin.key" ]; then
    echo "  $(L "CF Origin cert: not installed" "CF Origin 证书: 未安装")"; return 1
  fi
  san="$(cf_san_of "$dir/origin.pem")"
  exp="$(openssl x509 -in "$dir/origin.pem" -noout -enddate 2>/dev/null | cut -d= -f2)"
  echo "  $(L "Covers" "覆盖"): ${san:-?}"
  echo "  $(L "Expires" "到期"): ${exp:-?}"
  if ! openssl x509 -in "$dir/origin.pem" -noout -checkend 2592000 >/dev/null 2>&1; then
    warn "$(L "Less than 30 days left — re-issue it in the CF dashboard and run: sudo tuwunel cf-cert" "剩余不到 30 天 —— 去 CF 后台重签,然后执行: sudo tuwunel cf-cert")"
  fi
  return 0
}

# 装/换证书:收集 → 落盘 → 写 CF_ORIGIN=1 → 重新生成 Caddyfile 并重启
cf_cert_paste() {
  local dom
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; SELF_BIN="${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}"
  dom="$(env_saved MATRIX_DOMAIN)"
  [ -n "$dom" ] || { warn "$(L "No deployed config found — install the server first" "未找到已部署配置 —— 请先装服务器")"; return 1; }
  cf_collect_and_store "$dom" "$INSTALL_DIR/$CF_DIR_NAME" || return 1
  px_env_set CF_ORIGIN 1 || warn "$(L "Could not write .env" "写入 .env 失败")"
  px_env_set USE_CDN   1 || true
  echo ""
  echo "$(L "Applying: regenerating Caddyfile and restarting…" "正在应用:重新生成 Caddyfile 并重启…")"
  if [ -f "$SELF_BIN" ]; then
    CF_ORIGIN=1 INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" config
  else
    warn "$(L "Script copy missing — run: sudo tuwunel config" "缺少脚本副本 —— 请手动执行: sudo tuwunel config")"
  fi
}

# 关掉全橙云模式,回到 Caddy 自动 HTTPS(证书文件保留,想再开不用重签)
cf_cert_off() {
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; SELF_BIN="${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}"
  warn "$(L "Switch the records back to grey-cloud (DNS only) FIRST — otherwise Caddy still cannot get a certificate and the site stays down." "先把 DNS 记录改回灰云(仅 DNS)—— 否则 Caddy 照样签不出证书,站点会一直起不来。")"
  ask_opt "$(L "Records already grey-cloud? Continue? [y/N]: " "记录已经改回灰云了?继续吗? [y/N]: ")" "n"
  case "$REPLY" in y|Y) :;; *) echo "$(L Cancelled 已取消)"; return 0;; esac
  px_env_set CF_ORIGIN 0 || true
  if [ -f "$SELF_BIN" ]; then
    CF_ORIGIN=0 INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" config
  else
    warn "$(L "Script copy missing — run: sudo tuwunel config" "缺少脚本副本 —— 请手动执行: sudo tuwunel config")"
  fi
}

menu_cf_cert() {
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  local st
  [ "$(env_saved CF_ORIGIN)" = "1" ] && st="$(L ON 已开启)" || st="$(L OFF 未开启)"
  printf '\n%s%s%s  [%s]\n' "$C_B$C_CYAN" "$(L "CF Origin certificate — full orange-cloud mode" "CF Origin 证书 —— 全橙云模式")" "$C_RESET" "$st"
  cf_cert_status || true
  cat <<EOF

$(L "Behind an orange cloud Caddy can never get its own certificate; this loads a" "橙云后面 Caddy 永远签不出自己的证书;开启后改为加载一张")
$(L "CF-issued Origin Certificate instead (free, 15 years)." "CF 签发的 Origin 证书(免费,15 年)。")
${C_YELLOW}$(L "Cost of full orange-cloud: 100MB upload cap, and voice/video calls will not work." "全橙云的代价:上传上限 100MB,且语音视频通话用不了。")${C_RESET}

  1) $(L "Install / replace the certificate (paste it from the CF dashboard)" "安装 / 更换证书(从 CF 后台粘贴)")
  2) $(L "Turn OFF — back to Caddy automatic HTTPS (grey-cloud / DNS only)" "关闭 —— 回到 Caddy 自动 HTTPS(灰云 / 仅 DNS)")
  0) $(L Back 返回)
EOF
  ask_opt "$(L "Select [0-2]: " "请选择 [0-2]: ")" "0"
  case "$REPLY" in
    1) cf_cert_paste ;;
    2) cf_cert_off ;;
    *) : ;;
  esac
}


RECONFIG=0
if [ "${1:-}" = "config" ]; then RECONFIG=1; set --; fi
# 子命令: enable-admin / disable-admin —— 给已部署的老服务器单独开/关 Web 管理后台
# (只改后台一项,不动注册/联邦/大文件等,也不问向导;需先加好 admin.域名 的 DNS)
if [ "${1:-}" = "enable-admin" ];  then ENABLE_ADMIN=1; RECONFIG=1; set --; fi
if [ "${1:-}" = "disable-admin" ]; then ENABLE_ADMIN=0; RECONFIG=1; set --; fi
# 子命令: enable-elementx / disable-elementx —— 开/关 Element X 手机App自助注册(tuwunel 原生 OIDC)
# 开:仍强制邀请码(不绕过);关:Element X 只能用密码登录、注册改走网页或管理员建号。
if [ "${1:-}" = "enable-elementx" ];  then ENABLE_ELEMENTX=1; RECONFIG=1; set --; fi
if [ "${1:-}" = "disable-elementx" ]; then ENABLE_ELEMENTX=0; RECONFIG=1; set --; fi
# 子命令: privacy —— 隐私/元数据:看能删什么、当前加固状态、清容器日志
# 子命令: proxy —— VeilX 专用代理(REALITY)。无参进菜单,也可直接带动作。
if [ "${1:-}" = "proxy" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  case "${2:-}" in
    wizard|setup)    px_wizard ;;
    enable)          px_enable ;;
    disable)         px_disable ;;
    config|settings) px_config ;;
    add|add-client)  px_add_client "${3:-}" "${4:-}" ;;
    list)            px_list ;;
    "")              menu_proxy ;;
    *) die "$(L "Usage: sudo tuwunel proxy [wizard|enable|disable|config|add [label] [uuid]|list]" "用法: sudo tuwunel proxy [wizard|enable|disable|config|add [备注] [uuid]|list]")" ;;
  esac
  exit 0
fi

if [ "${1:-}" = "enable-oprf" ];  then ENABLE_OPRF=1; RECONFIG=1; set --; fi
if [ "${1:-}" = "disable-oprf" ]; then ENABLE_OPRF=0; RECONFIG=1; set --; fi
if [ "${1:-}" = "oprf" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; SELF_BIN="${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — install the server first" "找不到 $INSTALL_DIR —— 请先装服务器")"
  menu_oprf; exit 0
fi
if [ "${1:-}" = "oprf-repair" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found" "找不到 $INSTALL_DIR")"
  oprf_repair; exit 0
fi
# 子命令: license —— 服务器授权证书状态 + 手动续期(t1.24,见 VEILX-LOCK-SPEC.md)
if [ "${1:-}" = "license" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — install the server first" "找不到 $INSTALL_DIR —— 请先装服务器")"
  MD="$(env_saved MATRIX_DOMAIN)"; [ -n "$MD" ] || die "$(L "Can't read the domain" "读不到域名")"
  ISS="$(env_saved VEILX_ISSUER)"; [ -n "$ISS" ] && VEILX_ISSUER="$ISS"
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== VeilX server license ==" "== VeilX 服务器授权证书 ==")" "$C_RESET"
  DLEFT="$(license_days_left)"
  if [ "$DLEFT" -lt 0 ]; then
    warn "$(L "No license installed — VeilX clients will REFUSE this server." "未安装证书 —— VeilX 客户端会拒绝连接本服务器。")"
  else
    echo "$(L "Host: matrix.$MD   days left: $DLEFT   file: $INSTALL_DIR/veilx-wk/license.json" "主机: matrix.$MD   剩余天数: $DLEFT   文件: $INSTALL_DIR/veilx-wk/license.json")"
    [ "$DLEFT" -lt 14 ] && warn "$(L "Less than 14 days left — renewing is recommended." "剩余不足 14 天 —— 建议续期。")"
  fi
  echo "$(L "Issuer: $VEILX_ISSUER (issuing re-runs the health check: federation OFF, OPRF up, gate on)" "签发服务: $VEILX_ISSUER(签发会重新体检:联邦关、OPRF 在、拦 Element)")"
  if has_tty; then
    ask_opt "$(L "→ Renew now? [y/N]: " "→ 现在申请/续期吗?[y/N]: ")" "N"
    case "$REPLY" in y|Y) license_fetch "matrix.$MD" && license_cron_install ;; esac
  fi
  exit 0
fi
# 子命令: license-renew —— 给 cron 用:缺失或剩 <30 天才动作,静默
if [ "${1:-}" = "license-renew" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || exit 0
  MD="$(env_saved MATRIX_DOMAIN)"; [ -n "$MD" ] || exit 0
  ISS="$(env_saved VEILX_ISSUER)"; [ -n "$ISS" ] && VEILX_ISSUER="$ISS"
  DLEFT="$(license_days_left)"
  if [ "$DLEFT" -lt 14 ]; then
    if ! license_fetch "matrix.$MD"; then
      # 续不上且已进最后一周 → 告警到管理员房间(每天 cron 会重试,但人得知道)
      [ "$DLEFT" -lt 7 ] && alert_admin "🔴 $(L "VeilX license renewal keeps failing ($DLEFT days left) — clients will be locked out at expiry. Check: sudo tuwunel license" "VeilX 授权证书续期一直失败(仅剩 $DLEFT 天)—— 到期后客户端将全部连不上。请排查: sudo tuwunel license")"
    fi
  fi
  exit 0
fi
# 子命令: cf-cert —— CF Origin 证书(全橙云模式)。无参进小菜单。
if [ "${1:-}" = "cf-cert" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; SELF_BIN="${SELF_BIN:-$INSTALL_DIR/tuwunel-installer.sh}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — install the server first" "找不到 $INSTALL_DIR —— 请先装服务器")"
  case "${2:-}" in
    status)        cf_cert_status ;;
    off|disable)   cf_cert_off ;;
    ""|set|paste)  menu_cf_cert ;;
    *) die "$(L "Usage: sudo tuwunel cf-cert [status|off]" "用法: sudo tuwunel cf-cert [status|off]")" ;;
  esac
  exit 0
fi
if [ "${1:-}" = "privacy" ]; then INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; menu_privacy; exit 0; fi
if [ "${1:-}" = "forget-secrets" ]; then INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"; menu_forget_secrets; exit 0; fi
# 子命令: lang —— 切换脚本界面语言(English / 简体中文),写进 .env 持久化(装好后随时可改)
if [ "${1:-}" = "lang" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -f "$INSTALL_DIR/.env" ] || die "$(L "$INSTALL_DIR/.env not found — is the server deployed?" "找不到 $INSTALL_DIR/.env(服务器是否已部署?)")"
  LCUR="$(env_saved UI_LANG)"; LCUR="${LCUR:-zh}"
  printf '\nLanguage / 语言   (当前 Current: %s)\n  [1] English\n  [2] 中文(简体)\n' "$LCUR"
  ask_opt "→ [1/2]: " ""
  case "$REPLY" in 1) LNEW=en;; 2) LNEW=zh;; *) echo "$(L "Unchanged." "未改动。")"; exit 0;; esac
  if grep -qE '^UI_LANG=' "$INSTALL_DIR/.env" 2>/dev/null; then
    sed -i "s/^UI_LANG=.*/UI_LANG=$LNEW/" "$INSTALL_DIR/.env"
  else
    printf 'UI_LANG=%s\n' "$LNEW" >> "$INSTALL_DIR/.env"
  fi
  UI_LANG="$LNEW"
  ok "$(L "Interface language set to English (menu / prompts / messages)." "界面语言已切换为简体中文(菜单/提示/消息)。")"
  exit 0
fi
# 子命令: admin-url —— 【老服务器改后台网址】把管理面板子域名 admin. 改成别的(如 console. / manage.)
# 交互问新子域 → 提醒先加 DNS → 复用 config 重生成 Caddyfile 并重启(只改这一项,数据/账号不动)。
if [ "${1:-}" = "admin-url" ]; then
  INSTALL_DIR="${INSTALL_DIR:-/opt/tuwunel}"
  [ -d "$INSTALL_DIR" ] || die "$(L "$INSTALL_DIR not found — finish the deployment first" "找不到 $INSTALL_DIR,先完成部署")"
  AD="$(env_saved MATRIX_DOMAIN)"; [ -n "$AD" ] || die "$(L "Can't read the domain — deployment may be incomplete" "读不到域名,部署可能未完成")"
  [ "$(env_saved ENABLE_ADMIN)" = "1" ] || { warn "$(L "The admin panel is OFF. Enable it first (sudo tuwunel → 4), then change its URL." "后台未开启。请先开启(sudo tuwunel → 第 4 项),再改网址。")"; exit 0; }
  ACUR="$(env_saved ADMIN_SUB)"; ACUR="${ACUR:-admin}"; AIP="$(env_saved PUBLIC_IP)"
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "== Change admin panel URL ==" "== 修改后台网址 ==")" "$C_RESET"
  echo "$(L "Current: https://$ACUR.$AD" "当前: https://$ACUR.$AD")"
  ANEW=""
  while :; do
    ask_opt "$(L "→ New subdomain [Enter=$ACUR] (e.g. console / manage): " "→ 新子域名 [回车=$ACUR](如 console / manage): ")" "$ACUR"
    ANEW="$(echo "$REPLY" | tr 'A-Z' 'a-z' | tr -d '[:space:]')"
    case "$ANEW" in matrix|livekit|matrix-rtc|www) warn "$(L "'$ANEW' is reserved by another service — pick another." "『$ANEW』被其它服务占用 —— 换一个。")"; has_tty || exit 1; continue;; esac
    echo "$ANEW" | grep -Eq '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' && break
    warn "$(L "Invalid subdomain — one label only: lowercase letters/digits/hyphen, no dots." "子域名不合法 —— 只能一个标签:小写字母/数字/连字符,不含点。")"
    has_tty || exit 1
  done
  [ "$ANEW" = "$ACUR" ] && { ok "$(L "Unchanged." "未改动。")"; exit 0; }
  echo ""
  warn "$(L "IMPORTANT — add DNS FIRST: an A record for  $ANEW.$AD  →  ${AIP:-your server IP}" "重要 —— 先加 DNS:给  $ANEW.$AD  加一条 A 记录  →  ${AIP:-你的服务器IP}")"
  echo "$(L "(the old record $ACUR.$AD can be removed afterwards)" "(旧的 $ACUR.$AD 记录之后可以删掉)")"
  press_enter "$(L "Added the DNS record? Press Enter to apply (Ctrl+C to abort)… " "加好 DNS 记录了?回车应用(Ctrl+C 取消)… ")"
  exec env ADMIN_SUB="$ANEW" INSTALL_DIR="$INSTALL_DIR" bash "$0" config
fi

# ---------------------------------------------------------------------
# 0. 基础 / 已部署检测 → 管理菜单
# ---------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "$(L "root required: sudo bash tuwunel-installer.sh yourdomain" "需要 root: sudo bash tuwunel-installer.sh 域名")"

if [ "$RECONFIG" -eq 0 ] && [ -f "$INSTALL_DIR/CREDENTIALS.txt" ] \
   && grep -q "$MARKER" "$INSTALL_DIR/tuwunel.toml" 2>/dev/null; then
  cd "$INSTALL_DIR"; SELF_BIN="$INSTALL_DIR/tuwunel-installer.sh"
  # 重跑安装命令时,把本地脚本副本刷新成当前这份(这样老部署重跑一键命令即可拿到新版),并刷新全局命令。
  # 【t1.15 修复】必须用 cd 之前就解析好的【绝对路径】$SELF_SRC:此处已 cd 到 INSTALL_DIR,
  # 再用相对的 $0(如 `bash tuwunel.sh` 时 $0=tuwunel.sh)判存在会失败 → 复制被跳过 →
  # 菜单显示的是新功能、但按下去调用的仍是旧副本(经典症状:选 a 弹出没有 a 的旧菜单)。
  if [ -n "$SELF_SRC" ] && [ -f "$SELF_SRC" ] \
     && [ "$(readlink -f -- "$SELF_SRC" 2>/dev/null)" != "$(readlink -f -- "$SELF_BIN" 2>/dev/null)" ]; then
    cp -f "$SELF_SRC" "$SELF_BIN" 2>/dev/null && chmod +x "$SELF_BIN" 2>/dev/null || true
  fi
  install_launcher "$SELF_BIN"
  if has_tty; then
    MENU_DOMAIN="$(env_saved MATRIX_DOMAIN)"
    while :; do
      cat <<EOF

┌──────────────────────────────────────────────┐
│  $(L "tuwunel management menu" "tuwunel 管理菜单")   ${MENU_DOMAIN:-}
└──────────────────────────────────────────────┘
  1) $(L "View running status" "查看运行状态")
  2) $(L "Add a team member (one command: create account + set password)" "添加团队成员(一条命令建号并设密码)")
  3) $(L "Change config (admin panel on/off · calls · file size)" "修改配置(管理后台开关 · 通话 · 文件上限)")
  4) $([ "$(env_saved ENABLE_ADMIN)" = "1" ] && L "Disable Web admin panel" "关闭 Web 管理后台" || L "Enable Web admin panel (VeilX Admin; add to an existing server)" "开启 Web 管理后台(VeilX 自研,老服务器补装)")
  5) $(L "Back up now (config + database + media)" "立即备份(配置 + 数据库 + 媒体)")
  r) $(L "Restore / migrate from a backup (one command, old data kept aside)" "从备份恢复/迁移(一条命令,旧数据自动留档)")
  6) $(L "Upgrade service images (docker pull latest)" "升级服务镜像(docker 拉最新)")
  7) $(L "Clean up disk" "清理磁盘")
  8) $(L "Restart all services" "重启所有服务")
  9) $(L "Update script + apply new features (signed, from lockmere.io; data untouched)" "更新脚本 + 应用新功能(官网下载并验签,数据不动)")
 10) $(L "Uninstall completely" "彻底卸载")
  p) $(L "Privacy hardening / metadata cleanup (what can be deleted, clear logs)" "隐私加固 / 元数据清理(看能删什么、清日志)")
  s) $(L "Wipe plaintext credentials file (traceless cleanup: remove on-disk password/token)" "涂销明文凭据文件(无痕清理:去掉磁盘上的明文密码/邀请码)")
  b) $(L "Scheduled encrypted backup (optional: weekly auto, with rotation/skip-if-full)" "自动定时加密备份(可选:开启后每周自动,含轮转/满盘跳过)")
  a) $(L "Change admin panel URL (admin. → another subdomain)" "修改后台网址(admin. → 别的子域)")
  x) $(L "VeilX dedicated proxy (restricted-network connectivity; link + QR for the app)" "VeilX 专用代理(受限网络连接;给 App 出链接+二维码)")$([ "$(env_saved ENABLE_PROXY)" = "1" ] && L "  [ON]" "  [已开启]")
  o) $(L "VeilX hardening: server-assisted PIN (lost offline phones can't be cracked)" "VeilX 加固:服务器辅助 PIN(丢失的离线手机无法破解)")$([ "$(env_saved ENABLE_OPRF)" = "1" ] && L "  [ON]" "  [已开启]")
  c) $(L "CF Origin certificate (full orange-cloud: paste the cert Cloudflare generated)" "CF Origin 证书(全橙云:粘贴 Cloudflare 生成的证书)")$([ "$(env_saved CF_ORIGIN)" = "1" ] && L "  [ON]" "  [已开启]")
  k) $(L "VeilX server license (clients only connect licensed servers; status / renew)" "VeilX 服务器授权证书(客户端只连持证服务器;看状态/续期)")
  L) $(L "Switch interface language → 中文" "切换界面语言 → English")   (语言 / Language)
  0) $(L Exit 退出)
EOF
      MCHOICE=""
      if [ -t 0 ]; then read -rp "$(L "Select [0-10]: " "请选择 [0-10]: ")" MCHOICE || exit 0
      else read -rp "$(L "Select [0-10]: " "请选择 [0-10]: ")" MCHOICE < /dev/tty 2>/dev/null || exit 0; fi
      case "$MCHOICE" in
        1) menu_status ;;
        2) [ -f "$SELF_BIN" ] && bash "$SELF_BIN" adduser || warn "$(L "script copy missing" "缺少脚本副本")" ;;
        3) [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" config || warn "$(L "script copy missing" "缺少脚本副本")" ;;
        4) if [ "$(env_saved ENABLE_ADMIN)" = "1" ]; then
             [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" disable-admin || warn "$(L "script copy missing" "缺少脚本副本")"
           else
             MASUB="$(env_saved ADMIN_SUB)"; MASUB="${MASUB:-admin}"
             echo "$(L "Enabling requires ${MASUB}.${MENU_DOMAIN} already resolving to this host (otherwise it stalls at the DNS check)." "开启后需要 ${MASUB}.${MENU_DOMAIN} 已解析到本机(否则会卡在 DNS 检查)。")"
             [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" enable-admin || warn "$(L "script copy missing" "缺少脚本副本")"
           fi ;;
        5) menu_backup ;;
        6) echo "$(L "==> Pulling latest images and upgrading…" "==> 拉取最新镜像并升级…")"
           { docker compose pull -q && docker compose up -d --remove-orphans && ok "$(L "Upgrade complete" "升级完成")"; } || warn "$(L "Upgrade failed: docker compose logs --tail 30" "升级失败: docker compose logs --tail 30")" ;;
        7) menu_cleanup ;;
        8) { docker compose up -d && docker compose restart; } >/dev/null 2>&1 && ok "$(L Restarted 已重启)" || warn "$(L "Restart failed: docker compose ps" "重启失败: docker compose ps")" ;;
        9) [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" update || warn "$(L "script copy missing" "缺少脚本副本")"
           [ -d "$INSTALL_DIR" ] || exit 0 ;;
        10) [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" uninstall || warn "$(L "script copy missing" "缺少脚本副本")"
           [ -d "$INSTALL_DIR" ] || exit 0 ;;
        x|X) menu_proxy ;;
        o|O) menu_oprf ;;
        c|C) menu_cf_cert ;;
        k|K) [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" license || warn "$(L "script copy missing" "缺少脚本副本")" ;;
        p|P) menu_privacy ;;
        s|S) menu_forget_secrets ;;
        b|B) menu_autobackup ;;
        r|R) DEFB="$(ls -1t "$DEFAULT_BACKUP_DIR"/tuwunel-backup-* 2>/dev/null | head -1)"
             echo "$(L "Latest backup found: ${DEFB:-none}" "找到的最新备份: ${DEFB:-无}")"
             read -rp "$(L "  Backup file path [Enter=latest above]: " "  备份文件路径 [回车=上面的最新]: ")" RBF < /dev/tty 2>/dev/null || RBF=""
             RBF="${RBF:-$DEFB}"
             if [ -n "$RBF" ] && [ -f "$SELF_BIN" ]; then INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" restore "$RBF"
             else warn "$(L "No backup file given." "未提供备份文件。")"; fi ;;
        a|A) [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" admin-url || warn "$(L "script copy missing" "缺少脚本副本")"
             [ -d "$INSTALL_DIR" ] || exit 0 ;;
        l|L) [ -f "$SELF_BIN" ] && INSTALL_DIR="$INSTALL_DIR" bash "$SELF_BIN" lang || warn "$(L "script copy missing" "缺少脚本副本")"
             UI_LANG="$(env_saved UI_LANG)"; [ -n "$UI_LANG" ] || UI_LANG=zh ;;   # 重读,菜单立即用新语言重绘
        0|q|Q) echo "$(L Bye. 再见。)"; exit 0 ;;
        *) warn "$(L "Invalid choice, enter 0-10" "无效选择,请输入 0-10")" ;;
      esac
      press_enter "
$(L "Press Enter to return to the menu… " "按回车返回菜单… ")"
    done
  fi
  warn "$(L "Completed deployment detected — just restarting." "检测到已完成的部署,只做重启。")"; docker compose up -d; exit 0
fi

# ---- 语言选择 / Choose UI language(仅新装 + 交互 + 未预设 LANG_UI 时询问;菜单/reconfig 沿用已保存值)----
if [ "$RECONFIG" -eq 0 ] && has_tty && [ -z "$UI_LANG" ]; then
  printf '\nLanguage / 语言:\n  [1] English\n  [2] 中文(简体)\n'
  ask_opt "→ [1/2, Enter=2]: " "2"
  case "$REPLY" in 1) UI_LANG=en;; *) UI_LANG=zh;; esac
fi
[ -n "$UI_LANG" ] || UI_LANG=zh   # 兜底:非交互/未选 → 中文

# ---- 域名 ----
normalize_domain(){ echo "$1" | tr 'A-Z' 'a-z' | sed 's|^https\?://||; s|/.*$||' | tr -d '[:space:]'; }
domain_ok(){ echo "$1" | grep -Eq '^[a-z0-9.-]+\.[a-z]{2,}$' || return 1
  case "$1" in example.com|example.org|yourdomain.*|mydomain.*|domain.com) return 1;; esac; return 0; }

DOMAIN="$(normalize_domain "${1:-}")"
if [ "$RECONFIG" -eq 1 ]; then
  DOMAIN="$(normalize_domain "$(env_saved MATRIX_DOMAIN)")"
  [ -n "$DOMAIN" ] || die "$(L "No deployed config found. 'config' only works on an installed server" "未找到已部署配置。config 只能在装好的服务器上用")"
  echo "$(L "Reconfigure: $DOMAIN (domain can't change; data/accounts unchanged)" "修改配置: $DOMAIN(域名不可改;数据/账号保持不变)")"
fi
until domain_ok "$DOMAIN"; do
  [ -n "$DOMAIN" ] && warn "$(L "'$DOMAIN' is not a usable domain (needs a real one you've purchased)." "『$DOMAIN』不是可用域名(需你已购买的真实域名)。")"
  DPROMPT="$(L "Enter your domain (e.g. mychat.org): " "请输入你的域名(例 mychat.org): ")"
  if [ -t 0 ]; then read -rp "$DPROMPT" DOMAIN || die "$(L Cancelled 已取消)"
  elif [ -e /dev/tty ]; then read -rp "$DPROMPT" DOMAIN < /dev/tty 2>/dev/null || die "$(L "Read failed — pass the domain as an argument" "读取失败,请带域名参数运行")"
  else die "$(L "No interactive terminal. Pass the domain: sudo bash tuwunel-installer.sh mychat.org" "无交互终端。请带域名参数: sudo bash tuwunel-installer.sh mychat.org")"; fi
  DOMAIN="$(normalize_domain "$DOMAIN")"
done

ACME_EMAIL="${ACME_EMAIL:-admin@$DOMAIN}"
M_HOST="matrix.$DOMAIN"; LK_HOST="livekit.$DOMAIN"; RTC_HOST="matrix-rtc.$DOMAIN"; CALL_HOST="call.$DOMAIN"
# 管理后台子域名(默认 admin;可改成 console/manage 等)。ADMIN_SUB= 预设或安装时选择,存 .env。
_ADMIN_SUB_ENV="${ADMIN_SUB:-}"                                  # 记录是否由环境变量显式传入(用于 EXPLICIT 判定)
ADMIN_SUB="${ADMIN_SUB:-$(env_saved ADMIN_SUB)}"                 # 环境变量 > 旧配置;都没有则留空,下面按后台类型定默认
ADMIN_SUB_SET="$ADMIN_SUB"                                       # 非空 = 已显式指定过子域名(交互里不再强改)
if [ -z "$ADMIN_SUB" ]; then
  # 默认子域名随后台类型:VeilX 自研后台 → veilx.域名;Ketesa → admin.域名。
  case "${ADMIN_UI:-$(env_saved ADMIN_UI)}" in ketesa) ADMIN_SUB="admin";; *) ADMIN_SUB="veilx";; esac
fi
ADMIN_SUB="$(echo "$ADMIN_SUB" | tr 'A-Z' 'a-z' | tr -d '[:space:]')"
# 只允许一个合法 DNS 标签(小写字母数字+连字符,不含点);非法或与其它服务冲突则回落 admin
echo "$ADMIN_SUB" | grep -Eq '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' || ADMIN_SUB="admin"
case "$ADMIN_SUB" in matrix|livekit|matrix-rtc|www) ADMIN_SUB="admin";; esac
A_HOST="$ADMIN_SUB.$DOMAIN"
apt-get update -qq >/dev/null 2>&1 || true
command -v curl >/dev/null 2>&1 || apt-get install -y -qq curl || die "$(L "curl install failed" "curl 安装失败")"
command -v openssl >/dev/null 2>&1 || apt-get install -y -qq openssl || die "$(L "openssl install failed" "openssl 安装失败")"
PUBLIC_IP="$(curl -4 -fsS --max-time 10 https://ifconfig.me 2>/dev/null || curl -4 -fsS --max-time 10 https://api.ipify.org 2>/dev/null || true)"
bold "$(L "Target: $DOMAIN  →  server ${PUBLIC_IP:-unknown}  →  dir $INSTALL_DIR  (engine: tuwunel/Rust)" "目标: $DOMAIN  →  服务器 ${PUBLIC_IP:-未知}  →  目录 $INSTALL_DIR  (引擎: tuwunel/Rust)")"

# ---------------------------------------------------------------------
# 选项(回车=推荐默认;重跑沿用;环境变量可预设)
# ---------------------------------------------------------------------
EXPLICIT=0; [ -n "${REG_MODE:-}${ENABLE_FEDERATION:-}${ENABLE_CALLS:-}${ENABLE_WEB:-}${ENABLE_ADMIN:-}${ENABLE_ELEMENTX:-}${ENABLE_PRIVACY:-}${PRIVACY:-}${MAX_UPLOAD:-}${_ADMIN_SUB_ENV:-}${ENABLE_OPRF:-}${CF_ORIGIN:-}${VEILX_ONLY:-}" ] && EXPLICIT=1
# ============ VeilX 孤岛姿态:锁死,不再逐项询问 ============
# VeilX = 自建、只服务本团队的孤岛。以下六项是产品定义的一部分,不作为安装选项:
#   REG_MODE=token       仅持邀请码者可注册
#   ENABLE_FEDERATION=0  关联邦,外人无法向成员发消息(攻击面最小)
#   ENABLE_WEB=0         不装 Element Web 第三方网页客户端
#   ENABLE_ELEMENTX=0    关 Element X 自助注册(原生 OIDC)
#   ENABLE_PRIVACY=1     隐私加固/元数据最小化(IP 不入库、关在线状态/正在输入、撤回即焚、关遥测)
#   ENABLE_OPRF=1        服务器辅助 PIN(失窃离线手机无法爆破 PIN)
#   VEILX_ONLY=1         只面向 VeilX 客户端(拒绝 Element 等)
# 向导只再问【功能项】:通话 / 管理后台 / 文件上限。
# 极个别确需破例的,仍可用环境变量显式覆盖(如 ENABLE_FEDERATION=1),但绝不弹交互问题;
# 且刻意【不】读取旧配置里这几项 —— 重跑配置一律回到孤岛默认。
REG_MODE="${REG_MODE:-token}"
ENABLE_FEDERATION="${ENABLE_FEDERATION:-0}"
ENABLE_WEB="${ENABLE_WEB:-0}"
ENABLE_ELEMENTX="${ENABLE_ELEMENTX:-0}"
ENABLE_PRIVACY="${PRIVACY:-${ENABLE_PRIVACY:-1}}"
ENABLE_OPRF="${ENABLE_OPRF:-1}"
VEILX_ONLY="${VEILX_ONLY:-1}"
# —— 以下为仍可按团队需要选择的功能项 ——
ENABLE_CALLS="${ENABLE_CALLS:-$(env_saved ENABLE_CALLS)}"
ENABLE_ADMIN="${ENABLE_ADMIN:-$(env_saved ENABLE_ADMIN)}"   # 自托管 Web 管理后台(admin.你的域名)
ADMIN_UI="${ADMIN_UI:-$(env_saved ADMIN_UI)}"   # 后台用哪个:veilx(自研,默认)/ ketesa(通用 synapse-admin)
USE_CDN="${CDN:-$(env_saved USE_CDN)}"   # 服务器前是否有 Cloudflare/CDN 代理(影响 DNS 预检与提示;不改生成的配置)
CF_ORIGIN="${CF_ORIGIN:-$(env_saved CF_ORIGIN)}"   # 全橙云:用 CF Origin 证书顶替 ACME(会改 Caddyfile:每个站点加 tls 指令)
MAX_UPLOAD="${MAX_UPLOAD:-}"
SAVED_BYTES="$(env_saved MAX_UPLOAD_BYTES)"

DEF_REG=1; DEF_FED=N; DEF_CALL=n; DEF_WEB=Y; DEF_ADMIN=Y; DEF_OPRF=Y; DEF_VONLY=Y
if [ "$RECONFIG" -eq 1 ] && [ "$EXPLICIT" -eq 0 ]; then
  has_tty || die "$(L "config needs an interactive terminal; or use env vars: ENABLE_CALLS=1 sudo -E bash tuwunel-installer.sh config" "config 需交互终端;或用环境变量: ENABLE_CALLS=1 sudo -E bash tuwunel-installer.sh config")"
  [ "$ENABLE_CALLS" = "1" ] && DEF_CALL=Y || DEF_CALL=n
  [ "$ENABLE_ADMIN" = "0" ] && DEF_ADMIN=n || DEF_ADMIN=Y
  echo ""; echo "$(L "Isolation + hardening: VeilX island (fixed) — reg=token, federation=off, VeilX-only, no Element Web, privacy on, OPRF on." "隔离+加固: VeilX 孤岛(固定)—— 注册=令牌、联邦=关、只面向VeilX、不装Element Web、隐私加固开、OPRF开。")"
  echo "$(L "Current (adjustable)" "当前(可调)"): $(L Calls 通话)[$([ "$ENABLE_CALLS" = "1" ] && L on 开 || L off 关)] · $(L Admin 管理后台)[$([ "$ENABLE_ADMIN" = "1" ] && L on 开 || L off 关)] · $(L Big-files 大文件)[$(human "${SAVED_BYTES:-4294967296}")]"
  echo "$(L "Press Enter = keep current value." "直接回车 = 保持当前值。")"
  # 只重问【功能项】;隔离/加固六项已锁死为孤岛默认,不清空、不再问。
  ENABLE_CALLS=""; ENABLE_ADMIN=""
fi

if has_tty && { [ -z "$ENABLE_CALLS" ] || [ -z "$ENABLE_ADMIN" ] || [ -z "$MAX_UPLOAD" ]; }; then
  printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "Install options: unsure? just press Enter for the recommended values. (Isolation + hardening are fixed by VeilX — not asked.)" "安装选项:看不懂就直接回车用推荐值。(隔离与加固已由 VeilX 写死,不再询问。)")" "$C_RESET"

  if [ -z "$ENABLE_CALLS" ]; then
    printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "[Option 1/3] Voice/video calls (Element Call)" "【选项 1/3】语音/视频通话(Element Call)")" "$C_RESET"
    printf '%s\n' "$(L "  [n] Off (recommended first) — natively supported, but the call path is newer; get chat + big files stable first.
  [Y] On — also installs LiveKit + lk-jwt; needs two extra DNS records (livekit. / matrix-rtc.) and ports 7881/7882." "  [n] 关闭(推荐先关)—— tuwunel 原生支持,但通话链路较新;先跑稳聊天+大文件。
  [Y] 开启 —— 额外装 LiveKit + lk-jwt,需再加 livekit. / matrix-rtc. 两条 DNS 和 7881/7882 端口。")"
    ask_opt "$(L "→ [y/N, Enter=$DEF_CALL]: " "→ [y/N,回车=$DEF_CALL]: ")" "$DEF_CALL"
    case "$REPLY" in y|Y) ENABLE_CALLS=1;; *) ENABLE_CALLS=0;; esac
  fi

  if [ -z "$ENABLE_ADMIN" ]; then
    printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "[Option 2/3] Web admin panel" "【选项 2/3】Web 管理后台")" "$C_RESET"
    printf '%s\n' "$(L "  [Y] On (recommended) — a graphical admin panel at admin.your-domain: manage users,
       issue/revoke invite codes, deactivate accounts, reset passwords — all in a browser.
       Uses the VeilX Admin panel (our own, tailored to tuwunel). Needs one extra DNS record. ~20-60MB RAM.
  [n] Off — manage only via the command line (sudo tuwunel menu / admin-room commands)." "  [Y] 开启(推荐)—— 在【veilx.你的域名】放一个图形管理后台:浏览器里管理用户、
       发/吊销邀请码、停用账号、改密码,不用敲命令。用的是 VeilX 自研后台(贴着 tuwunel 做)。
       需再加一条对应子域的 DNS。多占约 20-60MB 内存。
  [n] 关闭 —— 只用命令行(sudo tuwunel 菜单 / 管理员房间命令)管理。")"
    ask_opt "$(L "→ [Y/n, Enter=$DEF_ADMIN]: " "→ [Y/n,回车=$DEF_ADMIN]: ")" "$DEF_ADMIN"
    case "$REPLY" in n|N) ENABLE_ADMIN=0;; *) ENABLE_ADMIN=1;; esac
    # 后台一律 VeilX 自研,不再弹问(与 t1.22「默认孤岛·不逐项问」同一哲学):
    # 自研是产品定义的一部分,Ketesa 在 tuwunel 上坏页一堆,问了只会让人选错。
    # 极个别破例仍可用环境变量显式覆盖(ADMIN_UI=ketesa),但绝不弹交互。
    if [ "$ENABLE_ADMIN" = 1 ] && [ -z "$ADMIN_UI" ]; then
      ADMIN_UI=veilx
      # 没显式指定过子域名时,默认跟随后台类型:自研 → veilx.域名;Ketesa → admin.域名。
      if [ -z "$ADMIN_SUB_SET" ]; then
        case "$ADMIN_UI" in ketesa) ADMIN_SUB="admin";; *) ADMIN_SUB="veilx";; esac
        A_HOST="$ADMIN_SUB.$DOMAIN"
      fi
    fi
    # 新装时可自定义后台子域名(默认 admin)。老服务器改子域走 `sudo tuwunel admin-url`。
    if [ "$ENABLE_ADMIN" = 1 ] && [ "$RECONFIG" -eq 0 ]; then
      while :; do
        ask_opt "$(L "   → Admin panel subdomain [Enter=$ADMIN_SUB] (e.g. admin / console / manage): " "   → 后台面板子域名 [回车=$ADMIN_SUB](如 admin / console / manage): ")" "$ADMIN_SUB"
        NEWSUB="$(echo "$REPLY" | tr 'A-Z' 'a-z' | tr -d '[:space:]')"
        case "$NEWSUB" in matrix|livekit|matrix-rtc|www) warn "$(L "'$NEWSUB' is reserved by another service — pick another." "『$NEWSUB』被其它服务占用 —— 换一个。")"; has_tty || { NEWSUB="admin"; break; }; continue;; esac
        echo "$NEWSUB" | grep -Eq '^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' && break
        warn "$(L "Invalid subdomain — one label only: lowercase letters/digits/hyphen, no dots." "子域名不合法 —— 只能一个标签:小写字母/数字/连字符,不含点。")"
        has_tty || { NEWSUB="admin"; break; }
      done
      ADMIN_SUB="$NEWSUB"; A_HOST="$ADMIN_SUB.$DOMAIN"
    fi
  fi

  if [ -z "$MAX_UPLOAD" ]; then
    printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "[Option 3/3] Max file size (send big files/photos/long videos)" "【选项 3/3】单文件上限(发大文件/大图/长视频)")" "$C_RESET"
    echo "$(L "  Set any size (e.g. 4G / 4.5G / 512M / 1T); bigger uses more disk. Enter = 4G." "  设多大都行(如 4G / 4.5G / 512M / 1T);越大越占磁盘。回车=4G。")"
    while :; do
      ask_opt "$(L "→ Max file size [Enter=$(human "${SAVED_BYTES:-4294967296}")]: " "→ 单文件上限 [回车=$(human "${SAVED_BYTES:-4294967296}")]: ")" "${SAVED_BYTES:+$(human "$SAVED_BYTES")}"
      [ -z "$REPLY" ] && REPLY="4G"
      if size_ok "$REPLY"; then MAX_UPLOAD="$REPLY"; break; fi
      warn "$(L "Didn't understand '$REPLY'. Write it like 4G / 4.5G / 512M / 1T (number + K/M/G/T), or press Enter for the default." "看不懂『$REPLY』。请写成 4G / 4.5G / 512M / 1T 这样(数字 + K/M/G/T),或直接回车用默认。")"
      has_tty || { MAX_UPLOAD="4G"; break; }
    done
  fi

fi
REG_MODE="${REG_MODE:-token}"; ENABLE_FEDERATION="${ENABLE_FEDERATION:-0}"; ENABLE_CALLS="${ENABLE_CALLS:-0}"; ENABLE_WEB="${ENABLE_WEB:-1}"; ENABLE_ADMIN="${ENABLE_ADMIN:-1}"
case "$ENABLE_FEDERATION" in 1) :;; *) ENABLE_FEDERATION=0;; esac
case "$ENABLE_CALLS" in 1) :;; *) ENABLE_CALLS=0;; esac
case "$ENABLE_WEB" in 0) :;; *) ENABLE_WEB=1;; esac
case "$ENABLE_ADMIN" in 0) :;; *) ENABLE_ADMIN=1;; esac
case "$ADMIN_UI" in ketesa) :;; *) ADMIN_UI=veilx;; esac   # 默认 VeilX 自研后台
ENABLE_OPRF="${ENABLE_OPRF:-1}"; case "$ENABLE_OPRF" in 0) :;; *) ENABLE_OPRF=1;; esac
# 第二层设备绑定档位。**必须在这里定**:写 .env 与 docker-compose 都在下面用到它,
# 而脚本跑在 set -u 下 —— 早先只在生成 Caddyfile 那段赋值,重跑 config 会
# 在「5/6 生成密钥与配置」处直接报 DEVICE_BIND: unbound variable 而中断。
DEVICE_BIND="${DEVICE_BIND:-$(env_saved DEVICE_BIND)}"; DEVICE_BIND="${DEVICE_BIND:-0}"
# 第三层联邦守卫。与 DEVICE_BIND 同样的赋值位置 —— t1.26 的 unbound variable 事故
# 就是因为变量在 .env heredoc **之后**才赋值,set -u 下当场中断(见 check.sh 检查 #2)。
FED_GUARD="${FED_GUARD:-$(env_saved FED_GUARD)}"; FED_GUARD="${FED_GUARD:-0}"
case "$FED_GUARD" in 1) :;; *) FED_GUARD=0;; esac
case "$DEVICE_BIND" in strict|1) :;; *) DEVICE_BIND=0;; esac
case "$ENABLE_ELEMENTX" in 0) :;; *) ENABLE_ELEMENTX=1;; esac
case "$ENABLE_PRIVACY" in 0) :;; *) ENABLE_PRIVACY=1;; esac
case "$REG_MODE" in token|open) :;; *) REG_MODE=token;; esac
if [ -n "$MAX_UPLOAD" ]; then MAX_UPLOAD_BYTES="$(to_bytes "$MAX_UPLOAD")"
elif [ -n "$SAVED_BYTES" ]; then MAX_UPLOAD_BYTES="$SAVED_BYTES"
else MAX_UPLOAD_BYTES=4294967296; fi

REQUIRED_HOSTS="$DOMAIN $M_HOST"; PORT_LINE="80/tcp 443/tcp 443/udp"
[ "$ENABLE_ADMIN" = "1" ] && REQUIRED_HOSTS="$REQUIRED_HOSTS $A_HOST"
if [ "$ENABLE_CALLS" = "1" ]; then REQUIRED_HOSTS="$REQUIRED_HOSTS $LK_HOST $RTC_HOST $CALL_HOST"; PORT_LINE="80/tcp 443/tcp 443/udp 7881/tcp 7882/udp"; fi
echo ""
printf '  %s%s[%s] · %s[%s] · %s[%s] · %s[%s] · %s[%s] · %s[%s] · %s[%s]%s\n' "$C_GREEN" \
  "$(L '✔ Config: reg' '✔ 配置: 注册')" "$REG_MODE" \
  "$(L Federation 联邦)"    "$([ "$ENABLE_FEDERATION" = 1 ] && L on 开 || L off 关)" \
  "$(L Calls 通话)"         "$([ "$ENABLE_CALLS" = 1 ] && L on 开 || L off 关)" \
  "$(L Web 网页客户端)"     "$([ "$ENABLE_WEB" = 1 ] && L on 开 || L off 关)" \
  "$(L Admin 管理后台)"     "$([ "$ENABLE_ADMIN" = 1 ] && L on 开 || L off 关)" \
  "$(L 'Phone-signup' 手机App注册)" "$([ "$ENABLE_ELEMENTX" = 1 ] && L on 开 || L off 关)" \
  "$(L 'Max-file' 大文件上限)" "$(human "$MAX_UPLOAD_BYTES")" "$C_RESET"

# ---- 向导:必须手动做的事 ----
if has_tty && [ "$RECONFIG" -eq 0 ]; then
  DNS_LINES="      $DOMAIN
      $M_HOST"
  [ "$ENABLE_ADMIN" = "1" ] && DNS_LINES="$DNS_LINES
      $A_HOST"
  [ "$ENABLE_CALLS" = "1" ] && DNS_LINES="$DNS_LINES
      $LK_HOST
      $RTC_HOST
      $CALL_HOST"
  cat <<EOF

${C_CYAN}┌──────────────────────────────────────────────────────────┐${C_RESET}
$(L "  tuwunel one-command install · Rust · lean · built for big files & privacy" "│  tuwunel 轻量一键安装 · Rust 省资源 · 为大文件与保密而生   │")
${C_CYAN}└──────────────────────────────────────────────────────────┘${C_RESET}
$(L "Two things you must do yourself (in your provider's web console):" "两件事必须你在【网页后台】手动做:")

 ${C_B}${C_YELLOW}$(L "① At your domain registrar → add these A records, all pointing to" "① 域名商 → 加下列 A 记录,全部指向") ${PUBLIC_IP:-$(L "your server IP" 本服务器IP)}:${C_RESET}
${C_GREEN}$DNS_LINES${C_RESET}

 ${C_B}${C_YELLOW}$(L "② At your VPS provider → security group / firewall, allow:" "② 服务器商 → 安全组/防火墙 放行:")${C_RESET}
      ${C_GREEN}$PORT_LINE${C_RESET}
EOF
  # 是否用了 Cloudflare/CDN(仅在未通过 CDN= 预设时询问)
  if [ -z "$USE_CDN" ]; then
    printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "Is there a Cloudflare / CDN proxy in front of your server?" "服务器前面用了 Cloudflare / CDN 代理吗?")" "$C_RESET"
    printf '%s\n' "$(L "  [n] No (recommended) — DNS resolves straight to this server. Pick this in almost all cases.
  [y] Yes — relaxes the DNS check. Note: a CDN orange-cloud kills big files (Free/Pro cap 100MB)
      and can block certificate issuance; the matrix & media hosts MUST stay grey-cloud (DNS-only)." "  [n] 没有(推荐)—— DNS 直接解析到本服务器,绝大多数情况选这个。
  [y] 用了 —— 会放宽 DNS 检查。但注意:CDN 橙云会掐死大文件(免费/Pro 上限 100MB)、
      还可能让证书签不出;matrix 与媒体主机务必走【灰云/仅DNS】。")"
    ask_opt "$(L "→ [y/N, Enter=n]: " "→ [y/N,回车=n]: ")" "n"
    case "$REPLY" in y|Y) USE_CDN=1;; *) USE_CDN=0;; esac
  fi
  if [ "$USE_CDN" = "1" ]; then cat <<CDNEOF

 ${C_B}${C_YELLOW}$(L "⚠️ You chose CDN — configure it exactly like this, or big files/certs will fail:" "⚠️ 你选了 CDN —— 务必按下面配,否则大文件/证书会失败:")${C_RESET}
$(L "   · ${C_GREEN}matrix.$DOMAIN and the media host MUST be grey-cloud (DNS-only)${C_RESET} (else Caddy can't get a cert, and big files hit the CDN 100MB cap)
   · At most, put only the static front-ends (web client / admin panel) behind the orange-cloud proxy
   · Cloudflare dashboard: turn OFF Bot Fight Mode; set Bypass cache for /_matrix/* and /.well-known/matrix/*
   · SSL/TLS mode = Full (Strict), not Flexible (causes infinite redirects)
   · Hiding the origin IP via orange-cloud barely works (CT logs / grey-cloud subdomains leak it) — don't rely on it for privacy" "   · ${C_GREEN}matrix.$DOMAIN 和媒体主机必须【灰云/仅DNS】${C_RESET}(否则 Caddy 签不出证书、大文件被 CDN 100MB 上限掐死)
   · 顶多把纯静态前端(网页版/管理后台)挂橙云代理
   · Cloudflare 后台:关 Bot Fight Mode;对 /_matrix/* 与 /.well-known/matrix/* 设 Bypass 缓存
   · SSL/TLS 模式设 Full(Strict),别用 Flexible(会无限重定向)
   · 想用橙云\"藏源站 IP\"基本无效(证书透明度/灰云子域会泄漏),别指望它做隐私")
CDNEOF
  fi
  # 全橙云:必须先问清楚。橙云下 Caddy 的 ACME 一定失败,不预先拿到 Origin 证书,
  # 装到最后会卡在"证书签不出→整站 502",而那时候用户已经不知道该回头改哪里了。
  if [ "$USE_CDN" = "1" ] && [ -z "$CF_ORIGIN" ]; then
    printf '\n%s%s%s\n' "$C_B$C_CYAN" "$(L "Full orange-cloud (every record Proxied)?" "要全橙云吗(所有记录都开代理)?")" "$C_RESET"
    printf '%s\n' "$(L "  [n] No (recommended) — matrix./livekit. stay grey-cloud, Caddy gets its own certs.
  [y] Yes — behind the orange cloud Caddy CANNOT get a certificate (TLS-ALPN-01 is
      terminated by CF; HTTP-01 deadlocks under Full (Strict)). You must paste a CF
      Origin Certificate, and this installer will ask you for it in a moment.
      Cost: 100MB upload cap, and voice/video calls will not work." "  [n] 不是(推荐)—— matrix. / livekit. 保持灰云,Caddy 自己签证书。
  [y] 是 —— 橙云后面 Caddy【签不出证书】(TLS-ALPN-01 被 CF 终止;HTTP-01 在
      Full(Strict) 下死锁)。必须粘一张 CF Origin 证书,稍后会让你粘。
      代价:上传上限 100MB,且语音视频通话用不了。")"
    ask_opt "$(L "→ [y/N, Enter=n]: " "→ [y/N,回车=n]: ")" "n"
    case "$REPLY" in y|Y) CF_ORIGIN=1;; *) CF_ORIGIN=0;; esac
  fi
  press_enter "$(L "Done with ① ②? Press Enter to start… (not done? Ctrl+C to quit) " "①② 做好了按回车开始…(没做?Ctrl+C 退出) ")"
fi

# ---------------------------------------------------------------------
# 1. DNS 预检
# ---------------------------------------------------------------------
case "$USE_CDN" in 1) :;; *) USE_CDN=0;; esac
case "$CF_ORIGIN" in 1) :;; *) CF_ORIGIN=0;; esac
# 全橙云 + 通话 = 一定不通(LiveKit 媒体走 UDP 7881/7882,CF 只代理 HTTP;而且
# use_external_ip=true 会把源站 IP 直接塞进 ICE candidate 发给每个通话参与者)。
if [ "$CF_ORIGIN" = "1" ] && [ "$ENABLE_CALLS" = "1" ]; then
  warn "$(L "Full orange-cloud + calls: LiveKit media is UDP and cannot pass through Cloudflare — calls will not work, and the origin IP leaks into ICE candidates anyway." "全橙云 + 通话:LiveKit 媒体走 UDP,过不了 Cloudflare —— 通话一定不通,而且源站 IP 照样会从 ICE candidate 漏出去。")"
fi
dns_check(){ local bad=0 h R4
  for h in $REQUIRED_HOSTS; do
    R4="$(getent ahosts "$h" 2>/dev/null | awk '{print $1}' | grep -E '^[0-9]+\.' | grep -Ev '^127\.' | sort -u || true)"
    if [ -z "$R4" ]; then warn "$h → $(L "no DNS record" 无解析)"; bad=1
    elif [ -n "$PUBLIC_IP" ] && ! echo "$R4" | grep -qx "$PUBLIC_IP"; then
      if [ "$USE_CDN" = "1" ]; then echo "  ~ $h → $(echo "$R4"|tr '\n' ' ')$(L "(via CDN, not direct — allowed for CDN mode; keep matrix/media grey-cloud)" "(经 CDN,未直连本机——已按 CDN 放行;确保 matrix/媒体走灰云)")"
      else warn "$h → $(echo "$R4"|tr '\n' ' ')$(L "(not pointing at this host $PUBLIC_IP; if you use Cloudflare/CDN, re-run and choose CDN, or set CDN=1)" "(未指向本机 $PUBLIC_IP;若用了 Cloudflare/CDN,请重跑选 CDN 或加 CDN=1)")"; bad=1; fi
    else echo "  ✓ $h → $(echo "$R4"|head -1)"; fi
  done; return $bad; }
if [ "${SKIP_DNS_CHECK:-0}" != "1" ]; then
  bold "$(L "1/6 Check DNS" "1/6 检查 DNS")"; A=0
  until dns_check; do A=$((A+1))
    has_tty || { echo "$(L "DNS not ready — fix the records and re-run." "DNS 未就绪,改好后重跑。")"; exit 1; }
    [ "$A" -ge 40 ] && die "$(L "Waited too long — check the records and re-run." "等待过久,请检查记录后重跑")"
    warn "$(L "DNS not propagated yet, retrying in 60s… (Ctrl+C to quit)" "DNS 还没生效,60 秒后重测…(Ctrl+C 退出)")"; sleep 60
  done; ok "$(L "DNS ready" "DNS 就绪")"
else warn "$(L "DNS pre-check skipped" "已跳过 DNS 预检")"; fi

# ---------------------------------------------------------------------
# 2. Docker + 内存/Swap/BBR + 防火墙
# ---------------------------------------------------------------------
bold "$(L "2/6 Install Docker" "2/6 安装 Docker")"
command -v docker >/dev/null 2>&1 || curl -fsSL https://get.docker.com | sh
docker compose version >/dev/null 2>&1 || apt-get install -y -qq docker-compose-plugin || die "$(L "docker compose v2 unavailable" "docker compose v2 不可用")"

bold "$(L "3/6 System tuning (Swap / BBR)" "3/6 系统调优(Swap / BBR)")"
RAM_MB="$(awk '/^MemTotal:/{print int($2/1024)}' /proc/meminfo 2>/dev/null || true)"   # 缺 /proc/meminfo 也不因 set -e 中止
if [ "${RAM_MB:-0}" -lt 2500 ] && [ -z "$(swapon --show --noheadings 2>/dev/null)" ]; then
  if { fallocate -l 2G /swapfile 2>/dev/null || dd if=/dev/zero of=/swapfile bs=1M count=2048 status=none 2>/dev/null; } \
     && chmod 600 /swapfile 2>/dev/null && mkswap /swapfile >/dev/null 2>&1 && swapon /swapfile 2>/dev/null; then
    grep -q '/swapfile' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
    echo 'vm.swappiness=10' > /etc/sysctl.d/99-tuwunel-swap.conf; sysctl -w vm.swappiness=10 >/dev/null 2>&1 || true
    echo "$(L "RAM ${RAM_MB}MB — added 2G swap." "内存 ${RAM_MB}MB,已加 2G swap。")"
  else rm -f /swapfile; warn "$(L "Couldn't add swap, skipping (no impact)." "无法加 swap,跳过(不影响)。")"; fi
else echo "$(L "RAM ${RAM_MB:-?}MB, swap OK, skipping." "内存 ${RAM_MB:-?}MB,swap OK,跳过。")"; fi
if modprobe tcp_bbr 2>/dev/null || grep -qw bbr /proc/sys/net/ipv4/tcp_available_congestion_control 2>/dev/null; then
  printf 'net.core.default_qdisc=fq\nnet.ipv4.tcp_congestion_control=bbr\n' > /etc/sysctl.d/99-tuwunel-bbr.conf
  { sysctl -p /etc/sysctl.d/99-tuwunel-bbr.conf || sysctl --system; } >/dev/null 2>&1 || true
  [ "$(sysctl -n net.ipv4.tcp_congestion_control 2>/dev/null)" = "bbr" ] && echo "$(L "BBR enabled." "已启用 BBR。")" || warn "$(L "BBR not active immediately (common on container VPS), check after reboot." "BBR 未即时生效(容器型 VPS 常见),重启后再看。")"
else warn "$(L "Kernel doesn't support BBR, skipping." "内核不支持 BBR,跳过。")"; fi
# 内存档:tuwunel 很轻
if [ "${RAM_MB:-0}" -ge 3500 ]; then TUWUNEL_MEM=2g; else TUWUNEL_MEM=1g; fi

bold "$(L "4/6 Firewall (ufw)" "4/6 防火墙 (ufw)")"
SSH_PORT="$(ss -tlnpH 2>/dev/null | awk '/sshd/{sub(/.*:/,"",$4); print $4; exit}' || true)"; SSH_PORT="${SSH_PORT:-22}"
if command -v ufw >/dev/null 2>&1 || apt-get install -y -qq ufw >/dev/null 2>&1; then
  if ufw allow "${SSH_PORT}/tcp" >/dev/null 2>&1; then
    ufw allow 80/tcp >/dev/null; ufw allow 443/tcp >/dev/null; ufw allow 443/udp >/dev/null
    if [ "$ENABLE_CALLS" = "1" ]; then ufw allow 7881/tcp >/dev/null; ufw allow 7882/udp >/dev/null
    else ufw delete allow 7881/tcp >/dev/null 2>&1 || true; ufw delete allow 7882/udp >/dev/null 2>&1 || true; fi
    ufw --force enable >/dev/null; echo "$(L "Allowed: SSH(${SSH_PORT}) + $PORT_LINE" "已放行: SSH(${SSH_PORT}) + $PORT_LINE")"
  else warn "$(L "ufw unavailable — open the ports yourself." "ufw 不可用,请自行放行端口。")"; fi
else warn "$(L "ufw unavailable — open the ports yourself." "ufw 不可用,请自行放行端口。")"; fi
warn "$(L "Your cloud provider's 'security group' must also allow: $PORT_LINE" "云服务商『安全组』也要放行: $PORT_LINE")"

# ---------------------------------------------------------------------
# 5. 目录 / 机密 / 配置文件
# ---------------------------------------------------------------------
mkdir -p "$INSTALL_DIR" || die "$(L "Cannot create $INSTALL_DIR" "无法创建 $INSTALL_DIR")"; cd "$INSTALL_DIR"; INSTALL_DIR="$PWD"
SELF_DST="$INSTALL_DIR/tuwunel-installer.sh"
if [ -n "$SELF_SRC" ] && [ "$SELF_SRC" != "$SELF_DST" ]; then
  cp -f "$SELF_SRC" "$SELF_DST" 2>/dev/null || true
elif [ ! -f "$SELF_DST" ] && [ -n "${TUWUNEL_INSTALLER_URL:-}" ]; then
  # curl|bash 管道模式无文件实体:从 GitHub 原始地址回源下载一份,供以后菜单/adduser 用
  curl -fsSL "$TUWUNEL_INSTALLER_URL" -o "$SELF_DST" 2>/dev/null || true
fi
[ -f "$SELF_DST" ] && HAVE_LOCAL=1 || HAVE_LOCAL=0
# 装一个全局命令:以后开菜单/加人直接 `sudo tuwunel`,不用记路径(BBR 脚本式便利)
if [ "$HAVE_LOCAL" = "1" ] && [ -d /usr/local/bin ]; then
  printf '#!/usr/bin/env bash\nexec bash %s "$@"\n' "$SELF_DST" > /usr/local/bin/tuwunel 2>/dev/null \
    && chmod +x /usr/local/bin/tuwunel 2>/dev/null || true
fi
if [ "$HAVE_LOCAL" = "1" ] && [ -d /etc/cron.d ]; then
  printf '30 4 * * * root INSTALL_DIR="%s" bash "%s" diskguard >/dev/null 2>&1\n' "$INSTALL_DIR" "$SELF_DST" \
    > /etc/cron.d/tuwunel-diskguard 2>/dev/null && chmod 644 /etc/cron.d/tuwunel-diskguard 2>/dev/null || true
fi

# 已完成的部署且非 config → 只重启
if [ "$RECONFIG" -eq 0 ] && [ -f tuwunel.toml ] && grep -q "$MARKER" tuwunel.toml 2>/dev/null && [ -f CREDENTIALS.txt ]; then
  warn "$(L "Completed deployment detected — just restarting." "检测到已完成的部署,只做重启。")"; docker compose up -d; exit 0
fi
# 端口占用预检。"自己的 Caddy 在占"要用两条线索判断:compose ps 依赖 .env 能被解析 ——
# .env 残缺时(如上次配置中途失败)它会返回空,于是把自家容器当成外人、白白拒绝重跑。
# 所以再问一次 docker 本身:凡是名字带 caddy 的运行中容器,都视为自己人。
OWN_CADDY=0
docker compose ps -q caddy 2>/dev/null | grep -q . && OWN_CADDY=1
docker ps -q --filter "name=caddy" 2>/dev/null | grep -q . && OWN_CADDY=1
if [ "$OWN_CADDY" = "0" ]; then
  if ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq ':(80|443)$'; then
    ss -tlnp 2>/dev/null | grep -E ':(80|443) ' | head -3
    die "$(L "80/443 is in use by another program (nginx/apache?) — stop it and re-run. If it is a leftover container of ours: cd $INSTALL_DIR && docker compose down" "80/443 被别的程序占用(nginx/apache?),先停掉再重跑。若是我们自己遗留的容器: cd $INSTALL_DIR && docker compose down")"; fi
fi

bold "$(L "5/6 Generate keys & config" "5/6 生成密钥与配置")"
umask 077
# 全橙云:证书必须在生成 Caddyfile 之前就位。拿不到就【退回自动 HTTPS】——
# 宁可退回,也不能让 Caddyfile 引用一个不存在的文件(那样 caddy 直接起不来 = 整站下线)。
( umask 077; mkdir -p "$CF_DIR_NAME" ) 2>/dev/null || true
if [ "$CF_ORIGIN" = "1" ] && { [ ! -s "$CF_DIR_NAME/origin.pem" ] || [ ! -s "$CF_DIR_NAME/origin.key" ]; }; then
  if ! cf_collect_and_store "$DOMAIN" "$PWD/$CF_DIR_NAME"; then
    warn "$(L "No usable CF Origin certificate — falling back to Caddy automatic HTTPS. If your records are orange-clouded the certificate will fail; set them to grey-cloud, or run: sudo tuwunel cf-cert" "没拿到可用的 CF Origin 证书 —— 退回 Caddy 自动 HTTPS。若记录是橙云,证书会签不出来;请改灰云,或稍后执行: sudo tuwunel cf-cert")"
    CF_ORIGIN=0
  fi
fi
[ -f .env ] && cp -a .env ".env.bak-$(date +%F-%H%M%S)" 2>/dev/null || true
env_get(){ grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- || true; }
REG_TOKEN="$(env_get REG_TOKEN)"; [ -n "$REG_TOKEN" ] || REG_TOKEN="$(openssl rand -base64 24 | tr -dc 'A-Za-z0-9' | cut -c1-24)"
# VeilX 专用代理:重跑安装/改配置时保留开关(compose 重新生成后由 px_apply_compose 再注入)
ENABLE_PROXY="$(env_get ENABLE_PROXY)"; case "$ENABLE_PROXY" in 1) :;; *) ENABLE_PROXY=0;; esac
PROXY_PORT="$(env_get PROXY_PORT)"; PROXY_DEST="$(env_get PROXY_DEST)"
PROXY_SNI="$(env_get PROXY_SNI)";   PROXY_FLOW="$(env_get PROXY_FLOW)"
PROXY_FP="$(env_get PROXY_FP)"; PROXY_HOST="$(env_get PROXY_HOST)"
[ -n "${ENABLE_CALLS_KEYS:-}" ] || true
LK_KEY="$(env_get LIVEKIT_API_KEY)"; [ -n "$LK_KEY" ] || LK_KEY="API$(openssl rand -hex 6)"
LK_SECRET="$(env_get LIVEKIT_API_SECRET)"; [ -n "$LK_SECRET" ] || LK_SECRET="$(openssl rand -hex 32)"

# VEILX_ONLY 的连带效果:别家客户端的入口必须一起关掉,否则"只支持 VeilX"只是句空话。
# Element Web 是竞品客户端本身;oidc_native_auth 那条原生 OIDC 就是专门给 Element X
# 自助注册用的 —— 留着它们等于服务器一边挡 Element、一边把 Element 递给用户。
case "$VEILX_ONLY" in
  0|"") VEILX_ONLY="${VEILX_ONLY:-0}" ;;
  strict)
    # strict = 连图形后台都不装。普通档(1)已经没有任何绕过口子了(后台走同源的 admin
    # 主机,只放行它真正要用的十来个端点,里面没有 /sync 也没有 /send),所以 strict
    # 纯粹是"连这十来个端点也不想暴露"的更保守选择,代价是只能用命令行管理。
    ENABLE_WEB=0; ENABLE_ELEMENTX=0
    if [ "$ENABLE_ADMIN" = "1" ]; then
      warn "$(L "VEILX_ONLY=strict: not installing the graphical admin panel. Manage from: sudo tuwunel" "VEILX_ONLY=strict:不安装图形管理后台。请用 sudo tuwunel 命令行菜单管理。")"
    fi
    ENABLE_ADMIN=0 ;;
  *)
    VEILX_ONLY=1; ENABLE_WEB=0; ENABLE_ELEMENTX=0 ;;
esac

# 先写临时文件再原子替换:heredoc 展开若失败(如未定义变量),`cat > .env` 会先把
# 旧 .env 清空再报错 —— t1.26 首发就这样把一台服务器的配置抹掉了。写 .env.new 则
# 失败时旧文件毫发无损。
cat > .env.new <<EOF
# ===== tuwunel 一键部署机密(勿泄露/删除) $(date +%F) =====
MATRIX_DOMAIN=$DOMAIN
UI_LANG=$UI_LANG
PUBLIC_IP=$PUBLIC_IP
REG_TOKEN=$REG_TOKEN
REG_MODE=$REG_MODE
ENABLE_FEDERATION=$ENABLE_FEDERATION
FED_GUARD=$FED_GUARD
ENABLE_CALLS=$ENABLE_CALLS
ENABLE_WEB=$ENABLE_WEB
ENABLE_ADMIN=$ENABLE_ADMIN
ADMIN_UI=$ADMIN_UI
ADMIN_SUB=$ADMIN_SUB
ENABLE_ELEMENTX=$ENABLE_ELEMENTX
ENABLE_PRIVACY=$ENABLE_PRIVACY
ENABLE_OPRF=$ENABLE_OPRF
DEVICE_BIND=$DEVICE_BIND
ENABLE_PROXY=$ENABLE_PROXY
PROXY_PORT=$PROXY_PORT
PROXY_DEST=$PROXY_DEST
PROXY_SNI=$PROXY_SNI
PROXY_FLOW=$PROXY_FLOW
PROXY_FP=$PROXY_FP
PROXY_HOST=$PROXY_HOST
USE_CDN=$USE_CDN
CF_ORIGIN=$CF_ORIGIN
VEILX_ONLY=$VEILX_ONLY
VEILX_ISSUER=$VEILX_ISSUER
MAX_UPLOAD_BYTES=$MAX_UPLOAD_BYTES
TUWUNEL_MEM=$TUWUNEL_MEM
LIVEKIT_API_KEY=$LK_KEY
LIVEKIT_API_SECRET=$LK_SECRET
EOF
mv -f .env.new .env
chmod 600 .env

# ---- tuwunel.toml ----
# 注册:token 模式 = allow_registration=true + registration_token(需令牌);open = 无令牌(需额外确认标志)
if [ "$REG_MODE" = "open" ]; then REG_LINES="allow_registration = true
yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse = true"
else REG_LINES="allow_registration = true
registration_token = \"$REG_TOKEN\""; fi
if [ "$ENABLE_FEDERATION" = "1" ]; then FED_LINE="allow_federation = true"; TRUST_LINE="trusted_servers = [\"matrix.org\"]"
else FED_LINE="allow_federation = false"; TRUST_LINE="trusted_servers = []"; fi
if [ "$ENABLE_CALLS" = "1" ]; then WK_LIVEKIT="livekit_url = \"https://$RTC_HOST\""; else WK_LIVEKIT="# livekit_url = \"\"  # 通话关闭"; fi
# Element X 手机 App 自助注册:开启 tuwunel 内置 OIDC(不需另装 MAS)。注册仍走 UIAA、强制邀请码,不绕过。
# 安全前提:本脚本【不添加任何 [[global.identity_provider]] 上游 IdP】(那才会绕过邀请码),故开着也守得住邀请制。
if [ "$ENABLE_ELEMENTX" = "1" ]; then OIDC_LINE="oidc_native_auth = true          # 让 Element X 手机App能自助注册/登录(原生OIDC;注册仍强制邀请码)"
else OIDC_LINE="# oidc_native_auth = false        # Element X 手机注册关闭(成员改用网页注册/管理员建号;Element X 仍可用密码登录)"; fi

# ---- 隐私加固(元数据最小化)----
# 依据 tuwunel v1.8.2 源码核实的键;只用确认存在的键(写错的键会让容器起不来,故本块有回滚保护)。
# 代价仅:在线状态/正在输入消失(对保密团队反而是优点)。关掉用: PRIVACY=0 sudo -E tuwunel config
if [ "$ENABLE_PRIVACY" = "1" ]; then PRIVACY_LINES='
# ===== 隐私加固:元数据最小化 =====
# 【最高价值】只认 TCP 对端(=Caddy 容器),真实客户端 IP 根本进不了库。
# tuwunel 在建设备时无条件记录 IP 且无开关可关,这是唯一缓解手段。
# 副作用:按 IP 的限流失效(所有人看起来同一 IP);邀请制内网部署可接受。
ip_source = "connect_info"

# 日志:warn 恰好切掉两处会把 client_ip 打进容器日志的 span。切勿设 debug(会记录每个请求)。
log = "warn"
log_span_events = "none"

# 在线状态:三个键必须一起关(只关出向会被启动校验拒绝)。关掉即不再落盘 presence,也不再刷新设备 last-seen。
allow_local_presence = false
allow_incoming_presence = false
allow_outgoing_presence = false

# 正在输入:tuwunel 只存内存不落盘,这两键是联邦方向的双保险。
allow_outgoing_typing = false
allow_incoming_typing = false

# 【陷阱修复】撤回即真撤回:默认会把撤回消息的原文再留 60 天且管理员可取回。
save_unredacted_events = false
redaction_retention_seconds = 0
allow_room_admins_to_request_unredacted_events = false

# 资料/目录暴露面收紧
require_auth_for_profile_requests = true
allow_inbound_profile_lookup_federation_requests = false
allow_device_name_federation = false
allow_public_room_directory_without_auth = false
allow_unlisted_room_search_by_id = false
# 注:show_all_local_users_in_user_directory 与 lockdown_public_room_directory 属于
# 【团队可发现性】,不在这里设 —— 它们由上面 [global] 统一按"团队通讯录"模型放开
# (元数据最小化 ≠ 关掉团队内部的用户/房间发现,两者是两回事)。此处若重复设置会与
# [global] 冲突(同一 key 出现两次),故刻意留在 [global]。

# 管理房操作流水:默认会把注册/改密码/停用推进管理房,形成可读的元数据流水
admin_room_notices = false
log_guest_registrations = false

# 第三方遥测(默认就关,写死防回归)
sentry = false
allow_jaeger = false
tracing_flame = false

# RocksDB 自身日志
rocksdb_log_level = "error"
rocksdb_log_stderr = false
rocksdb_max_log_files = 1'
else PRIVACY_LINES='# (隐私加固未启用:PRIVACY=1 sudo -E tuwunel config 可开启元数据最小化)'; fi

# 改配置前先备份,便于新键不被本版 tuwunel 接受时自动回滚(见下方 READY 失败处理)
TOML_BAK=""
[ -f tuwunel.toml ] && { TOML_BAK="tuwunel.toml.bak-$(date +%s)"; cp -a tuwunel.toml "$TOML_BAK"; }
cat > tuwunel.toml <<EOF
# ===== $MARKER($(date +%F))=====
[global]
server_name = "$DOMAIN"          # 一旦部署不可更改!改需清库
database_path = "/var/lib/tuwunel"   # RocksDB:数据库+媒体都在这
address = ["0.0.0.0"]            # Docker 下必须 0.0.0.0,让 Caddy 能连到
port = 8008
max_request_size = $MAX_UPLOAD_BYTES   # 单文件上限(字节)= $(human "$MAX_UPLOAD_BYTES")
allow_encryption = true          # 允许端到端加密(注意:这只是"允许",不强制)
# 【关键】强制新建房间默认开启 E2EE。此键默认为 "none",不写的话客户端没主动要求加密时,
# 消息正文会以明文存进数据库——对保密场景是致命缺口。
encryption_enabled_by_default_for_room_type = "all"
grant_admin_to_first_user = true # 第一个注册的人=服务器管理员
create_admin_room = true
new_user_displayname_suffix = "" # 去掉默认昵称后缀
# 【团队通讯录】默认 false 时,搜索只返回"和你同过房 / 在公开房间里"的人 —— 一个刚注册、
# 还没进任何房的同事谁都搜不到。VeilX 是团队服务器,设 true 让任何成员都能在搜索里找到
# 本服【全部】用户。这只影响【用户目录】的可见性:私密房间不受影响,外服用户由客户端域名
# 过滤挡在门外。要退回"只显示见过的人"把这行删掉即可。
show_all_local_users_in_user_directory = true
# 【团队通讯录·房间】默认 false=普通成员就能把自己建的公开房发布到目录,别人才搜得到。
# 设 true 会锁成"只有管理员能发布",于是成员建的公开房永远进不了目录、谁都搜不到
# (客户端建公开房时发布被拒,会退回"不发布",房间存在但搜索为空)。VeilX 要成员能自助
# 建可被发现的群,这里必须放开。要收紧成"管理员审核制"就改成 true。
lockdown_public_room_directory = false
# 公开房间目录:不对联邦开放(下行),但本服已登录成员仍可查询 —— 通讯录里的"公开房间/群组"
# 靠的就是它,私密房间不发布就不进目录。
allow_public_room_directory_over_federation = false
$OIDC_LINE
$FED_LINE
$TRUST_LINE
$REG_LINES
$PRIVACY_LINES

[global.well_known]
client = "https://$M_HOST"
server = "$M_HOST:443"
$WK_LIVEKIT
EOF
chmod 600 tuwunel.toml

# ===== VEILX_ADMIN_FILES (generated by scripts/sync-admin.py — do not edit by hand) =====
# 把自研管理后台静态文件解包到 $1。内容 = index/css/js + logo(gzip tar → base64)。
# 由 scripts/sync-admin.py 从 veilx-admin/ 生成;别手改这段 base64。
veilx_admin_write_files() {
  local dir="${1:?veilx_admin_write_files: missing dir}"; mkdir -p "$dir"
  base64 -d <<'VEILX_ADMIN_B64' | tar xzf - -C "$dir"
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
7Quveqz+qWtXn/2yY9KcDcveG/rQmisGFvYc6Zlo7jNCLOS/GP7KJaJv+9OP3tPy9Lkjvv9o1wLH
0bb55rW3tSx+5Y4X1rzuGTK69rzb33mnsfzHNw/e3+j5LuVPradf1rz+ueM/fiw8d7uj2xlXXdx8
T3hW9u8eWt+649HUc76sXfH4kgkbgi/ssVzaO27SE2deFmi6/IS7slvjsttG/n365yvnFa84OcK1
K/P0Flik/bM3j4z/fdL2+JuWnjX+q5TLPpnbPKLv+88POYOffWJx3Ywdh7989f33j3nfm33y7m8G
eb/c9PiW1n65//o4d9pFw27jrokfeefrZ939z+CVC3ZusexbYjmYKhZdcW+3AeNrnnpnbf2g44V9
br9i3KKVF/bKXfnCrTsX9K7NmLaLu7Z8+O0nF34Uf/7XcdNyHj82+OVeJ//0NOd4qnLulSWVN53Y
u6hq2ztvvvn2G2WfJ9w/c8L0l79M+XDZlJOTDzgm/XFb8QzuRud9V33auDE9tGf1gPDs/U8nTih+
eh9/4wnLu93Xrt3x9bjJZ89deeb92z7u5Szdumze7qIpp80c8Ibl7xc/88Swpt01OcK3w1z7j1bO
XNpn9l/j7nv1YOP5Fx3s9+c7BjV/81a/H2/wX3jiguPHJtWcdmaNsPmBtwt7PbNgUdK7r5VUXLZk
ZOmbpZdMeXJiYfeR9++tHOoe/EzWzIm9Vkxff0ZhUfkDXy/+emyjZYho2rV+SWZ51l7zZwd2DLlh
esHv7qk5MdzT50OubtWOu/4UvO3KYX94bPoPkS0f3LjpphdPDvrHy3n/eOfJioH7J05/smCue/vl
W8YnjZ7Zqzg0/K7UV675MnHE3NMaT1+d9XJTyhD7VP620c9smH35rBFl1skzN5x//jNTul+7t23j
R38ffODqFEdxvPWFK2afMX5BSfLyUm7PW+c+OaRn7h+K0v/wzdMbx33Y8/4/fnnFzjf3fPCyRyx5
/9whL11xUeNf+o15oceeHS+XWqYkjx62YvGexsqDfXcFFxVek/H8jukPXfmHgic2DT/j0b1r1+zP
/sj7dPatT1Wumf78h5ud/W5sCF+YaI1ctcSe/PYoU4+N3IbxiU98fmL08hFnN6a//cO7gdXLbFPf
vm/GM3F3Tx05dtsD1rXTXuh1+L3x269+tzgndfdXI+Lu+mdOVd2e/dtGPfn6Fe0TCzLGu56Yde0T
Lw75U+uty0bG/XuTc6v7xp2XD5lWULW09Ik7Pr5326UnGi7Iyhv67bn+SzMOpL7Q9zvP5zXWfy8p
y9tw7clj//5374fvevW+r/62Nc/+4JHK2565cNvs4TNm93l3W+SBb5ZWda/rf/uJKf/cdUbNbVlX
TKj464Qb7BPeuvXVe7vvO33LeedOmDAsa2Qv4eiIFdmpu95Yc/nNZ058puqmIYMfnfXn+N6XTT19
5Ac9Hnx6yLPbs1/Lnv7g/BXdLv1X69blB19acfStlJ23/61pQ+WsTfuXLnF+/dSoPgnbJq89bfdl
Ww4nZH5sGzys+3Nt82fG3VQZd/im+nTzzpVvra16MG3BmAMHxp23+rbZJXFj7iqcl3dG/BgT//qS
6fNveer00UXuolmmS/wnC/78zvDQgun/k+mNHCj6c/DOg09lZe++s29PYdHJbk+dGNznwW6j+911
TS+h1ytP9bqyT/3cP7864VLfny2nv5T24d7GPv0mba0aU/nW4fbbRp99ru+h7C9vahxzxfBVb1+2
f03ZY7U35W6IW1Tz9OY92a+9uf/VW7/8cLVv3DixvHXF9hWvPzk757vlY9avvf7MPiObnBNvu+N/
nJd98/qw/WNvuGnyk9/MW3VPUs3mOT3eunvbvvNe9yx+/4LG1Mcr+1zQ47ZBPWueuK03v+r2TxNP
v3hsS/3w8IXmBTNyPdcv+4t1Qq+pNWdes+Di6deftungwYN3ekdtf4B/oXnE1wsyLttSJQzPT9z4
lz8lpfYf+t73mdPXZJ5TsLZoT+a7eytGnPhs51uTA5esmbWYv+bTV/+0Zsh3G8Sjr5333TUDL/7I
EkltrB54ZHL7+uuzgpufz2u+0V7taZuc8+bmG/+xs2b32zm3mC4+dHrlVeW3ZPc6s/vud/uf/8WK
pmX1dVmfLXvznB7Lhry9cWivE4lLbdXP9ph25hPPn5U/YGefSR8emFn1WPe7XzrxXfD3Xnf3i5+4
+PGsCSXPrn/Z/dSzdX0vG/VUzZL6pCtW7vBdsUAY+u0ni+IuuKh5yXtxfe+87Mmmq4odc3rZn+u2
/o/fNiy74bSxVcNHv1s88bkLN2W8+smoLfXByelTs0ae5p1z7ekl71Vcsu9A+S3X7nnP1//dNwdV
PnpRsLh47LqBwrfvRV78oOiLNf2bR6+2l717aG96VXVt6advFq3ud/wfz3+S/7f9ZyW8e+u+ivKL
Zndf5+eeL716/Q+Wx+obRx/tw2e0vpw+M+mG0Uv/Odt0qenczYsemmbOHi9csuv5e5wXH7rwpozJ
pxf0mvXwZ9WHxz698sbNTz0zLenWuxbN2+1746bfXbHsoePCyLS4+j9cMC598g2vvVfxpx5nr/G9
+9HGBQOCf3m//MPZvWe965gsTuu5zDrl3tap335w36fXfzvoH23nJRypLp/uu3Dxisbny06kX366
45b9Xxx7fdT8UM3ZW77o+0nftN69khpto5ckzbs49drmLw6vaJ15+eLHZh+41tx++cozUt7skeEy
j3j6i/ABa8lHdVclv5Z604UXeMbb1wbi/AVJ27//59muk8l/DzUM/NsFDfe/kXX2FzNPfpH80aMn
Tpv/Ys8ZQ54rv7D3S/sKu3/2t94P//vt0HH4X79p+04ceKD82de7P+Uc/VX17iUjqmbX3bTDsvOe
M2qeT795ZuId7/714qnfNC8dmHX1rP7ltptGnBg8akrDhGl/5Cd/Pqvb0GD73Z8ufvb5Edf2N93p
XDh9do9b3pxRPqNkbean7wZeTD+5pt/zD2deFrn4o6nJhY/XP5b1xaq/8i8XfPl+r5fu/XTaietv
+Kjn50/VvvRj24519387d+cdu+5+8roi8xPnvRhecm/k34l93rz6oRMPlj+f3j3cPWfMS1dXPZoU
unBeYPzVySPGvt3/vCsDu6yLP9j11odjN2+4oPqHjOdTw+9c/FjRZZfyV/snPvqs6XePXbl8d3PT
8HWBN91XHWvdHWh9/qxu/V41T+35+/bNRaOWxd//2d+2riv1PzN36c5Ft98387Gr51/7Rv8t5V+1
PHPZtAf+nPfte/kJSy7rPZYb5N3yxDV/PPLHLxeLU4ufXPj72deFGieMf/Pyi2888/itce+ee9dB
68vT6hf/PWtj9ZX7Ltr+LeeyjNm7Zdes9FmN6/a1Llo6qmr8yFu+PPuxC89bdIWQlHR53Ne9517y
1Jp+S7cv+eySC+d+/80H4sn7v/vH2QsHFSd9njmr1/Hv3z+74cevdx//8Q9Tb374zv1FnxZ/tXJO
f3vc+L+eSH4tPfXxJ65+LfOs617+6MYLZ7y3c9zjR3Pee2/jM2mpLRe0fXvwg3/9bd6P/9rb++FP
vvii+PKvpr4w+KmN/7r25k+O9TEPf+J3jrEXXbrt6qQbui9q6jt15avJ3Z4Z3/+iV/9668y4y564
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
aAEA
VEILX_ADMIN_B64
}
# ===== /VEILX_ADMIN_FILES =====

# ---- docker-compose.yml ----
cat > docker-compose.yml <<EOF
name: tuwunel

x-logging: &log
  driver: json-file
  options: { max-size: "10m", max-file: "3" }

services:
  tuwunel:
    image: $TUWUNEL_IMAGE
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    environment:
      TUWUNEL_CONFIG: /etc/tuwunel.toml
    volumes:
      - ./tuwunel.toml:/etc/tuwunel.toml:ro
      - ./data/tuwunel:/var/lib/tuwunel
    mem_limit: \${TUWUNEL_MEM}
    networks: [internal]
EOF

if [ "$ENABLE_CALLS" = "1" ]; then
cat >> docker-compose.yml <<'EOF'

  lk-jwt-service:
    image: ghcr.io/element-hq/lk-jwt-service:latest
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    environment:
      LIVEKIT_URL: "wss://livekit.${MATRIX_DOMAIN}"
      LIVEKIT_KEY: ${LIVEKIT_API_KEY}
      LIVEKIT_SECRET: ${LIVEKIT_API_SECRET}
      LIVEKIT_FULL_ACCESS_HOMESERVERS: ${MATRIX_DOMAIN}
      LIVEKIT_JWT_BIND: ":8080"
    extra_hosts:
      - "${MATRIX_DOMAIN}:host-gateway"
      - "matrix.${MATRIX_DOMAIN}:host-gateway"
    mem_limit: 64m
    networks: [internal]

  livekit:
    image: livekit/livekit-server:latest
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    command: ["--config", "/etc/livekit.yaml"]
    volumes:
      - ./livekit.yaml:/etc/livekit.yaml:ro
    ports:
      - "7881:7881/tcp"
      - "7882:7882/udp"
    mem_limit: 256m
    networks: [internal]

  # 自托管通话前端 —— 不再从 call.element.io 拉界面。
  # 媒体本来就走自己的 LiveKit;把这一层也拿回来之后,通话链路【完全在自己服务器上】,
  # 断开外网也能内部通话,element.io 也看不到"谁在什么时候开了通话"这类元数据。
  element-call:
    # ⚠️ **锁版本,不要用 :latest**。通话前端是通过 matrix-rust-sdk 的 widget driver
    # 跟客户端说话的,而客户端那个 SDK 是锁死的(26.7.x)。前端跑太新会出现版本错配:
    # 新前端发的动作旧 driver 不认识(实测报 `unknown variant io.element.device_mute`
    # / `io.element.join` / `set_always_on_screen`),新前端要的能力旧 driver 没有槽位
    # (always_on_screen / timeline / sticky_event / rtc_transports 全被拒),
    # 表现就是**通话能通、但表情等功能整块失灵**。
    # v0.22.0(2026-07-20)与 SDK 26.7.15 同期,是当前匹配的版本。
    # 升级客户端 SDK 时,这里要一起往上抬。
    image: ghcr.io/element-hq/element-call:v0.22.0
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    volumes:
      - ./element-call-config.json:/app/config.json:ro
    mem_limit: 128m
    networks: [internal]
EOF
fi

# 自托管网页客户端 Element Web(你的域名注册/登录)
if [ "$ENABLE_WEB" = "1" ]; then
cat >> docker-compose.yml <<'EOF'

  element-web:
    image: vectorim/element-web:latest
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    environment:
      ELEMENT_WEB_PORT: "8080"   # 新版镜像是非 root nginx,绑不了 80;改用 8080(Caddy 转发到这)
    volumes:
      - ./element-config.json:/app/config.json:ro
    mem_limit: 96m
    networks: [internal]
EOF
fi

# 自托管 Web 管理后台。ADMIN_UI=ketesa 时才起 Ketesa 容器;VeilX 自研后台是纯静态,
# 不需要单独容器——由 Caddy 的 file_server 直接托管(见下方 veilx-admin 挂载与 Caddy 配置)。
if [ "$ENABLE_ADMIN" = "1" ] && [ "$ADMIN_UI" = "ketesa" ]; then
cat >> docker-compose.yml <<'EOF'

  ketesa:
    image: ghcr.io/etkecc/ketesa:latest
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    environment:
      SERVER_HOST: "0.0.0.0"    # 非 root SWS,绑所有 IPv4 让 Caddy 连得到(默认可能只监听 IPv6)
    volumes:
      - ./ketesa-config.json:/var/public/config.json:ro
    mem_limit: 64m
    networks: [internal]
EOF
fi

# VeilX 加固:服务器辅助 PIN(OPRF)。保管每账号的 ristretto255 密钥 k,对客户端盲化点算 k·B。
# 它看不到 PIN、不下发 k;因为解锁必须问它,失窃且离线的手机就无法爆破 PIN。
if [ "$ENABLE_OPRF" = "1" ]; then
  oprf_write_files
cat >> docker-compose.yml <<'EOF'

  oprf:
    build: ./oprf
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    depends_on: [tuwunel]
    environment:
      HOMESERVER: "http://tuwunel:8008"   # 容器内直连,校验 token 归属(不出网)
      BIND: "0.0.0.0:8787"
      DB: "/data/oprf.db"
      RATE_LIMIT: "30"
      RATE_WINDOW_SECS: "3600"
      # 第三层联邦守卫。1=只和持有厂商证书的 VeilX 服务器联邦(默认 0=关)。
      # 厂商公钥与客户端 pin 的是同一把 —— 服务器之间用同一套信任根。
      FED_GUARD: "${FED_GUARD:-0}"
      VENDOR_PUBKEY: "$VENDOR_PUBKEY"
      # 第二层设备绑定档位。0=关(默认,只记录不拦截) 1=观察 strict=强制。
      # strict 由 \`sudo tuwunel device-bind\` 切换,那里有二次确认与断线警告。
      DEVICE_BIND: "$DEVICE_BIND"
    volumes:
      - ./data/oprf:/data
    mem_limit: 64m
    networks: [internal]
EOF
fi

cat >> docker-compose.yml <<'EOF'

  caddy:
    image: caddy:2
    restart: unless-stopped
    logging: *log
    security_opt: ["no-new-privileges:true"]
    depends_on: [tuwunel]
    ports: ["80:80", "443:443", "443:443/udp"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - ./cf-origin:/etc/caddy/cf:ro          # 全橙云用的 CF Origin 证书(没开时是空目录,无副作用)
      - ./veilx-admin:/srv/veilx-admin:ro     # VeilX 自研后台静态文件(ADMIN_UI=veilx 时由 file_server 托管;否则是空目录,无副作用)
      - ./veilx-wk:/srv/veilx-wk:ro           # 服务器授权证书 license.json(t1.24;没申请到时是空目录,无副作用)
      - ./data/caddy/data:/data
      - ./data/caddy/config:/config
    mem_limit: 128m
    networks: [internal]

networks:
  internal: { driver: bridge }
EOF

# ---- well-known JSON(开通话则带 rtc_foci)----
if [ "$ENABLE_CALLS" = "1" ]; then
  # 通告自建通话前端(`io.element.call.url`):客户端据此自动指向 call.你的域名,
# 用户不必手动填,也就不会再落回 call.element.io。
CLIENT_WK="{\"m.homeserver\":{\"base_url\":\"https://$M_HOST\"},\"org.matrix.msc4143.rtc_foci\":[{\"type\":\"livekit\",\"livekit_service_url\":\"https://$RTC_HOST\"}],\"io.element.call.url\":\"https://$CALL_HOST\"}"
else
  CLIENT_WK="{\"m.homeserver\":{\"base_url\":\"https://$M_HOST\"}}"
fi

# ---- Element Web 配置(锁定到本服务器)+ 根域名要不要挂网页客户端 ----
if [ "$ENABLE_WEB" = "1" ]; then
  cat > element-config.json <<EOF
{
  "default_server_config": {
    "m.homeserver": { "base_url": "https://$M_HOST", "server_name": "$DOMAIN" }
  },
  "disable_custom_urls": true,
  "disable_guests": true,
  "brand": "$DOMAIN",
  "default_country_code": "CN",
  "show_labs_settings": false
}
EOF
  chmod 644 element-config.json   # 关键!element-web 以非 root nginx 运行,须可读;此文件无机密(公开的客户端配置)
  ROOT_HANDLE="reverse_proxy element-web:8080"
else
  rm -f element-config.json 2>/dev/null || true
  ROOT_HANDLE="respond \"$DOMAIN — Matrix (tuwunel)\" 200"
fi

# ---- 管理后台文件:按 ADMIN_UI 写对应的一份 ----
# veilx-admin 目录始终存在(Caddy 恒挂载它;不用时是空目录,无副作用)。
mkdir -p veilx-admin
if [ "$ENABLE_ADMIN" = "1" ] && [ "$ADMIN_UI" = "veilx" ]; then
  # VeilX 自研后台:纯静态,解包到 veilx-admin/,由 Caddy file_server 托管(见 Caddyfile 的 $A_HOST 块)。
  # 面板同源调用 /_matrix、/_synapse、/oprf —— 都由那块的 handle 反代给 tuwunel / oprf。
  veilx_admin_write_files veilx-admin
  chmod -R a+rX veilx-admin   # Caddy 以非 root 运行,静态文件须可读(无机密)
  rm -f ketesa-config.json 2>/dev/null || true
elif [ "$ENABLE_ADMIN" = "1" ] && [ "$ADMIN_UI" = "ketesa" ]; then
  # Ketesa 配置:restrictBaseUrl 锁死"服务器地址"输入框,指向 admin 主机自己(同源、免 CORS、
  # 无需在 matrix 主机为后台开例外)。wellKnownDiscovery=false = 直连 admin 主机上的 /_matrix 与 /_synapse。
  find veilx-admin -mindepth 1 -delete 2>/dev/null || true   # 清空未用的自研后台文件
  cat > ketesa-config.json <<EOF
{"restrictBaseUrl":"https://$A_HOST","wellKnownDiscovery":false}
EOF
  chmod 644 ketesa-config.json   # Ketesa 以非 root(sws)运行,须可读;无机密(仅指向你的服务器地址)
else
  rm -f ketesa-config.json 2>/dev/null || true
  find veilx-admin -mindepth 1 -delete 2>/dev/null || true
fi

# ---- Caddyfile ----
# 全橙云:显式指定证书 → Caddy 对这些站点完全不启用 ACME(橙云下 ACME 必失败)。
# CF_ORIGIN=0 时 $TLS_LINE 为空串,站点块里只多一个空行,行为和以前完全一致。
if [ "$CF_ORIGIN" = "1" ]; then
  TLS_LINE="	tls /etc/caddy/cf/origin.pem /etc/caddy/cf/origin.key"
else
  TLS_LINE=""
fi
# 只面向 VeilX 客户端:拒绝 User-Agent 不含 VeilX 的 /_matrix/client/* 与
# /_synapse/admin/*。三条刻意的例外,少一条就会打到自己人:
#  · /_matrix/client/versions —— 规范规定它是公开的,而且安装器的就绪检查、
#    菜单的在线检查、客户端的"主服务器加速"验证都在打它(那几处是裸 curl/URLSession)。
#  · 管理后台**不在这里开例外**。Ketesa 改成同源走 admin 主机(见 ketesa-config.json),
#    它需要的那十来个端点在 admin 主机上单独放行 —— 那份清单里没有 /sync、没有
#    /rooms/*/messages、没有 /send,所以就算有人把 Element 指过去也同步不了、发不出。
#    早先版本在这里放行 `Origin: https://admin.<域名>`,那是个人人可用的口子:
#    Origin 同样是请求方自述的,而 admin 子域是公开 DNS —— 加一个请求头即可绕过整道拦截。
#  · /_matrix/federation/* 与 /_matrix/key/* 不在拦截范围内 —— 联邦流量不带 UA 约束,
#    否则一旦开启联邦会整个断掉。
# ⚠️ UA 与 Origin 都是请求方自述的字符串,伪造成本约等于零;已登录的会话也不受影响。
# 这一层挡的是"随手装了个 Element 的同事",不是有心人。
if [ "$VEILX_ONLY" != "0" ]; then
  # 自建通话前端(call.<域名>)也要放行:Element Call 跑在浏览器/WebView 里,
  # 要访问 /_matrix/client/* 才能工作,而它的 UA 不含 VeilX —— 不放行就会被自家网关
  # 拦成 M_FORBIDDEN,表现为通话永远卡在 "Waiting for media"。
  # ⚠️ Origin 和 User-Agent 一样是**请求方自述**的头,伪造成本相同 ——
  # 所以这条例外**不比 UA 检查更弱**,没有额外削弱这层门槛(它本来就是门槛不是保证)。
  # 只放行【自己部署的】那个前端;通话没开时这条例外根本不存在。
  _vx_call=""
  [ "$ENABLE_CALLS" = "1" ] && _vx_call="
		not header Origin https://$CALL_HOST"
  VEILX_GATE="	@notveilx {
		path /_matrix/client/* /_synapse/admin/*
		not path /_matrix/client/versions
		not header User-Agent *VeilX*$_vx_call
	}
	handle @notveilx {
		header Content-Type application/json
		respond \`{\"errcode\":\"M_FORBIDDEN\",\"error\":\"This homeserver serves VeilX clients only.\"}\` 403
	}
"
else
  VEILX_GATE=""
fi
# OPRF 挂在 matrix.域名/oprf/ 这个路径上:复用现有证书,不需要新域名/新 DNS。
if [ "$ENABLE_OPRF" = "1" ]; then
  OPRF_ROUTE=$'\thandle /oprf/* {\n\t\treverse_proxy oprf:8787\n\t}'
else
  OPRF_ROUTE=""
fi
# 第二层·设备绑定强制(t1.26):只有用手机安全芯片密钥绑定过 access token 的会话,
# 才能走 /_matrix/client/*。Element 能登录拿 token,但绑不了(没有那把不可导出的密钥),
# 于是 sync/send 全被这道 forward_auth 拦下。规范见 VEILX-LOCK-SPEC.md §9。
#
# ⚠️ 只在 DEVICE_BIND=strict 时才生成这段配置。开 strict 之前【所有员工必须先升级 App】,
#    否则全员立刻断线 —— 这是本项目最容易自伤的开关,故默认关闭且切换时要二次确认。
#
# 放行清单少一条就会自锁,逐条都有理由(规范 §9.4):
#   versions      —— 规范公开;安装器就绪检查、客户端加速探测都打它
#   login/*       —— 不放行就没人能登录拿到 token,死锁
#   register/*    —— 注册必须先于绑定
#   logout/*      —— 要允许退出,否则 token 作废不掉
#   refresh       —— 刷新令牌;新 token 由客户端重新绑定
# 联邦与 /oprf/* 本就不在 path 匹配范围内(前者不带用户 token,后者是绑定接口自身)。
if [ "$DEVICE_BIND" = "strict" ] && [ "$ENABLE_OPRF" = "1" ]; then
  BIND_GATE="	@needbind {
		path /_matrix/client/*
		not path /_matrix/client/versions
		not path /_matrix/client/v3/login /_matrix/client/v3/login/*
		not path /_matrix/client/v3/register /_matrix/client/v3/register/*
		not path /_matrix/client/v3/logout /_matrix/client/v3/logout/*
		not path /_matrix/client/v3/refresh
	}
	handle @needbind {
		forward_auth oprf:8787 {
			uri /oprf/device/check
			copy_headers Authorization
		}
		reverse_proxy tuwunel:8008
	}
"
else
  BIND_GATE=""
fi

# 第三层 · 联邦守卫:`/_matrix/federation/*` 只放行**持有厂商证书**的对端。
#
# Matrix 的联邦是全网开放的 —— 一开 allow_federation,matrix.org 上的 Element 用户
# 就能直接私聊本站用户。产品要的是「VeilX 服务器之间互通、别家进不来」,原生
# Matrix 没有这一档,所以在网关这里加。判据见守卫里的 fed_check:拉对端的
# /.well-known/veilx/license.json 验厂商签名。
#
# 放行清单(少一条联邦就握不上手):
#   /_matrix/key/*  —— 对方**取我们公钥**用的,发生在建立签名信任之前,必须匿名可达
#   /_matrix/federation/v1/version —— 探活,规范允许匿名
# 其余联邦端点一律要过检查。联邦没开时这段根本不生成。
if [ "$ENABLE_FEDERATION" = "1" ] && [ "$FED_GUARD" = "1" ] && [ "$ENABLE_OPRF" = "1" ]; then
  FED_GATE="	@needfed {
		path /_matrix/federation/*
		not path /_matrix/federation/v1/version
	}
	handle @needfed {
		forward_auth oprf:8787 {
			uri /oprf/fed/check
			copy_headers Authorization
		}
		reverse_proxy tuwunel:8008
	}
"
else
  FED_GATE=""
fi
# 服务器授权证书(t1.24):/.well-known/veilx/license.json,客户端跨域拉取所以带 CORS。
# 该路径不在 VEILX_GATE 拦截范围(它只拦 /_matrix/client/*),Element 也能看到证书 ——
# 无所谓:证书本来就是公开文件,保密性不靠它,靠的是别人签不出来。
mkdir -p veilx-wk
LICENSE_ROUTE=$'\thandle_path /.well-known/veilx/* {\n\t\theader Access-Control-Allow-Origin *\n\t\troot * /srv/veilx-wk\n\t\tfile_server\n\t}'
cat > Caddyfile <<EOF
{
	email $ACME_EMAIL
	# 隐私:降到 ERROR,减少日志面(访问日志本就未开启=opt-in)。
	# 不用 output discard,否则证书申请失败时无从排查。
	log default {
		level ERROR
	}
}

# 委派:server_name=$DOMAIN,实际服务在 $M_HOST
$DOMAIN {
$TLS_LINE
	handle /.well-known/matrix/server {
		header Content-Type application/json
		header Access-Control-Allow-Origin *
		respond \`{"m.server":"$M_HOST:443"}\` 200
	}
	handle /.well-known/matrix/client {
		header Content-Type application/json
		header Access-Control-Allow-Origin *
		respond \`$CLIENT_WK\` 200
	}
	handle {
		$ROOT_HANDLE
	}
}

EOF
# matrix host 块:开了后台则加"举报页空桩"。tuwunel 未实现 event_reports/user_reports(会 404),
# Ketesa 的"报告事件/被举报用户"两页会红报错;拦截这两个精确路径返回空列表(200)→ 页面显示干净的"暂无数据"。
# 这两条路径不经过 tuwunel,故 CORS 由 Caddy 自补,且严格限定在 handle 内(不污染被代理路径的 tuwunel CORS)。
if [ "$ENABLE_ADMIN" = "1" ]; then
cat >> Caddyfile <<EOF

$M_HOST {
$TLS_LINE
$VEILX_GATE$OPRF_ROUTE
$LICENSE_ROUTE
$BIND_GATE
$FED_GATE
	handle {
		reverse_proxy tuwunel:8008
	}
}
EOF
# 管理子域 admin.域名:按 ADMIN_UI 出不同块。两者都把 /_matrix、/_synapse 白名单反代给 tuwunel;
# 区别在"非 API 路径":Ketesa=其静态容器;VeilX 自研=Caddy file_server 直接托管 veilx-admin/。
if [ "$ADMIN_UI" = "ketesa" ]; then
cat >> Caddyfile <<EOF

$A_HOST {
$TLS_LINE
	@opts method OPTIONS
	@ev path /_synapse/admin/v1/event_reports
	@ur path /_synapse/admin/v1/user_reports
	handle @ev {
		route {
			header Content-Type "application/json"
			respond @opts 204
			respond \`{"event_reports":[],"total":0}\` 200
		}
	}
	handle @ur {
		route {
			header Content-Type "application/json"
			respond @opts 204
			respond \`{"user_reports":[],"total":0}\` 200
		}
	}
	@ketesa_api path /_synapse/* /_matrix/media/* /_matrix/client/versions /_matrix/client/v1/* /_matrix/client/unstable/org.matrix.msc2965/auth_metadata /_matrix/client/v3/login /_matrix/client/v3/login/* /_matrix/client/v3/logout /_matrix/client/v3/account/whoami /_matrix/client/v3/profile/* /_matrix/client/v3/publicRooms /_matrix/client/v3/user/* /_matrix/client/v3/directory/*
	handle @ketesa_api {
		reverse_proxy tuwunel:8008
	}
	handle {
		reverse_proxy ketesa:8080
	}
}
EOF
else
cat >> Caddyfile <<EOF

$A_HOST {
$TLS_LINE
$OPRF_ROUTE
$LICENSE_ROUTE
	@vxadmin_api path /_synapse/* /_matrix/media/* /_matrix/client/versions /_matrix/client/v1/* /_matrix/client/unstable/org.matrix.msc2965/auth_metadata /_matrix/client/v3/login /_matrix/client/v3/login/* /_matrix/client/v3/logout /_matrix/client/v3/account/whoami /_matrix/client/v3/profile/* /_matrix/client/v3/publicRooms /_matrix/client/v3/user/* /_matrix/client/v3/directory/*
	handle @vxadmin_api {
		reverse_proxy tuwunel:8008
	}
	handle {
		root * /srv/veilx-admin
		file_server
	}
}
EOF
fi
else
cat >> Caddyfile <<EOF

$M_HOST {
$TLS_LINE
$VEILX_GATE$OPRF_ROUTE
$LICENSE_ROUTE
$BIND_GATE
$FED_GATE
	handle {
		reverse_proxy tuwunel:8008
	}
}
EOF
fi
if [ "$ENABLE_CALLS" = "1" ]; then
cat >> Caddyfile <<EOF

$LK_HOST {
$TLS_LINE
	reverse_proxy livekit:7880
}
$RTC_HOST {
$TLS_LINE
	reverse_proxy lk-jwt-service:8080
}
$CALL_HOST {
$TLS_LINE
	reverse_proxy element-call:8080
}
EOF
fi

# ---- element-call-config.json(自托管通话前端)----
# 让前端指向【你自己的】homeserver 与 LiveKit,不引用 element.io 的任何东西。
if [ "$ENABLE_CALLS" = "1" ]; then
cat > element-call-config.json <<EOF
{
  "default_server_config": {
    "m.homeserver": {
      "base_url": "https://$M_HOST",
      "server_name": "$DOMAIN"
    }
  },
  "livekit": {
    "livekit_service_url": "https://$RTC_HOST"
  },
  "features": {
    "feature_use_device_session_member_events": true
  },
  "app_prompt": false
}
EOF
chmod 644 element-call-config.json
fi

# ---- livekit.yaml(仅通话)----
if [ "$ENABLE_CALLS" = "1" ]; then
cat > livekit.yaml <<EOF
port: 7880
bind_addresses: ["0.0.0.0"]
log_level: info
rtc: { tcp_port: 7881, udp_port: 7882, use_external_ip: true }
room: { auto_create: false }
keys: { $LK_KEY: $LK_SECRET }
webhook: { api_key: $LK_KEY, urls: ["http://lk-jwt-service:8080/sfu_webhook"] }
turn: { enabled: false }
EOF
chmod 600 livekit.yaml
else rm -f livekit.yaml 2>/dev/null || true; fi

mkdir -p data/tuwunel
# 清理历史遗留的手动 override:早期修 502 时手工建的 docker-compose.override.yml(只给 element-web 设了
# ELEMENT_WEB_PORT、无 image)。脚本已内置该修复;若网页客户端关闭,主 compose 无 element-web,残留
# override 会变成"无 image 的服务"导致校验失败。识别到就备份移除。
if [ -f docker-compose.override.yml ] && grep -qE 'ELEMENT_WEB_PORT|element-web' docker-compose.override.yml 2>/dev/null; then
  mv -f docker-compose.override.yml "docker-compose.override.yml.obsolete-$(date +%s)" 2>/dev/null || rm -f docker-compose.override.yml
  warn "$(L "Removed a leftover docker-compose.override.yml (early manual 502 patch — now built in; would conflict)." "已移除历史遗留的 docker-compose.override.yml(早期手动 502 补丁,脚本已内置,留着会冲突)。")"
fi
docker compose config -q || die "$(L "compose config validation failed" "compose 配置校验失败")"

# 预校验 Caddyfile:语法错会让 caddy 起不来 → 整站 502。用一次性 caddy 容器先校验;
# 不通过则本次【不重启 caddy】(老配置继续跑,不中断),并提示用户。
CADDY_OK=1
if command -v docker >/dev/null 2>&1 && docker image inspect caddy:2 >/dev/null 2>&1; then
  # cf-origin 也要挂进来:caddy validate 会走完整 provision,tls 指令引用的证书文件
  # 不存在就会校验失败 —— 不挂的话开了 CF_ORIGIN 会被误判成"语法错"而跳过重启。
  if docker run --rm -v "$PWD/Caddyfile:/etc/caddy/Caddyfile:ro" -v "$PWD/cf-origin:/etc/caddy/cf:ro" caddy:2 \
       caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile >/dev/null 2>&1; then
    ok "$(L "Caddyfile validated" "Caddyfile 校验通过")"
  else
    CADDY_OK=0
    warn "$(L "Caddyfile syntax check FAILED! Skipped the caddy restart to avoid taking the whole site down. Please send me $INSTALL_DIR/Caddyfile to debug." "Caddyfile 语法校验未通过!已跳过 caddy 重启以免整站中断。请把 $INSTALL_DIR/Caddyfile 发我排查。")"
  fi
fi

# ---------------------------------------------------------------------
# 6. 启动 + 验收
# ---------------------------------------------------------------------
bold "$(L "6/6 Start up (first run pulls images, a few minutes)" "6/6 启动(首次拉镜像需几分钟)")"
# compose 刚被重新生成,若 VeilX 代理是开启状态,需把 xray 段和 caddy 端口改动重新注入。
if [ "$ENABLE_PROXY" = "1" ]; then px_apply_compose || warn "$(L "Failed to re-apply the VeilX proxy to docker-compose.yml" "重新注入 VeilX 代理到 docker-compose.yml 失败")"; fi
[ "$RECONFIG" -eq 0 ] && docker compose pull -q || true
# OPRF 先单独编译:它是唯一需要本地 build 的服务,失败不能连累整个 Matrix 起不来。
# 编译失败 → 自动退回“未开启”并重生成编排/Caddy,保证聊天服务照常可用。
if [ "$ENABLE_OPRF" = "1" ]; then
  echo "$(L "Building the VeilX hardening service (first time compiles Rust, 2-5 min)…" "编译 VeilX 加固服务(首次编译 Rust,约 2-5 分钟)…")"
  if docker compose build oprf; then
    ok "$(L "VeilX hardening service built" "VeilX 加固服务编译完成")"
  else
    warn "$(L "Build failed — continuing WITHOUT the hardening service so chat still works. Retry later: sudo tuwunel oprf" "编译失败 —— 先不装加固服务,保证聊天正常。稍后可重试: sudo tuwunel oprf")"
    ENABLE_OPRF=0
    sed -i "s/^ENABLE_OPRF=.*/ENABLE_OPRF=0/" .env 2>/dev/null || true
    # 把 oprf 服务段和 Caddy 路由摘掉(否则 up -d 仍会尝试 build 而失败)
    awk '/^  oprf:$/{skip=1} skip && /^  [a-z]/ && !/^  oprf:$/{skip=0} !skip' docker-compose.yml > .dc.tmp && mv .dc.tmp docker-compose.yml
    awk '/^\thandle \/oprf\/\* \{$/{skip=2} skip>0{if(/^\t\}$/)skip=0; next} {print}' Caddyfile > .cf.tmp && mv .cf.tmp Caddyfile
  fi
fi
# up -d 不会因 Caddyfile(绑定挂载)内容变化而重启正在运行的 caddy,只创建新容器(如 ketesa)/启动停止的容器,故对运行中的站点安全。
docker compose up -d --remove-orphans
# 只有显式 restart caddy 才会让它重读 Caddyfile;Caddyfile 没过校验就跳过,保住老配置不中断。
if [ "$RECONFIG" -eq 1 ]; then
  if [ "$CADDY_OK" = "1" ]; then docker compose restart tuwunel caddy >/dev/null 2>&1 || true; echo "$(L "Restarted with the new config." "已按新配置重启。")"
  else docker compose restart tuwunel >/dev/null 2>&1 || true; warn "$(L "caddy not restarted, new Caddyfile not applied yet; once fixed run: cd $INSTALL_DIR && docker compose restart caddy" "caddy 未重启,新 Caddyfile 暂未生效;修好后执行: cd $INSTALL_DIR && docker compose restart caddy")"; fi
fi

echo "$(L "Waiting for tuwunel to be ready…" "等待 tuwunel 就绪…")"; READY=0
for i in $(seq 1 40); do
  if docker compose exec -T tuwunel curl -fsS --max-time 3 http://localhost:8008/_matrix/client/versions >/dev/null 2>&1 \
     || curl -4 -fsS --max-time 3 "http://127.0.0.1" >/dev/null 2>&1; then :; fi
  # 先 IPv4 再双栈兜底:IPv6-only 的服务器此前会被 -4 误报"未就绪"
  if curl -4 -fsS --max-time 4 "https://$M_HOST/_matrix/client/versions" >/dev/null 2>&1 \
     || curl -fsS --max-time 4 "https://$M_HOST/_matrix/client/versions" >/dev/null 2>&1; then READY=1; ok "$(L "tuwunel is online, HTTPS is live." "tuwunel 在线,HTTPS 已生效。")"; break; fi
  sleep 5
done
if [ "$READY" -ne 1 ] && [ -n "${TOML_BAK:-}" ] && [ -f "$TOML_BAK" ]; then
  # tuwunel 起不来最常见的原因是配置里有本版不支持的键(尤其隐私加固新键)。自动回滚,保住服务可用。
  if ! docker compose ps tuwunel 2>/dev/null | grep -qi 'up\|healthy'; then
    warn "$(L "tuwunel failed to start — this version may not accept a config key. Auto-rolling back to the previous tuwunel.toml…" "tuwunel 未能启动 —— 可能本版不支持某个配置键。正在自动回滚到改动前的 tuwunel.toml…")"
    cp -a "$TOML_BAK" tuwunel.toml
    docker compose up -d tuwunel >/dev/null 2>&1 || true
    for i in $(seq 1 12); do
      curl -4 -fsS --max-time 4 "https://$M_HOST/_matrix/client/versions" >/dev/null 2>&1 && { READY=1; break; }
      sleep 5
    done
    if [ "$READY" -eq 1 ]; then
      warn "$(L "Rolled back and restored service. Privacy hardening is NOT applied — please send this line + 'docker compose logs tuwunel --tail 30' to the author to check the key name." "已回滚并恢复服务。隐私加固未生效 —— 请把这句和 docker compose logs tuwunel --tail 30 发给作者排查键名。")"
    else
      warn "$(L "Still not ready after rollback, check: cd $INSTALL_DIR && docker compose logs tuwunel --tail 40" "回滚后仍未就绪,请看: cd $INSTALL_DIR && docker compose logs tuwunel --tail 40")"
    fi
  fi
fi
[ "$READY" -eq 1 ] || warn "$(L "Not ready yet. Common causes: cloud security group not allowing 80/443, or DNS not fully propagated; Caddy retries the cert automatically, no reinstall needed. Logs: cd $INSTALL_DIR && docker compose logs --tail 40" "还没就绪。常见:云安全组没放行 80/443,或 DNS 未全球生效;Caddy 会自动重试证书,无需重装。看日志: cd $INSTALL_DIR && docker compose logs --tail 40")"

# ---- 服务器授权证书(t1.24):签发服务要回访体检本机,所以必须等 HTTPS 就绪后才申请 ----
if [ "$READY" -eq 1 ]; then
  if [ -f veilx-wk/license.json ] && [ "$(license_days_left)" -ge 14 ]; then
    ok "$(L "VeilX server license present ($(license_days_left) days left)." "VeilX 服务器授权证书已在($(license_days_left) 天有效)。")"
    license_cron_install
  else
    echo "$(L "==> Requesting the VeilX server license from the issuer (it health-checks this host first)…" "==> 正在向厂商签发服务申请 VeilX 服务器授权证书(它会先回访体检本机)…")"
    if license_fetch "$M_HOST"; then license_cron_install
    else warn "$(L "No license yet — VeilX clients will refuse this server until you run: sudo tuwunel license" "证书暂缺 —— 在执行 sudo tuwunel license 续上之前,VeilX 客户端会拒绝连接本服务器。")"
    fi
  fi
else
  warn "$(L "Service not ready — skipped the license request. Once it's up, run: sudo tuwunel license" "服务未就绪 —— 跳过证书申请。就绪后执行: sudo tuwunel license")"
fi
# 看门狗(t1.25):5 分钟一次,掉线自动拉起 + 告警到管理员房间(装好就常开,无需配置)。
watchdog_cron_install

[ "$ENABLE_WEB" = "1" ] && WEB_URL="https://$DOMAIN" || WEB_URL=""
[ "$ENABLE_ADMIN" = "1" ] && ADMIN_URL="https://$A_HOST" || ADMIN_URL=""

# ---- 自动创建管理员(首个账号=管理员)+ 写凭据(成功才写,作为"部署完成"标志)----
ADMIN_USER="admin"; ADMIN_PASS=""; ADMIN_OK=0
if [ -f CREDENTIALS.txt ]; then
  ADMIN_OK=1                       # config/续跑:已有账号,不重复建、不覆盖凭据
elif [ "$READY" -eq 1 ]; then
  ADMIN_PASS="$(openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | cut -c1-16)"
  echo "$(L "==> Auto-creating admin @$ADMIN_USER:$DOMAIN (the first account is the server admin)…" "==> 自动创建管理员 @$ADMIN_USER:$DOMAIN(第一个账号即服务器管理员)…")"
  if register_user "$ADMIN_USER" "$ADMIN_PASS" "https://$M_HOST" "$REG_TOKEN"; then
    ADMIN_OK=1; ok "$(L "Admin created automatically." "管理员已自动创建。")"
  else
    warn "$(L "Auto-creating the admin failed (service may have just started). Later run: sudo bash tuwunel-installer.sh adduser to create it (the first one is the admin)." "自动建管理员失败(服务可能刚起未完全就绪)。稍后执行: sudo bash tuwunel-installer.sh adduser 建号(首个即管理员)。")"
  fi
fi

if [ "$ADMIN_OK" -eq 1 ] && [ ! -f CREDENTIALS.txt ]; then
cat > CREDENTIALS.txt <<EOF
==== $(L "tuwunel deployment credentials" "tuwunel 部署凭据")  $DOMAIN  $(date '+%F %T') ====
$(L "Install dir:  " "安装目录:  ")  $INSTALL_DIR
$(L "Engine:       " "引擎:      ")  tuwunel (Rust, RocksDB, no Postgres)
$([ -n "$WEB_URL" ] && echo "$(L "Web signup/login:" "网页注册/登录:") $WEB_URL   $(L "(your domain — register in the browser, no element.io)" "(你的域名,浏览器直接注册,不用 element.io)")")
$(L "Client login: " "客户端登录:")  $(L "also Element X app / app.element.io, server =" "也可用 Element X App / app.element.io,服务器填") $DOMAIN
$(L "Admin user:   " "管理员账号:")  $ADMIN_USER   (ID: @$ADMIN_USER:$DOMAIN)
$(L "Admin password:" "管理员密码:")  $ADMIN_PASS
$([ -n "$ADMIN_URL" ] && echo "$(L "Web admin panel:" "Web 管理后台:")  $ADMIN_URL   $(L "(log in with the admin user/password above; graphical user/invite/room management)" "(用上面的管理员账号密码登录;图形化管理用户/邀请码/房间)")")
$(L "Registration token:" "注册令牌:")  $REG_TOKEN$([ "$VEILX_ONLY" != "0" ] && L "   (used internally by adduser — do NOT hand this to members: with VeilX-only on, nothing can self-register)" "   (仅供 adduser 内部使用 —— 不要发给成员:开了仅-VeilX 之后没有任何客户端能自助注册)")
$(L "Add member:   " "加成员:    ")  sudo bash tuwunel-installer.sh adduser
$(L "Max file size:" "单文件上限:")  $(human "$MAX_UPLOAD_BYTES")   (MAX_UPLOAD=10G sudo -E bash tuwunel-installer.sh config)
$(L "★ Must back up:" "★ 必须备份:")  $(L "the whole data/tuwunel dir (database + all media) + tuwunel.toml + .env" "整个 data/tuwunel 目录(含数据库与全部媒体)+ tuwunel.toml + .env")
EOF
chmod 600 CREDENTIALS.txt
fi

# ---- 收尾报告 ----
CALL_NOTE=""
[ "$ENABLE_CALLS" = "1" ] && CALL_NOTE="
 $(L "Calls (Element Call) are on: start a group call from any client. If it doesn't connect, first check curl -s https://$RTC_HOST returns 200 and that 7882/udp is open." "通话(Element Call)已开:客户端里发起群组通话即可。若不通,先 curl -s https://$RTC_HOST 看是否 200,并确认 7882/udp 放行。")"

if [ "$ADMIN_OK" -eq 1 ] && [ -n "$ADMIN_PASS" ]; then
  ADMIN_INFO="   $(L User 账号): ${C_B}$ADMIN_USER${C_RESET}    $(L Password 密码): ${C_B}${C_GREEN}$ADMIN_PASS${C_RESET}
   $(L "→ Log in with Element (server = $DOMAIN) and you're the admin." "→ 用 Element 直接登录(服务器填 $DOMAIN)即可,你就是管理员。")"
elif [ "$ADMIN_OK" -eq 1 ]; then
  ADMIN_INFO="   $(L "User/password unchanged (see $INSTALL_DIR/CREDENTIALS.txt)" "账号密码不变(见 $INSTALL_DIR/CREDENTIALS.txt)")"
else
  ADMIN_INFO="   ${C_YELLOW}$(L "Not created yet" 还没建好)${C_RESET} → $(L "once the service is ready, run:" "服务就绪后执行:") ${C_B}sudo bash tuwunel-installer.sh adduser${C_RESET}$(L " (the first account created is the admin)" "(首个建成的即管理员)")"
fi

cat <<EOF

${C_GREEN}========================================================${C_RESET}
 ${C_B}${C_GREEN}$(L "🎉 tuwunel deployment complete!" "🎉 tuwunel 部署完成!")${C_RESET}  ${C_B}$DOMAIN${C_RESET}
 ($(L reg 注册)[$REG_MODE] · $(L federation 联邦)[$([ "$ENABLE_FEDERATION" = 1 ] && L on 开 || L off 关)] · $(L calls 通话)[$([ "$ENABLE_CALLS" = 1 ] && L on 开 || L off 关)] · $(L web 网页客户端)[$([ "$ENABLE_WEB" = 1 ] && L on 开 || L off 关)] · $(L admin 管理后台)[$([ "$ENABLE_ADMIN" = 1 ] && L on 开 || L off 关)] · $(L phone-signup 手机App注册)[$([ "$ENABLE_ELEMENTX" = 1 ] && L on 开 || L off 关)] · $(L big-files 大文件)[$(human "$MAX_UPLOAD_BYTES")] · $(L "engine tuwunel/Rust, no Postgres" "引擎 tuwunel/Rust,免Postgres"))

 ${C_B}${C_YELLOW}$(L "Member signup / login" "成员注册 / 登录")${C_RESET}
$(if [ "$VEILX_ONLY" != "0" ]; then
    printf '   %s\n   %s\n   %s' \
      "$(L "This server serves VeilX only, so members do NOT self-register — you create the account:" "本服务器只面向 VeilX,因此成员【不能自助注册】,由你建号:")" \
      "$(L "  1) ${C_B}sudo tuwunel adduser${C_RESET}  → prints a username + password" "  1) ${C_B}sudo tuwunel adduser${C_RESET}  → 打印用户名和密码")" \
      "$(L "  2) Send the member: server ${C_B}$DOMAIN${C_RESET} + that username/password; they sign in from VeilX." "  2) 把【服务器 ${C_B}$DOMAIN${C_RESET} + 该用户名密码】发给成员,让 TA 在 VeilX 里登录。")"
  else
    [ -n "$WEB_URL" ] && printf '   %s\n' "$(L "Web: open $C_B$C_GREEN$WEB_URL$C_RESET in a browser to register & log in." "网页版:浏览器打开 $C_B$C_GREEN$WEB_URL$C_RESET 直接注册登录。")"
    printf '   %s%s' "$(L "Phone app: install ${C_B}Element X${C_RESET} → server = ${C_B}$DOMAIN${C_RESET} → " "手机 App:装 ${C_B}Element X${C_RESET} → 服务器填 ${C_B}$DOMAIN${C_RESET} → ")" "$([ "$ENABLE_ELEMENTX" = 1 ] && L "register directly (invite token required) or log in." "可【直接注册】(需邀请码)或登录。" || L "log in with username + password (Element X cannot register here)." "用【用户名+密码登录】(此处 Element X 不能注册)。")"
    [ "$REG_MODE" = "token" ] && printf '\n   %s' "$(L "(invite token is in CREDENTIALS.txt)" "(邀请码在 CREDENTIALS.txt)")"
  fi)

 ${C_B}${C_YELLOW}$(L "Admin (created automatically — no manual signup)" "管理员(已自动创建,不用你手动注册)")${C_RESET}
$ADMIN_INFO
   $(L "(credentials stored in $INSTALL_DIR/CREDENTIALS.txt)" "(凭据存于 $INSTALL_DIR/CREDENTIALS.txt)")
$([ -n "$ADMIN_URL" ] && printf '\n %s%s%s%s\n   %s\n   %s\n   %s' "$C_B" "$C_YELLOW" "$(L "Web admin panel (graphical, recommended)" "Web 管理后台(图形化,推荐)")" "$C_RESET" "$(L "Open $C_B$C_GREEN$ADMIN_URL$C_RESET in a browser, log in with the admin user/password above." "浏览器打开 $C_B$C_GREEN$ADMIN_URL$C_RESET,用上面的管理员账号密码登录。")" "$(L "Graphically manage members, issue/revoke invite codes, view rooms & media, deactivate accounts, reset passwords — no commands." "可【图形化】管理成员、发/吊销邀请码、看房间与媒体、停用账号、改密码——不用敲命令。")" "$(L "(panel locked to your server; Ketesa, officially supported by tuwunel)" "(面板锁定到你的服务器;成熟面板 Ketesa,tuwunel 官方支持)")")

 $(L "Day-to-day management (global command installed — no paths, no domain needed):" "日常管理(已装好全局命令,不用记路径、不用再带域名):")
   ${C_B}sudo tuwunel${C_RESET}          $(L "open the management menu" "打开管理菜单")
   ${C_B}sudo tuwunel adduser${C_RESET}  $(L "add a member (one command: create account + set password)" "加成员(一条命令建号并设密码)")
$CALL_NOTE
 $(L "Send big files/photos/long videos:" "发大文件/大图/长视频:") $(L "the limit is set to" "已把上限设到") $(human "$MAX_UPLOAD_BYTES")$(L ", just send them in chat." ",直接在聊天里发即可。")
   $(L "Note: media is stored in data/tuwunel (local disk); big videos grow it fast, watch disk; very large files in E2EE rooms use more client memory." "注意:媒体存在 data/tuwunel(本地盘),大视频涨盘快,留意磁盘;E2EE 房超大文件较吃客户端内存。")

 $(L "Self-check:" "自检:")
   curl -s https://$DOMAIN/.well-known/matrix/client
$([ "$CF_ORIGIN" = "1" ] && printf '   %s\n   %s' "$(L "CF Origin certificate mode is ON — Caddy does not use ACME. Check it with: sudo tuwunel cf-cert status" "已启用 CF Origin 证书模式 —— Caddy 不走 ACME。查看: sudo tuwunel cf-cert status")" "$(L "CF dashboard must be: SSL/TLS = Full (Strict), Bot Fight Mode off, /_matrix/* cache bypassed." "CF 后台务必:SSL/TLS = Full (Strict)、Bot Fight Mode 关、/_matrix/* 设 Bypass cache。")")
   cd $INSTALL_DIR && docker compose ps    # $(L "all containers should be running" "容器都应 running")

 ${C_YELLOW}$(L "⚠️ Cloud security group must allow: $PORT_LINE" "⚠️ 云安全组放行: $PORT_LINE")${C_RESET}
$([ -n "$ADMIN_URL" ] && printf ' %s%s%s' "$C_DIM" "$(L "Note: the Ketesa admin panel relies on tuwunel v1.8.1+ Synapse admin API (newer); if the panel login errors, first run docker compose pull tuwunel. Core features (users/invites/rooms/media) work; reports/rate-limit and other edge pages being unavailable is normal." "提示:管理后台 Ketesa 依赖 tuwunel v1.8.1+ 的 Synapse 管理 API(较新);若面板登录报错,先 docker compose pull tuwunel 拉最新版。核心功能(用户/邀请码/房间/媒体)可用,举报/限速等边角页面不可用属正常。")" "$C_RESET")
${C_GREEN}========================================================${C_RESET}
EOF
