feat: Drouter 1.0.0 —— Debian 13 软路由管理系统

一套跑在 Debian 13 (trixie) 上的软路由管理系统,提供 43 个功能页面的
Web 管理面板:WAN 接入、PPPoE 多拨、DHCP/DNS、IPv6、防火墙、QoS 限速、
访问控制、DDNS、文件共享、Docker 管理等。

核心设计原则:不重新实现任何协议
  PPPoE 交给 ppp,DHCP 交给 dnsmasq,IPv6 RA 交给 radvd,NAT 交给 nftables。
  Web 面板只做三件事:渲染原生配置文件 → 语法预检 → 原子写入并 reload。
  面板删掉后,系统仍是一台配置完好的 Debian 路由器。

安全默认值
  · 安装后不接管网络:不改默认路由、不开 IP 转发、不启动 DHCP 服务
  · 所有改网络的动作都需要用户在面板上亲手点「应用」
  · 避开 5900 端口,不触碰 VNC / X 会话
  · 配置写入前先做语法预检,失败则回滚

架构
  · backend/  Python 3 标准库,零第三方依赖(9 个模块)
  · web/      原生 JS/CSS,无构建步骤
  · 常驻 helper 通过 Unix socket 提供 75 个动作,socket 不可用时回退 subprocess
  · 鉴权走 X-Token 请求头

打包
  · packaging/build-deb.sh            → drouter_1.0.0_all.deb(含 6 个 systemd 单元)
  · scripts/make-offline-deps.sh      → 236 个依赖 .deb + 本地仓库索引
  · packaging/build-offline-bundle.sh → 自包含离线包,目标机零外网可装
  · packaging/build-docker.sh         → 容器镜像(体验用)

测试
  40 套单元测试 + 3 个前端检查脚本(菜单映射 / 离线渲染 / 手机适配),
  全部挂在 devtools/sync.sh 的预检链里。

