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:
1 parent
79f24aa264
commit
cd5ef7411f
20 files changed
+685
-109
No files matched your search
@@ -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 — 修「部署之后设置莫名回退」与几处静默失败
|
||||
|
||||
纯修复版本,无新增功能,**不改动配置存储格式**(老配置直接可用)。
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {}):
|
||||
|
||||
@@ -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
@@ -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、
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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)"
|
||||
|
||||
|
||||
@@ -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,默认关) |
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user