1 需求背景
公司有一台内网服务器(btk-acloud,Ubuntu 24.04 LTS),上面 /data/baic_n60/ 目录里存着团队的共享文件。内部同事需要随时通过浏览器访问这些文件——浏览目录、下载。
这台机器有几个约束:
| 约束项 | 详情 |
|---|---|
| 网络 | 无外网——apt 源走内网镜像,但 pip/npm/git clone 全部不可用 |
| Python | 系统预装 Python 3.12.3,但 PEP 668 禁止 pip 全局安装包 |
| 访问路径 | 通过跳板机 10.10.0.4 转发才到 112.31.22.151:3022 |
| 数据量 | 45.5TB 机械盘,/data 已用 7.5TB |
| 并发 | 团队规模 10-20 人,偶尔同时下载大文件 |
不允许在这个不带外网的机器上折腾 pip install、uv pip install、docker pull、或者 apt-get install nginx 拉一堆依赖——这台机器不是开发机,是生产服务器,不能为一个小需求破坏系统 Python 环境。
2 环境拓扑
[ 同事浏览器 ]
│
↓ HTTP :19527
[ 112.31.22.151:3022 ] ← 对外暴露的 SSH 端口(NAT 映射)
↑
[ 跳板机 10.10.0.4 ] ← SSH 隧道中转
↑
[ 10.177.100.30 ] ← 内网目标机(btk-acloud)
│
↓
[ /data/baic_n60/ ] ← 45.5TB 机械盘,实际数据 7.5TB
连接命令:
sshpass -p '<password>' ssh \
-o "ProxyCommand ssh -i ~/.ssh/id_ed25519 herui@10.10.0.4 -W %h:%p" \
-p 3022 sup1whu@112.31.22.151
由于内网同事通过 HTTP 请求直接打到目标机的 19527 端口,这里不需要 Nginx 反代。服务直接监在 0.0.0.0:19527 即可。
3 核心架构决策
3.1 为什么不用 Flask / FastAPI / Django
装不上。没有外网,pip 不可用。apt-get install python3-flask 也不行——Debian 系的 Python 包命名风格和 pypi 不一致,很多现代 Web 框架没有 apt 打好的包。
3.2 为什么不用 Nginx autoindex
apt-get install nginx 理论上可以,但对一台 45TB 数据盘的生产服务器,装 nginx 意味着多一个进程、多一套配置、多一个攻击面。这个文件服务器的需求很简单——列目录、下载文件——Python 标准库完全够用。
3.3 选型对比
| 方案 | 依赖 | 部署复杂度 | 功能覆盖 | 安全风险 |
|---|---|---|---|---|
| Nginx autoindex | apt nginx | 中(需配置 server block) | 完整(但无定制 UI) | Nginx CVE |
| Python Flask + pip 包 | pip + 外网 | 高(需要 venv) | 完整 | 依赖链 |
| Python 标准库 http.server | 无 | 低 | 完整 | 仅 Python 运行时 |
| busybox httpd | apt busybox | 低 | 弱(无目录列表样式) | 低 |
Python 标准库方案对这台「无外网 + 生产环境 + PEP 668」的机器是最优解。
3.4 模块选择:ThreadingHTTPServer
Python 3.12 标准库自带了两个 HTTP 服务器:
| 类 | 并发模型 | 适用场景 |
|---|---|---|
http.server.HTTPServer | 单线程,请求排队 | 开发调试 |
http.server.ThreadingHTTPServer | 每请求一个线程 | 生产环境 |
10-20 人团队,ThreadingHTTPServer 够用。不需要引入 asyncio 或者 socketserver.ForkingMixIn——多线程在这个并发量级下足够。
4 代码实现逻辑
完整代码约 350 行,零外部 import。只用了 Python 3.12 自带的这些模块:
import http.server # ThreadingHTTPServer, SimpleHTTPRequestHandler
import os # 文件遍历、路径校验
import re # URL 路径清理(合并重复 /)
import urllib.parse # URL 编解码
import sys # 日志输出
import datetime # 文件修改时间格式化
from pathlib import Path
4.1 总体结构(三段式)
main()
├── 信号处理(SIGINT / SIGTERM 优雅退出)
├── 创建 ThreadingHTTPServer((0.0.0.0, 19527), FileHandler)
└── server.serve_forever()
FileHandler(SimpleHTTPRequestHandler)
├── translate_path() → URL → 文件系统路径(含防穿越)
├── do_GET() → 目录→列表页,文件→下载
└── list_directory() → 生成 HTML 目录列表
工具函数
├── human_size() → 字节 → 人类可读(KB/MB/GB)
├── format_time() → Unix 时间戳 → YYYY-MM-DD HH:MM
├── build_breadcrumb() → 生成面包屑 HTML
└── escape_html() → HTML 实体转义
4.2 路径安全:防穿越
这是最关键的一行代码。如果用户访问 /../../../etc/passwd,os.path.normpath 会把路径规范化,然后用 os.path.commonpath 检查结果是否仍然在 ROOT_DIR 内:
def translate_path(self, path):
path = urllib.parse.unquote(path.split("?")[0].split("#")[0])
rel = path.lstrip("/")
fs_path = os.path.normpath(os.path.join(ROOT_DIR, rel))
# 关键:检查最终路径是否在 ROOT_DIR 范围内
if os.path.commonpath([os.path.realpath(fs_path),
os.path.realpath(ROOT_DIR)]) != os.path.realpath(ROOT_DIR):
return os.path.join(ROOT_DIR, "") # 越界→返回空路径→404
return fs_path
这里三处调用 os.path.realpath 是为了处理符号链接绕过——os.path.normpath 只做字符串规范化,不解析软链接。os.path.realpath 把路径解析到真实的 inode,再比较。
4.3 目录列表:HTML 模板渲染
list_directory() 的逻辑:
os.scandir(path)遍历目录(比os.listdir+os.stat快,一次系统调用同时拿到文件名和 stat)- 目录和文件分两组,各自按名称排序(不区分大小写),目录排前面
- 每个条目生成
<tr>:图标(📁/📄)、带链接的文件名、人类可读大小、日期 - 用
HTML_TEMPLATE.format()填充整页
文件名列没有设固定宽度、没有 text-overflow: ellipsis。CSS 用了 word-break: break-all 和 overflow-wrap: anywhere——文件名多长都能完整显示。这是最早版本用 min-width: 0 + word-break 的组合,测试过 80+ 字符的文件名不会溢出也不换行错乱。
4.4 文件下载:强制下载头
self.send_header(
"Content-Disposition",
f'attachment; filename="{urllib.parse.quote(fname)}"'
)
只要请求的是文件(不是目录),就加 Content-Disposition: attachment。不管浏览器能不能内联打开(PDF、图片、txt),都给下载。这是团队明确的需求——这些文件本身就是用来下载的,不是在线预览的。
4.5 深色/浅色切换
用 CSS 自定义属性(--bg、--text 等)+ data-theme 属性切主题:
:root { --bg: #ffffff; --text: #1a1a1a; ... }
[data-theme="dark"] { --bg: #0f172a; --text: #e2e8f0; ... }
JS 就两件事:
- 页面加载时读
localStorage.getItem('baic-theme'),设置初始主题 - 点击开关时切
data-theme属性 + 更新 localStorage
不需要 cookie、不需要后端 session、不需要 CSS 预处理。8 行 JavaScript 解决问题。
5 部署步骤
5.1 上传代码到目标机
通过跳板机 SCP 把本地写好的 baic-server.py 推到目标机:
sshpass -p '<password>' scp \
-o "ProxyCommand ssh -i ~/.ssh/id_ed25519 herui@10.10.0.4 -W %h:%p" \
-P 3022 /tmp/baic-server.py sup1whu@112.31.22.151:~/baic-server.py
5.2 验证手动启动
# SSH 到目标机
ssh sup1whu@btk-acloud
# 手动启动,观察输出
python3 ~/baic-server.py
输出:
BiTECH File System running on http://0.0.0.0:19527/
Serving: /data/baic_n60
Press Ctrl+C to stop.
在另一台能访问目标机的内网机器上:
curl -sI http://10.177.100.30:19527/
# HTTP/1.0 200 OK
# Content-Type: text/html; charset=utf-8
Ctrl+C 停掉手动进程,确认端口释放:
ss -tlnp | grep 19527
# 应该无输出
5.3 配置 systemd 用户服务
服务文件路径:~/.config/systemd/user/baic-files.service
[Unit]
Description=BiTECH File System — /data/baic_n60/ file server
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/sup1whu/baic-server.py
Restart=always
RestartSec=5
WorkingDirectory=/home/sup1whu
[Install]
WantedBy=default.target
每个参数的含义:
| 参数 | 值 | 说明 |
|---|---|---|
Type | simple | 服务启动即视为就绪。Python 脚本前台运行,不需要 forking |
ExecStart | /usr/bin/python3 ... | 绝对路径。不用 python3 是因为 systemd 不读 shell PATH |
Restart | always | 无论退出码是什么都重启。进程 crash、OOM kill、手动 kill 都会自动拉起来 |
RestartSec | 5 | 等 5 秒再重启。防止疯狂重启循环刷日志 |
WorkingDirectory | /home/sup1whu | Python 进程的 CWD。脚本本身用了绝对路径,但以防万一 |
5.4 启用 linger + 启动服务
用户级 systemd 服务默认只在用户登录时运行。这个机器没有图形界面、没有常驻登录会话,需要开启 linger 让服务在系统启动时就起:
# 允许 sup1whu 用户在系统启动时自动起服务(不需要登录会话)
sudo loginctl enable-linger sup1whu
# 重载 systemd 用户实例、启用服务
systemctl --user daemon-reload
systemctl --user enable baic-files.service
systemctl --user start baic-files.service
三步的区别:
| 命令 | 作用 |
|---|---|
daemon-reload | 让 systemd 重新读 .service 文件 |
enable | 写入启动链(default.target.wants/),但不立即启动 |
start | 立即启动。如果已经在跑,start 是幂等的 |
验证:
systemctl --user status baic-files --no-pager
输出示例:
● baic-files.service - BiTECH File System — /data/baic_n60/ file server
Loaded: loaded (.../baic-files.service; enabled; preset: enabled)
Active: active (running) since Wed 2026-07-15 17:04:46 CST; 2h ago
Main PID: 2145802 (python3)
Tasks: 1 (limit: 38415)
Memory: 10.2M (peak: 11.1M)
内存 10MB——对于一个 HTTP 服务器来说很少。ThreadingHTTPServer 只在有请求时才起线程,平时就一个主线程。
5.5 功能验证
用 curl 跑了 7 项测试:
| # | 测试项 | URL | 预期 | 结果 |
|---|---|---|---|---|
| 1 | 根目录 | GET / | 200 + HTML 目录列表 | ✅ |
| 2 | 子目录 | GET /subdir1/ | 200 + 子目录内容 | ✅ |
| 3 | 长文件夹名 | GET /very_long_folder_name_test_2026_Q3/ | 200 + 空目录提示 | ✅ |
| 4 | 文件下载 | GET /test_file_1.txt | 200 + Content-Disposition | ✅ |
| 5 | 长文件名下载 | GET /report_2026_...very_long_name.pdf | 200 + 正确文件名 | ✅ |
| 6 | 嵌套文件 | GET /subdir1/nested.txt | 200 | ✅ |
| 7 | 404 | GET /nonexistent | 404 | ✅ |
6 运维管理常用命令
6.1 日常操作
| 操作 | 命令 |
|---|---|
| 查看服务状态 | systemctl --user status baic-files --no-pager |
| 查看最近日志 | journalctl --user -u baic-files -n 50 --no-pager |
| 实时跟踪日志 | journalctl --user -u baic-files -f |
| 重启服务 | systemctl --user restart baic-files |
| 停止服务 | systemctl --user stop baic-files |
| 禁用开机自启 | systemctl --user disable baic-files |
| 查看 linger 状态 | loginctl show-user sup1whu | grep Linger |
| 查看监听端口 | ss -tlnp | grep 19527 |
| 查看进程树 | ps auxf | grep baic-server |
| 查看连接数 | ss -tn state established '( sport = :19527 )' | wc -l |
| 检查错误日志 | journalctl --user -u baic-files -p 3 --no-pager |
6.2 更新代码流程
# 1. 本地修改 baic-server.py,测试通过
python3 /tmp/baic-server.py &
curl -sI http://127.0.0.1:19527/
kill %1
# 2. 通过跳板机推到目标机
sshpass -p '<password>' scp \
-o "ProxyCommand ssh -i ~/.ssh/id_ed25519 herui@10.10.0.4 -W %h:%p" \
-P 3022 /tmp/baic-server.py sup1whu@112.31.22.151:~/baic-server.py
# 3. 重启服务(systemd 用新代码)
sshpass -p '<password>' ssh \
-o "ProxyCommand ssh -i ~/.ssh/id_ed25519 herui@10.10.0.4 -W %h:%p" \
-p 3022 sup1whu@112.31.22.151 \
"systemctl --user restart baic-files && systemctl --user status baic-files --no-pager"
不要直接在目标机上用 vim 改代码然后 systemctl --user restart。目录 /data/baic_n60/ 是生产数据,vim 出错了没有 git 回滚。
6.3 磁盘监控
| 检查项 | 命令 |
|---|---|
| 磁盘使用 | df -h /data |
| 大目录排查 | du -sh /data/baic_n60/* | sort -rh | head -20 |
| 文件数量统计 | find /data/baic_n60 -type f | wc -l |
| 最近修改的文件 | find /data/baic_n60 -type f -mtime -7 |
7 踩坑记录
7.1 PEP 668:externally-managed-environment
最开始想在目标机上 pip install flask,报错:
error: externally-managed-environment
× This environment is externally managed
Ubuntu 24.04 默认的 Python 3.12 开启了 PEP 668 保护——禁止 pip 往系统 site-packages 装东西。这是好事,保护系统 Python 不被破坏,但意味着不能走常规的 pip 安装路线。
绕不过去:
pip install --break-system-packages:语法上能绕过 PEP 668,但没网,pypi.org 连不上python3 -m venv+ pip:同样卡在外网上apt-get install python3-flask:Debian/Ubuntu 没有 Flask 的 apt 包
回到标准库。http.server.ThreadingHTTPServer 在这个并发量级下够了。
7.2 systemd 用户服务不启动
systemctl --user enable baic-files 成功后重启机器服务没起来。检查:
loginctl show-user sup1whu | grep Linger
# Linger=no
用户服务在 session 级别启动。没有 linger 的情况下,WantedBy=default.target 不会在系统启动时触发——因为 default.target 对于用户实例,只有在用户首次登录时才启动。
修复:sudo loginctl enable-linger sup1whu,下次重启服务就自动起来了。这个行为在 systemd 官方文档 logind.conf(5) 里有说明,但不踩这个坑不会注意到。
7.3 ExecStart 写 python3 而不是 /usr/bin/python3
systemd 用户实例的 $PATH 非常精简(大概只有 /usr/local/bin:/usr/bin:/bin),但行为与登录 shell 不同。直接用 python3 虽然大概率能跑(因为 /usr/bin 在 PATH 里),但写绝对路径 /usr/bin/python3 消除了所有不确定性。systemd 官方文档也在 systemd.service(5) 里建议 ExecStart 用绝对路径。
7.4 从临时进程切换到 systemd 时的端口冲突
手动 python3 ~/baic-server.py & 启动的进程在后台跑着,后来 systemctl --user start baic-files 也起了。两个进程抢 19527 端口,后者报 Address already in use。
systemd 的 Restart=always 会不断重试,日志里刷了一串失败。手动 kill 掉旧进程后 systemd 自动恢复。
先停手动进程、再起 systemd。如果已经乱了,pkill -f baic-server.py 清掉所有残留,systemd 会在下一个 RestartSec 后自动拉起来。
8 最终状态一览
| 项目 | 值 |
|---|---|
| 服务名称 | baic-files.service |
| 端口 | 19527 |
| 监听地址 | 0.0.0.0 |
| 数据目录 | /data/baic_n60/ |
| 磁盘 | /dev/sda1 (46TB, 17% 已用) |
| 系统 | Ubuntu 24.04.1 LTS, kernel 6.17.0-35 |
| Python | 3.12.3(标准库,零 pip 包) |
| systemd | 用户级服务 + linger 开机自启 |
| 内存占用 | ~10MB |
| 并发模型 | ThreadingHTTPServer (per-request thread) |
| 前端功能 | 深色/浅色切换、面包屑导航、长文件名完整展示、日期+大小列 |
| 安全 | 路径穿越防护(realpath + commonpath)、Content-Disposition 附件下载 |
整个部署从零到 production-ready 花了一个小时——其中 30 分钟在调研「无外网 + PEP 668」环境下的可行方案,20 分钟写代码,10 分钟部署 + 验证。