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 installuv pip installdocker 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 autoindexapt nginx中(需配置 server block)完整(但无定制 UI)Nginx CVE
Python Flask + pip 包pip + 外网高(需要 venv)完整依赖链
Python 标准库 http.server完整仅 Python 运行时
busybox httpdapt 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/passwdos.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() 的逻辑:

  1. os.scandir(path) 遍历目录(比 os.listdir + os.stat 快,一次系统调用同时拿到文件名和 stat)
  2. 目录和文件分两组,各自按名称排序(不区分大小写),目录排前面
  3. 每个条目生成 <tr>:图标(📁/📄)、带链接的文件名、人类可读大小、日期
  4. HTML_TEMPLATE.format() 填充整页

文件名列没有设固定宽度、没有 text-overflow: ellipsis。CSS 用了 word-break: break-alloverflow-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 就两件事:

  1. 页面加载时读 localStorage.getItem('baic-theme'),设置初始主题
  2. 点击开关时切 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

每个参数的含义:

参数说明
Typesimple服务启动即视为就绪。Python 脚本前台运行,不需要 forking
ExecStart/usr/bin/python3 ...绝对路径。不用 python3 是因为 systemd 不读 shell PATH
Restartalways无论退出码是什么都重启。进程 crash、OOM kill、手动 kill 都会自动拉起来
RestartSec5等 5 秒再重启。防止疯狂重启循环刷日志
WorkingDirectory/home/sup1whuPython 进程的 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.txt200 + Content-Disposition
5长文件名下载GET /report_2026_...very_long_name.pdf200 + 正确文件名
6嵌套文件GET /subdir1/nested.txt200
7404GET /nonexistent404

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
Python3.12.3(标准库,零 pip 包)
systemd用户级服务 + linger 开机自启
内存占用~10MB
并发模型ThreadingHTTPServer (per-request thread)
前端功能深色/浅色切换、面包屑导航、长文件名完整展示、日期+大小列
安全路径穿越防护(realpath + commonpath)、Content-Disposition 附件下载

整个部署从零到 production-ready 花了一个小时——其中 30 分钟在调研「无外网 + PEP 668」环境下的可行方案,20 分钟写代码,10 分钟部署 + 验证。