#!/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"