许可:MIT
This commit is contained in:
liuzhuohua committed 2026-09-30 19:26:24 +08:00
commit 64df3cf331
135 files changed
+56059

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
# 交付前最终状态审计
echo "===== 1. 后端服务 ====="
systemctl is-active drouter-web; systemctl is-enabled drouter-web
systemctl show drouter-web -p MemoryCurrent -p ProtectSystem -p PrivateTmp -p User 2>/dev/null
echo; echo "===== 2. 监听端口 ====="
ss -lntup 2>/dev/null | awk 'NR==1 || /8080|8443|5900|:22/'
echo; echo "===== 3. 网络服务应全部 inactive+disabled ====="
for s in dnsmasq radvd dhcpcd miniupnpd chrony nftables; do
printf "%-12s active=%-10s enabled=%s\n" "$s" "$(systemctl is-active $s 2>&1)" "$(systemctl is-enabled $s 2>&1)"
done
echo; echo "===== 4. 端口 53/67/68 占用(应为空) ====="
ss -lnu 2>/dev/null | grep -E ':53 |:67 |:68 ' || echo " 53/67/68 空闲 ✔"
echo; echo "===== 5. nftables 规则(应为空) ====="
nft list ruleset 2>/dev/null | wc -l
echo; echo "===== 6. 构建保护模式 ====="
if [ -f /etc/drouter/BUILD_MODE ]; then echo " BUILD_MODE 存在 —— 保护开启 ✔"; cat /etc/drouter/BUILD_MODE; else echo " 未开启"; fi
echo; echo "===== 7. 已生成配置文件 ====="
for f in /etc/dnsmasq.d/drouter.conf /etc/nftables.d/drouter-v4.nft /etc/nftables.d/drouter-v6.nft \
/etc/radvd.conf /etc/dhcpcd.conf /etc/miniupnpd/miniupnpd.conf /etc/chrony/chrony.conf \
/etc/ppp/peers/drouter-wan; do
[ -f "$f" ] && printf " ✔ %-42s %s 字节\n" "$f" "$(stat -c%s "$f")" || printf " ✘ %-42s 缺失\n" "$f"
done
echo; echo "===== 8. 快照数量 ====="
ls -1 /opt/drouter/snapshots 2>/dev/null | wc -l
echo; echo "===== 9. 内存占用 ====="
ps -o rss=,cmd= -C python3 2>/dev/null | awk '{printf " RSS=%.1f MB %s\n", $1/1024, $2}'
echo; echo "===== 10. 网卡 ====="
ip -br -4 addr show ens18
echo; echo "===== 11. VNC 进程 ====="
ps aux | grep -i '[v]nc' | awk '{print " "$11" "$12" "$13}'
echo; echo "===== 12. 审计日志尾部 ====="
tail -5 /var/log/drouter/audit.jsonl 2>/dev/null || echo " (无)"
echo "AUDIT_DONE"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# 认领新网卡 ens19 为 WAN 口
B=http://127.0.0.1:8080
TOK=$(curl -s $B/api/login -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' \
| python3 -c 'import sys,json;print((json.load(sys.stdin).get("data") or {}).get("token",""))')
curl -s $B/api/iface/meta -H "X-Token: $TOK" | python3 -m json.tool --no-ensure-ascii
echo
echo "=== 认领 ens19 为 WAN ==="
curl -s $B/api/iface/save -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
-d '{"mac":"bc:24:11:f2:5a:47","name":"ens19","remark":"WAN口(PPPoE拨号)","role":"wan"}' \
| python3 -m json.tool --no-ensure-ascii
echo
echo "=== 确认 ens18 仍为 LAN ==="
curl -s $B/api/iface/save -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
-d '{"mac":"bc:24:11:1a:8f:43","name":"ens18","remark":"LAN管理口","role":"lan"}' \
| python3 -m json.tool --no-ensure-ascii
echo
echo "=== 结果 ==="
sqlite3 -header -column /opt/drouter/data/drouter.db 'select mac,name,remark,role from ifaces;'
echo
echo "--- system 配置 ---"
sqlite3 /opt/drouter/data/drouter.db "select value from settings where key='system';" | python3 -m json.tool --no-ensure-ascii
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# 清理回归测试残留:PPPoE 测试账号(peers / chap-secrets / pap-secrets)
set -u
echo "=== 清理前 ==="
grep -l "test12345" /etc/ppp/peers/* /etc/ppp/*-secrets 2>/dev/null
echo "=== 删除测试生成的 PPPoE 文件 ==="
rm -f /etc/ppp/peers/drouter-wan
# secrets 文件如果是本次测试生成的(仅含测试账号),恢复为空的模板
for f in /etc/ppp/chap-secrets /etc/ppp/pap-secrets; do
if [ -f "$f" ] && [ "$(wc -l < "$f")" -le 1 ] && grep -q "test12345" "$f" 2>/dev/null; then
printf '# 由 drouter 自动生成:PPPoE 认证凭据\n# 在 WEB 界面填写 PPPoE 账号密码并保存后,此处会被写入\n' > "$f"
chmod 600 "$f"; chown root:root "$f"
echo "已重置 $f"
fi
done
echo "=== 重置数据库 pppoe 配置块 ==="
python3 - <<'PY'
import sqlite3, json
db = '/opt/drouter/data/drouter.db'
c = sqlite3.connect(db); c.row_factory = sqlite3.Row
row = c.execute("SELECT value FROM settings WHERE key='pppoe'").fetchone()
cur = json.loads(row['value']) if row else {}
cur.setdefault('isp', 'auto'); cur.setdefault('mtu', 1492); cur.setdefault('mru', 1492)
cur.setdefault('persist', True); cur.setdefault('maxfail', 0); cur.setdefault('holdoff', 5)
cur.setdefault('service_name', '')
cur['iface'] = ''; cur['username'] = ''; cur['password'] = ''
cur['saved_only'] = True; cur['note'] = '仅保存凭据,未执行拨号'
c.execute("UPDATE settings SET value=? WHERE key='pppoe'", (json.dumps(cur, ensure_ascii=False),))
c.commit()
print('pppoe ->', json.dumps(cur, ensure_ascii=False))
PY
echo "=== 清理后核对 ==="
if grep -rq "test12345" /etc/ppp/ 2>/dev/null; then
echo "仍有残留!"; grep -rn "test12345" /etc/ppp/ 2>/dev/null
else
echo "无测试账号残留 ✔"
fi
ls -la /etc/ppp/peers/ /etc/ppp/chap-secrets /etc/ppp/pap-secrets 2>&1
echo "CLEANUP_DONE"
+428
View File
@@ -0,0 +1,428 @@
#!/bin/bash
# Debian13 主路由系统 —— 部署脚本
# 将后端、前端、systemd 服务、sudoers 白名单安装到位。
# 不会启动任何网络服务,不会修改网络配置。
set -e
# 源码目录可用 DROUTER_SRC 覆盖(一键脚本 drouter-ctl.sh 会传入实际路径)
SRC="${DROUTER_SRC:-/tmp/drouter-src}"
OPT=/opt/drouter
UNIT_WEB=drouter-web
echo "=== 1. 创建目录结构 ==="
mkdir -p $OPT/backend $OPT/web $OPT/data $OPT/snapshots $OPT/certs $OPT/bin $OPT/docs
mkdir -p /etc/drouter/generated
mkdir -p /etc/nftables.d
mkdir -p /var/log/drouter
mkdir -p /etc/dnsmasq.d
mkdir -p /var/lib/drouter/trash # 文件管理器"回收站"
mkdir -p $OPT/dpi # DPI 识别规则库与源码(#25)
mkdir -p /etc/ppp/ip-up.d /etc/ppp/ip-down.d # PPPoE 多拨策略路由钩子(#23)
# helper 私有临时目录:/run 为 tmpfs(重启自清),且不归低权用户所有,
# 避开 /tmp、/var/tmp 的 sticky 位与 fs.protected_regular=2 写保护。
mkdir -p /run/drouter-helper
chmod 700 /run/drouter-helper
sha_of() { [ -f "$1" ] && sha1sum < "$1" | cut -d' ' -f1 || echo "-"; }
# shelld 是常驻守护,代码在内存里,不重启就是旧行为。
# 判断依据必须是「本次源码指纹」vs「上次真正重启过的指纹」(记在 mark 文件里),
# 不能拿 $SRC 和 $OPT 比 —— install 之后两边同源,cmp 恒等,永远不重启。
# 也不能拿 install 前的 $OPT 比 —— 若上一轮已经把新文件拷进去了但没重启成功,
# 这一轮两边又相等,漏网。
SHELLD_NEW=$(sha_of "$SRC/backend/drouter-shelld.py")
SHELLD_MARK=/var/lib/drouter/shelld.deployed.sha
echo "=== 2. 安装后端 ==="
install -m 0644 $SRC/backend/render.py $OPT/backend/render.py
install -m 0644 $SRC/backend/drouter-helper.py $OPT/backend/drouter-helper.py
install -m 0644 $SRC/backend/drouter-web.py $OPT/backend/drouter-web.py
install -m 0644 $SRC/backend/theme.py $OPT/backend/theme.py
# drouter-logd:统一日志采集守护。helper 写出的 drouter-logd.service 直接
# ExecStart 这个文件,漏装会导致日志定时器起不来(此前确实漏了,务必保留)。
install -m 0644 $SRC/backend/drouter-logd.py $OPT/backend/drouter-logd.py
install -m 0644 $SRC/backend/drouter-snapshotd.py $OPT/backend/drouter-snapshotd.py
install -m 0644 $SRC/backend/drouter-rescue.py $OPT/backend/drouter-rescue.py
# drouter-shelld:Web 真·PTY 终端的会话守护。它必须由常驻进程持有 PTY
# (helper 每次都是跑完就退出的新进程,PTY fd 跨不了请求),漏装则终端连不上。
install -m 0644 $SRC/backend/drouter-shelld.py $OPT/backend/drouter-shelld.py
install -m 0644 $SRC/backend/drouter-helpd.py $OPT/backend/drouter-helpd.py
# 主题之家(#12):自定义主题目录 + 生效主题标记
mkdir -p /etc/drouter/themes
echo "default" > /etc/drouter/active-theme
chmod 644 /etc/drouter/active-theme
echo "=== 3. 安装前端 ==="
install -m 0644 $SRC/web/index.html $OPT/web/index.html
install -m 0644 $SRC/web/app.css $OPT/web/app.css
install -m 0644 $SRC/web/app.js $OPT/web/app.js
if [ -f "$SRC/web/logo.svg" ]; then
install -m 0644 $SRC/web/logo.svg $OPT/web/logo.svg
fi
echo "=== 3.5 安装运维脚本(自检/回归用)==="
if [ -d "$SRC/scripts" ]; then
install -d -m 0755 $OPT/scripts
for s in "$SRC"/scripts/*.sh; do
[ -f "$s" ] || continue
install -m 0755 "$s" "$OPT/scripts/$(basename "$s")"
done
echo "已安装脚本:$(ls $OPT/scripts | tr '\n' ' ')"
fi
echo "=== 3.6 安装文档(systemd unit 的 Documentation= 指向这里)==="
if [ -d "$SRC/docs" ]; then
for d in "$SRC"/docs/*; do
[ -f "$d" ] || continue
install -m 0644 "$d" "$OPT/docs/$(basename "$d")"
done
echo "已安装文档:$(ls $OPT/docs | tr '\n' ' ')"
fi
echo "=== 4. 创建专用低权用户 drouter ==="
if ! id drouter >/dev/null 2>&1; then
useradd -r -s /usr/sbin/nologin -d $OPT -M drouter
echo "已创建系统用户 drouter"
else
echo "用户 drouter 已存在"
fi
echo "=== 5. 权限设置 ==="
chown -R drouter:drouter $OPT/data $OPT/snapshots $OPT/certs
chmod 750 $OPT/data $OPT/snapshots $OPT/certs
chmod +x $OPT/backend/drouter-helper.py
chmod +x $OPT/backend/drouter-snapshotd.py
chmod +x $OPT/backend/drouter-rescue.py
chown root:root $OPT/backend/drouter-helper.py
chown -R root:root $OPT/backend
chown -R root:root $OPT/web
chmod 755 /var/log/drouter
chmod 755 /etc/drouter/generated
chown -R drouter:drouter /var/lib/drouter 2>/dev/null || true
chmod -R 750 /var/lib/drouter 2>/dev/null || true
echo "=== 6. sudoers 白名单(仅放行 helper,且限定参数形态)==="
cat > /etc/sudoers.d/drouter <<'SUDO'
# drouter:后端仅可通过该脚本以 root 执行白名单操作
# 参数被严格限制为 action + JSON 字符串
drouter ALL=(root) NOPASSWD: /usr/bin/python3 /opt/drouter/backend/drouter-helper.py *
SUDO
chmod 0440 /etc/sudoers.d/drouter
visudo -c -f /etc/sudoers.d/drouter >/dev/null && echo "sudoers 语法检查通过"
echo "=== 7. 日志轮转配置 ==="
cat > /etc/logrotate.d/drouter <<'LR'
/var/log/drouter/*.log /var/log/drouter/*.jsonl {
daily
rotate 7
missingok
notifempty
compress
delaycompress
copytruncate
create 0644 root root
}
LR
echo "=== 8. systemd 服务单元 ==="
cat > /etc/systemd/system/drouter-web.service <<'UNIT'
[Unit]
Description=drouter 主路由管理后台
Documentation=file:/opt/drouter/docs/
After=network-online.target
Wants=network-online.target
Before=shutdown.target
[Service]
Type=simple
User=drouter
Group=drouter
WorkingDirectory=/opt/drouter
# systemd 默认 PATH 不含 /usr/sbin,而 nft/dnsmasq/radvd 都在那儿。
# 后端虽已自行归一化 PATH,这里再显式声明一次,避免任何遗漏路径静默失败。
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-web.py
Restart=on-failure
RestartSec=5
# 资源限制:适配 2 核 / 3.9G 小内存环境
MemoryMax=160M
MemoryHigh=120M
CPUQuota=40%
Nice=5
IOSchedulingClass=best-effort
IOSchedulingPriority=5
# 安全加固
# 说明:后端以低权用户 drouter 运行,写 /etc 由 root helper 完成(白名单 action)。
# 注意:这里必须 ProtectSystem=false —— 「依赖自检 / 一键安装」需要通过 apt/dpkg
# 往 /usr/bin、/var/cache/apt、/var/lib/dpkg 写入;若把 /usr 挂为只读,
# apt 会报 "只读文件系统" 而安装失败。安全边界由 helper 的 action 白名单保证:
# 非白名单动作一律拒绝,且所有参数逐个强校验。
NoNewPrivileges=false
PrivateTmp=false
ProtectSystem=false
ProtectHome=read-only
ReadWritePaths=/opt/drouter/data /opt/drouter/snapshots /opt/drouter/certs /var/log/drouter
# 额外保护:这些路径不给写(即使 helper 被滥用也改不到)
InaccessiblePaths=/boot
StandardOutput=journal
StandardError=journal
SyslogIdentifier=drouter-web
[Install]
WantedBy=multi-user.target
UNIT
echo "=== 8.5 自动快照定时器 + 紧急救援通道服务单元 ==="
# 自动快照:默认「开启」,每 6 小时一次。真实参数由界面写入
# /etc/drouter/snapshot.conf,helper 保存策略时会重建本单元。
cat > /etc/systemd/system/drouter-snapshot.service <<'SVC'
[Unit]
Description=drouter 自动配置快照
After=network.target
[Service]
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-snapshotd.py
[Install]
WantedBy=multi-user.target
SVC
cat > /etc/systemd/system/drouter-snapshot.timer <<'TMR'
[Unit]
Description=drouter 自动快照定时器
[Timer]
OnBootSec=10min
OnUnitActiveSec=6h
Persistent=true
Unit=drouter-snapshot.service
[Install]
WantedBy=timers.target
TMR
# 紧急救援通道:默认「关闭」。只有用户在界面上勾选开启后,
# helper 才会创建虚拟网卡别名并 enable/start 本服务。
cat > /etc/systemd/system/drouter-rescue.service <<'RSC'
[Unit]
Description=drouter 紧急救援通道(独立于 WAN/LAN 的应急恢复入口)
After=network.target
Wants=network.target
[Service]
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Type=simple
User=root
ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-rescue.py
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=false
ProtectSystem=false
[Install]
WantedBy=multi-user.target
RSC
# 救援通道默认关闭:不 enable、不 start(避免影响现有局域网)
echo "救援通道单元已就位(默认关闭,需在界面显式开启)"
# 常驻执行守护(#7 性能优化):把「每个请求 spawn 一次 python3+sudo」的
# 236ms 固定开销去掉。以 root 运行,但只监听 Unix socket(0660 root:drouter),
# 不占任何 TCP 端口;drouter-web 连不上它时会自动回退到 subprocess,所以它
# 挂掉只会变慢、不会让面板不可用。
cat > /etc/systemd/system/drouter-helpd.service <<'HPD'
[Unit]
Description=drouter 特权动作常驻执行守护
Documentation=file:/opt/drouter/docs/
After=network.target
[Service]
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Type=simple
User=root
Group=root
# 刻意不用 RuntimeDirectory:见 drouter-shelld.service 里的说明。
# /run/drouter 由守护自己 mkdir 并 chmod 0755(drouter-web 要能进目录连
# helper.sock,而 socket 自身是 0660 root:drouter,权限并没有放宽)。
ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-helpd.py
Restart=on-failure
RestartSec=2
# 常驻 import 了整套 helper,比其它守护占得多;上限防内存泄漏拖垮路由器
MemoryMax=300M
# 故意不设 CPUQuota:它要跑格式化大硬盘这类子进程,限 CPU 会把这些操作拖慢
StandardOutput=journal
StandardError=journal
SyslogIdentifier=drouter-helpd
ProtectHome=read-only
PrivateTmp=false
NoNewPrivileges=false
[Install]
WantedBy=multi-user.target
HPD
echo "常驻执行守护单元已就位"
# Web 终端守护(#4 重做):真 PTY 会话必须由常驻进程持有。
# 说明为什么不用 socket 激活:helper 在连不上时会自动 systemctl start 拉起,
# 但那要等几秒;预启动能让用户点「连接」时立刻进入 shell。
# 它以 root 运行(要开 root shell),但只监听 0600 的 Unix socket,不占任何端口。
cat > /etc/systemd/system/drouter-shelld.service <<'SHD'
[Unit]
Description=drouter Web 终端 PTY 会话守护
Documentation=file:/opt/drouter/docs/
After=network.target
[Service]
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Type=simple
User=root
Group=root
# 刻意不用 RuntimeDirectory=drouter:systemd 会在「最后一个声明它的单元停止」
# 时把整个目录删掉重建。两个守护共用 /run/drouter,于是 restart helpd 的瞬间
# systemd 重建目录,把 shelld 的 shell.sock 一起删了 —— shelld 依旧 active,
# socket 却没了,用户点终端只看到「守护未运行」。改成两个守护各自 mkdir。
ExecStart=/usr/bin/python3 /opt/drouter/backend/drouter-shelld.py
Restart=on-failure
RestartSec=3
# 只是转字节流,用不了多少内存;上限是防异常输出把内存吃光
MemoryMax=80M
CPUQuota=15%
StandardOutput=journal
StandardError=journal
SyslogIdentifier=drouter-shelld
# 安全加固:不碰系统目录、不给新特权(它本身就以 root 跑,靠 socket 0600 收口)
ProtectHome=read-only
PrivateTmp=false
NoNewPrivileges=false
[Install]
WantedBy=multi-user.target
SHD
echo "Web 终端守护单元已就位"
echo "=== 9. 生成 isp-dns 占位文件(dnsmasq resolv-file 引用)==="
cat > /etc/drouter/generated/isp-dns.conf <<'ISP'
# 由 drouter 自动维护:运营商 PPPoE 下发的 DNS
# 拨号成功后由 pppd 的 usepeerdns 写入 /etc/ppp/resolv.conf,
# 可执行 /opt/drouter/bin/sync-isp-dns.sh 将其同步到此处。
ISP
cat > $OPT/bin/sync-isp-dns.sh <<'SYNC'
#!/bin/bash
# 将 pppd 获取到的运营商 DNS 同步给 dnsmasq
SRC=/etc/ppp/resolv.conf
DST=/etc/drouter/generated/isp-dns.conf
{
echo "# 由 drouter 自动生成:运营商下发 DNS(来源 $SRC)"
if [ -f "$SRC" ]; then
grep -E '^nameserver' "$SRC" || echo "# 尚未获取到运营商 DNS"
else
echo "# $SRC 不存在(尚未拨号成功)"
fi
} > "$DST"
chmod 644 "$DST"
SYNC
chmod +x $OPT/bin/sync-isp-dns.sh
# 存量机器迁移:/etc/drouter/rescue.conf 旧版本写成了 0600 属主 root,
# 以 drouter 身份运行的快照读不到它,一个文件就让整个 /etc/drouter 目录
# 进不了快照(界面却显示「已创建快照」)。这里把它放宽成 0640 属组 drouter。
if [ -f /etc/drouter/rescue.conf ]; then
chmod 0640 /etc/drouter/rescue.conf 2>/dev/null || true
chgrp drouter /etc/drouter/rescue.conf 2>/dev/null || true
fi
echo "=== 10. 校验 ==="
# 逐个编译校验:缺文件直接中止部署(避免上线后才发现少装了一个守护进程)
for f in render.py drouter-helper.py drouter-web.py drouter-logd.py \
drouter-snapshotd.py drouter-rescue.py drouter-shelld.py \
drouter-helpd.py theme.py; do
p="$OPT/backend/$f"
if [ ! -f "$p" ]; then echo "✘ 缺少文件: $p"; exit 1; fi
python3 -c 'import ast,sys; ast.parse(open(sys.argv[1],encoding="utf-8").read())' "$p" \
&& echo "语法OK: $p" || { echo "✘ 语法错误: $p"; exit 1; }
done
echo "--- 目录 ---"
ls -la $OPT/
echo "--- 服务文件 ---"
systemctl daemon-reload
systemctl is-enabled drouter-web 2>&1 || true
echo "--- 自动快照定时器(默认开启)---"
systemctl enable --now drouter-snapshot.timer 2>&1 | tail -2 || true
systemctl is-active drouter-snapshot.timer 2>&1 || true
echo "--- 紧急救援通道(默认关闭)---"
systemctl is-active drouter-rescue 2>&1 || true
echo "--- 磁盘与日志清理定时器(默认开启)---"
# 单元与入口脚本由 helper 的 cleanup save 动作生成(默认参数即推荐值),
# 这里只负责在部署时把它建立起来 —— 用户之后在界面改策略会重新生成。
/usr/bin/python3 $OPT/backend/drouter-helper.py cleanup '{"op":"save","enabled":true}' \
>/dev/null 2>&1 && echo "清理策略已初始化" || echo "⚠ 清理策略初始化失败(界面保存一次即可恢复)"
systemctl is-active drouter-cleanup.timer 2>&1 || true
echo "--- 常驻执行守护 ---"
# 必须 restart 而不是 enable --now:守护把 helper 代码常驻在内存里,
# 只 enable --now 的话它还在跑上一版代码,改的东西根本不生效。
systemctl enable drouter-helpd 2>&1 | tail -1 || true
systemctl restart drouter-helpd 2>&1 | tail -2 || true
sleep 1
systemctl is-active drouter-helpd 2>&1 || echo "⚠ 常驻执行守护未运行(面板会自动回退到慢路径)"
echo "--- Web 终端守护 ---"
# 重启 shelld 会断掉正在用的终端会话,所以默认只 ensure 起来。
# 但两种情况必须重启:① socket 文件没了(多半是被 helpd 重启连带删掉的);
# ② 代码确实变了 —— 不重启的话跑的还是旧代码。
mkdir -p /var/lib/drouter
SHELLD_SEEN=$(cat "$SHELLD_MARK" 2>/dev/null || echo "-")
if [ ! -S /run/drouter/shell.sock ] || [ "$SHELLD_NEW" != "$SHELLD_SEEN" ]; then
echo "终端守护代码有更新($SHELLD_SEEN → $SHELLD_NEW),重启以生效"
systemctl restart drouter-shelld 2>&1 | tail -2 || true
# 只有真的起来了才更新 mark,否则下一轮还会重试重启
if systemctl is-active --quiet drouter-shelld; then
echo "$SHELLD_NEW" > "$SHELLD_MARK"
else
echo "⚠ 终端守护未能启动,mark 不更新(下一轮部署会再试)"
fi
else
systemctl enable --now drouter-shelld 2>&1 | tail -1 || true
echo "$SHELLD_NEW" > "$SHELLD_MARK"
fi
systemctl is-active drouter-shelld 2>&1 || true
# socket 必须真的存在:systemd 说 active 不代表 socket 已经在盘上
# (restart 是异步的,守护起来后还要自己 mkdir + bind,得给它几秒)。
for _i in 1 2 3 4 5 6 7 8 9 10; do
[ -S /run/drouter/shell.sock ] && break
sleep 1
done
[ -S /run/drouter/shell.sock ] && echo "终端 socket 就位" \
|| echo "⚠ 终端 socket 缺失,Web 终端将不可用"
echo "--- 网络与 VNC 复核 ---"
ip -4 -o addr show ens18 2>/dev/null || echo "(ens18 不存在,跳过)"
ss -lnt | grep 5900 || echo "5900 异常"
# 重启管理后台,让新代码真正生效。
# 必须放在语法校验之后:一旦新代码有语法错误,宁可继续跑旧进程也不能把自己关在门外。
echo "--- 重启管理后台 ---"
systemctl restart $UNIT_WEB 2>&1 | tail -3 || echo "(重启失败,请手动检查)"
sleep 2
systemctl is-active $UNIT_WEB || echo "⚠ drouter-web 未在运行"
# 统一日志采集定时器:默认配置是 enabled=true,但 service/timer 单元只有
# 在界面里点过一次「保存日志设置」才会被 helper 写出来。全新部署后防火墙日志
# 页面会一直是空的,表现为「日志功能没做」。这里补一次同步,让默认开关真正生效。
echo "--- 同步日志采集定时器 ---"
python3 - <<'PY' || echo "(日志定时器同步失败,可在界面「日志设置」里保存一次重建)"
# 文件名带连字符,不能用普通 import,走 importlib 按路径加载
import importlib.util
spec = importlib.util.spec_from_file_location(
'drouter_helper', '/opt/drouter/backend/drouter-helper.py')
h = importlib.util.module_from_spec(spec)
spec.loader.exec_module(h)
r = h.act_logd_sync({'interval_sec': 60})
print(r.get('msg_cn') or r)
PY
systemctl is-active drouter-logd.timer 2>&1 || true
echo "DEPLOY_DONE"
+424
View File
@@ -0,0 +1,424 @@
#!/bin/bash
# =============================================================================
# drouter 一键运维脚本 —— 安装 / 卸载 / 修复 / 更新 / 状态
#
# drouter-ctl.sh install 交互式安装(询问网卡、LAN IP/网段、网关、DNS、管理员账号)
# drouter-ctl.sh uninstall 卸载(保留配置与快照;加 --purge 彻底清除)
# drouter-ctl.sh repair 修复(重部署文件 + 修权限 + 重建单元 + 校验)
# drouter-ctl.sh update 更新(更新代码,保留配置与主题)
# drouter-ctl.sh status 状态(服务/端口/版本/配置摘要/自检)
#
# 安全红线(任何时候都不得违反):
# 1. 绝不启动/启用 dnsmasq、radvd、miniupnpd 等会抢端口或发 DHCP 的服务
# 2. 绝不触碰 RealVNC(5900)
# 3. /etc/drouter/BUILD_MODE 存在时(构建保护模式),任何写网络动作一律中止
# 4. 卸载默认保留 /etc/drouter 配置与快照,避免用户配置被误删
# =============================================================================
set -u
export DEBIAN_FRONTEND=noninteractive
OPT=/opt/drouter
SRC_DEFAULT=/tmp/drouter-src
DB=$OPT/data/drouter.db
UNIT_WEB=drouter-web
UNIT_SNAP=drouter-snapshot.timer
UNIT_LOGD=drouter-logd.timer
UNIT_RESCUE=drouter-rescue
UNIT_SHELLD=drouter-shelld
UNIT_HELPD=drouter-helpd
C_RESET=$'\033[0m'; C_RED=$'\033[31m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_DIM=$'\033[2m'
say() { printf '%s\n' "$*"; }
ok() { printf '%s✔%s %s\n' "$C_GRN" "$C_RESET" "$*"; }
warn() { printf '%s⚠%s %s\n' "$C_YEL" "$C_RESET" "$*"; }
err() { printf '%s✘%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
step() { printf '\n%s==>%s %s\n' "$C_DIM" "$C_RESET" "$*"; }
die() { err "$*"; exit 1; }
# ---------------------------------------------------------------- 前置检查
need_root() {
[ "$(id -u)" = 0 ] || die "请以 root 运行(sudo bash $0 $1)"
}
# 构建保护模式:存在该文件时禁止一切可能改网络的动作
guard_build_mode() {
if [ -f /etc/drouter/BUILD_MODE ]; then
die "检测到构建保护模式 /etc/drouter/BUILD_MODE,已中止。
如需继续,请先确认当前不在构建阶段,然后:rm -f /etc/drouter/BUILD_MODE"
fi
}
# 安全红线自检:本脚本绝不去动这些服务/端口
assert_no_network_daemons() {
local bad=""
for s in dnsmasq radvd miniupnpd dhcpcd isc-dhcp-server; do
if systemctl is-enabled "$s" 2>/dev/null | grep -qx enabled; then bad="$bad $s"; fi
done
[ -z "$bad" ] || warn "以下服务当前是 enabled 状态:$bad(本脚本不会启用它们,请自行确认)"
}
# ---------------------------------------------------------------- 输入校验
valid_ipv4() {
python3 - "$1" <<'PY'
import sys, ipaddress
try:
ipaddress.IPv4Address(sys.argv[1]); print('yes')
except Exception:
print('no')
PY
}
valid_cidr() {
python3 - "$1" <<'PY'
import sys, ipaddress
try:
ipaddress.IPv4Network(sys.argv[1], strict=False); print('yes')
except Exception:
print('no')
PY
}
# 由网段推导:网络地址、掩码、可用主机范围
net_info() {
python3 - "$1" <<'PY'
import sys, ipaddress
n = ipaddress.IPv4Network(sys.argv[1], strict=False)
hosts = list(n.hosts())
gw = str(hosts[0]) if hosts else str(n.network_address)
p1 = str(hosts[min(10, len(hosts)-1)]) if hosts else ''
p2 = str(hosts[-1]) if hosts else ''
print(' '.join([str(n.network_address), str(n.netmask), str(n.broadcast_address), gw, p1, p2]))
PY
}
ask() { # ask 变量名 提示 默认值
local var="$1" prompt="$2" def="$3" val
if [ -n "${NONINTERACTIVE:-}" ]; then val="$def"
else
read -r -p "$(printf '%s%s%s [%s]: ' "$C_DIM" "$prompt" "$C_RESET" "$def")" val
val="${val:-$def}"
fi
printf -v "$var" '%s' "$val"
}
ask_secret() { # 密码类:不回显
local var="$1" prompt="$2" val
if [ -n "${NONINTERACTIVE:-}" ]; then val="${3:-}"; printf -v "$var" '%s' "$val"; return; fi
read -r -s -p "$(printf '%s%s%s: ' "$C_DIM" "$prompt" "$C_RESET")" val; echo
printf -v "$var" '%s' "$val"
}
# =============================================================================
cmd_install() {
need_root install
guard_build_mode
local SRC="${1:-$SRC_DEFAULT}"
[ -d "$SRC/backend" ] || die "源码目录不存在:$SRC(请先同步源码到该目录)"
step "1/7 收集安装参数"
# 自动探测候选网卡(排除 lo / 虚拟口)
local ifaces
ifaces=$(ls /sys/class/net 2>/dev/null | grep -vE '^(lo|docker|br-|veth|virbr|ppp)' | tr '\n' ' ')
say " 可用网卡:$ifaces"
ask LAN_IFACE "LAN 网卡(内网)" "$(echo "$ifaces" | awk '{print $1}')"
ask WAN_IFACE "WAN 网卡(外网,可留空稍后在界面选)" ""
ask LAN_CIDR "LAN 地址(IP/掩码位数)" "192.168.7.3/24"
valid_cidr "$LAN_CIDR" | grep -qx yes || die "LAN 地址格式不对:$LAN_CIDR(示例 192.168.7.3/24)"
read -r _NET _MASK _BC _GW _P1 _P2 <<<"$(net_info "$LAN_CIDR")"
say " ${C_DIM}网段 ${_NET} 掩码 ${_MASK} 可用 ${_P1} ~ ${_P2}${C_RESET}"
ask GATEWAY "本机 LAN 网关(路由器自身地址,通常就是 LAN IP)" "${LAN_CIDR%%/*}"
valid_ipv4 "$GATEWAY" | grep -qx yes || die "网关地址不合法:$GATEWAY"
ask DNS_LIST "DNS(逗号分隔)" "223.5.5.5,119.29.29.29"
ask DHCP_ON "是否开启 DHCP 地址池?(y/N)" "N"
POOL_START=""; POOL_END=""
if [ "${DHCP_ON}" = "y" ] || [ "${DHCP_ON}" = "Y" ]; then
ask POOL_START "地址池起始" "${_P1:-192.168.7.100}"
ask POOL_END "地址池结束" "${_P2:-192.168.7.200}"
valid_ipv4 "$POOL_START" | grep -qx yes || die "地址池起始不合法"
valid_ipv4 "$POOL_END" | grep -qx yes || die "地址池结束不合法"
fi
ask WEB_PORT "Web 管理端口" "8443"
ask ADM_USER "Web 管理员用户名" "admin"
ask_secret ADM_PASS "Web 管理员密码(至少 6 位,留空则沿用 admin123)" ""
if [ -n "$ADM_PASS" ] && [ "${#ADM_PASS}" -lt 6 ]; then die "密码至少 6 位"; fi
ask PPP_USER "PPPoE 宽带账号(可留空)" ""
PPP_PASS=""
if [ -n "$PPP_USER" ]; then ask_secret PPP_PASS "PPPoE 宽带密码" ""; fi
step "2/7 安装系统依赖(进入构建保护模式,不会启动任何服务)"
bash "$SRC/scripts/install-base.sh" 2>&1 | tail -12
step "3/7 部署程序文件"
DROUTER_SRC="$SRC" bash "$SRC/scripts/deploy.sh" 2>&1 | tail -20
step "4/7 启动管理后台(只启动 drouter-web,绝不启动 DHCP/DNS)"
# 自定义端口必须写进 /etc/drouter/web-port 才会生效(后端启动时读取)
if [ "$WEB_PORT" != "8443" ]; then
mkdir -p /etc/drouter
printf '%s\n' "$WEB_PORT" > /etc/drouter/web-port
ok "Web 端口已设为 $WEB_PORT"
fi
systemctl daemon-reload
systemctl enable "$UNIT_WEB" >/dev/null 2>&1 || true
systemctl restart "$UNIT_WEB"
# 等待端口就绪
local i=0
while [ $i -lt 30 ]; do
if ss -lnt 2>/dev/null | grep -q ":${WEB_PORT}\b"; then break; fi
sleep 1; i=$((i+1))
done
ss -lnt 2>/dev/null | grep -q ":${WEB_PORT}\b" \
&& ok "管理后台已监听 :${WEB_PORT}" \
|| { systemctl status "$UNIT_WEB" --no-pager -n 20; die "管理后台未能启动"; }
step "5/7 写入预置配置(走官方 API,保证与界面同一套校验)"
local pw="${ADM_PASS:-admin123}"
LOGIN_USER="$ADM_USER" LOGIN_PASS="$pw" PORT="$WEB_PORT" \
LAN_IFACE="$LAN_IFACE" WAN_IFACE="$WAN_IFACE" LAN_CIDR="$LAN_CIDR" \
GATEWAY="$GATEWAY" DNS_LIST="$DNS_LIST" POOL_START="$POOL_START" POOL_END="$POOL_END" \
PPP_USER="$PPP_USER" PPP_PASS="$PPP_PASS" \
python3 - <<'PY'
import json, os, ssl, urllib.request, urllib.error, sys
CTX = ssl._create_unverified_context() # 自签证书,仅本机回环调用
# 优先 HTTPS(自定义端口),失败回退 HTTP 8080(后端同时监听两者)
CANDIDATES = ['https://127.0.0.1:%s' % os.environ['PORT'], 'http://127.0.0.1:8080']
BASE = None
def call(path, payload=None, token=None):
global BASE
bases = [BASE] if BASE else CANDIDATES
last = {'ok': False, 'msg_cn': '无可用地址'}
for b in bases:
data = json.dumps(payload or {}).encode('utf-8')
req = urllib.request.Request(b + path, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
if token: req.add_header('X-Token', token)
try:
with urllib.request.urlopen(req, timeout=20, context=CTX) as r:
BASE = b
return json.loads(r.read().decode('utf-8'))
except urllib.error.HTTPError as e:
try:
BASE = b
return json.loads(e.read().decode('utf-8'))
except Exception:
last = {'ok': False, 'msg_cn': 'HTTP %s' % e.code}
except Exception as e:
last = {'ok': False, 'msg_cn': str(e)}
return last
r = call('/api/login', {'username': os.environ['LOGIN_USER'],
'password': os.environ['LOGIN_PASS']})
if not r.get('ok'):
print(' 登录失败:%s' % r.get('msg_cn')); sys.exit(1)
tok = (r.get('data') or {}).get('token')
print(' 已登录(%s)' % BASE)
lan = os.environ['LAN_IFACE']; wan = os.environ['WAN_IFACE']
patch = {
'system': {'lan_iface': lan, 'lan_address': os.environ['LAN_CIDR'],
'gateway': os.environ['GATEWAY'], 'wan_iface': wan,
'wan_present': bool(wan)},
'nft_v4': {'lan_ifaces': [lan], 'wan_iface': wan or 'ppp0'},
'nft_v6': {'lan_ifaces': [lan], 'wan_iface': wan or 'ppp0'},
'radvd': {'iface': lan},
'dhcpv6': {'lan_iface': lan},
'dnsmasq': {'option_gateway': os.environ['GATEWAY'],
'option_dns': os.environ['GATEWAY'],
'dns_custom': os.environ['DNS_LIST'],
'dhcp_enabled': bool(os.environ.get('POOL_START'))},
}
if os.environ.get('POOL_START'):
patch['dnsmasq'].update({'pool_start': os.environ['POOL_START'],
'pool_end': os.environ['POOL_END'],
'pool_netmask': '255.255.255.0'})
if os.environ.get('PPP_USER'):
patch['pppoe'] = {'username': os.environ['PPP_USER'],
'password': os.environ.get('PPP_PASS', ''),
'iface': wan or '', 'saved_only': True}
for mod, data in patch.items():
rr = call('/api/config', {'module': mod, 'data': data}, tok)
print(' %-8s %s' % (mod, '已保存' if rr.get('ok') else '失败:%s' % rr.get('msg_cn')))
PY
step "6/7 安全红线复核"
assert_no_network_daemons
if ss -lnt 2>/dev/null | grep -q ':5900\b'; then ok "RealVNC 5900 仍在监听(未被影响)"
else warn "5900 未监听 —— 请确认 RealVNC 状态(本脚本未做任何操作)"; fi
[ -f /etc/drouter/BUILD_MODE ] && rm -f /etc/drouter/BUILD_MODE && ok "已退出构建保护模式"
step "7/7 完成"
local ip="${LAN_CIDR%%/*}"
ok "安装完成"
say " 管理地址:https://${ip}:${WEB_PORT}/"
say " 管理员:${ADM_USER} / ${ADM_PASS:+(已设置)}${ADM_PASS:-admin123(请尽快修改)}"
say ""
say " ${C_DIM}提示:以上只写入了配置,尚未应用到系统。${C_RESET}"
say " ${C_DIM}请在界面的「应用配置」里确认后再下发,避免影响现有网络。${C_RESET}"
}
# =============================================================================
cmd_uninstall() {
need_root uninstall
guard_build_mode
local PURGE=0
[ "${1:-}" = "--purge" ] && PURGE=1
step "停止并禁用服务"
for u in $UNIT_RESCUE $UNIT_WEB $UNIT_SNAP $UNIT_LOGD $UNIT_SHELLD $UNIT_HELPD; do
systemctl stop "$u" 2>/dev/null || true
systemctl disable "$u" 2>/dev/null || true
done
systemctl daemon-reload
step "移除 systemd 单元与系统配置"
rm -f /etc/systemd/system/drouter-*.service /etc/systemd/system/drouter-*.timer
rm -f /etc/sudoers.d/drouter /etc/logrotate.d/drouter
systemctl daemon-reload
step "移除程序目录"
rm -rf "$OPT"
if [ "$PURGE" = 1 ]; then
warn "--purge:同时删除配置、快照与日志"
rm -rf /etc/drouter /var/log/drouter /var/lib/drouter /run/drouter-helper
rm -f /etc/drouter/generated/theme.css
ok "已彻底清除(含用户配置与快照)"
else
ok "已卸载;保留 /etc/drouter(配置与主题)、/var/log/drouter(日志)"
say " ${C_DIM}如需彻底清除:bash $0 uninstall --purge${C_RESET}"
fi
# 顺带处理 apt 装的包?不动 —— 卸载程序不应卸载用户可能共用的系统包
say " ${C_DIM}注:系统软件包(dnsmasq/nftables 等)未被移除,避免影响其它服务。${C_RESET}"
}
# =============================================================================
cmd_repair() {
need_root repair
guard_build_mode
local SRC="${1:-$SRC_DEFAULT}"
[ -d "$SRC/backend" ] || die "源码目录不存在:$SRC"
step "1/5 重新部署文件(覆盖,不影响已有配置)"
DROUTER_SRC="$SRC" bash "$SRC/scripts/deploy.sh" 2>&1 | tail -12
step "2/5 修复权限与属主"
id drouter >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin -d "$OPT" -M drouter
chown -R root:root "$OPT/backend" "$OPT/web"
chown -R drouter:drouter "$OPT/data" "$OPT/snapshots" "$OPT/certs" 2>/dev/null || true
chmod 750 "$OPT/data" "$OPT/snapshots" "$OPT/certs" 2>/dev/null || true
chmod 700 /run/drouter-helper 2>/dev/null || true
chmod 0440 /etc/sudoers.d/drouter
visudo -c -f /etc/sudoers.d/drouter >/dev/null && ok "sudoers 语法正确"
step "3/5 重建服务单元并重启管理后台"
systemctl daemon-reload
systemctl enable $UNIT_WEB >/dev/null 2>&1 || true
systemctl restart $UNIT_WEB
systemctl enable $UNIT_SNAP >/dev/null 2>&1 || true
step "4/5 校验关键文件"
local bad=0
for f in render.py drouter-helper.py drouter-web.py drouter-logd.py \
drouter-snapshotd.py drouter-rescue.py drouter-shelld.py \
drouter-helpd.py theme.py; do
[ -f "$OPT/backend/$f" ] || { err "缺少 $f"; bad=1; }
done
[ -d /etc/drouter/themes ] || { mkdir -p /etc/drouter/themes; ok "已补建主题目录"; }
[ "$bad" = 0 ] && ok "8 个后端模块齐全"
step "5/5 复核安全红线"
assert_no_network_daemons
ok "修复完成"
}
# =============================================================================
cmd_update() {
need_root update
guard_build_mode
local SRC="${1:-$SRC_DEFAULT}"
[ -d "$SRC/backend" ] || die "源码目录不存在:$SRC"
step "1/4 更新前自动快照(便于回滚)"
if [ -f "$OPT/backend/drouter-snapshotd.py" ]; then
python3 "$OPT/backend/drouter-snapshotd.py" >/dev/null 2>&1 \
&& ok "快照已生成" || warn "快照失败(继续更新)"
fi
step "2/4 备份当前版本"
local ts; ts=$(date +%Y%m%d-%H%M%S)
local BAK="/opt/drouter-backup-$ts"
mkdir -p "$BAK"
cp -a "$OPT/backend" "$BAK/" 2>/dev/null || true
cp -a "$OPT/web" "$BAK/" 2>/dev/null || true
ok "已备份到 $BAK"
step "3/4 部署新版本(配置与主题不受影响)"
DROUTER_SRC="$SRC" bash "$SRC/scripts/deploy.sh" 2>&1 | tail -12
step "4/4 重启并校验"
systemctl daemon-reload
systemctl restart $UNIT_WEB
sleep 2
systemctl is-active $UNIT_WEB | grep -qx active \
&& ok "管理后台已重启" || { systemctl status $UNIT_WEB --no-pager -n 15; die "启动失败,可回滚:$BAK"; }
ok "更新完成(回滚:把 $BAK 里的目录拷回 $OPT 后 restart)"
}
# =============================================================================
cmd_status() {
step "服务"
printf ' %-24s %-10s %s\n' "单元" "活动" "开机自启"
for u in $UNIT_WEB $UNIT_SNAP $UNIT_LOGD $UNIT_RESCUE $UNIT_SHELLD $UNIT_HELPD; do
printf ' %-24s %-10s %s\n' "$u" \
"$(systemctl is-active "$u" 2>/dev/null)" "$(systemctl is-enabled "$u" 2>/dev/null)"
done
step "安装"
if [ -d "$OPT" ]; then
ok "程序目录:$OPT"
local n=0
for f in "$OPT"/backend/*.py; do [ -f "$f" ] && n=$((n+1)); done
say " 后端模块:$n 个"
[ -f "$DB" ] && say " 配置库:$(du -h "$DB" | cut -f1)" || warn "配置库不存在(可能尚未启动过)"
else
warn "未安装($OPT 不存在)"
fi
step "端口"
ss -lntup 2>/dev/null | grep -E ':(8443|53|67|546|5900)\b' | sed 's/^/ /' || say " (无相关端口)"
step "主题之家"
if [ -f /etc/drouter/active-theme ]; then
say " 当前主题:$(cat /etc/drouter/active-theme)"
say " 自定义主题数:$(ls -1 /etc/drouter/themes 2>/dev/null | wc -l)"
else warn " 主题状态文件不存在"; fi
step "安全红线"
if [ -f /etc/drouter/BUILD_MODE ]; then warn "构建保护模式:开启(写网络操作被禁止)"
else say " 构建保护模式:关闭"; fi
ss -lnt 2>/dev/null | grep -q ':5900\b' && say " RealVNC 5900:正常监听" || say " RealVNC 5900:未监听"
}
# =============================================================================
usage() {
cat <<'USAGE'
drouter 一键运维脚本
用法:drouter-ctl.sh <命令> [参数]
install [源码目录] 交互式安装(询问网卡 / LAN IP 网段 / 网关 / DNS / 管理员账号)
非交互可用环境变量 NONINTERACTIVE=1 并配合默认值
uninstall [--purge] 卸载。默认保留 /etc/drouter 配置与日志;--purge 彻底清除
repair [源码目录] 修复:重部署 + 修权限 + 重建单元 + 校验
update [源码目录] 更新:先快照备份,再部署新版本,配置与主题保留
status 状态:服务 / 端口 / 版本 / 主题 / 安全红线
安全约束:本脚本绝不启用 dnsmasq/radvd 等会抢端口的服务,绝不触碰 RealVNC 5900。
USAGE
}
case "${1:-}" in
install) shift; cmd_install "$@" ;;
uninstall) shift; cmd_uninstall "$@" ;;
repair) shift; cmd_repair "$@" ;;
update) shift; cmd_update "$@" ;;
status) shift; cmd_status "$@" ;;
*) usage; exit 1 ;;
esac
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# Debian13 主路由系统 —— 底座安装脚本 v2(干净环境版)
#
# 安全约束(重要):
# 1. 安装期间禁止任何服务自动启动(policy-rc.d 返回 101)
# 2. 不触碰 NetworkManager(ens18 的网络保持原样)
# 3. 不触碰 RealVNC / LightDM / X 会话
# 4. 不加载任何 nftables 生效规则
set -e
export DEBIAN_FRONTEND=noninteractive
MARK=/etc/drouter-build-phase
echo "=== 阶段 1/6:进入构建保护模式 ==="
mkdir -p "$(dirname "$MARK")"
printf 'build' > "$MARK"
# 阻止一切服务在安装期间自动启动/重启
printf '#!/bin/sh\nexit 101\n' > /usr/sbin/policy-rc.d
chmod +x /usr/sbin/policy-rc.d
# 记录 RealVNC 状态用于事后校验
echo "--- 安装前 RealVNC 状态 ---"
systemctl is-active vncserver-x11-serviced 2>/dev/null || true
ss -lnt 2>/dev/null | grep ':5900' || echo "5900 未监听"
echo "--- 安装前网络 ---"
ip -4 -o addr show ens18 2>/dev/null || echo "(ens18 不存在,跳过)"
echo "=== 阶段 2/6:apt update ==="
apt-get update -qq 2>&1 | tail -3
echo "=== 阶段 3/6:安装软件包 ==="
# 注意:这里刻意不含 samba / nfs-kernel-server / docker.io ——
# 它们装完会自动拉起常驻服务,属于用户在界面上按需安装的组件,
# 不应在「装底座」这一步就被带起来。
PACKAGES="
ppp pppoe pppoeconf
dnsmasq radvd
nftables
miniupnpd chrony
ethtool iperf3 mtr-tiny traceroute dnsutils conntrack
bridge-utils vlan iproute2
sqlite3 logrotate rsyslog
curl wget
python3
exfatprogs ntfs-3g dosfstools xfsprogs btrfs-progs f2fs-tools
etherwake zip unzip p7zip-full iputils-ping vim-tiny
"
apt-get install -y --no-install-recommends $PACKAGES 2>&1 | tail -25
echo "=== 阶段 4/6:确保新装服务处于停止+禁用 ==="
for s in dnsmasq radvd dhcpcd miniupnpd chrony rsyslog; do
systemctl stop "$s" 2>/dev/null || true
systemctl disable "$s" 2>/dev/null || true
done
echo "=== 阶段 5/6:退出保护模式 ==="
rm -f /usr/sbin/policy-rc.d
echo "=== 阶段 6/6:校验 ==="
echo "--- RealVNC 状态 ---"
systemctl is-active vncserver-x11-serviced 2>/dev/null || true
ss -lnt 2>/dev/null | grep ':5900' || echo "5900 未监听(异常!)"
echo "--- 网络(应与安装前一致)---"
ip -4 -o addr show ens18 2>/dev/null || echo "(ens18 不存在,跳过)"
echo "--- 服务状态 ---"
for s in dnsmasq radvd miniupnpd chrony NetworkManager; do
printf "%-16s active=%-10s enabled=%s\n" "$s" \
"$(systemctl is-active $s 2>/dev/null)" "$(systemctl is-enabled $s 2>/dev/null)"
done
echo "--- 端口占用 ---"
ss -lntup 2>/dev/null | grep -E ':(53|67|546|5900)\b' || echo "(53/67/546 均未被占用,符合预期)"
echo "INSTALL_DONE"
+324
View File
@@ -0,0 +1,324 @@
#!/bin/bash
# =============================================================================
# 离线依赖打包:把 drouter 的全部依赖(含传递依赖)预下载成一个自洽目录,
# 供**没有外网**的目标机离线安装。
#
# bash scripts/make-offline-deps.sh [输出目录]
#
# 产出目录结构:
# debs/ 所有依赖的 .deb(含 libc6 这类最底层传递依赖)
# Packages.gz 由 dpkg-scanpackages 生成的本地索引(没有就退化为无索引模式)
# install.sh 离线安装脚本(先装依赖,再装 drouter 本体)
#
# 为什么不能只 apt-get download 一串包名:
# `apt-get download a b c` 只下载**指定**的那些包,不拉传递依赖。
# 目标机上 dpkg -i *.deb 会因为 libxxx 没装而大面积失败。必须先把
# apt-cache depends --recurse 展开成闭包,再整体下载。
#
# 为什么用 apt-cache depends 而不是 apt-rdepends:
# apt-rdepends 不是 Debian 基础系统自带的(要额外装),而 apt-cache 一定有。
# `apt-cache depends --recurse --no-recommends --no-suggests ...` 的输出
# 已经能拿到完整闭包,不必引入外部依赖。
#
# 用法(目标机):
# 把整个目录拷过去,执行
# sudo bash install.sh drouter_1.0.0_all.deb
# =============================================================================
set -euo pipefail
HERE="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${1:-$HERE/dist/offline-deps}"
# 与 backend/drouter-helper.py 的 DEPS「必需项 + 常用可选项」保持一致。
# 手动同步点:改了 DEPS 就要同步这里,_dev/t-deps.py 会核对关键包是否都在。
# 注意:samba / nfs-kernel-server / docker.io / cups / snmpd / smartmontools / xfce4
# 会拉起常驻服务或体积巨大,不进离线包,用户在界面上按需安装即可。
DEPS="python3 nftables dnsmasq radvd dhcpcd-base ppp pppoe pppoeconf miniupnpd chrony
ethtool iperf3 mtr-tiny traceroute dnsutils conntrack bridge-utils iproute2
sqlite3 logrotate rsyslog curl wget procps sudo openssl ipset kmod systemd
adduser ca-certificates tcpdump jq htop lm-sensors dmidecode
e2fsprogs exfatprogs ntfs-3g dosfstools xfsprogs btrfs-progs f2fs-tools
util-linux usbutils tar coreutils hostname udev
etherwake zip unzip p7zip-full iputils-ping vim-tiny"
command -v apt-get >/dev/null 2>&1 || { echo "需要 apt-get(Debian/Ubuntu)"; exit 1; }
echo "== 1/4 准备目录 =="
rm -rf "$OUT"
mkdir -p "$OUT/debs"
cd "$OUT/debs"
echo "== 2/4 展开传递依赖闭包 =="
# --no-recommends / --no-suggests:把可选依赖排除掉,否则 4GB 机器光依赖就装爆。
# 过滤规则:只保留顶格的包名行(依赖关系行前面有空格和「依赖:」等前缀)。
#
# ⚠️ 这里有一个必须显式补的坑:`Essential: yes` 的包**不会**出现在
# apt-cache depends --recurse 的输出里 —— apt 认定「任何 Debian 系统都必有它们」,
# 所以从不把它们列为别人 的依赖。实测缺失的有:dash / diffutils / libc-bin /
# grep / findutils / base-files / base-passwd / dpkg / coreutils / sed / tar …
# 在正常的 Debian 上装没问题(本机已有了),但如果目标机是**刚 debootstrap
# 出来的极简根**或换了个发行版,缺了它们的表现是:
# dpkg: error: 3 expected programs not found in PATH or not executable
# (缺 sh / diff / ldconfig —— 分别来自 dash / diffutils / libc-bin)
# 而且这个错在装 drouter 本体时才暴露,排查起来很费劲。所以显式补一份。
ESSENTIAL="${DROUTER_ESSENTIAL:-base-files base-passwd bash coreutils dash diffutils
dpkg grep gzip findutils hostname libc-bin libc6 libgcc-s1 libselinux1 libpcre2-8-0
libacl1 libattr1 libcap2 libaudit1 libaudit-common sed tar zlib1g liblzma5 libbz2-1.0
xz-utils libtinfo6 libreadline8 libmount1 libblkid1 libsmartcols1 libuuid1
libsystemd0 libudev1 libzstd1 libcap-ng0 libcrypt1 libgmp10 libnettle8 libhogweed6
libidn2-0 libunistring5 libtasn1-6 libp11-kit0 libffi8 libgnutls30 libgpg-error0
libgcrypt20 libseccomp2 libstdc++6 libmd0 libedit2 libncursesw6 libfdisk1
libapparmor1 libxxhash0 libcom-err2 libext2fs2 libjansson4 libargon2-1
debianutils debconf init-system-helpers mount util-linux passwd login.defs
sysvinit-utils mawk perl-base readline-common sensible-utils}"
# 再补一层「基线库」。踩过的坑:apt 自己的依赖链(libapt-pkg / liblz4-1 /
# libgnutls30 / libnettle8 / libstdc++6 …)**不会**被 drouter 的依赖闭包带进来 ——
# 因为 apt 在任何 Debian 上都已装好,它的依赖也被视为已满足。
# 后果是:在**没有 apt 的干净根 / 换发行版**里跑 install.sh 时,
# 「建立本地仓库索引」这一步会直接报
# apt-get: error while loading shared libraries: liblz4.so.1
# 然后整条安装链路退化成 dpkg 乱装。补上这几个库成本极小(几百 KB),
# 换来的是「任何 Debian 用户态环境都能装」。
BASELIBS="${DROUTER_BASELIBS:-liblz4-1 libapt-pkg7.0 libgnutls30 libnettle8
libhogweed6 libstdc++6 libgmp10 libgnutls30 libunistring5 libidn2-0 libp11-kit0
libffi8 libtasn1-6 libssl3t64 zlib1g liblzma5 libzstd1 libbz2-1.0 libpcre2-8-0
libselinux1 libmount1 libblkid1 libuuid1 libcap2 libcap-ng0 libaudit1
libsystemd0 libudev1 libseccomp2 libgpg-error0 libgcrypt20}"
# ---------------------------------------------------------------------------
# 闭包求解:用 apt 自己算,不要用 `apt-cache depends --recurse` 做文本展开。
#
# 踩过的坑(很隐蔽,装到最后一步才炸):
# `apt-cache depends --recurse` 会把**虚拟包的每一个提供者**都展开成一行。
# systemd / systemd-standalone-sysusers / opensysusers 三者都 Provides
# `systemd-sysusers`,同时又互相 Conflicts。文本展开会把三家**全**收进来,
# 下载没问题;但装上时 apt 解冲突发现无解:
# E: Unmet dependencies. Try 'apt --fix-broken install'
# systemd : 预依赖 libsystemd-shared 但是它将不会被安装
# 看起来像「离线包缺包」,实际是一堆互相冲突的替代品被硬凑在一起。
#
# `apt-get install --print-uris -y <目标>` 走的是**真正的依赖求解器**:
# 它会按 Conflicts/Provides 选一套自洽的方案,只给出确实要装的包。
# 这才是「离线包」该用的闭包来源。
# ---------------------------------------------------------------------------
CLOSURE_FILE="$OUT/.closure"
: > "$CLOSURE_FILE"
# ⚠️ 关键:求解必须在一个**空的 dpkg status** 上做,不能直接用宿主机的状态。
# 踩过的坑:在装了一堆东西的开发机上求解,apt 会认为「这些依赖已满足」而不列出来
# (libicu76 / liburcu8t64 / python3-dbus 就是这么漏的 —— 宿主机上有,
# 于是不进闭包;到了干净目标机上就变成
# `Depends: libicu76 but it is not installable`,整包装不上)。
# 所以先造一个只有「空 status」的临时 apt 根,让 apt 以为什么都没装。
EMPTYROOT="$OUT/.solveroot"
rm -rf "$EMPTYROOT"
mkdir -p "$EMPTYROOT/var/lib/dpkg" "$EMPTYROOT/var/lib/apt/lists/partial" \
"$EMPTYROOT/var/cache/apt/archives/partial" "$EMPTYROOT/etc/apt/apt.conf.d" \
"$EMPTYROOT/etc/apt/preferences.d" "$EMPTYROOT/etc/apt/sources.list.d" \
"$EMPTYROOT/etc/apt/trusted.gpg.d" "$EMPTYROOT/usr/share/keyrings" \
"$EMPTYROOT/var/lib/dpkg/info" "$EMPTYROOT/var/lib/dpkg/updates"
# 签名校验要用到 keyring,否则 apt 会拒绝所有源:
# E: 仓库 “…” 没有数字签名。 (trusted.gpg.d 不是目录)
# 只借「公钥」目录即可,不复制 status —— status 必须是空的,那是本步的目的。
cp -f /etc/apt/trusted.gpg.d/* "$EMPTYROOT/etc/apt/trusted.gpg.d/" 2>/dev/null || true
cp -f /usr/share/keyrings/* "$EMPTYROOT/usr/share/keyrings/" 2>/dev/null || true
# apt 自身仍从宿主机运行,签名校验走宿主 keyring 也通常可行,这里只是兜底。
: > "$EMPTYROOT/var/lib/dpkg/status"
touch "$EMPTYROOT/etc/apt/apt.conf.d/.keep"
# 复用宿主机的源列表(只是用来读 Packages 索引,不会真的下载)
[ -f /etc/apt/sources.list ] && cp -f /etc/apt/sources.list "$EMPTYROOT/etc/apt/" 2>/dev/null || true
mkdir -p "$EMPTYROOT/etc/apt/sources.list.d"
cp -f /etc/apt/sources.list.d/*.list "$EMPTYROOT/etc/apt/sources.list.d/" 2>/dev/null || true
cp -f /etc/apt/sources.list.d/*.sources "$EMPTYROOT/etc/apt/sources.list.d/" 2>/dev/null || true
SOLVER_OPTS=(
-o "Dir=$EMPTYROOT/"
-o "Dir::State=$EMPTYROOT/var/lib/apt"
-o "Dir::State::status=$EMPTYROOT/var/lib/dpkg/status"
-o "Dir::Cache=$EMPTYROOT/var/cache/apt"
-o "Dir::Etc::sourcelist=$EMPTYROOT/etc/apt/sources.list"
)
echo " 在空 status 上求解依赖(模拟全新目标机)…"
# update 失败不致命:说明宿主机源列表不可用,但本地 Packages 索引可能还在,
# 后面的 -s 求解会自己报出真正的问题,这里不吞错也不中止。
apt-get "${SOLVER_OPTS[@]}" update -qq >/dev/null 2>&1 || \
echo " (索引刷新失败,继续用已有索引求解)"
# 求解:只列 Inst 行的包名。--no-install-recommends 与发布口径一致。
if apt-get "${SOLVER_OPTS[@]}" -s --no-install-recommends -y install $DEPS \
>"$OUT/.sim.txt" 2>"$OUT/.sim.err"; then
sed -n 's/^Inst \([^ ]*\) .*/\1/p' "$OUT/.sim.txt" >> "$CLOSURE_FILE"
echo " 依赖求解成功(apt 求解器 / 空 status)"
else
echo " ⚠ 空 status 求解失败,回退到宿主状态求解"
if apt-get -s --no-install-recommends -y install $DEPS >"$OUT/.sim.txt" 2>/dev/null; then
sed -n 's/^Inst \([^ ]*\) .*/\1/p' "$OUT/.sim.txt" >> "$CLOSURE_FILE"
echo " 已用宿主状态求解"
else
echo " ⚠ 仍失败,回退到文本展开(可能含冲突替代品)"
apt-cache depends --recurse --no-recommends --no-suggests \
--no-conflicts --no-breaks --no-replaces --no-enhances \
$DEPS 2>/dev/null \
| grep -E '^[a-zA-Z0-9][a-zA-Z0-9.+_-]*$' >> "$CLOSURE_FILE"
fi
fi
rm -f "$OUT/.sim.txt" "$OUT/.sim.err"
ALL=$(cat "$CLOSURE_FILE" | sort -u)
ALL="$ALL $ESSENTIAL $BASELIBS"
ALL=$(echo $ALL | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ')
# 显式排掉已知的「冲突替代品」:它们是 systemd-sysusers 的多余提供者,
# 只要 systemd 在,就不能同时出现。留着必然导致上面那个 Unmet dependencies。
for drop in systemd-standalone-sysusers opensysusers systemd-standalone-tmpfiles; do
if [ -n "$(ls "$OUT"/debs/${drop}_*.deb 2>/dev/null)" ]; then
echo " (移除冲突替代品:$drop)"
rm -f "$OUT"/debs/${drop}_*.deb
fi
ALL=$(echo "$ALL" | tr ' ' '\n' | grep -vx "$drop" | tr '\n' ' ')
done
# 过滤掉纯粹是虚拟/元包而下载不到实体的名字
REAL=""
for p in $ALL; do
if apt-get download --print-uris "$p" >/dev/null 2>&1; then
REAL="$REAL $p"
fi
done
ALL="$REAL"
echo " 闭包内可下载的包:$(echo $ALL | wc -w) 个"
echo "== 3/4 下载(只下不装)=="
# 分批下载:命令行过长会在老 shell 上踩 ARG_MAX,且失败时更容易定位是哪个包。
# 陷阱:apt-get download 的落地目录取自**进程的当前目录**,不是 -o 参数;
# 早期写法用 `xargs -a partfile apt-get download`——xargs 在部分实现下会以
# 自己的 CWD 起子进程,结果 apt 把 .deb 往 / 根目录写,全部「权限不够」。
# 改成显式 for 循环 + 显式 cd,且每批都重新确认 CWD,绝不让 xargs 起进程。
DEBDIR="$OUT/debs"
echo "$ALL" | tr ' ' '\n' | grep -v '^$' > "$OUT/.allpkgs"
BATCH=40
total=$(wc -l < "$OUT/.allpkgs")
i=0
while [ "$i" -lt "$total" ]; do
batch=$(sed -n "$((i+1)),$((i+BATCH))p" "$OUT/.allpkgs")
# shellcheck disable=SC2086
( cd "$DEBDIR" && apt-get download $batch 2>&1 | grep -E '^E:' | head -3 ) || true
i=$((i + BATCH))
done
rm -f "$OUT/.allpkgs"
N=$(ls -1 "$DEBDIR"/*.deb 2>/dev/null | wc -l)
echo " 已下载 $N 个 .deb(约 $(du -sh "$DEBDIR" | cut -f1))"
[ "$N" -gt 0 ] || { echo "✘ 一个都没下到,检查网络/源"; exit 1; }
echo "== 4/4 生成本地索引与安装脚本 =="
# 有 dpkg-scanpackages(dpkg-dev 提供)就生成真正的本地仓库索引,
# 这样目标机可以用 `apt-get install -o Dir::Etc::sourcelist=... drouter` 一次装齐。
cd "$OUT"
if command -v dpkg-scanpackages >/dev/null 2>&1; then
# ⚠️ 索引必须落在 **debs/** 里,和 install.sh 里那条
# `deb [trusted=yes] file:$HERE/debs ./` 对上。
# 踩过的坑:原来生成到 $OUT/Packages.gz,而 apt 去 $OUT/debs/ 找 Packages,
# 结果 `E: Failed to fetch file:.../debs/./Packages File not found`,
# 整条「本地仓库」链路静默退化成 dpkg -i 乱装。
( cd debs && dpkg-scanpackages -m . /dev/null 2>/dev/null > Packages )
# 同时给一份 gzip 版:apt 优先用 Packages.gz,没有才用 Packages。
# 两个都放,避免不同 apt 版本偏好不一致又踩一次。
[ -s debs/Packages ] && gzip -9c debs/Packages > debs/Packages.gz
echo " 已生成 debs/Packages(+.gz)(本地仓库索引)"
HAS_IDX=1
else
echo " 未装 dpkg-dev,跳过 Packages,改用 dpkg -i 逐个安装"
HAS_IDX=0
fi
cat > "$OUT/install.sh" <<'EOS'
#!/bin/bash
# =============================================================================
# drouter 离线安装脚本
#
# sudo bash install.sh [drouter_x.y.z_all.deb]
#
# 不传参数则在当前目录里自动找 drouter_*.deb。
# 做三件事:① 装齐全部依赖;② 装 drouter 本体;③ 打印后续步骤。
# =============================================================================
set -e
HERE="$(cd "$(dirname "$0")" && pwd)"
PKG="${1:-}"
yesno() { printf ' → %s' "$1"; }
if [ "$(id -u)" -ne 0 ]; then
# 先切到 sudo 再回来执行自己,免得用户忘了加 sudo 跑到一半才失败
exec sudo -E bash "$0" "$@"
fi
# 1) 找本体
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.0_all.deb)"; exit 1; }
echo "安装包:$PKG"
echo ""
echo "== 1/3 安装离线依赖 =="
# 优先走本地仓库模式:让 apt 自己算依赖顺序,比 dpkg -i 一通乱装可靠得多。
if [ -f "$HERE/Packages.gz" ] && command -v apt-get >/dev/null 2>&1; then
echo " 使用本地仓库索引安装(推荐)"
tmp_list=$(mktemp)
trap 'rm -f "$tmp_list"' EXIT
cat > "$tmp_list" <<EOF
deb [trusted=yes] file:$HERE/debs ./
EOF
apt-get -o Dir::Etc::sourcelist="$tmp_list" \
-o Dir::Etc::sourceparts=/dev/null \
-o APT::Get::List-Cleanup=0 \
-o Acquire::Languages=none \
update -qq 2>&1 | tail -2 || true
# 先把闭包里的包整体装掉(--no-install-recommends 避免又去网上拉推荐包)
DEBLIST=$(ls -1 "$HERE"/debs/*.deb 2>/dev/null | sed 's|.*/||; s|_.*||' | sort -u)
# shellcheck disable=SC2086
apt-get -o Dir::Etc::sourcelist="$tmp_list" \
-o Dir::Etc::sourceparts=/dev/null \
-o APT::Get::List-Cleanup=0 \
-o Acquire::Languages=none \
--no-install-recommends -y install $DEBLIST 2>&1 | tail -6 || true
else
echo " 无本地索引,逐个 dpkg -i(可能报依赖未满足,最后会用 -f 补齐)"
dpkg -i "$HERE"/debs/*.deb 2>&1 | tail -4 || true
apt-get --no-download -y -f install 2>&1 | tail -4 || true
fi
echo ""
echo "== 2/3 安装 drouter 本体 =="
dpkg -i "$PKG" 2>&1 | tail -6 || apt-get --no-download -y -f install 2>&1 | tail -6
echo ""
echo "== 3/3 完成 =="
echo " · 查看状态:sudo drouter-ctl status"
echo " · 打开面板:https://<本机IP>:8443/ (默认账号 admin / admin123,请尽快改掉)"
echo " · 首次部署建议在界面里先走「新手向导」"
EOS
chmod +x "$OUT/install.sh"
# 把本体也放进离线目录,方便整包分发
if ls "$HERE"/dist/drouter_*.deb >/dev/null 2>&1; then
cp -f "$HERE"/dist/drouter_*.deb "$OUT/" 2>/dev/null || true
fi
# 清掉构建期的中间产物。踩过的坑:`.closure`(包名列表)和 `.solveroot/`
# (空 status 求解用的临时 apt 根)会被原样打进 tar 包,用户解包后会看到两个
# 莫名其妙的隐藏目录 —— 既占地方又让人困惑「这是不是漏了什么」。
rm -rf "$OUT/.solveroot" "$OUT/.closure" "$OUT/.allpkgs" \
"$OUT/.sim.txt" "$OUT/.sim.err" 2>/dev/null || true
echo ""
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.0_all.deb"
echo "======================================================================"
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# drouter 八模块回归自检(构建保护模式下应全部写成到磁盘,不启动服务)
B=http://127.0.0.1:8080
C="curl -s"
TOK=$($C $B/api/login -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print((d.get("data") or {}).get("token",""))')
if [ -z "$TOK" ]; then echo "登录失败"; exit 1; fi
echo "token ok: ${TOK:0:12}..."
A(){ $C $B/api/apply -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' -d "$1" \
| python3 -c 'import sys,json;d=json.load(sys.stdin);print("ok=",d.get("ok"),"|",d.get("msg_cn") or d.get("code") or "")'; }
echo "--- 1 dnsmasq ---"
A '{"module":"dnsmasq","data":{"lan_iface":"ens18","lan_ip":"192.168.7.3","dhcp_enabled":false,"pool_start":"192.168.7.100","pool_end":"192.168.7.200","lease_time":43200,"dns_mode":"custom","dns_custom":"223.5.5.5,119.29.29.29","options":[{"enabled":true,"code":"6","value":"192.168.7.3"},{"enabled":true,"code":"3","value":"192.168.7.3"}]}}'
echo "--- 2 nft_v4 ---"
A '{"module":"nft_v4","data":{"wan_iface":"","lan_iface":"ens18","allow_estab":true,"icmp":true,"input_policy":"drop"}}'
echo "--- 3 nft_v6 ---"
A '{"module":"nft_v6","data":{"wan_iface":"","lan_iface":"ens18","allow_estab":true,"icmpv6":true,"input_policy":"drop"}}'
echo "--- 4 radvd ---"
A '{"module":"radvd","data":{"lan_iface":"ens18","prefix":"fd00:7::/64","adv_link_mtu":1500}}'
echo "--- 5 dhcpv6 ---"
A '{"module":"dhcpv6","data":{"wan_iface":"","lan_iface":"ens18","pd_len":60}}'
echo "--- 6 upnp ---"
A '{"module":"upnp","data":{"enable":false,"lan_iface":"ens18","wan_iface":""}}'
echo "--- 7 ntp ---"
A '{"module":"ntp","data":{"servers":["ntp.aliyun.com","time.cloudflare.com"]}}'
echo "--- 8 ppp ---"
A '{"module":"ppp","data":{"iface":"ens18","username":"test12345","password":"pass67890","isp":"ct","mtu":1492}}'
echo "=== 生成文件落盘检查 ==="
ls -la /etc/dnsmasq.d/drouter.conf /etc/nftables.d/drouter-v4.nft /etc/nftables.d/drouter-v6.nft \
/etc/radvd.conf /etc/dhcpcd.conf /etc/miniupnpd/miniupnpd.conf /etc/chrony/chrony.conf \
/etc/ppp/peers/drouter-wan 2>&1
echo "=== 安全不变量 ==="
echo -n "dnsmasq: "; systemctl is-active dnsmasq 2>&1
echo -n "radvd: "; systemctl is-active radvd 2>&1
echo -n "dhcpcd: "; systemctl is-active dhcpcd 2>&1
echo -n "miniupnpd:"; systemctl is-active miniupnpd 2>&1
echo -n "chrony: "; systemctl is-active chrony 2>&1
echo -n "53端口: "; ss -lnu | grep -c ':53 ' || true
echo -n "67端口: "; ss -lnu | grep -c ':67 ' || true
echo -n "VNC5900: "; ss -lnt | grep -c ':5900'
echo -n "BUILD_MODE: "; test -f /etc/drouter/BUILD_MODE && echo "存在(保护中)" || echo "不存在"
echo -n "ens18: "; ip -4 -o addr show ens18 | awk '{print $4}'
echo "=== nft 规则 ==="
nft list ruleset 2>/dev/null | head -5 || echo "nftables 表为空"
echo "REGRESSION_DONE"
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# 前端契约回归:验证登录能拿到 token,且每个前端页面依赖的接口都返回 200
B=http://127.0.0.1:8080
echo "=== 1. 登录应返回真实 token ==="
RESP=$(curl -s $B/api/login -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}')
echo "$RESP" | python3 -m json.tool --no-ensure-ascii
TOK=$(echo "$RESP" | python3 -c 'import sys,json;print((json.load(sys.stdin).get("data") or {}).get("token") or "")')
if [ -z "$TOK" ]; then echo "❌ token 为空 —— 登录仍坏"; exit 1; fi
echo "✅ token = ${TOK:0:16}..."
echo
echo "=== 2. 用该 token 请求各页面依赖接口 ==="
FAIL=0
check() {
local ep="$1" m="${2:-GET}" body="${3:-}"
local args=(-s -o /tmp/o.json -w '%{http_code}' "$B$ep" -H "X-Token: $TOK")
[ "$m" = POST ] && args+=(-X POST -H 'Content-Type: application/json' -d "${body:-{\}}")
local code=$(curl "${args[@]}")
local ok=$(python3 -c 'import json;print(json.load(open("/tmp/o.json")).get("ok"))' 2>/dev/null)
printf " %-22s %-5s HTTP=%-4s ok=%-6s %s\n" "$ep" "$m" "$code" "$ok" \
"$( [ "$code" = 200 ] && [ "$ok" = True ] && echo ✔ || { echo ✘; FAIL=1; } )"
}
# 概览页
check /api/sysinfo; check /api/services; check /api/ifaces
check /api/ipv6; check /api/routes
# 网卡/WAN/LAN
check /api/iface/meta; check /api/config
# 防火墙/日志/诊断/电源/用户/升级
check /api/nft; check /api/leases; check /api/users; check /api/ntp
check /api/logs; check /api/journal; check /api/audit
check /api/snapshot/list; check /api/upgrade/info
# 构建保护模式(前端用 GET)
check /api/buildmode GET
check /api/pppoe/saved
# 新增:Web 端口 / 网卡配置方式 / 拨号实时日志
check /api/webport
check /api/iface_method
check /api/ppp/log
# 新增:IPv6 连通性测试工具
check /api/diag POST '{"tool":"ipv6test","which":"cn"}'
# 新增:自动快照策略 / 紧急救援通道
check /api/snapshot/auto
check /api/rescue
# 新增批次(#17–#22):通用 API / 依赖自检 / NAT / 软加速 / VLAN / WOL / 文件管理
check /api/openapi
check /api/examples
check /api/deps/check
check /api/vlan
check /api/wol
check /api/nat/last
check /api/accel POST '{"op":"get"}'
# 新增批次(#23–#25):PPPoE 多拨 / QoS 智能限速 / DPI 应用识别
check /api/pppoe-multi
check /api/pppoe-multi POST '{"op":"get"}'
check /api/pppoe-multi/log POST '{"idx":1}'
check /api/qos
check /api/qos POST '{"op":"get"}'
check /api/dpi
check /api/dpi POST '{"op":"get"}'
check /api/dpi POST '{"op":"plan","which":"apt"}'
check /api/dpi/log
check '/api/files/list?path=/etc/drouter'
check '/api/files/read?path=/etc/drouter/snapshot.conf'
# 写操作:只保存(不应用)
check /api/config POST '{"module":"system","data":{"hostname":"debian-primaryrouter"}}'
echo
echo "=== 3. 关键字段核对 ==="
curl -s $B/api/buildmode -H "X-Token: $TOK" | python3 -c '
import sys,json;d=json.load(sys.stdin)
print(" buildmode.data.enabled =", d["data"]["enabled"], "(解除后应为 False)")'
curl -s $B/api/ifaces -H "X-Token: $TOK" | python3 -c '
import sys,json;d=json.load(sys.stdin)
for i in d["data"]:
if i["name"]=="lo": continue
n=i.get("name"); m=i.get("mac"); st=i.get("oper") or i.get("operstate") or ""
print(" %-8s mac=%s state=%s" % (n, m, st))'
curl -s $B/api/routes -H "X-Token: $TOK" | python3 -c '
import sys,json;d=json.load(sys.stdin)
print(" 默认路由:", [(r["gateway"], r["dev"]) for r in d["data"]["v4"] if r["dst"]=="default"])'
echo
[ $FAIL = 0 ] && echo "FRONTEND_CONTRACT_OK ✔ 全部接口正常" || echo "FRONTEND_CONTRACT_FAIL ❌ 存在异常接口"
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# 模拟“用户在 WEB 里填写 PPPoE 账号密码后点击保存并应用”的完整链路
B=http://127.0.0.1:8080
TOK=$(curl -s $B/api/login -X POST -H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}' \
| python3 -c 'import sys,json;print((json.load(sys.stdin).get("data") or {}).get("token",""))')
[ -z "$TOK" ] && { echo "登录失败"; exit 1; }
echo "=== A. 保存 pppoe 模块配置(仅保存)==="
curl -s $B/api/save -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
-d '{"module":"pppoe","data":{"iface":"ens18","username":"user@test","password":"secret999","isp":"ct","mtu":1492}}' \
| python3 -m json.tool --no-ensure-ascii 2>/dev/null || echo "(接口不存在,尝试 apply)"
echo
echo "=== B. 通过 apply 接口写盘(构建保护模式下 live=false)==="
curl -s $B/api/apply -X POST -H "X-Token: $TOK" -H 'Content-Type: application/json' \
-d '{"module":"ppp","data":{"iface":"ens18","username":"user@test","password":"secret999","isp":"ct","mtu":1492},"live":false}' \
| python3 -m json.tool --no-ensure-ascii
echo
echo "=== C. 落盘结果 ==="
echo "--- /etc/ppp/peers/drouter-wan ---"; cat /etc/ppp/peers/drouter-wan 2>/dev/null | head -12
echo "--- /etc/ppp/chap-secrets ---"; cat /etc/ppp/chap-secrets 2>/dev/null
echo "--- 权限 ---"; ls -l /etc/ppp/peers/drouter-wan /etc/ppp/chap-secrets 2>/dev/null
echo
echo "=== D. 服务是否仍未被启动(安全不变量)==="
for s in pppd dnsmasq; do printf " %-8s %s\n" "$s" "$(systemctl is-active $s 2>&1)"; done
ss -lnu | grep -E ':53 |:67 ' && echo " 端口被占用(异常)" || echo " 53/67 仍空闲 ✔"
echo "PPPOE_SAVE_PATH_OK"