fix: 清「假输入框 / 隐身字段」这批前后端缺陷,版本号 1.0.4

把全量代码(后端 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 链接检查全绿。
This commit is contained in:
liuzhuohua committed 2026-10-02 00:23:30 +08:00
1 parent 79f24aa264
commit cd5ef7411f
20 files changed
+685 -109

No files matched your search

+64 -22
View File
@@ -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
<summary><b>包里到底装了什么(点开看)</b></summary>
```
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/
</details>
**想要更小的包?** 如果你的目标机有网(哪怕是内网自建 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 — 修「部署之后设置莫名回退」与几处静默失败
纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。
+1 -1
View File
@@ -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
@@ -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"
+32
View File
@@ -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)
+176
View File
@@ -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 != <DMZ 主机> 的反向条件',
'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)
+32
View File
@@ -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))
+36 -11
View File
@@ -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 网关)均可直接调用。'
+191 -27
View File
@@ -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 != <DMZ 主机> dnat to <DMZ 主机>`,这个条件是反的:
# 它排除的正是「本来就要发给 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 = [
+8 -2
View File
@@ -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 {}):
+6 -6
View File
@@ -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()
+5 -5
View File
@@ -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、
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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; }
+1 -1
View File
@@ -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)"
+12 -12
View File
@@ -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,默认关) |
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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"
+3 -3
View File
@@ -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 "======================================================================"
+90 -9
View File
@@ -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() {
<label>前缀首选生命期<input id="ra-pl" type="number" value="${r.pref_life || 14400}"></label>
<label>RDNSS(下发 DNS)<input id="ra-rdnss" value="${esc(r.rdnss || '')}" placeholder="2400:3200::1"></label>
<label>DNSSL(域名)<input id="ra-dnssl" value="${esc(r.dnssl || '')}" placeholder="lan"></label>
<label>RDNSS 生命期(秒)<input id="ra-rdnss-life" type="number" min="60" max="9000"
value="${Number(r.rdnss_life || 600)}"></label>
<label>DNSSL 生命期(秒)<input id="ra-dnssl-life" type="number" min="60" max="9000"
value="${Number(r.dnssl_life || r.rdnss_life || 600)}"></label>
</div>
<p class="hint-inline">两个生命期决定客户端保留这份 DNS 多久。DNSSL 没填时跟随 RDNSS 的值;
注意不要写成 <span class="mono">r.rdnss_life || 600</span> 之外的省略形式 —— 它们是数字输入框,
0 不会被使用,这里给的都是合法默认值。</p>
<label class="switch"><input type="checkbox" id="ra-mg" ${r.managed ? 'checked' : ''}><i></i>M 标志(客户端用 DHCPv6 取地址)</label>
<label class="switch"><input type="checkbox" id="ra-oc" ${r.other_config ? 'checked' : ''}><i></i>O 标志(客户端用 DHCPv6 取其他配置)</label>
</div>
@@ -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() {
<option value="dhcpv6" ${c.method === 'dhcpv6' ? 'selected' : ''}>DHCPv6 有状态</option>
<option value="both" ${c.method === 'both' ? 'selected' : ''}>两者同时</option>
</select></label>
<label>子网 ID<input id="d6-sid" value="${esc(c.subnet_id || '0')}" placeholder="例如 0"></label>
<label>子网 ID(SLA-ID)<input id="d6-sid" value="${esc(c.sla_id || '::1')}" placeholder="例如 ::1 或 0"></label>
</div>
<p class="hint-inline">这里的「子网 ID」与「IPv6 / RA」页面里的「SLA-ID 后缀」是同一个值
(原先这里写的是 subnet_id,渲染器读的是 sla_id —— 改了什么都不生效)。</p>
</div>
<div class="card">
@@ -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 = `<pre>${esc(r2.msg_cn || '')}\n` +
p.map(x => ` • ${x.type} 记录 ${x.record} → ${x.ip}(${x.provider})`).join('\n') + '</pre>';
p.map(x => ` • ${esc(x.type)} 记录 ${esc(x.record)} → ${esc(x.ip)}(${esc(x.provider)})`).join('\n') + '</pre>';
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 = '<pre>生成中…</pre>';
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 = '<p class="desc">请先到「网卡与桥接」页把某个网口的角色设为 WAN,'
+ '否则迁移配置里缺少 WAN 网卡,无法生成。</p>';
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 = '<pre>生成中…</pre>';
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 => `<div style="margin-bottom:10px"><b class="mono">${esc(f.name)}</b><pre>${esc(f.content)}</pre></div>`).join('')
: '<p class="desc">' + esc(r.msg_cn) + '</p>';
: '<p class="desc">' + esc(r.msg_cn || '未生成任何文件') + '</p>';
};
}
@@ -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(