Files
drouter/scripts/deploy.sh
T
liuzhuohua 64df3cf331 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
2026-09-30 19:26:24 +08:00

429 lines
17 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"