From cd5ef7411fb879cf2efc12119ed300b80d5dff99 Mon Sep 17 00:00:00 2001 From: liuzhuohua <29739020+liuzhuohua@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:23:30 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B8=85=E3=80=8C=E5=81=87=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=A1=86=20/=20=E9=9A=90=E8=BA=AB=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E3=80=8D=E8=BF=99=E6=89=B9=E5=89=8D=E5=90=8E=E7=AB=AF=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=EF=BC=8C=E7=89=88=E6=9C=AC=E5=8F=B7=201.0.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把全量代码(后端 9 模块 + 前端单文件 SPA,约 4.5 万行)过了一遍,用 AST 抽出 渲染器读取的每个配置键,与页面上输入框绑定的字段做双向差集,专门找两类问题: 前端发了后端从不读(假输入框)、后端读了前端没有入口(隐身字段)。 会写坏系统的: - render_network 违反 render() 契约返回字典,调用方按二元组解包后把字典的键 当成路径和内容 —— 迁移 networkd 会生成文件名叫 name、内容写着 content 的垃圾文件 - DMZ 规则方向写反:注释写「排除到本机自身」,条件写成反面,管理口流量也一并 DNAT 五个「填了等于没填」的输入框: - WAN 的 MTU 与「是否接受上游 DNS」后端零消费,现真正写入 MTUBytes= / UseDNS= - DHCPv6 的「子网 ID」绑的是不存在的键,真生效字段是 sla_id - Samba WINS 要求一个从不回填的字段同时为真,勾了永不生效 - 网桥「启用 STP」未绑定事件,勾完保存不进去 - 主题设计器「按主色生成配色」按钮无 handler 其余: - RA 页补齐 rdnss_life / dnssl_life 输入框(1.0.3 修了错位却没给入口) - 迁移预览只发 lan,必报「WAN 网卡不能为空」且网桥从不出现 → 补发 wan + bridge - dnsmasq / miniupnpd / ppp / dhcpcd IPv6 / RA 地址池 / NFS 等插值零校验 - chrony 服务器校验只认域名和 IPv4,IPv6 NTP 服务器被误拒 - v_int 改用 try/except:上标数字能通过 isdigit() 却让 int() 抛异常导致 500 - ensure_cert 删掉硬编码的开发机 IP,改为本机地址 + 默认路由推导;补 openssl 失败检测 - DDNS 面板 XSS(记录名/服务商未转义);主题 CSS 注释注入(前后端各一处) 测试:render 返回值契约断言、CSS 注释注入回归、模板孤儿控件守卫, _dev/t-*.py 42/42、前端三查 + md 链接检查全绿。 --- README.md | 86 +++++-- _dev/build-image-remote.sh | 2 +- ...moke-docker-103.sh => smoke-docker-104.sh} | 26 ++- _dev/t-linkage.py | 32 +++ _dev/t-render.py | 176 ++++++++++++++ _dev/t-theme.py | 32 +++ backend/drouter-web.py | 47 +++- backend/render.py | 218 +++++++++++++++--- backend/theme.py | 10 +- devtools/retag-docker-image.py | 12 +- docs/02-部署与使用手册.md | 10 +- packaging/build-deb.sh | 2 +- packaging/build-docker.sh | 2 +- packaging/build-offline-bundle.sh | 2 +- packaging/docker/DOCKER-GUIDE.md | 24 +- packaging/docker/Dockerfile | 2 +- packaging/docker/docker-compose.yml | 4 +- packaging/offline-install.sh | 2 +- scripts/make-offline-deps.sh | 6 +- web/app.js | 99 +++++++- 20 files changed, 685 insertions(+), 109 deletions(-) rename _dev/{smoke-docker-103.sh => smoke-docker-104.sh} (81%) diff --git a/README.md b/README.md index 53d02c0..9bf2c90 100644 --- a/README.md +++ b/README.md @@ -462,7 +462,7 @@ Drouter 是普通 Debian 软件包,跑在哪都行: 路由器往往出现在**不方便接外网**的地方:机房、弱电箱、隔离网段、 或者一台你根本不想让它联网的机器。所以 Release 里的 -`drouter-1.0.3-offline-amd64.tar.gz` 不是「只发一个 `.deb`」, +`drouter-1.0.4-offline-amd64.tar.gz` 不是「只发一个 `.deb`」, 而是把 **Drouter 本体 + 它跑起来需要的全部 Debian 依赖**一起打成了一个包。 解包出来是这样: @@ -470,8 +470,8 @@ Drouter 是普通 Debian 软件包,跑在哪都行: ```bash # 1) 把 tar.gz 拷到目标机(U 盘 / scp / 内网共享都行) # 2) 解包 -tar xzf drouter-1.0.3-offline-amd64.tar.gz -cd drouter-1.0.3-offline-amd64 +tar xzf drouter-1.0.4-offline-amd64.tar.gz +cd drouter-1.0.4-offline-amd64 # 3) 一条命令装完(会自动 sudo) sudo bash install.sh @@ -494,11 +494,11 @@ sudo bash install.sh 包里到底装了什么(点开看) ``` -drouter-1.0.3-offline-amd64/ +drouter-1.0.4-offline-amd64/ ├── install.sh 安装脚本(自动提权 + 本地 file: 源,零外网) ├── README-离线安装.md 给不熟悉 Linux 的人看的逐步说明 ├── SHA256SUMS 237 条校验和(236 依赖 + 本体) -├── drouter_1.0.3_all.deb Drouter 本体(含 6 个 systemd 单元) +├── drouter_1.0.4_all.deb Drouter 本体(含 6 个 systemd 单元) └── debs/ 236 个依赖 .deb ├── nftables_*.deb ├─ 直接依赖 ├── dnsmasq_*.deb @@ -535,9 +535,9 @@ drouter-1.0.3-offline-amd64/ **想要更小的包?** 如果你的目标机有网(哪怕是内网自建 apt 镜像), -可以只拿 `drouter_1.0.3_all.deb`(约 430 KB),见下方「方式二」。 +可以只拿 `drouter_1.0.4_all.deb`(约 435 KB),见下方「方式二」。 -**已经装过依赖、只想升级本体?** 同样只用那个 430 KB 的 `.deb` +**已经装过依赖、只想升级本体?** 同样只用那个 435 KB 的 `.deb` `dpkg -i` 覆盖安装即可,不必重新走离线包。 ### 方式二:在线安装(目标机有网) @@ -545,24 +545,24 @@ drouter-1.0.3-offline-amd64/ 如果目标机能连上 Debian 官方源(或内网 apt 镜像),只需要本体那一个文件: ```bash -sudo apt install ./drouter_1.0.3_all.deb +sudo apt install ./drouter_1.0.4_all.deb sudo drouter-ctl status ``` > `apt install ./xxx.deb` 会自动把 `Depends` 交给 apt 去网上补齐 —— -> 这正是它能「只发一个 430 KB 文件」的前提。 +> 这正是它能「只发一个 435 KB 文件」的前提。 > 目标机没网时请走**方式一**,否则 apt 会卡在拉依赖上。 ### 方式三:Docker(体验用) -项目提供 `drouter-1.0.3-docker.tar`(约 169 MB,已含完整根文件系统)。 +项目提供 `drouter-1.0.4-docker.tar`(约 169 MB,已含完整根文件系统)。 **方式 A:docker compose(推荐)** Release 里附了现成的 `docker-compose.yml`,直接下下来用: ```bash -docker load -i drouter-1.0.3-docker.tar +docker load -i drouter-1.0.4-docker.tar # 把 docker-compose.yml 放到当前目录 docker compose up -d @@ -572,7 +572,7 @@ docker compose ps # 等 health 变 healthy **方式 B:docker run** ```bash -docker load -i drouter-1.0.3-docker.tar +docker load -i drouter-1.0.4-docker.tar docker run -d --name drouter --restart unless-stopped \ --network host \ @@ -582,11 +582,11 @@ docker run -d --name drouter --restart unless-stopped \ -v drouter-data:/opt/drouter/data \ -v drouter-snapshots:/opt/drouter/snapshots \ -v drouter-log:/var/log/drouter \ - drouter:1.0.3 + drouter:1.0.4 ``` -两种方式加载后镜像标签都是 **`drouter:1.0.3`**(另有一个 -`liuzhuohua/drouter:1.0.3` 别名,同一个镜像)。 +两种方式加载后镜像标签都是 **`drouter:1.0.4`**(另有一个 +`liuzhuohua/drouter:1.0.4` 别名,同一个镜像)。 访问 `https://<宿主IP>:8443/`,账号 `admin` / `admin123`。 @@ -618,17 +618,17 @@ docker run -d --name drouter --restart unless-stopped \ git clone https://github.com/liuzhuohua/drouter.git cd drouter -# 本体 deb(→ dist/drouter_1.0.3_all.deb,约 430 KB) -bash packaging/build-deb.sh 1.0.3 +# 本体 deb(→ dist/drouter_1.0.4_all.deb,约 435 KB) +bash packaging/build-deb.sh 1.0.4 # 离线依赖库(→ dist/offline-deps/,236 个 .deb + Packages 索引) bash scripts/make-offline-deps.sh -# 自包含离线包(→ dist/drouter-1.0.3-offline-amd64.tar.gz,约 83 MB) -bash packaging/build-offline-bundle.sh 1.0.3 +# 自包含离线包(→ dist/drouter-1.0.4-offline-amd64.tar.gz,约 83 MB) +bash packaging/build-offline-bundle.sh 1.0.4 -# Docker 镜像(→ dist/drouter-1.0.3-docker.tar,约 169 MB) -bash packaging/build-docker.sh 1.0.3 +# Docker 镜像(→ dist/drouter-1.0.4-docker.tar,约 169 MB) +bash packaging/build-docker.sh 1.0.4 ``` 构建顺序上,`build-offline-bundle.sh` 会**自己**先调 `build-deb.sh` @@ -960,7 +960,7 @@ sudo ss -tlnp | grep 8443 # 看端口有没有在听 ```bash # 下载新版 deb -sudo apt install ./drouter_1.0.3_all.deb # 直接覆盖安装,配置保留 +sudo apt install ./drouter_1.0.4_all.deb # 直接覆盖安装,配置保留 ``` 配置在 `/etc/drouter/` 和 SQLite 里,`apt remove` 也会保留(`purge` 才删)。 @@ -985,6 +985,48 @@ sudo apt purge drouter # 连配置一起删 ## 📝 更新日志 +### v1.0.4 — 清「点了没反应」与「东西写错地方」这批缺陷 + +纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。 +这一轮是把全量代码(约 4.5 万行)从头过了一遍,重点找两类最伤人的问题: + +- **「假输入框」** —— 页面上有这个框、能填、能保存,后端却从来不读,等于白填; +- **「隐身字段」** —— 后端真的会用,页面上却没有入口,永远走默认值。 + +排查方式是拿 AST 把渲染器里所有读取的配置键抽出来,再和页面上的输入框清单做差集。 + +- **修:点了「迁移到 systemd-networkd」,写出来的文件名叫 `name`、内容写着 `content`。** + 渲染层约定是所有渲染器都返回「(绝对路径, 内容)」二元组列表,唯独 networkd 这个 + 返回的是字典。调用方按二元组去解包,字典的两个键刚好被当成路径和内容 —— + 迁移不是「失败」,而是**真的生成了一堆垃圾文件**。这是本轮唯一会直接写坏系统的缺陷。 +- **修:DMZ 规则方向写反。** 注释写的是「排除到本机自身的流量」,条件却写成了它的 + 反面:本来只有外部流量该被转进内网主机,实际连管理流量也一并转走。 +- **修:一批「填了等于没填」的输入框(5 处)。** ① WAN 页的 MTU 与「是否接受 + 上游 DNS」后端从不读,现在真正写进 `systemd-networkd` 的 `MTUBytes=` / `UseDNS=`; + ② DHCPv6 页的「子网 ID」绑的是个不存在的键,真正生效的前缀委派 ID 无入口, + 现在绑对了;③ Samba 的「WINS 支持」要求另一个从不回填的字段同时为真,勾了也 + 永不生效;④ 网桥的「启用 STP」没有绑定事件,勾完保存不进去;⑤ 主题设计器里 + 「按主色生成整套配色」按钮压根没接线,点了没反应。 +- **修:IPv6 路由通告页缺少「下发 DNS / 搜索域」的生命期输入框。** 1.0.3 修了字段 + 错位,但页面上一直没有入口 —— 现在补齐。 +- **修:迁移预览永远报「WAN 网卡不能为空」。** 前端只提交了 LAN 段,WAN 和网桥配置 + 一次都没发过,预览里既看不到 WAN 也看不到网桥。 +- **修:一批校验缺失导致的「保存完服务起不来 / 页面 500」。** 过去这些字段是直接从 + 表单拼进配置文件的:dnsmasq 的域名与日志文件路径、miniupnpd 的租约文件路径与 + UUID、PPPoE 的运营商名与服务名、IPv6 前缀委派 ID、路由通告的地址池五项、 + NFS 的导出选项与线程数 —— 现在全部按各自语法校验并给中文提示。另外 + chrony 的服务器地址校验只认域名和 IPv4,**填 IPv6 的 NTP 服务器会被误判成非法**。 + 顺带修了 `int()` 前只判断 `isdigit()` 的老毛病:Unicode 的上标数字能通过 `isdigit()` + 却会让 `int()` 抛异常,界面直接 500。 +- **修:自签证书的 SAN 里写死了开发者的内网 IP。** 生成的证书主题别名里必定带上 + `192.168.7.3`,跟你的机器一点关系都没有;有些客户端会因为它校验不过而报警。 + 现在改为从本机实际地址和默认路由推导,并保底保留 `127.0.0.1` / `localhost`。 + 同时补了 openssl 失败检测 —— 过去生成失败时不会报错,只是给你一个不存在的证书路径。 +- **修:两处模板注入。** ① DDNS 面板的「记录名 / 服务商」是拼进 HTML 的, + 填一段带尖括号的内容就能在页面上执行自己的脚本;② 导出的主题 CSS 把主题名和版本 + 拼进注释里,名字里带 `*/` 就能提前闭合注释,往里塞任意 CSS。现在前端转义、 + 后端清注释结束符,两边都堵住。 + ### v1.0.3 — 修「部署之后设置莫名回退」与几处静默失败 纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。 diff --git a/_dev/build-image-remote.sh b/_dev/build-image-remote.sh index 6065f3f..879f7fc 100644 --- a/_dev/build-image-remote.sh +++ b/_dev/build-image-remote.sh @@ -15,7 +15,7 @@ # 把标签从 buildah 的 localhost/... 改成 docker 惯用的名字。 set -e -VER="${1:-1.0.3}" +VER="${1:-1.0.4}" SRC="${2:-/tmp/drouter-build}" cd "$SRC" || exit 1 diff --git a/_dev/smoke-docker-103.sh b/_dev/smoke-docker-104.sh similarity index 81% rename from _dev/smoke-docker-103.sh rename to _dev/smoke-docker-104.sh index ac23484..acdf4ad 100644 --- a/_dev/smoke-docker-103.sh +++ b/_dev/smoke-docker-104.sh @@ -1,14 +1,16 @@ #!/bin/bash # 容器成品镜像冒烟(第二段):登录 + Web 终端建会话 + 接口抽查 -# 1.0.3 本轮无容器相关改动,这里主要当**回归**用;顺带验证 1.0.2 的两条修复还在: +# 1.0.4 本轮改动集中在渲染器与 Web 面板,容器形态同样受影响 -> 回归 + 针对性验证: # ① 容器里没有 systemd,终端守护仍能被拉起(_shelld_spawn 回退路径) # ② /dev/ptmx 存在,PTY 能开 +# ③ 1.0.4 新:自签证书的 SAN 取自本机地址,不得再出现作者写死的内网 IP +# (过去直接硬编码 192.168.7.3,任何机器上签出来都带着它) # 用 --network none:不碰宿主网络,只在容器内自测 127.0.0.1。 set -u -TAR=/tmp/drouter-103-final.tar -NAME=drouter-smoke103 -IMG=drouter:1.0.3 +TAR=/tmp/drouter-104-final.tar +NAME=drouter-smoke104 +IMG=drouter:1.0.4 BASE=https://127.0.0.1:8443 PODMAN="podman" @@ -121,6 +123,22 @@ for ep in sysinfo ifaces metrics; do grep -qE '"ok": *true' <<<"$R" && { echo "ok ✔"; pass=$((pass+1)); } || { echo "✘ $(head -c 120 <<<"$R")"; fail=$((fail+1)); } done +echo +echo "=== 9. 自签证书 SAN(1.0.4:不得再写死作者内网 IP)===" +CERT=$($PODMAN exec "$NAME" sh -c 'C=$(ls /opt/drouter/certs/server.crt 2>/dev/null); [ -n "$C" ] || exit 1; openssl x509 -in "$C" -noout -text 2>/dev/null' 2>&1) +if [ -n "$CERT" ]; then + echo " SAN: $(grep -A1 -i 'Subject Alternative Name' <<<"$CERT" | tail -1 | head -c 200)" + ck "证书 SAN 含 loopback 127.0.0.1" '127\.0\.0\.1' "$CERT" + # 反向断言:写死的作者内网 IP 绝不能出现(ck 只能做「包含」判断,这里手写) + if grep -q '192\.168\.7\.3' <<<"$CERT"; then + echo " ✘ 证书里仍写着硬编码的 192.168.7.3"; fail=$((fail+1)) + else + echo " ✔ 证书 SAN 不含写死的 192.168.7.3"; pass=$((pass+1)) + fi +else + echo " ✘ 读不到 /opt/drouter/certs/server.crt($CERT)"; fail=$((fail+1)) +fi + echo echo "================================================" echo " 容器冒烟:通过 $pass / 失败 $fail" diff --git a/_dev/t-linkage.py b/_dev/t-linkage.py index 3a92a81..ef7dfb5 100644 --- a/_dev/t-linkage.py +++ b/_dev/t-linkage.py @@ -198,5 +198,37 @@ for name, fn, fields in FIELD_CASES: miss2 = [f for f in fields if f not in HELPER] chk('%s 的字段后端确实会产出' % name, not miss2, '→ 后端无 %s' % miss2) +print('\n--- 孤儿控件:模板渲染了却没有任何 JS 接管的输入控件 ---') +# 曾经的真实 bug:网桥页的「生成树协议 STP」下拉框(#br-stp)只有模板没有 handler, +# 用户改完点保存,system.bridge_stp 从未更新 —— 一个改不生效的死控件。 +# 这里把「渲染出来但没人管」当成回归项守住。 +_NO_REF_OK = { + # 这三个是 DDNS 表单按服务商动态渲染的字段,由 + # `$$('#dd-fields input')` 统一按 dataset.f 收集,不需要逐个 id 接管。 + 'dd-fuser', 'dd-ftoken', 'dd-fpass', + # 运行时由 caGenCsr 注入的只读文本区(内容来自模板本身,供全选复制) + 'ca-cs-text', + # 只读展示框:复制按钮取的是闭包里的 d.secret,不读这个 input + 'oh-secret-val', +} +_ids = sorted(set(re.findall(r'<(?:input|select|textarea)\b[^>]*\sid="([\w-]+)"', APP))) +_orphan = [] +for _i in _ids: + if _i in _NO_REF_OK: + continue + # 模板里出现一次是必然;JS 里若一次都没引用过,说明没人接管它 + if len(re.findall(r"['\"`#]%s\b" % re.escape(_i), APP)) <= 1: + _orphan.append(_i) +chk('输入控件没有孤儿(渲染了但没接线)', not _orphan, '→ 疑似死控件 %s' % _orphan) +print(' 已检查 %d 个输入控件,白名单放行 %d 个' % (len(_ids), len(_NO_REF_OK))) + +print('\n--- 主题设计器「一键配色」必须真的接了后端 ---') +# 这个按钮原先只有模板、没有 handler,后端 palette op 却一直存在 —— 点了毫无反应。 +chk('前端为 #tm-palette 绑了 handler', "palBtn.onclick = async" in APP) +chk('前端用的是真存在的 op=palette', "op: 'palette'" in APP) +chk('后端 act_theme 有 palette 分支', "op == 'palette'" in HELPER) +chk('palette 分支没有重复实现', HELPER.count("op == 'palette'") == 1, + '→ 出现 %d 次' % HELPER.count("op == 'palette'")) + print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails)) sys.exit(1 if fails else 0) diff --git a/_dev/t-render.py b/_dev/t-render.py index ca7bd50..913e25d 100644 --- a/_dev/t-render.py +++ b/_dev/t-render.py @@ -179,5 +179,181 @@ chk('没有 dnssl_life 时退回 rdnss_life', 'AdvDNSSLLifetime 900;' in ra({'rdnss': 'fd00:7::1', 'dnssl': 'lan', 'rdnss_life': 900})) +# ------------------------------------------------- 1.0.4:未落地校验的补齐 +# 以下字段原先都是「用户在页面上填什么就原样拼进配置行」,同段的其它字段 +# 都有校验、唯独它们漏了。风险不只是填错:配置是一行一条指令,值里带一个 +# 换行就能凭空造出一条新指令。这里统一按 UI 上真实的输入框逐项锁死。 + +def rej(label, fn_call): + try: + fn_call() + chk(label, False, '居然通过了') + except render.ValidateError as ex: + chk(label, True, str(ex)) + + +print('\n--- dnsmasq:domain / log_file ---') +rej('域名含换行被拒绝', + lambda: render.render_dnsmasq({'lan_iface': 'ens18', + 'domain': 'lan\ndhcp-range=1.1.1.1,2.2.2.2'})) +rej('域名含空格被拒绝', + lambda: render.render_dnsmasq({'lan_iface': 'ens18', 'domain': 'my lan'})) +rej('日志文件含换行被拒绝', + lambda: render.render_dnsmasq({'lan_iface': 'ens18', + 'log_file': '/var/log/x\nsecurity=off'})) +_d = render.render_dnsmasq({'lan_iface': 'ens18', 'domain': 'home.lan'}) +chk('合法域名正常渲染', 'domain=home.lan' in _d) +chk('默认域名仍是 lan', + 'domain=lan' in render.render_dnsmasq({'lan_iface': 'ens18'})) +chk('默认日志路径未被改动', + 'log-facility=/var/log/drouter/dnsmasq.log' + in render.render_dnsmasq({'lan_iface': 'ens18'})) + +print('\n--- ppp:运营商 / 服务名 ---') +_PPPOE = {'iface': 'ens19', 'username': 'u1', 'password': 'p1'} + + +def ppp(extra): + return render.render_ppp(dict(_PPPOE, **extra)) + + +rej('PPPoE 服务名含引号被拒绝', + lambda: ppp({'service_name': 'cmcc"\nnoauth'})) +rej('PPPoE 运营商含换行被拒绝', + lambda: ppp({'isp': '江苏电信\nnoauth'})) +_peers = ppp({'service_name': 'cmcc', 'isp': '江苏电信'})[0][1] +chk('合法服务名正常写出', 'servicename "cmcc"' in _peers, ) +chk('运营商写进注释行', '# 运营商:江苏电信' in _peers) + +print('\n--- dhcpv6:SLA-ID 后缀 ---') +rej('SLA-ID 含换行被拒绝', + lambda: render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '::1\nia_na 1'}})) +# 注意别拿 abc 当非法值:那是一串合法的十六进制(::abc 也是合法 IPv6) +rej('SLA-ID 非 IPv6 被拒绝', + lambda: render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '1.2.3.4'}})) +rej('SLA-ID 含非十六进制字符被拒绝', + lambda: render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '::zzz'}})) +_cd = render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '::1'}}) +chk('合法 SLA-ID 正常渲染', 'ia_pd 0/br0/0/64/::1' in _cd, _cd.split('\n')[-2] if _cd else '') +# 页面默认值 ::1,也允许用户偷懒只写 1 +chk('SLA-ID 支持简写(1 → ::1)', + '::1' in render.render_dhcpcd({'dhcpv6': {'iface': 'ppp0', 'sla_id': '1'}})) + +print('\n--- miniupnpd:lease_file / uuid ---') +rej('UPnP 租约文件路径含注入被拒绝', + lambda: render.render_miniupnpd({'upnp': {'lease_file': '/tmp/x\next_ifname=lo'}})) +rej('UPnP uuid 含换行被拒绝', + lambda: render.render_miniupnpd({'upnp': {'uuid': 'x\nenable_upnp=no'}})) +_u = render.render_miniupnpd({'upnp': {}}) +chk('UPnP 默认租约文件/UUID 未变', + 'lease_file=/var/run/miniupnpd.leases' in _u and 'uuid=drouter-0001' in _u) + +print('\n--- radvd:IPv6 地址池块 ---') +rej('池名称含换行被拒绝', + lambda: render.render_radvd({'iface': 'ens18', 'pool_name': 'x\nprefix 2001:db8::/64 {};'})) +rej('池前缀非法被拒绝', + lambda: render.render_radvd({'iface': 'ens18', 'pool_prefix': '2408::/999'})) +rej('池策略含换行被拒绝', + lambda: render.render_radvd({'iface': 'ens18', 'pool_policy': ['a\nb']})) +rej('起始大于结束被拒绝', + lambda: render.render_radvd({'iface': 'ens18', 'pool_start': 10, 'pool_end': 5})) +# 0 是合法起始子网 ID:别写成 pool_start or 1(同 hour=0 的老毛病) +_r0 = render.render_radvd({'iface': 'ens18', 'pool_start': 0, 'pool_end': 254}) +chk('起始子网 ID 0 不会被吞成 1', '子网 ID 0 - 254' in _r0, _r0.strip().split('\n')[-2]) + +print('\n--- chrony:IPv6 NTP 服务器 ---') +_n = render.render_chrony({'ntp': {'servers': 'ntp.aliyun.com,2400:3200::1'}}) +chk('IPv6 的 NTP 服务器被接受', 'server 2400:3200::1 iburst' in _n, ) +rej('NTP 服务器里的换行仍被拒绝', + lambda: render.render_chrony({'ntp': {'servers': 'x.com\nserver 1.2.3.4'}})) + +print('\n--- nfs:备注 / 选项 / 模板 ---') +_NFS = {'nfs': {'exports': [{'path': '/srv/share', + 'clients': [{'net': '192.168.7.0/24', 'preset': 'macos'}]}]}} +_nexp = render.render_nfs(_NFS) +chk('NFS 正常导出', '/srv/share 192.168.7.0/24(' in _nexp) +rej('NFS 备注含换行被拒绝', + lambda: render.render_nfs({'nfs': {'exports': [ + {'path': '/srv/share', 'comment': 'ok\n/srv 127.0.0.1(rw)', + 'clients': [{'net': '192.168.7.0/24'}]}]}})) +rej('NFS 自定义选项含空格/换行被拒绝', + lambda: render.render_nfs({'nfs': {'exports': [ + {'path': '/srv/share', 'clients': [ + {'net': '192.168.7.0/24', 'options': 'rw\n/srv 0.0.0.0/0(rw)'}]}]}})) +rej('未知 NFS 模板不再静默退回 linux', + lambda: render.render_nfs({'nfs': {'exports': [ + {'path': '/srv/share', 'clients': [{'net': '192.168.7.0/24', 'preset': 'mac'}]}]}})) +rej('NFS 线程数非数字给中文提示', + lambda: render.render_nfs_conf({'nfs': {'threads': 'abc'}})) + +print('\n--- v_int:Unicode 数字不能绕成 500 ---') +for _bad in ('²', '٣'): + try: + render.v_int(_bad, field='测试值', lo=0, hi=65535) + chk('v_int 拒绝 %r' % _bad, False, '居然通过了') + except render.ValidateError as ex: + chk('v_int 拒绝 %r' % _bad, True, str(ex)) + +print('\n--- DMZ:不能再把本机自己的流量转走 ---') +_dmz = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], + 'portfwd': {'dmz': {'enable': True, 'host': '192.168.7.50'}}}) +chk('DMZ 前先放行到本机自身的流量', + 'fib daddr type local accept' in _dmz) +chk('DMZ 规则不再用 daddr != 的反向条件', + 'daddr != 192.168.7.50' not in _dmz) +chk('DMZ 主机仍能收到转发', 'dnat to 192.168.7.50' in _dmz) + +print('\n--- 端口转发备注:换行不得注入 nft ---') +_pf = render.render_nft('v4', {'wan_iface': 'ppp0', 'lan_ifaces': ['ens18'], + 'portfwd': {'enable': True, 'rules': [ + {'name': 'a\nmasquerade', 'proto': 'tcp', + 'ext_port': 80, 'int_ip': '192.168.7.10', 'int_port': 80}]}}) +chk('备注里的换行被去掉', '\nmasquerade' not in _pf and 'masquerade' in _pf) + +_NET = {'wan': {'iface': 'ens19', 'mode': 'dhcp', 'mtu': 1500}, + 'lan': {'iface': 'ens18', 'address': '192.168.7.3/24', 'mtu': 1500}, + 'bridge': {'enabled': True, 'name': 'br0', 'members': ['ens18'], 'stp': True}} +_net = render.render('network', _NET) +chk('render("network") 按契约返回 (路径, 内容) 二元组', + all(isinstance(f, tuple) and len(f) == 2 for f in _net)) +# 曾经的 bug:这里返回的是 {'name','content'} 字典,helper 里 +# `for path, content in files` 解包 dict 只会拿到它的两个**键名**, +# 于是把字面量 'content' 写进一个叫 'name' 的文件。 +chk('路径是 /etc/systemd/network/ 下的绝对路径', + all(f[0].startswith('/etc/systemd/network/') for f in _net)) +chk('内容是文本而不是 dict 的键名', + all(isinstance(f[1], str) and f[1] != 'content' for f in _net)) +_net_names = [f[0].split('/')[-1] for f in _net] +chk('停用式迁移会产出 WAN 单元', any(n.startswith('10-drouter-wan-') for n in _net_names)) +chk('网桥 netdev 单元存在', '20-drouter-br0.netdev' in _net_names) +chk('桥接成员单元存在', '21-drouter-br0-member-ens18.network' in _net_names) +chk('STP 打开时写 STP=yes', + 'STP=yes' in dict((f[0].split('/')[-1], f[1]) for f in _net).get('20-drouter-br0.netdev', '')) +_net_off = render.render('network', dict(_NET, bridge={'enabled': False, 'members': ['ens18']})) +chk('网桥关闭时不产出任何 bridge 单元', + not any('/br0' in f[0] for f in _net_off)) +chk('网桥关闭时 LAN 地址直接落在物理口上', + any('30-drouter-lan-ens18.network' in f[0] for f in _net_off)) +rej('缺 WAN 网卡时给出中文字段级提示', + lambda: render.render('network', {'lan': {'iface': 'ens18'}})) + +print('\n--- network:WAN 页 DHCP 模式的 MTU / DNS 开关不再是假控件 ---') + + +def _wan_unit(wan): + fs = render.render('network', {'lan': {'iface': 'ens18', 'address': '192.168.7.3/24'}, + 'bridge': {'enabled': False}, 'wan': wan}) + return fs[0][1] + + +chk('未传 use_dns 时沿用「使用上级 DNS」', + 'UseDNS=yes' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp'})) +chk('用户在 WAN 页关掉后能真的关掉', + 'UseDNS=no' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp', 'use_dns': False})) +chk('用户在 WAN 页打开后仍是 yes', + 'UseDNS=yes' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp', 'use_dns': True})) +chk('DHCP 模式填的 MTU 能落进配置文件', + 'MTUBytes=1400' in _wan_unit({'iface': 'ens19', 'mode': 'dhcp', 'mtu': 1400})) + print('\n结果: %s' % ('全部通过' if fails == 0 else '%d 项失败' % fails)) sys.exit(1 if fails else 0) diff --git a/_dev/t-theme.py b/_dev/t-theme.py index bb14906..0dd5dfd 100644 --- a/_dev/t-theme.py +++ b/_dev/t-theme.py @@ -582,6 +582,38 @@ for x in bt: has('内置 %s 渲染非空' % x['id'], len(css) > 200) has('内置 %s 无 < 字符' % x['id'], '<' not in css) +print() +print('== 12. 主题 meta 不得把内容注出 CSS 注释 ==') + + +def _strip_comments(css): + """按 CSS 规范扫注释,返回注释之外的「有效 CSS」。""" + out, i, n = [], 0, len(css) + while i < n: + if css.startswith('/*', i): + j = css.find('*/', i + 2) + if j < 0: + break # 未闭合:剩余全是注释 + i = j + 2 + else: + out.append(css[i]) + i += 1 + return ''.join(out) + + +_META_ATTACKS = [ + ('名称里塞规则体', {'name': 'x*/}body{display:none}/*', 'version': '1.0'}, 'body{display:none}'), + ('版本号里塞 @import', {'name': 'a', 'version': '1*/@import url(evil)/*'}, '@import'), + ('换行 + 提前闭合', {'name': 'n\n*/}\n*{background:url(//x)}\n/*', 'version': '2'}, 'background:url'), + ('中文混合注入', {'name': '中*/}html{filter:invert(1)}/*文', 'version': ''}, 'filter:invert'), +] +for _label, _meta, _needle in _META_ATTACKS: + _t = dict(_meta, vars={'--pri': '#111'}, css='', dark=False) + _eff = _strip_comments(theme.theme_to_css(_t)) + has('%s → 攻击串留在注释内' % _label, _needle not in _eff, _eff[:80]) + has('%s → 有效结构仍然完好' % _label, + '--pri:#111' in _eff and 'color-scheme:light' in _eff, _eff[:80]) + print() print('=' * 56) print('通过 %d 项,失败 %d 项' % (PASS, FAIL)) diff --git a/backend/drouter-web.py b/backend/drouter-web.py index 285b9b2..e26326b 100644 --- a/backend/drouter-web.py +++ b/backend/drouter-web.py @@ -422,26 +422,51 @@ def ensure_cert(): if os.path.isfile(crt) and os.path.isfile(key): return crt, key ips = set() + + def _add(ip): + try: + ipaddress.ip_address(ip) + except Exception: + return + ips.add(ip) + try: rc = subprocess.run(['hostname', '-I'], capture_output=True, text=True, timeout=5) for x in (rc.stdout or '').split(): - try: - ipaddress.ip_address(x) - ips.add(x) - except Exception: - pass + _add(x) except Exception: pass - ips.update({'127.0.0.1', '192.168.7.3'}) - san = ','.join('IP:%s' % i for i in sorted(ips)) + ',DNS:localhost' + if not ips or ips <= {'127.0.0.1', '::1'}: + # hostname -I 不可用或还没拿到地址时,退回「默认路由的源地址」—— + # 也就是本机真正在局域网上用的那张脸。写得这么绕是为了不再把某个 + # 具体的 192.168.x.x 钉死在代码里:那台是他自己家的机器,装到别人 + # 家里证书里却带着一个不属于对方的 IP。 + try: + rc = subprocess.run(['ip', '-o', 'route', 'get', '1.1.1.1'], + capture_output=True, text=True, timeout=5) + m = re.search(r'\bsrc\s+(\S+)', rc.stdout or '') + if m: + _add(m.group(1)) + except Exception: + pass + _add('127.0.0.1') + san = ','.join('IP:%s' % i for i in sorted(ips)) + ',DNS:localhost,DNS:drouter.local' config = os.path.join(CERT_DIR, 'openssl.cnf') with open(config, 'w') as f: f.write('[req]\ndistinguished_name=dn\nx509_extensions=v3\nprompt=no\n' '[dn]\nCN=drouter.local\nO=drouter\n' '[v3]\nsubjectAltName=%s\nbasicConstraints=CA:FALSE\n' % san) - subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', - '-keyout', key, '-out', crt, '-days', '3650', - '-config', config], capture_output=True, timeout=60) + rc = subprocess.run(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', + '-keyout', key, '-out', crt, '-days', '3650', + '-config', config], capture_output=True, timeout=60) + # 原先完全不看返回值:openssl 没装 / 失败时只是静默留下两个不存在的文件, + # 报错要等到后面 SSLContext.load_cert_chain 才炸,而且完全看不出是签发失败。 + if rc.returncode != 0 or not (os.path.isfile(crt) and os.path.isfile(key)): + err = ((rc.stderr or b'') if isinstance(rc.stderr, bytes) else (rc.stderr or '')) + raise RuntimeError( + '自签证书生成失败(openssl 未安装或被拒绝?)。' + '管理面板的 HTTPS 需要一张证书:请先 apt-get install -y openssl 后重启' + ' drouter-web。openssl 输出:%s' % str(err)[-400:]) try: os.chmod(key, 0o600) except Exception: @@ -1811,7 +1836,7 @@ def build_openapi(base): 'openapi': '3.0.3', 'info': { 'title': 'Drouter 通用管理 API', - 'version': '1.0.3', + 'version': '1.0.4', 'description': ('Drouter(Debian 13 拼装主路由)的统一 REST 接口。' '任意语言 / 框架(curl、Python、Node、Go、PHP、Java、.NET、' 'Shell、Postman、工单系统、IoT 网关)均可直接调用。' diff --git a/backend/render.py b/backend/render.py index fe181a2..64bae0d 100644 --- a/backend/render.py +++ b/backend/render.py @@ -94,9 +94,15 @@ def v_int(v, field='值', lo=0, hi=65535, default=None, allow_empty=False): if default is not None: return default raise ValidateError(f'{field}不能为空', field) - if not s.isdigit(): + # 必须先看 ascii 再看 digit:'²'(上标二)、'٣'(阿拉伯数字)这类 + # Unicode 字符 str.isdigit() 也是 True,但 int('²') 会直接抛 ValueError —— + # 那就不走 ValidateError,而是变成一个 500 + traceback,页面只剩「请求失败」。 + if not (s.isascii() and s.isdigit()): + raise ValidateError(f'{field}必须是数字:{s}', field) + try: + n = int(s) + except Exception: raise ValidateError(f'{field}必须是数字:{s}', field) - n = int(s) if n < lo or n > hi: raise ValidateError(f'{field}必须在 {lo} ~ {hi} 之间', field) return n @@ -131,6 +137,53 @@ def v_choice(v, choices, field='值'): return v +CTRL_RE = re.compile(r'[\x00-\x1f\x7f]') + + +def v_text(v, field='值', maxlen=128, allow_empty=False, pattern=None): + """渲染进配置文件的自由文本:必须过滤换行与控制字符。 + + 这类字段看着最无害,风险却最大 —— 配置都是一行一条指令,用户填的值里 + 只要有一个 \\n,后面那一截就会变成新的配置行(往 radvd.conf 里加一个 + prefix、往 peers 文件里加一个 noauth、往 miniupnpd.conf 里改哪条链, + 都是灾难),而界面只会回一句英文 syntax error,指不到是哪个输入框。 + 所以凡是「拿用户原话直接拼进配置行」的地方,都必须先过这里。 + """ + v = (v or '').strip() + if not v: + if allow_empty: + return '' + raise ValidateError(f'{field}不能为空', field) + if CTRL_RE.search(v): + raise ValidateError(f'{field}不能包含换行或不可见控制字符', field) + if len(v) > maxlen: + raise ValidateError(f'{field}过长(最多 {maxlen} 个字符)', field) + if pattern is not None: + m = pattern.match(v) if hasattr(pattern, 'match') else re.match(pattern, v) + if not m: + raise ValidateError(f'{field}含不支持的字符:{v}', field) + return v + + +def v_ipv6_suffix(v, field='IPv6 后缀'): + """dhcpcd 的 ia_pd 第 5 段(SLA-ID 后缀),形如 ::1 / 1 / 0:0:0:1。 + + 页面上的默认值是 ::1,用户也可能只填 1 或写成 0:0:0:2。统一按 + IPv6 地址校验(不完整的写法补 :: 前缀再解析),既能挡住注入, + 也能把 SLA-ID 写错导致的 ia_pd 配置失效挡在保存之前。 + """ + v = (v or '').strip() + if not v: + return '::1' + if CTRL_RE.search(v) or not re.match(r'^[0-9a-fA-F:]{1,39}$', v): + raise ValidateError(f'{field}只能由十六进制数字与冒号组成:{v}', field) + cand = v if ':' in v else ('::' + v) + try: + return str(ipaddress.IPv6Address(cand)) + except Exception: + raise ValidateError(f'{field}不是合法的 IPv6 后缀:{v}(示例 ::1)', field) + + def v_option_code(v): return v_int(v, field='DHCP Option Code', lo=1, hi=254) @@ -154,6 +207,15 @@ def v_option_value(v, code): def render_dnsmasq(cfg): """cfg 结构见 web 配置:lan_iface / pool / options / dns / static_leases""" lan = v_ifname(cfg.get('lan_iface') or '', field='LAN 接口') + # domain / log_file 原先是直接取用户原值拼进配置行的:同段的 pool、 + # option 都有校验,唯独这两个漏了。后果不只是「填错 dnsmasq 起不来」—— + # 值里带一个换行就能凭空造出一条新指令(比如再插一条 dhcp-range), + # 而界面只回一句英文语法错误,看不出是哪个输入框的问题。 + domain = v_text(cfg.get('domain') or 'lan', field='局域网域名', + maxlen=253, pattern=DOMAIN_RE) + log_file = (v_path(cfg.get('log_file'), field='dnsmasq 日志文件') + if (cfg.get('log_file') or '').strip() + else '/var/log/drouter/dnsmasq.log') lines = [ '# 由 drouter 自动生成,请勿手工修改', '# 模块:dnsmasq(DHCPv4 + DNS 转发)', @@ -164,7 +226,7 @@ def render_dnsmasq(cfg): 'domain-needed', 'bogus-priv', 'expand-hosts', - f"domain={cfg.get('domain') or 'lan'}", + f'domain={domain}', 'local=/lan/', '', '# ---- DHCPv4 地址池 ----', @@ -242,12 +304,12 @@ def render_dnsmasq(cfg): continue lines.append(f'server={s}') if cfg.get('dns_lan_server'): - lines.append(f"server=/{cfg.get('domain') or 'lan'}/{v_ipv4(cfg.get('dns_lan_server'), field='内网 DNS')}") + lines.append(f'server=/{domain}/{v_ipv4(cfg.get("dns_lan_server"), field="内网 DNS")}') lines += [ f"cache-size={v_int(cfg.get('dns_cache_size') or 1000, field='DNS 缓存', lo=0, hi=20000)}", 'no-negcache' if not v_bool(cfg.get('dns_negcache', True)) else '', 'log-queries' if v_bool(cfg.get('dns_query_log')) else '', - f"log-facility={cfg.get('log_file') or '/var/log/drouter/dnsmasq.log'}", + f'log-facility={log_file}', 'dhcp-leasefile=/var/lib/misc/dnsmasq.leases', ] return '\n'.join([l for l in lines if l != '']) + '\n' @@ -614,7 +676,9 @@ def _pf_rules(cfg, fam, family_kw): ip = v_ipv4(ip, '端口转发第 %d 条内网 IP' % (i + 1)) else: ip = v_ipv6(ip, '端口转发第 %d 条内网 IPv6' % (i + 1)) - name = (r.get('name') or '').replace('"', '')[:40] + # 备注整串写进 comment "…",原先只删了双引号:留下换行同样能 + # 闭合注释、再起一行任意 nft 指令。这里连同控制字符一起去掉。 + name = CTRL_RE.sub('', str(r.get('name') or '').replace('"', ''))[:40].strip() cmt = name or ('端口转发 %d' % (i + 1)) protos = ['tcp', 'udp'] if proto == 'tcp/udp' else [proto] for pr in protos: @@ -640,10 +704,23 @@ def _pf_rules(cfg, fam, family_kw): host = v_ipv6(host, 'DMZ 主机 IPv6') wan = v_ifname(cfg.get('wan_iface') or 'ppp0', field='WAN 接口') dest = '[' + host + ']' if fam == '6' else host - # 排除到本机自身的流量,避免把管理口也转走 + # 到本机自身的流量必须先放行,否则 DMZ 会把管理口一并转走。 + # + # 原先写的是 `daddr != dnat to `,这个条件是反的: + # 它排除的正是「本来就要发给 DMZ 主机」的包(不 DNAT 也照样走到它), + # 而真正需要保护的、发往本机 WAN 地址的流量(SSH 22、面板 8443/8080) + # 反而全部命中规则、被丢到内网那台 DMZ 主机上 —— 也就是注释声称要 + # 避免的那件事,恰好是这条规则在做的事。表现是「一开 DMZ,路由器 + # 自己就登不上了」,而且很难联想到是 DMZ 干的。 + # + # fib daddr type local 判断目的地址是否属于本机(含本机所有地址), + # accept 只结束本条 prerouting 链的后续规则、不再做 DNAT。 nat_lines.append( - ' iifname "%s" %s daddr != %s dnat to %s comment "DMZ 全端口映射"' - % (wan, family_kw, dest, dest)) + ' iifname "%s" fib daddr type local accept comment "到本机自身的流量不进 DMZ"' + % wan) + nat_lines.append( + ' iifname "%s" %s dnat to %s comment "DMZ 全端口映射"' + % (wan, family_kw, dest)) filter_lines.append( ' %s daddr %s accept comment "放通 DMZ 主机"' % (family_kw, host)) return nat_lines, filter_lines @@ -651,6 +728,11 @@ def _pf_rules(cfg, fam, family_kw): # ---------------------------------------------------------------- systemd-networkd +# systemd-networkd 的单元目录。render() 返回的是 **完整路径**, +# render_network 内部只用文件名拼装,出口再补上这里的前缀。 +NETWORKD_DIR = '/etc/systemd/network' + + def render_network(cfg): """ 返回 [{name, content}],写入 /etc/systemd/network/ @@ -719,7 +801,10 @@ def render_network(cfg): 'IPv6AcceptRA=yes', '', '[DHCPv4]', - 'UseDNS=yes', + # WAN 页 DHCP 模式下的「使用上级下发的 DNS」开关(pppoe.dhcp_use_dns) + # 由前端透传到这里。以前这一项是写死的 yes —— 用户在界面上关掉它, + # 照样会被上级的 DNS 覆盖,等于一个改不生效的假开关。 + 'UseDNS=%s' % ('yes' if v_bool(wan.get('use_dns', True)) else 'no'), 'UseRoutes=yes', f"RouteMetric={v_int(wan.get('metric') or 100, field='路由优先级', lo=1, hi=9999)}", '', @@ -799,7 +884,12 @@ def render_network(cfg): lines.append(f"Address={lan['ipv6_addr'].strip()}") lines.append('') out.append({'name': f'30-drouter-lan-{lif}.network', 'content': '\n'.join(lines)}) - return out + # render() 的对外契约是 [(完整路径, 内容)],其余渲染器都按这个返回。 + # 这里内部为了方便拼装用了 {name, content} 字典,必须在出口转成契约形态—— + # 否则 act_migrate_networkd 的 `for path, content in files` 解包 dict 时 + # 只会拿到它的两个**键名**字符串 'name' 和 'content', + # 于是把字面量 'content' 当成文件内容、写进一个叫 'name' 的文件里。 + return [('%s/%s' % (NETWORKD_DIR, u['name']), u['content']) for u in out] # ---------------------------------------------------------------- PPPoE @@ -840,10 +930,20 @@ def render_ppp(cfg): raise ValidateError('PPPoE 密码不能包含空格或引号', 'password') mtu = v_int(p.get('mtu') or 1492, field='PPPoE MTU', lo=576, hi=9000) mru = v_int(p.get('mru') or mtu, field='PPPoE MRU', lo=576, hi=9000) + # 这两个字段用户改得很随意,但也有同样的注入面: + # isp —— 进了注释行,带换行就会变成真的 pppd 选项 + # service_name —— 被写进 servicename "…",含引号就能闭合引号接着写选项 + # 用户名/密码早就检查了引号与空格,这里补上缺的那两个(原先只对 caveman + # 做了限制,servicename 完全没有)。 + isp = v_text(p.get('isp') or '自动', field='运营商', maxlen=32, + pattern=r'^[A-Za-z0-9\u4e00-\u9fff ._+-]{1,32}$') + svc = v_text(p.get('service_name') or '', field='PPPoE 服务名', + maxlen=64, allow_empty=True, + pattern=r'^[A-Za-z0-9._-]{1,64}$') peers = [ '# 由 drouter 自动生成:PPPoE 拨号配置', - '# 运营商:' + (p.get('isp') or '自动'), + '# 运营商:' + isp, f'user "{user}"', f'plugin rp-pppoe.so {iface}', 'noipdefault', @@ -864,8 +964,8 @@ def render_ppp(cfg): 'novj', 'novjccomp', ] - if (p.get('service_name') or '').strip(): - peers.append(f"servicename \"{p['service_name'].strip()}\"") + if svc: + peers.append(f'servicename "{svc}"') peers += ['', ''] secrets = f'"{user}" * "{passwd}"\n' return [('/etc/ppp/peers/drouter-wan', '\n'.join([l for l in peers if l != ''])), @@ -939,15 +1039,43 @@ def render_radvd(cfg): lines += ['};', ''] # IPv6 地址池说明块(供人工与后续脚本参考;radvd 本身通过 prefix 段生效) if r.get('pool_enabled', True) is not False: - pol = r.get('pool_policy') or [] + # 整块都是「先取值再原样拼」的写法,池名/前缀/策略三个字段原先一个 + # 都没校验:填了换行的池名会把注释后面变成真的 radvd 指令,而页面 + # 只会报一句 radvd.conf 语法错误。这里逐个过一遍。 + pol = listify(r.get('pool_policy')) or ['recommend'] + for it in pol: + v_text(it, field='IPv6 池策略', maxlen=32, + pattern=r'^[A-Za-z0-9_-]{1,32}$') + pool_prefix = v_text(r.get('pool_prefix') or '', field='IPv6 池前缀', + maxlen=45, allow_empty=True, + pattern=r'^[0-9a-fA-F:.]{1,45}$') + if pool_prefix and '/' in pool_prefix: + try: + ipaddress.IPv6Network(pool_prefix, strict=False) + except Exception: + raise ValidateError(f'IPv6 池前缀不合法:{pool_prefix}(示例 2408:8207:xxxx::/56)', + 'pool_prefix') + # 「起始子网 ID」允许 0,别写成 `or 1` —— 0 会被当成没填而悄悄改成 1, + # 用户以为从 0 号子网开始分,实际渲染出来是 1(同 _clean_auto 的 hour=0)。 + _ps, _pe = r.get('pool_start'), r.get('pool_end') + if _ps in (None, ''): + _ps = 1 + if _pe in (None, ''): + _pe = 254 + pstart = v_int(_ps, field='起始子网 ID', lo=0, hi=65535) + pend = v_int(_pe, field='结束子网 ID', lo=0, hi=65535) + if pstart > pend: + raise ValidateError( + f'IPv6 池分配范围不合法:起始子网 ID({pstart})大于结束子网 ID({pend})', + 'pool_start') lines += [ '# ---------------- IPv6 地址池 / 池策略(RouterOS 风格) ----------------', - f"# 池名称:{r.get('pool_name') or 'drouter-wan-pool'}", - f"# 地址来源:{r.get('pool_from') or 'pool'}", - f"# 池前缀:{r.get('pool_prefix') or '(跟随运营商 PD)'}", + f"# 池名称:{v_text(r.get('pool_name') or 'drouter-wan-pool', field='IPv6 池名称', maxlen=48, pattern=r'^[A-Za-z0-9._-]{1,48}$')}", + f"# 地址来源:{v_text(r.get('pool_from') or 'pool', field='IPv6 池来源', maxlen=32, pattern=r'^[A-Za-z0-9._-]{1,32}$')}", + f"# 池前缀:{pool_prefix or '(跟随运营商 PD)'}", f"# 子网 ID 长度:{v_int(r.get('pool_subnet_bits') or 8, field='子网 ID 长度', lo=0, hi=64)}", - f"# 分配范围:子网 ID {v_int(r.get('pool_start') or 1, field='起始子网 ID', lo=0, hi=65535)}" - f" - {v_int(r.get('pool_end') or 254, field='结束子网 ID', lo=0, hi=65535)}", + f"# 分配范围:子网 ID {pstart}" + f" - {pend}", f"# 池策略:{', '.join(pol) if pol else 'recommend'}", '# ---------------------------------------------------------------------', '', @@ -983,7 +1111,10 @@ def render_dhcpcd(cfg): lines.append(' ipv6rs') if v_bool(d.get('request_pd', True)): iaid = v_int(d.get('iaid') or 0, field='IAID', lo=0, hi=4294967295) - suffix = (d.get('sla_id') or '::1').strip() + # sla_id 原先只做了 .strip(),然后直接拼进 ia_pd 的第 5 段: + # 一个换行就能在 dhcpcd.conf 里加一行 parameter-like 指令, + # 填个非 IPv6 的串则让整个 PD 请求失效(而且只在系统日志里有痕迹)。 + suffix = v_ipv6_suffix(d.get('sla_id'), field='SLA-ID 后缀') lines.append(f' ia_pd {iaid}/{lan}/0/{plen}/{suffix}') if d.get('rapid_commit'): lines.append(' option rapid_commit') @@ -1038,8 +1169,10 @@ def render_miniupnpd(cfg): 'upnp_nat_chain=prerouting_miniupnpd', 'upnp_nat_postrouting_chain=postrouting_miniupnpd', 'system_uptime=yes', - f"lease_file={u.get('lease_file') or '/var/run/miniupnpd.leases'}", - f"uuid={u.get('uuid') or 'drouter-0001'}", + # lease_file / uuid 原先直接用用户的原值,没设在用户表格里就能被外部 + # 直接调 API 改成带换行的值,从而往 miniupnpd.conf 里追加任意指令。 + f"lease_file={v_path(u.get('lease_file'), field='UPnP 租约文件') if (u.get('lease_file') or '').strip() else '/var/run/miniupnpd.leases'}", + f"uuid={v_text(u.get('uuid') or 'drouter-0001', field='UPnP UUID', maxlen=64, pattern=r'^[A-Za-z0-9._-]{1,64}$')}", 'force_igd_desc_v1=yes' if v_bool(u.get('igd_v1')) else '', '', '# UPnP 权限规则:仅允许内网申请临时端口,末尾默认拒绝', @@ -1061,8 +1194,14 @@ def render_chrony(cfg): if not servers: servers = ['ntp.aliyun.com', 'time.cloudflare.com'] for s in servers: + # 原先只认「域名」或「IPv4」,于是 IPv6 的 NTP 服务器(2400:3200::1 + # 这种国内常用的阿里公共 NTP)一律被判非法 —— 用户只能退回填域名。 if not DOMAIN_RE.match(s) and not IPV4_RE.match(s): - raise ValidateError(f'NTP 服务器地址不合法:{s}', 'servers') + try: + ipaddress.IPv6Address(s.split('%')[0]) + except Exception: + raise ValidateError( + f'NTP 服务器地址不合法:{s}(可填域名、IPv4 或 IPv6 地址)', 'servers') lines = ['# 由 drouter 自动生成:NTP 客户端'] for s in servers: lines.append(f'server {s} iburst') @@ -1155,7 +1294,12 @@ def render_samba(cfg): if v_bool(sb.get('disable_netbios')): lines += [' # 关闭 NetBIOS 名称服务(纯本地网段建议开启,减少广播)', ' disable netbios = yes', ''] - if v_bool(sb.get('wins_enable')) and sb.get('wins_support'): + # WINS 原先写成「wins_enable 与 wins_support 必须同时为真」,但页面上只有 + # 一个开关 —— 它写入的是 wins_enable(sw('#sh-wins','wins_enable')), + # wins_support 从来没有任何界面会去写。于是勾上「本机充当 WINS 服务器」 + # 再保存、应用,smb.conf 里一行都不会多出来,是个彻头彻尾的死开关。 + # 这里以 wins_enable 为准,wins_support 降级为历史配置里的别名。 + if v_bool(sb.get('wins_enable')) or v_bool(sb.get('wins_support')): lines += [' # 由本机充当 WINS 服务器', ' wins support = yes', ' local master = yes', @@ -1273,6 +1417,12 @@ def render_nfs(cfg): for c in clients: net = (c.get('net') or '').strip() preset = (c.get('preset') or 'linux').strip().lower() + # 模板名写错时必须报错而不是「静默退回 linux」:no_root_squash 这类的 + # 差异会让用户以为选的是 macOS 模板,实际拿到一套完全不同的导出参数。 + if preset not in NFS_OPTS: + raise ValidateError( + f'NFS 导出「{path}」的客户端模板不支持:{preset}' + f'(可选:{", ".join(sorted(NFS_OPTS))})', 'preset') if not net: raise ValidateError(f'NFS 导出「{path}」存在空的客户端网段', 'clients') # 通配符 * 允许,其余按 CIDR / 单机地址校验 @@ -1288,6 +1438,10 @@ def render_nfs(cfg): continue seen.add(net) opts = (c.get('options') or '').strip() or NFS_OPTS.get(preset, NFS_OPTS['linux']) + # 自定义选项整串会原样写进 /etc/exports 的 (…) 里:带个空格或换行 + # 就能越过当前共享项去改别的导出(exports 是按行解析的)。 + v_text(opts, field=f'NFS 导出「{path}」的选项', maxlen=200, + pattern=r'^[A-Za-z0-9_,=:.\-]{1,200}$') # 兜底:用户自定义选项里若出现危险组合,给出中文提示 if 'no_root_squash' in opts and preset != 'legacy': raise ValidateError( @@ -1295,7 +1449,11 @@ def render_nfs(cfg): f'这会让客户端 root 直接以 root 身份写共享目录,存在安全风险。' f'如确需如此,请把模板选为「兼容旧设备(不安全)」。', 'options') rendered.append(f'{net}({opts})') - lines.append(f'# {e.get("comment") or path}') + # 备注进了注释行,同样要挡换行(smb 那边早就这么做了,这里补上)。 + cmt = v_text(e.get('comment') or path, field=f'第 {i + 1} 个 NFS 导出的备注', + maxlen=120, allow_empty=False, + pattern=r'^[A-Za-z0-9\u4e00-\u9fff ._/@:-]{1,120}$') + lines.append(f'# {cmt}') lines.append(f'{path} {" ".join(rendered)}') lines.append('') return '\n'.join(lines) @@ -1304,7 +1462,13 @@ def render_nfs(cfg): def render_nfs_conf(cfg): """渲染 /etc/nfs.conf.d/drouter.conf:线程数 / 版本 / 端口固定。""" n = _sub(cfg, 'nfs') - threads = int(n.get('threads') or 8) + # 原先是裸 int(threads or 8):填个 abc 会抛 ValueError(500 + traceback), + # 而不是「NFS 服务线程数必须是数字」这种能看懂的提示。这里只补上捕获, + # 范围提示沿用原文案(页面上的说明写的是「1–128」)。 + try: + threads = int(str(n.get('threads') or 8).strip()) + except Exception: + raise ValidateError('NFS 服务线程数必须是数字', 'threads') if not (1 <= threads <= 128): raise ValidateError('NFS 服务线程数应在 1–128 之间', 'threads') lines = [ diff --git a/backend/theme.py b/backend/theme.py index 8f5129e..fc8dd66 100644 --- a/backend/theme.py +++ b/backend/theme.py @@ -313,9 +313,15 @@ def theme_to_css(theme): 同时输出一份深/浅色标记注释,便于排查。 """ lines = [] + # 主题 meta(名称 / 版本)是自由文本,原样拼进 /* */ 注释时,一个 "*/" + # 就能提前闭合注释,让后面的内容变成真正的 CSS 规则(主题名写成 + # `x*/}body{display:none}/*` 即可注入)。先剥掉注释终止符与换行。 + def _cmt(s): + return str(s or '').replace('*/', '').replace('\r', ' ').replace('\n', ' ') + lines.append('/* Drouter 主题:%s%s —— 由「主题之家」生成,请勿手工编辑 */' - % (theme.get('name') or '未命名', - (' v' + theme['version']) if theme.get('version') else '')) + % (_cmt(theme.get('name') or '未命名'), + (' v' + _cmt(theme['version'])) if theme.get('version') else '')) lines.append('/* 生成时间:%s */' % time.strftime('%Y-%m-%d %H:%M:%S')) lines.append(':root{') for k in sorted(theme.get('vars') or {}): diff --git a/devtools/retag-docker-image.py b/devtools/retag-docker-image.py index e6ec3bc..fe81736 100644 --- a/devtools/retag-docker-image.py +++ b/devtools/retag-docker-image.py @@ -5,11 +5,11 @@ 为什么需要这个脚本 ------------------ -镜像原本是用 **buildah** 以 root 构建的,标签被钉成 `localhost/drouter:1.0.3`。 +镜像原本是用 **buildah** 以 root 构建的,标签被钉成 `localhost/drouter:1.0.4`。 `localhost/` 是 Podman 的"本地镜像"约定,Docker 虽然能 load 进来,但这个前缀 既不好看也容易让人误解(像是某个私服地址)。所以这里把标签改成: - 主标签 drouter:1.0.3 ← docker load 后直接用这个跑 - 别名 liuzhuohua/drouter:1.0.3 ← 同时打一个带用户名的,方便以后推 Docker Hub + 主标签 drouter:1.0.4 ← docker load 后直接用这个跑 + 别名 liuzhuohua/drouter:1.0.4 ← 同时打一个带用户名的,方便以后推 Docker Hub 顺带修两个真实缺陷: 1. 镜像 LABEL 里 `org.opencontainers.image.licenses` 写的是 **GPL-3.0**, @@ -30,10 +30,10 @@ import os import sys import tarfile -SRC = sys.argv[1] if len(sys.argv) > 1 else "dist/drouter-1.0.3-docker.tar" -DST = sys.argv[2] if len(sys.argv) > 2 else "dist/drouter-1.0.3-docker.tar.new" +SRC = sys.argv[1] if len(sys.argv) > 1 else "dist/drouter-1.0.4-docker.tar" +DST = sys.argv[2] if len(sys.argv) > 2 else "dist/drouter-1.0.4-docker.tar.new" -NEW_TAGS = ["drouter:1.0.3", "liuzhuohua/drouter:1.0.3"] +NEW_TAGS = ["drouter:1.0.4", "liuzhuohua/drouter:1.0.4"] with tarfile.open(SRC, "r:") as tin: members = tin.getmembers() diff --git a/docs/02-部署与使用手册.md b/docs/02-部署与使用手册.md index 228eaf9..11673b7 100644 --- a/docs/02-部署与使用手册.md +++ b/docs/02-部署与使用手册.md @@ -222,21 +222,21 @@ drouter-ctl status # 查看服务 / 端口 / 主题 / 安全红线 ```bash # 1) .deb 安装包(在 Debian/Ubuntu 上执行) -bash packaging/build-deb.sh 1.0.3 # 产出 dist/drouter_1.0.3_all.deb -sudo apt install ./dist/drouter_1.0.3_all.deb +bash packaging/build-deb.sh 1.0.4 # 产出 dist/drouter_1.0.4_all.deb +sudo apt install ./dist/drouter_1.0.4_all.deb sudo apt purge drouter # 彻底卸载 # 2) 离线依赖包(目标机无外网时) bash scripts/make-offline-deps.sh # 产出 dist/offline-deps/ # 把整个目录拷到目标机后: -bash install.sh drouter_1.0.3_all.deb +bash install.sh drouter_1.0.4_all.deb # 3) Docker 镜像 -bash packaging/build-docker.sh 1.0.3 +bash packaging/build-docker.sh 1.0.4 docker run -d --name drouter --restart unless-stopped \ --network host --cap-add NET_ADMIN --cap-add NET_RAW \ -v drouter-data:/opt/drouter/data -v drouter-etc:/etc/drouter \ - drouter:1.0.3 + drouter:1.0.4 ``` `.deb` 的 `control` 里已声明完整 `Depends`(nftables、dnsmasq、ppp、pppoe、 diff --git a/packaging/build-deb.sh b/packaging/build-deb.sh index a668b2b..c548306 100644 --- a/packaging/build-deb.sh +++ b/packaging/build-deb.sh @@ -13,7 +13,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="${1:-1.0.3}" +VERSION="${1:-1.0.4}" DIST="$HERE/dist" PKG="drouter" STAGE="$DIST/${PKG}_${VERSION}_all" diff --git a/packaging/build-docker.sh b/packaging/build-docker.sh index 60dfa37..556d55d 100644 --- a/packaging/build-docker.sh +++ b/packaging/build-docker.sh @@ -10,7 +10,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -TAG="${1:-1.0.3}" +TAG="${1:-1.0.4}" command -v docker >/dev/null 2>&1 || { echo "需要 docker"; exit 1; } diff --git a/packaging/build-offline-bundle.sh b/packaging/build-offline-bundle.sh index 22dff69..24bac13 100644 --- a/packaging/build-offline-bundle.sh +++ b/packaging/build-offline-bundle.sh @@ -27,7 +27,7 @@ set -euo pipefail HERE="$(cd "$(dirname "$0")/.." && pwd)" -VERSION="${1:-1.0.3}" +VERSION="${1:-1.0.4}" OUTDIR="${2:-$HERE/dist}" ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)" diff --git a/packaging/docker/DOCKER-GUIDE.md b/packaging/docker/DOCKER-GUIDE.md index 73adfa3..bb7a035 100644 --- a/packaging/docker/DOCKER-GUIDE.md +++ b/packaging/docker/DOCKER-GUIDE.md @@ -1,6 +1,6 @@ # Drouter · Docker 使用说明 -> 适用镜像:`drouter:1.0.3`(`drouter-1.0.3-docker.tar`,约 169 MB) +> 适用镜像:`drouter:1.0.4`(`drouter-1.0.4-docker.tar`,约 169 MB) --- @@ -20,7 +20,7 @@ 不过 **Web 终端不受 systemd 缺失影响** —— 1.0.1 起终端守护会绕开 init 系统直接启动,容器里一样能开出 root shell(见第六节第 4 条自测)。 -**真正当路由器跑** → 用 `drouter-1.0.3-offline-amd64.tar.gz` 装到 Debian 13 物理机/虚拟机上。 +**真正当路由器跑** → 用 `drouter-1.0.4-offline-amd64.tar.gz` 装到 Debian 13 物理机/虚拟机上。 那才是这个项目的主路径,也是它被设计出来的场景。 **想快速看看界面长什么样、试试 API** → 用 Docker,一分钟就能跑起来。 @@ -30,14 +30,14 @@ ## 一、加载镜像 ```bash -docker load -i drouter-1.0.3-docker.tar +docker load -i drouter-1.0.4-docker.tar ``` 加载完会看到两个标签(同一个镜像): ``` -drouter:1.0.3 -liuzhuohua/drouter:1.0.3 +drouter:1.0.4 +liuzhuohua/drouter:1.0.4 ``` 验证: @@ -48,11 +48,11 @@ docker images | grep drouter > **为什么有两个标签?** > 带用户名的那个是给将来推 Docker Hub 用的。 -> 日常用短的 `drouter:1.0.3` 就行。 +> 日常用短的 `drouter:1.0.4` 就行。 > **如果你用的是 Podman**:本镜像最初用 buildah 构建,内部标签曾为 -> `localhost/drouter:1.0.3`。发布版已统一改成 `drouter:1.0.3` -> (另有别名 `liuzhuohua/drouter:1.0.3`),`podman load` 和 `docker load` +> `localhost/drouter:1.0.4`。发布版已统一改成 `drouter:1.0.4` +> (另有别名 `liuzhuohua/drouter:1.0.4`),`podman load` 和 `docker load` > 都能直接加载。若你手上是更早期的 tar 包,引用时才需要 `localhost/` 前缀。 --- @@ -103,7 +103,7 @@ docker run -d \ -v drouter-data:/opt/drouter/data \ -v drouter-snapshots:/opt/drouter/snapshots \ -v drouter-log:/var/log/drouter \ - drouter:1.0.3 + drouter:1.0.4 ``` 就这几行。参数含义与 compose 里**完全一致**,逐个对应。 @@ -385,8 +385,8 @@ docker inspect --format '{{json .Mounts}}' drouter | python3 -m json.tool ```bash # 目标机上 -tar xzf drouter-1.0.3-offline-amd64.tar.gz -cd drouter-1.0.3-offline-amd64 +tar xzf drouter-1.0.4-offline-amd64.tar.gz +cd drouter-1.0.4-offline-amd64 sudo bash install.sh # 恢复你在容器里试出来的配置 @@ -402,7 +402,7 @@ sudo systemctl restart drouter-web drouter-helpd drouter-shelld |---|---| | 基础镜像 | `debian:trixie-slim` | | 架构 | amd64 / linux | -| 标签 | `drouter:1.0.3`、`liuzhuohua/drouter:1.0.3` | +| 标签 | `drouter:1.0.4`、`liuzhuohua/drouter:1.0.4` | | 入口 | `/opt/drouter/bin/docker-init.sh` | | 默认命令 | `/usr/bin/python3 /opt/drouter/backend/drouter-web.py` | | 暴露端口 | 8443(HTTPS 面板)、8080(备用 HTTP,默认关) | diff --git a/packaging/docker/Dockerfile b/packaging/docker/Dockerfile index 15af536..087a379 100644 --- a/packaging/docker/Dockerfile +++ b/packaging/docker/Dockerfile @@ -10,7 +10,7 @@ # --network host --cap-add NET_ADMIN --cap-add NET_RAW \ # -v drouter-data:/opt/drouter/data \ # -v drouter-etc:/etc/drouter \ -# drouter:1.0.3 +# drouter:1.0.4 # ============================================================================= FROM debian:trixie-slim diff --git a/packaging/docker/docker-compose.yml b/packaging/docker/docker-compose.yml index c541830..81c6023 100644 --- a/packaging/docker/docker-compose.yml +++ b/packaging/docker/docker-compose.yml @@ -2,7 +2,7 @@ # Drouter —— Docker Compose 编排 # # 用法: -# docker load -i drouter-1.0.3-docker.tar +# docker load -i drouter-1.0.4-docker.tar # docker compose up -d # 浏览器打开 https://<宿主IP>:8443/ # @@ -24,7 +24,7 @@ services: drouter: # 镜像从 tar 加载后即为这个名字。若你改过标签,这里同步改。 - image: drouter:1.0.3 + image: drouter:1.0.4 # 固定容器名,方便 docker exec / logs 直接引用 container_name: drouter diff --git a/packaging/offline-install.sh b/packaging/offline-install.sh index d16bdac..139d087 100644 --- a/packaging/offline-install.sh +++ b/packaging/offline-install.sh @@ -102,7 +102,7 @@ if [ -z "$PKG" ]; then PKG=$(ls -1 "$HERE"/drouter_*_all.deb 2>/dev/null | head -1 || true) fi if [ -z "$PKG" ]; then - die "目录里找不到 drouter_*_all.deb(用法:bash install.sh drouter_1.0.3_all.deb)" + die "目录里找不到 drouter_*_all.deb(用法:bash install.sh drouter_1.0.4_all.deb)" fi [ -f "$PKG" ] || PKG="$HERE/$PKG" [ -f "$PKG" ] || die "找不到安装包:$PKG" diff --git a/scripts/make-offline-deps.sh b/scripts/make-offline-deps.sh index cd5a376..5be4b79 100644 --- a/scripts/make-offline-deps.sh +++ b/scripts/make-offline-deps.sh @@ -22,7 +22,7 @@ # # 用法(目标机): # 把整个目录拷过去,执行 -# sudo bash install.sh drouter_1.0.3_all.deb +# sudo bash install.sh drouter_1.0.4_all.deb # ============================================================================= set -euo pipefail @@ -260,7 +260,7 @@ if [ -z "$PKG" ]; then PKG=$(ls -1 "$HERE"/drouter_*_all.deb "$HERE"/*/drouter_*_all.deb 2>/dev/null | head -1 || true) fi [ -n "$PKG" ] && [ -f "$PKG" ] || PKG="$HERE/$PKG" -[ -f "$PKG" ] || { echo "✘ 找不到 drouter 安装包(用法:bash install.sh drouter_1.0.3_all.deb)"; exit 1; } +[ -f "$PKG" ] || { echo "✘ 找不到 drouter 安装包(用法:bash install.sh drouter_1.0.4_all.deb)"; exit 1; } echo "安装包:$PKG" echo "" @@ -320,5 +320,5 @@ echo "======================================================================" echo "离线包已生成:$OUT" echo " 体积:$(du -sh "$OUT" | cut -f1) (依赖 $(ls -1 "$OUT"/debs/*.deb 2>/dev/null | wc -l) 个 deb)" echo " 分发:把整个目录拷到目标机,执行" -echo " sudo bash install.sh drouter_1.0.3_all.deb" +echo " sudo bash install.sh drouter_1.0.4_all.deb" echo "======================================================================" diff --git a/web/app.js b/web/app.js index 650c0d6..e141e7f 100644 --- a/web/app.js +++ b/web/app.js @@ -825,6 +825,13 @@ async function viewIface() { }); $('#br-en').onchange = e => { S.cfg.system = Object.assign($w('system'), { bridge_enabled: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; $('#br-name').oninput = e => { S.cfg.system = Object.assign($w('system'), { bridge_name: e.target.value }); setActionMsg('已修改,请点击保存并应用'); }; + // STP 下拉框原先只有模板、没有任何 handler —— 改了之后点保存, + // system.bridge_stp 压根不会被更新,等于一个改不生效的死控件。 + const stp = $('#br-stp'); + if (stp) stp.onchange = e => { + S.cfg.system = Object.assign($w('system'), { bridge_stp: e.target.value === '1' }); + setActionMsg('已修改,请点击保存并应用'); + }; $$('.brm').forEach(c => c.onchange = () => { const sel = $$('.brm').filter(x => x.checked).map(x => x.value); S.cfg.system = Object.assign($w('system'), { bridge_members: sel }); @@ -1786,7 +1793,14 @@ function viewIpv6() { + + +

两个生命期决定客户端保留这份 DNS 多久。DNSSL 没填时跟随 RDNSS 的值; + 注意不要写成 r.rdnss_life || 600 之外的省略形式 —— 它们是数字输入框, + 0 不会被使用,这里给的都是合法默认值。

@@ -1838,6 +1852,8 @@ function viewIpv6() { lifetime: Number($('#ra-life').value), hop_limit: Number($('#ra-hop').value), valid_life: Number($('#ra-vl').value), pref_life: Number($('#ra-pl').value), rdnss: $('#ra-rdnss').value, dnssl: $('#ra-dnssl').value, + rdnss_life: Number($('#ra-rdnss-life').value) || 600, + dnssl_life: Number($('#ra-dnssl-life').value) || Number($('#ra-rdnss-life').value) || 600, managed: $('#ra-mg').checked, other_config: $('#ra-oc').checked, }); S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { @@ -1849,7 +1865,8 @@ function viewIpv6() { setActionMsg('已修改,请点击保存并应用'); }; ['#ra-if', '#ra-pfx', '#ra-max', '#ra-min', '#ra-life', '#ra-hop', '#ra-vl', '#ra-pl', - '#ra-rdnss', '#ra-dnssl', '#dv-wan', '#dv-lan', '#dv-plen', '#dv-sla', '#dv-iaid'] + '#ra-rdnss', '#ra-dnssl', '#ra-rdnss-life', '#ra-dnssl-life', + '#dv-wan', '#dv-lan', '#dv-plen', '#dv-sla', '#dv-iaid'] .forEach(s => { const e = $(s); if (e) e.oninput = rsync; }); ['#ra-mg', '#ra-oc', '#dv-pd', '#dv-na'].forEach(s => { const e = $(s); if (e) e.onchange = rsync; }); @@ -3291,8 +3308,10 @@ function viewDhcpv6() { - + +

这里的「子网 ID」与「IPv6 / RA」页面里的「SLA-ID 后缀」是同一个值 + (原先这里写的是 subnet_id,渲染器读的是 sla_id —— 改了什么都不生效)。

@@ -3305,7 +3324,10 @@ function viewDhcpv6() { e.onchange = h; if (e.tagName === 'INPUT') e.oninput = h; }; bind('#d6-len', 'prefix_len'); bind('#d6-iface', 'iface'); - bind('#d6-method', 'method'); bind('#d6-sid', 'subnet_id'); + bind('#d6-method', 'method'); + // 写 sla_id 而不是 subnet_id:渲染器(render_dhcpcd 的 ia_pd 第 5 段)读的是 + // sla_id,存到 subnet_id 的话用户改完点「保存并应用」什么都不会变。 + bind('#d6-sid', 'sla_id'); $('#d6-en').onchange = e => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { enabled: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; $('#d6-rapid').onchange = e => { S.cfg.dhcpv6 = Object.assign($w('dhcpv6'), { rapid_commit: e.target.checked }); setActionMsg('已修改,请点击保存并应用'); }; liveV6(); @@ -4529,7 +4551,7 @@ async function viewDdns() { const r2 = await api('/api/ddns', { method: 'POST', body: { op: 'update' } }); const p = (r2.data || {}).plan || []; $('#dd-out').innerHTML = `
${esc(r2.msg_cn || '')}\n` +
-      p.map(x => `  • ${x.type} 记录  ${x.record} → ${x.ip}(${x.provider})`).join('\n') + '
'; + p.map(x => ` • ${esc(x.type)} 记录 ${esc(x.record)} → ${esc(x.ip)}(${esc(x.provider)})`).join('\n') + ''; toast(r2.msg_cn, r2.ok ? 'ok' : 'err'); }; $('#dd-refresh').onclick = () => { toast('已刷新 DDNS 状态', 'ok'); viewDdns(); }; @@ -7448,12 +7470,41 @@ function viewSys() { setActionMsg('允许网段已修改,请点击保存并应用'); }; $('#sy-mig').onclick = async () => { - $('#sy-migout').innerHTML = '
生成中…
'; - const r = await api('/api/migrate/preview', { method: 'POST', body: { cfg: { lan: { iface: $w('system').lan_iface, address: $w('system').lan_address } } } }); + const out = $('#sy-migout'); + // render_network 需要 wan/lan/bridge 三段齐全:只传 lan 会让它在 + // `v_ifname(wan.get('iface') or '')` 这里直接抛「WAN 网卡不能为空」, + // 预览永远出不来;缺 bridge 则用户配好的网桥一个文件都不会显示在预览里。 + const s = $w('system'), p = $w('pppoe'); + if (!s.wan_iface) { + out.innerHTML = '

请先到「网卡与桥接」页把某个网口的角色设为 WAN,' + + '否则迁移配置里缺少 WAN 网卡,无法生成。

'; + return; + } + const mode = p.mode || 'dhcp'; + const wan = { iface: s.wan_iface, mode }; + if (mode === 'static') { + wan.address = p.static_address || ''; + wan.gateway = p.static_gateway || ''; + wan.dns = p.static_dns || ''; + } else if (mode === 'pppoe') { + wan.mtu = p.mtu || 1492; + } else { + wan.mtu = p.dhcp_mtu || 1500; + // 「使用上级下发的 DNS」若不给后端,render_network 里会按默认 yes 处理, + // 用户在 WAN 页关掉它就白关了。 + wan.use_dns = p.dhcp_use_dns !== false; + } + out.innerHTML = '
生成中…
'; + const r = await api('/api/migrate/preview', { method: 'POST', body: { cfg: { + wan, + lan: { iface: s.lan_iface || '', address: s.lan_address || '192.168.7.3/24', mtu: s.lan_mtu || 1500 }, + bridge: { enabled: !!s.bridge_enabled, name: s.bridge_name || 'br0', + members: s.bridge_members || [], stp: !!s.bridge_stp }, + } } }); const files = (r.data || {}).files || []; - $('#sy-migout').innerHTML = files.length + out.innerHTML = files.length ? files.map(f => `
${esc(f.name)}
${esc(f.content)}
`).join('') - : '

' + esc(r.msg_cn) + '

'; + : '

' + esc(r.msg_cn || '未生成任何文件') + '

'; }; } @@ -7935,7 +7986,10 @@ function tmIssuesHTML(list) { /* 把主题渲染为 CSS 文本(与后端一致的输出格式,用于离线预览与「查看代码」) */ function tmToCss(t) { const lines = []; - lines.push(`/* Drouter 主题:${t.name}${t.version ? ' v' + t.version : ''} */`); + // 主题名 / 版本是自由文本,原样拼进 /* */ 注释时,一个 "*/" 就能提前闭合注释、 + // 把后面的内容变成真正的 CSS 规则。这里必须先把注释终止符和换行剥掉。 + const cmt = s => String(s || '').replace(/\*\//g, '').replace(/[\r\n]+/g, ' '); + lines.push(`/* Drouter 主题:${cmt(t.name)}${t.version ? ' v' + cmt(t.version) : ''} */`); lines.push(':root{'); Object.keys(t.vars || {}).sort().forEach(k => { lines.push(` ${k}:${t.vars[k]};`); }); lines.push('}'); @@ -8470,6 +8524,33 @@ function tmBindDesign() { $('#tm-save').onclick = () => tmSave(false); $('#tm-save-apply').onclick = () => tmSave(true); $('#tm-export-cur').onclick = () => tmExportDraft(); + // 「按此主色生成整套配色」原先把取色器和按钮都渲染出来了,却从没接过任何 handler, + // 后端也没有对应的 op —— 用户点了完全没反应。配色统一交给后端 make_palette, + // 与内置主题共用同一套对比度保证,前端不自己造一份(否则会和有权重的那份跑偏)。 + const palBtn = $('#tm-palette'); + if (palBtn) palBtn.onclick = async () => { + const seed = ($('#tm-seed') || {}).value || '#1f6feb'; + const dkEl = document.getElementById('tm-dark'); + palBtn.disabled = true; + palBtn.textContent = '生成中…'; + const r = await api('/api/theme/op', { method: 'POST', + body: { op: 'palette', color: seed, dark: !!(dkEl && dkEl.checked) } }); + palBtn.disabled = false; + palBtn.textContent = '按此主色生成整套配色'; + if (!r.ok) { toast('配色生成失败:' + (r.msg_cn || ''), 'err', 6000); return; } + const vars = (r.data || {}).vars || {}; + Object.keys(vars).forEach(k => { + const key = k.replace(/[^a-z0-9-]/gi, ''); + const txt = document.getElementById('tm-v-' + key); + if (txt) txt.value = vars[k]; + const cb = document.getElementById('tm-o-' + key); + if (cb) cb.checked = true; // 勾选 = 写进主题 + const sw = document.querySelector('.tm-color[data-for="' + key + '"]'); + if (sw) sw.value = tmToHex(vars[k], sw.value); + }); + tmRefreshPreview(); + toast(`已按主色生成 ${Object.keys(vars).length} 个配色变量`, 'ok'); + }; $('#tm-copy-css').onclick = () => { const txt = document.getElementById('tm-code').textContent; navigator.clipboard.writeText(txt).then(