SVN Web 界面搭建实战:CentOS 7 下从 svnserve 到 GitHub 风格浏览器
mod_dav_svn 独立端口部署、Flask 自研前端、只读校验与 svnserve 开机自启修复的完整记录
很多年前搭的 SVN 服务器,往往就停在一个最朴素的形态: svnserve -d -r /svn ,客户端靠 svn://host/repo 拉代码,服务端连一个能看的页面都没有。平时一个人写代码没什么问题,但只要需要给人展示目录结构、翻一翻提交历史、或者只是想确认某个文件长什么样,就不得不先检出整个仓库 - 哪怕只是为了看一个几十行的配置文件。

我手上这台阿里云 ECS 就是这种情况。它跑着 CentOS 7.9,上面有四个 SVN 仓库,80/443 被 nginx 占着做正经业务,SVN 走的是标准 svnserve(3690 端口)。这次的目标很明确: 给它加一个能用的 Web 浏览界面,同时不要碰正在服务的 nginx 。
整个过程分成了两段。第一段用 Apache + mod_dav_svn 快速搭了一个能跑的版本,但在实际使用中很快暴露了它的上限 - 它本质上只能渲染"文件列表"这一层,没有像样的提交历史、没有代码高亮、没有 diff 视图。于是有了第二段:用 Flask 从零写一个 GitHub 风格的浏览器。
界面做完之后,还剩一件更要紧的事: 验证它到底是不是真的只读,以及顺手体检一下这台服务器的 SVN 权限配置 。这一查,查出了一个足以让整台服务器 SVN 服务瘫痪的隐患。
本文是这次改造的完整记录,包含全部踩过的坑和可直接复用的示例代码。
说明:文中所有服务器 IP、账号、密码均已脱敏处理,示例中的
svn://<SERVER_IP>/repo请替换为你自己的地址。
一、起点:一台只在 3690 端口跑的服务器
先把现状描述清楚。这台机器的基本情况是:
| 项目 | 值 |
|---|---|
| 系统 | CentOS 7.9(Core) |
| 内核 | 3.10.0-1160.119.1.el7.x86_64 |
| 主机 | 阿里云 ECS |
| Web 服务 | nginx,占用 80 / 443 |
| 版本控制 | svnserve, svnserve -d -r /svn ,监听 3690 |
| 仓库 | /svn/ 下四个: myblog 、 myCode 、 p2pSpider 、 videoViewer |
第一轮侦察的命令没什么技术含量,但顺序值得记一下 - 先确认"有没有",再确认"能不能装",最后才动手 :
which svn svnadmin svnserve
rpm -qa | grep -i subversion
ps aux | grep svnserve | grep -v grep
ss -tlnp | grep -E ':(80|443|3690|8080)\s'输出确认了两件事:一是 SVN 确实用的是 svnserve 方式 (不是 Apache 的 WebDAV 方式),所以服务端没有任何 HTTP 入口,自然也没有 Web 管理界面;二是 80/443 上是 nginx,这就构成了后面所有方案的硬约束 - 不能动 nginx 。
顺带发现一个当时没在意、后来才发现很关键的细节:
svnserve.service - Subversion protocol daemon
Loaded: loaded (/usr/lib/systemd/system/svnserve.service; enabled)
Active: failed (Result: exit-code)单元状态是 failed ,但 3690 端口明明在监听,进程也活着。当时我判断为"systemd 服务单元状态和实际进程不一致,因为它是手动启动的,功能正常"。 这个判断在一次审计之后被彻底推翻了 - 后面第七节会详细说。
二、先把 远程操作 做成可复用的能力
在改服务器之前,先解决一个工程效率问题: 怎么稳定地在这台机器上执行命令 。
2.1 为什么不用 sshpass
最直觉的做法是 sshpass -p '密码' ssh root@host '命令' 。但这台本机(Windows + Git Bash)没有装 sshpass,而 OpenSSH 本身出于安全设计 不提供命令行传密码的接口 。临时补充工具当然可以,但更好的选择是用 Python 的 paramiko - 它既能在脚本里用,也能顺手封装成一个长期可复用的能力。
2.2 paramiko 执行器
核心代码就是一层薄封装。这部分写一次以后都能用:
# ssh_exec.py
import paramiko
class SSHExecutor:
def __init__(self, host, port=22, user="root", password=None,
key_file=None, timeout=20):
self.host, self.port = host, port
self.user, self.password, self.key_file = user, password, key_file
self.timeout = timeout
def _connect(self):
c = paramiko.SSHClient()
# 生产环境可换成已知主机校验;此处为自动化脚本场景
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
self.host, port=self.port, username=self.user,
password=self.password, key_filename=self.key_file,
timeout=self.timeout, banner_timeout=self.timeout,
auth_timeout=self.timeout,
allow_agent=False, look_for_keys=False,
)
return c
def run(self, cmd, timeout=600):
"""在远端执行命令,返回 (退出码, stdout, stderr)。"""
c = self._connect()
try:
stdin, stdout, stderr = c.exec_command(cmd, timeout=timeout)
out = stdout.read().decode("utf-8", "replace")
err = stderr.read().decode("utf-8", "replace")
rc = stdout.channel.recv_exit_status()
return rc, out, err
finally:
c.close()
def put(self, local_path, remote_path):
"""通过 SFTP 上传文件。"""
c = self._connect()
try:
with c.open_sftp() as sftp:
sftp.put(local_path, remote_path)
finally:
c.close()
if __name__ == "__main__":
import sys
ex = SSHExecutor("<SERVER_IP>", user="root", password="<PASSWORD>")
rc, out, err = ex.run(sys.argv[1] if len(sys.argv) > 1 else "hostname")
print(out, end="")
if err:
print("[stderr]", err, file=sys.stderr)
sys.exit(rc)用法很直接:
python ssh_exec.py "uname -a; uptime; df -h /"2.3 封装成一个 MCP Server
脚本能跑之后,进一步的封装是把它做成 MCP Server ,这样在支持 MCP 的客户端里就能像调用内置工具一样使用它,而不用每次拼命令行。用官方 mcp 包的 FastMCP 写,骨架大约三十行:
# server.py
import json, os
import paramiko
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("ssh-server")
CRED_PATH = os.path.join(os.path.expanduser("~"), ".workbuddy",
"ssh_credentials.json")
def _load_credentials():
"""凭据集中存放,不硬编码在代码里。"""
if not os.path.exists(CRED_PATH):
return {}
with open(CRED_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def _connect():
cred = _load_credentials().get("server", {})
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(
os.environ.get("SSH_HOST", cred.get("host")),
port=int(os.environ.get("SSH_PORT", cred.get("port", 22))),
username=os.environ.get("SSH_USER", cred.get("user", "root")),
password=os.environ.get("SSH_PASSWORD", cred.get("password", "")),
timeout=20, banner_timeout=20, auth_timeout=20,
allow_agent=False, look_for_keys=False,
)
return c
@mcp.tool()
def run_command(command: str) -> str:
"""在远程服务器执行 Shell 命令,返回 JSON 格式的退出码与输出。"""
c = _connect()
try:
_, stdout, stderr = c.exec_command(command, timeout=600)
return json.dumps({
"exit_code": stdout.channel.recv_exit_status(),
"stdout": stdout.read().decode("utf-8", "replace"),
"stderr": stderr.read().decode("utf-8", "replace"),
}, ensure_ascii=False)
finally:
c.close()
@mcp.tool()
def system_info() -> str:
"""查看服务器系统状态:系统版本、负载、内存、磁盘。"""
cmd = ("cat /etc/redhat-release; uptime; free -m; df -h /")
c = _connect()
try:
_, stdout, _ = c.exec_command(cmd)
return stdout.read().decode("utf-8", "replace")
finally:
c.close()
if __name__ == "__main__":
mcp.run()2.4 这一段踩的两个坑
坑一: mcp 包的 2.x 版本改了 API。 新版里 FastMCP 已被重命名,照旧代码写会直接抛导入错误。因为这个 server 只需要最基础的 stdio 能力,锁定 1.x 是最省事的解法:
pip install "mcp<2"坑二:凭据的 JSON 结构层级。 密码被嵌套在 server 对象下,而一开始的读取代码按顶层键取值:
# 错误:顶层没有 password 键,取到空值
password = cred.get("password", "")结果就是 SSH 版本协商成功、但认证必然失败 - paramiko 会正常连上 22 端口拿到 banner,然后在 auth 阶段被拒。这种错误信息("Authentication error")很容易被误判成密码本身写错了,实际上问题出在取值路径。改成 cred.get("server", {}) 再取内层,认证立刻通过。
这类问题的通用教训: 凭据读取失败时,先打印路径是否存在、再打印结构的键名,最后才怀疑密码本身 。
三、第一版 Web 界面:Apache + mod_dav_svn
既然原始需求是"能浏览代码",最正统的做法就是 Apache 的 mod_dav_svn 模块 - 它把 SVN 仓库直接挂成一个 HTTP 站点,浏览器访问即可看到目录列表。
3.1 为什么选独立端口
核心约束是 不能影响 nginx 。所以方案定为: Apache 单独监听一个端口(比如 8080),nginx 完全不动 。这样两者的配置、进程、端口互不干扰,回滚也只需要停掉 httpd。
动手前先做了四件事的可行性侦察:
getenforce # SELinux 状态
rpm -q httpd # httpd 是否已装(避免冲突)
ss -tlnp | grep -E ':(8080|8081|8443)\s' # 目标端口是否空闲
yum repolist # yum 源是否可用结果很理想: SELinux 已 disabled (直接省掉了大量 context 相关的权限麻烦)、httpd 未安装、8080 空闲。但最后一项 yum repolist 出了大问题。
3.2 yum 源是第一道拦路虎
CentOS 7 已经 EOL,官方源迁移到了 vault.centos.org 。在国内网络下访问它普遍超时或返回 403, 表现是服务器根本装不了任何软件 :
Could not resolve host: vault.centos.org
或
HTTP Error 403 - Forbidden这台机器在阿里云上,所以直接切到阿里云的内网镜像源,速度和质量都最优。先测连通性再改配置:
#!/bin/bash
# 测试候选镜像源
MIRRORS=(
"http://mirrors.cloud.aliyuncs.com/centos-vault"
"http://mirrors.aliyun.com/centos-vault"
)
RELEASE=$(rpm -q --qf '%{VERSION}' centos-release)
for m in "${MIRRORS[@]}"; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 8 "$m/$RELEASE/os/x86_64/repodata/repomd.xml")
echo "$code $m"
done实测 mirrors.cloud.aliyuncs.com 返回 200 且耗时 0.12 秒(内网直连),于是批量替换:
#!/bin/bash
set -e
RELEASE=$(rpm -q --qf '%{VERSION}' centos-release)
BASE="http://mirrors.cloud.aliyuncs.com/centos-vault/$RELEASE"
mkdir -p /etc/yum.repos.d/backup
cp -a /etc/yum.repos.d/*.repo /etc/yum.repos.d/backup/
# 1) 替换基础源里的 baseurl / mirrorlist
sed -i -E \
-e "s|^mirrorlist=.*|#mirrorlist=|" \
-e "s|^#?\s*baseurl=.*|baseurl=$BASE/os/x86_64/|" \
/etc/yum.repos.d/CentOS-Base.repo
# 2) SCLo 源也要处理,否则依旧 403 阻塞整个 yum
for f in /etc/yum.repos.d/CentOS-SCLo-*.repo; do
[ -f "$f" ] || continue
sed -i -E \
-e "s|^mirrorlist=.*|#mirrorlist=|" \
-e "s|^#?\s*baseurl=.*|baseurl=$BASE/sclo/x86_64/rh/|" \
-e "s|^enabled=.*|enabled=0|" \
"$f"
done
yum clean all && yum makecache这里有个容易漏的点: SCLo 源( CentOS-SCLo-scl*.repo )是独立仓库 ,它同样指向失效的 vault 地址。只修 CentOS-Base.repo 是不够的 - yum 在解析仓库列表阶段就会被 403 卡住,连基础包也装不上。应对方式是不修 SCLo 而直接停用(这个服务器不需要 Software Collections),或者同样切到镜像并加 skip_if_unavailable=1 。
3.3 mod_dav_svn 是独立包
装完 httpd 和 subversion 之后,按惯例去找 mod_dav_svn.so ,结果找不到:
find / -name 'mod_dav_svn.so'
# 无输出
rpm -ql subversion | grep -i dav
# 无输出原因是 CentOS 7 把 mod_dav_svn 拆成了独立包 ,它不在 subversion 主包里:
yum install -y mod_dav_svn
# 安装后模块位于 /usr/lib64/httpd/modules/mod_dav_svn.somod_dav_svn 还依赖 authz_svn (做路径级授权),以及用于渲染浏览页面的 XSL 样式文件 - 后者随 subversion 提供,在 /usr/share/doc/subversion-1.7.14/xslt/ 。
3.4 配置:只监听 8080,挂载 /svn
配置分两块。第一块是让 httpd 不去抢 80 端口,把主配置里的 Listen 80 改掉(先备份):
cp -a /etc/httpd/conf/httpd.conf /etc/httpd/conf/httpd.conf.bak.$(date +%Y%m%d)
sed -i 's|^Listen 80$|Listen 8080|' /etc/httpd/conf/httpd.conf第二块是站点配置。注意 CentOS 7 的 httpd 已经通过 conf.modules.d/10-subversion.conf 自动加载了 SVN 模块, 再写一遍 LoadModule 会触发 "module already loaded" 告警 ,所以站点配置里不要重复加载:
# /etc/httpd/conf.d/svn.conf
# 注意:mod_dav_svn / mod_authz_svn 已由 conf.modules.d/10-subversion.conf
# 自动加载,此处不要再写 LoadModule,否则会告警。
<Location /svn>
DAV svn
# 父目录模式:/svn 下所有仓库自动可见,无需逐个 SVNPath
SVNParentPath /svn
SVNListParentPath on
# 基础认证
AuthType Basic
AuthName "Subversion Repository"
AuthUserFile /etc/svn-http.passwd
Require valid-user
# 用 XSL 把原始 XML 渲染成可读的 HTML 列表
SVNIndexXSLT "/svnxslt/svnindex.xsl"
</Location>把 XSL 样式拷到可公开访问的目录:
mkdir -p /var/www/html/svnxslt
cp /usr/share/doc/subversion-1.7.14/xslt/* /var/www/html/svnxslt/创建认证用户并启动:
htpasswd -bc /etc/svn-http.passwd <USER> '<PASSWORD>'
apachectl configtest
systemctl enable --now httpd
firewall-cmd --permanent --add-port=8080/tcp && firewall-cmd --reload本地 curl 验证通过:不带认证返回 401 ,带上认证返回 200 并正确列出四个仓库。
3.5 端口与安全组
接下来是那个经典问题:服务器本地验证全过,公网访问超时。对比测试各端口的公网可达性:
for p in 80 443 3690 8080; do
timeout 6 bash -c "echo > /dev/tcp/<SERVER_IP>/$p" 2>/dev/null \
&& echo "port $p OK" || echo "port $p BLOCKED"
done结果是 80、443、3690 全通, 只有 8080 被挡 。这就把问题定位到了云平台的 安全组 (而非服务器本地 firewalld,那里已经放行了)。
这里的处理有一个巧合:把端口从 8080 换成 37245 之后,公网 直接就能访问 了。说明这台机器的安全组对高位端口的策略和对 8080 不同。所以最终没有去控制台改安全组,换端口就解决了。
这一版的最终形态:
| 项目 | 值 |
|---|---|
| 地址 | http://<SERVER_IP>:37245/svn/ |
| 认证 | Basic 认证 + htpasswd |
| 效果 | 可在浏览器浏览目录、文件、版本历史(套用了 XSL 样式) |
它确实能用,但用了几次就发现天花板很明显: mod_dav_svn 的浏览页面只能渲染"文件列表"这一层 ,点进文件是原始文本、没有语法高亮,也没有独立的提交历史页和 diff 对比页。于是有了下一节。
四、第二版:自己写一个 GitHub 风格的浏览器
需求很明确: 要一个看起来像 GitHub 的界面 - 仓库卡片、文件树表格、语法高亮、提交时间线、绿增红删的 diff。
4.1 技术选型
选型的原则是"依赖少、部署简单、稳定":
| 层 | 选择 | 理由 |
|---|---|---|
| Web 框架 | Flask 3.0.3 | 轻量,模板足够 |
| 代码高亮 | Pygments 2.18 | 支持语言多,可定制配色 |
| WSGI 服务器 | waitress 3.0.0 | 纯 Python,跨平台,不依赖 C 扩展 |
| Python | 3.9(服务器自带) | 独立 venv,不污染系统环境 |
架构上有一个关键决策: 不通过 HTTP 协议访问 SVN,而是直接用 svn / svnlook 命令行读取本地仓库 。这样做的好处是速度快、不需要处理 SVN 的 HTTP 认证,而且完全是本地操作。
浏览器 ──HTTP:37245──> waitress ──> Flask (/opt/svnweb/app.py)
│
└─ subprocess 调用 svn / svnlook
(file:///svn/<repo>)新界面复用 37245 端口,所以 URL 不变,只是把 httpd 停掉、让 Flask 接管。
4.2 直接读本地仓库的三个坑
坑一: svn 命令传本地路径会被当成工作副本。
这是最容易踩的一个。直觉上会这么写:
target = os.path.join("/svn", name, path)
run_svn(["svn", "list", target])结果报错:
svn: E155007: '/svn/myCode/xxx' is not a working copy原因是 svn 的 CLI 把"传路径"理解为操作 工作副本(working copy) ,而仓库(repository)必须用 URL 访问。正确做法是构造 file:// URL:
from urllib.parse import quote
def repo_url(name, path="", rev=None):
"""构造仓库的 file:// URL。中文/空格路径必须百分号编码。"""
clean = (path or "").strip("/")
url = "file:///svn/" + quote(name)
if clean:
url += "/" + quote(clean)
# 含 @ 的路径需要以 @ 结尾消歧(peg revision 语法)
if rev is not None:
url += "@" + str(rev)
elif "@" in url:
url += "@"
return urlquote() 这一步不能省 - 仓库里的路径包含中文和空格(比如 mysql 安装日志.txt 、 Vue 项目模板/ ),不编码会直接被 shell 或 SVN 解析错误。还有一个隐蔽的细节: 路径中如果包含 @ ,必须追加一个结尾 @ ,因为 @ 在 SVN 里是 peg revision 的分隔符。
坑二: svn list --xml 的输出是两层嵌套。
svn list --xml 的结构是 <lists><list><entry> ,如果按单层取就什么都拿不到:
# 错误:只找根下第一层的 entry
root = ET.fromstring(out)
for e in root.findall("entry"):
...
# 正确:递归找任意层级的 entry
root = ET.fromstring(out)
for e in root.iter("entry"):
kind = e.get("kind") # 'file' 或 'dir'
name = e.findtext("name")
size = e.findtext("size")
commit = e.find("commit")
rev = commit.get("revision")
author = commit.findtext("author")
date = commit.findtext("date")用 iter() 而不是 findall() ,就不用关心它到底包了几层。 svn log --xml 同理。
坑三:Flask 的 <path:path> 不匹配空路径。
/r/<name>/tree/ 这种"仓库根目录"的 URL, path 是空字符串,而 <path:path> 转换器 不匹配空串 ,所以路由直接不成立、返回 404。解法是加一条带默认值的路由:
@app.route("/r/<name>/tree/", defaults={"path": ""})
@app.route("/r/<name>/tree/<path:path>")
def tree(name, path):
...4.3 应用骨架
路由设计上,整个应用 只有 /login 接受非 GET 请求 ,其余全部是只读页面。这是"只读"的第一层保证:
# app.py(精简示意)
import os, subprocess
from functools import wraps
from flask import Flask, render_template, request, session, redirect, url_for
app = Flask(__name__)
app.secret_key = os.environ["SVNWEB_SECRET"]
REPO_ROOT = "/svn"
def run_svn(args, timeout=60):
"""执行 svn / svnlook 命令。"""
p = subprocess.run(args, capture_output=True, timeout=timeout)
return p.returncode, p.stdout.decode("utf-8", "replace"), \
p.stderr.decode("utf-8", "replace")
def list_repos():
"""仓库白名单:所有请求的 name 都要在这里面。"""
if not os.path.isdir(REPO_ROOT):
return []
return sorted(
n for n in os.listdir(REPO_ROOT)
if os.path.isdir(os.path.join(REPO_ROOT, n, "db"))
)
def login_required(f):
@wraps(f)
def wrapper(*a, **kw):
if not session.get("user"):
return redirect(url_for("login"))
return f(*a, **kw)
return wrapper
@app.route("/login", methods=["GET", "POST"]) # 全站唯一的写方法入口
def login():
if request.method == "POST":
# 校验 config.json 中的 scrypt 哈希
...
session["user"] = request.form["username"]
return redirect(url_for("index"))
return render_template("login.html")
@app.route("/r/<name>/tree/", defaults={"path": ""})
@app.route("/r/<name>/tree/<path:path>")
@login_required
def tree(name, path):
if name not in list_repos(): # 白名单校验,挡住路径穿越
return render_template("error.html", msg="仓库不存在"), 404
rc, out, err = run_svn(["svn", "list", "--xml", repo_url(name, path)])
...list_repos() 这个白名单是关键:所有涉及 name 的路由都先校验它,所以 ../../etc 之类的构造 在进入路径拼接之前就被拒了 。实测对 /r/myCode/blob/../../../../etc/passwd 的请求返回 404,拿不到系统文件。
list_repos() 还有一个隐藏用途:它用 os.path.isdir(REPO_ROOT/n/db) 判断,只把真正是 SVN 仓库的目录纳入列表,避免误把普通目录当成仓库。
4.4 界面能力
最终的页面构成:
| 页面 | 内容 |
|---|---|
| 仓库列表 | 卡片式,显示最新版本号、最后提交人、更新时间 |
| 文件树 | 类 GitHub 表格:图标 + 名称 / 大小 / 最后修改(版本号·时间·提交说明) |
| 代码查看 | Pygments 语法高亮 + 行号,附「历史 / 原始 / 下载」 |
| 提交历史 | 时间线,含提交人、时间、改动文件清单(新增/修改/删除 彩色标记) |
| 版本差异 | 按文件分组的完整 diff,绿增红删着色,带增减行数统计 |
| 历史版本 | 任意页面输入版本号即可回溯查看快照 |
4.5 用 systemd 托管
应用需要一个常驻服务,用 systemd 管理最省心:
# /etc/systemd/system/svnweb.service
[Unit]
Description=GitHub-style SVN web browser
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/svnweb
ExecStart=/opt/svnweb/venv/bin/waitress-serve \
--listen=0.0.0.0:37245 --threads=8 --channel-timeout=120 app:app
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable --now svnweb同时停掉上一个方案的 httpd(彻底交还端口):
systemctl stop httpd && systemctl disable httpd配置文件的权限也要收一下,因为它存着登录密码的哈希和 session 密钥:
chmod 600 /opt/svnweb/config.json登录密码用 werkzeug 的 generate_password_hash 生成(scrypt),不落明文。
五、验证"只读":一个不能只信代码的命题
界面跑起来之后,有一个必须回答的问题: 它到底是不是真的只能看、不能提交? 这个问题不能靠"我写代码的时候没写提交功能"来回答 - 代码会被改,依赖会有漏洞。所以做了六项交叉验证。
5.1 六项交叉验证
| # | 方法 | 结果 | | - | ------------------------------- | ---------------------------------------------------------- | | 1 | 枚举全部路由 | 12 个路由中唯一非 GET 的是 POST /login | | 2 | 向内容页发 POST / PUT / DELETE | 全部返回 405 Method Not Allowed | | 3 | 检索代码中是否调用 svn 写命令 | commit/ci/add/del/mkdir/import/copy/move/propset 零命中 | | 4 | 检查 SVN hook | 四个仓库 hooks/ 下 无任何启用脚本 (仅 .tmpl 模板) | | 5 | 服务日志中的写操作痕迹 | 无 | | 6 | 路径穿越攻击 | 全部 404 ,仓库名走白名单校验 |
其中第 2 项的批量探测脚本很实用,可以复用到任何一个"声称只读"的 Web 服务上:
#!/bin/bash
# 对所有页面发三种写方法,观察是否被拒
for m in POST PUT DELETE; do
for u in "/r/myCode/tree/" "/r/myCode/commit/54" "/login"; do
code=$(curl -s -o /dev/null -w '%{http_code}' -m 10 -X "$m" \
-b /tmp/cookies.txt "http://127.0.0.1:37245$u")
echo "$m $u -> $code"
done
done
# 对照组:GET 应该正常
curl -s -o /dev/null -w 'GET /r/myCode/tree/ -> %{http_code}\n' \
-b /tmp/cookies.txt "http://127.0.0.1:37245/r/myCode/tree/"结果很干净:内容页的写方法一律 405, /login 的 POST 是 200(登录表单),GET 正常 200。
最终判定:Web 端确实只能查看,不能提交。
不过在做这项验证的同时,也发现了两个"只读保证方式"上的事实,值得单独说明。
第一,只读是"代码约束"而不是"权限约束"。 服务以 root 身份运行,而实测 root 对四个仓库的 db 目录全部可写:
SVCUSER=$(systemctl show -p User --value svnweb); echo "service user = $SVCUSER"
for r in /svn/*/; do
runuser -u "$SVCUSER" -- test -w "$r/db" 2>/dev/null \
&& echo "$r -> 可写" || echo "$r -> 不可写"
done也就是说,当前之所以只读,仅仅是因为 代码里没有写操作 。这把"只读"从权限层面的硬保证,降级成了一个软件约束。
第二,Web 端读取完全绕过 svnserve 的 authz。 应用是用 file:// URL 直读本地仓库的,不经过 svnserve,因此 完全不受各仓库 authz 文件的约束 。任何能登录 Web 的人,都能看到全部四个仓库的全部内容。当前只有一个账号且对全部仓库 rw ,所以这一点在实际影响上为零 - 但它是这个架构的固有特性。
5.2 一次被缓存骗了的匿名访问测试
这部分值得单独写,因为太容易误判了。
配置里 anon-access = none ,本意是禁止匿名访问。我用命令行验证:
svn info --non-interactive svn://127.0.0.1/myCode
svn list --non-interactive svn://127.0.0.1/myCode两个命令都成功了,还正常列出了文件列表。 当时的第一反应是"配置没生效,匿名能读仓库" - 这是个大问题。
但真相不是这样。原因是 root 用户的 ~/.subversion/auth/ 里存着之前用过的凭据缓存 , svn 客户端在收到服务端的认证请求后,自动从缓存里取出了账号密码,所以这次访问其实 是认证过的 ,不是匿名。
要测真正的匿名访问,必须同时做两件事: 隔离 HOME(让客户端读不到缓存)+ 禁用认证缓存 :
#!/bin/bash
# 真·匿名访问测试
export HOME=/tmp/svn_anon_$$ # 关键一:隔离 HOME,读不到 ~/.subversion
rm -rf "$HOME"; mkdir -p "$HOME"
echo "--- info ---"
svn info --non-interactive --no-auth-cache svn://127.0.0.1/myCode 2>&1 | tail -1
echo "--- list ---"
svn list --non-interactive --no-auth-cache svn://127.0.0.1/myCode 2>&1 | tail -1
echo "--- log ---"
svn log --non-interactive --no-auth-cache svn://127.0.0.1/myCode 2>&1 | tail -1
rm -rf "$HOME"三个入口全部返回:
svn: E170001: Can't get password说明 anon-access = none 是真实生效的 ,匿名读、匿名写都被拒绝。之前那个"匿名能访问"的结论,完全是缓存造成的假象。
这个坑的通用性很强: 任何"认证后应该有/不应该有权限"的验证,都必须先确认客户端没有隐式携带凭据 。
--no-auth-cache单独用还不够,HOME 目录里的既有缓存同样会被读取。
六、权限审计:三个真实存在的问题
界面和只读性确认完毕之后,顺手对这台服务器的 SVN 权限配置做了一次完整体检。查出来三个问题,其中一个的严重程度远超预期。
6.1 高危:svnserve 重启后必然瘫痪
回到第一节那个被搁置的细节 - svnserve.service 是 failed 但端口在监听。这次把它彻底查清了。
第一步,看单元为什么失败:
journalctl -u svnserve --no-pager -n 40输出直指根因:
svnserve: Root path '/var/svn' does not exist or is not a directory.
svnserve.service: control process exited, code=exited status=1/FAILURE第二步,看这个路径从哪儿来:
cat /etc/sysconfig/svnserve
# OPTIONS is used to pass command-line arguments to svnserve.
# Specify the repository location in -r parameter:
OPTIONS="-r /var/svn"-r /var/svn ,但真实仓库在 /svn 。 这两个路径不一致,而 /var/svn 根本不存在。
第三步,确认当前进程的归属:
pgrep -a svnserve
# 32763 svnserve -d -r /svn <- 注意参数是 -r /svn
cat /proc/32763/cgroup | grep name=systemd
# 1:name=systemd:/user.slice/user-0.slice/session-24364.scope最后这行是决定性的:进程的 systemd cgroup 归属是 user.slice/session-*.scope ,也就是"某个登录会话",而 不是 system.slice/svnserve.service 。这说明它 不是 systemd 启动的 ,而是有人在终端里手工敲了 svnserve -d -r /svn 。
第四步,确认没有任何自启补偿:
crontab -l | grep -i svn # 无
grep -i svn /etc/rc.d/rc.local # 无把四步串起来,结论就清楚了:
/etc/sysconfig/svnserve里写的是-r /var/svn(不存在),且单元是enabled状态;- 所以 每次开机,systemd 都会尝试启动并一定失败 ;
- 当前之所以能用,是因为有个 手工启动 的进程撑着,但它不在 systemd 管控之下;
- 也没有任何 cron / rc.local 会去重新拉起它。
净结果:只要这台服务器重启一次,3690 端口就再也没人监听,所有 SVN 客户端全部无法连接。 而单元是 enabled 的,表面上看"已经配置了开机自启",很容易让人以为一切正常。这是一个非常隐蔽的、以"看起来配置过了"伪装起来的隐患。
6.2 realm 配置错误
第二个问题在 /svn/myCode/conf/svnserve.conf 里:
[general]
anon-access = none
auth-access = write
password-db = passwd
authz-db = authz
realm = /svn/myblog # <-- 这里是 myblog,应为 myCode四个仓库里,其余三个的 realm 都正确对应自己的路径,只有 myCode 写成了 /svn/myblog - 明显是从 myblog 复制配置时漏改了。
realm 的作用是 给凭证缓存做隔离标识 :客户端按 realm 分别缓存账号密码,不同 realm 互不串用。两个仓库共用同一个 realm 时,客户端会认为"这两个地址是同一个认证域",从而拿 A 的凭据去连 B。
当前这两个仓库的密码恰好相同,所以 尚未表现出故障 。但只要给它们分别设置不同密码,就会出现认证混乱 - 客户端拿着 myblog 的密码去连 myCode,认证失败。
6.3 明文密码文件权限过宽
第三个问题在文件权限上:
stat -c '%A %n' /svn/*/conf/passwd
# -rw-r--r-- /svn/myblog/conf/passwd
# -rw-r--r-- /svn/myCode/conf/passwd
# -rw-r--r-- /svn/p2pSpider/conf/passwd
# -rw-r--r-- /svn/videoViewer/conf/passwd644 ,即 所有本地用户都可读 。而 svnserve 的 passwd 文件是 明文存储密码 的(这是它的机制,不是配置问题),所以这个权限意味着本机上任何账号都能读到四个仓库的全部明文 SVN 密码。
顺带看一眼这台机器有哪些可登录账号:
awk -F: '$7 !~ /nologin|false/ {print $1, $7}' /etc/passwd
# root /bin/bash
# mysql /bin/bash
# manticore /bin/bash
# ...确实存在除 root 之外的可登录账号,所以这个权限不是纯理论问题。
6.4 顺带确认没有问题的部分
审计也确认了几处配置是正确、无需改动的:
| 检查项 | 结论 |
|---|---|
匿名访问( anon-access = none ) | 真实生效,读写均被拒 |
| authz 兜底规则 | 四个仓库均有 * = ,未授权用户零权限 |
| 认证读取 | 四个仓库全部通过(r396 / r54 / r225 / r25) |
| HTTP(SVN) 协议入口 | 已彻底关闭( svn co http://... 返回 E160013 ) |
| SVN hook | 无启用项,无被利用执行命令的风险 |
| workcopy 残留 | /svn 下无游离 .svn 目录 |
| Web 配置安全 | config.json 权限 600,登录密码以 scrypt 哈希存储 |
| nginx | 未受影响,且无任何 svn 相关代理配置 |
七、修复与验证
三个问题按"备份 → 修改 → 验证"的流程处理。备份永远放在第一步:
#!/bin/bash
set -u
TS=$(date +%Y%m%d_%H%M%S)
BK="/root/svnfix_backup_$TS"
mkdir -p "$BK"
# 系统配置
cp -a /usr/lib/systemd/system/svnserve.service "$BK/"
cp -a /etc/sysconfig/svnserve "$BK/"
# 各仓库的敏感配置
for d in /svn/*/; do
n=$(basename "$d")
mkdir -p "$BK/$n"
cp -a "$d/conf/svnserve.conf" "$d/conf/passwd" "$d/conf/authz" "$BK/$n/"
done
echo "备份完成: $BK"7.1 修复 svnserve 开机自启
改路径:
sed -i -E 's|^([[:space:]]*OPTIONS[[:space:]]*=).*|\1"-r /svn"|' /etc/sysconfig/svnserve
grep '^OPTIONS' /etc/sysconfig/svnserve
# OPTIONS="-r /svn"补一个容易被忽略的环节: /run/svnserve 的生存期。
单元的 ExecStart 里用了 --pid-file=/run/svnserve/svnserve.pid ,而 /run 是 tmpfs,重启就会被清空 。原来的目录之所以存在,是因为它在运行期被创建过 - 重启之后它会消失, --pid-file 写不进去,服务一样起不来。
这台机器上其实已经有 tmpfiles 配置兜底:
cat /usr/lib/tmpfiles.d/svnserve.conf
# D /run/svnserve 0700 root root -但为了不依赖单一机制,再加一层 systemd 的 drop-in 更稳妥:
mkdir -p /etc/systemd/system/svnserve.service.d
cat > /etc/systemd/system/svnserve.service.d/override.conf <<'EOF'
[Service]
# /run 是 tmpfs,重启会清空,需要 systemd 在每次启动前重建该目录
RuntimeDirectory=svnserve
RuntimeDirectoryMode=0755
# 异常退出时自动拉起
Restart=on-failure
RestartSec=3
EOFRuntimeDirectory=svnserve 让 systemd 在服务启动前自动创建 /run/svnserve ,这正是重启场景所需要的。
交还托管权:
# 杀掉手工启动的旧进程,让出 3690
pkill -x svnserve
sleep 2
pgrep -x svnserve || echo "已清理"
ss -tln | grep ':3690' || echo "端口已释放"
# 由 systemd 接管启动
systemctl daemon-reload
systemctl reset-failed svnserve
systemctl start svnserve验证托管状态:
systemctl is-active svnserve # active
systemctl is-enabled svnserve # enabled
SSVPID=$(pgrep -x svnserve)
cat /proc/$SSVPID/cgroup | grep name=systemd
# 1:name=systemd:/system.slice/svnserve.service <-- 已纳入托管cgroup 从 user.slice/session-*.scope 变成 system.slice/svnserve.service ,说明这次是真正由 systemd 启动的。
7.2 用"模拟重启"做硬验证
systemctl restart 只能证明"现在能起来",不能证明"重启后能起来" - 因为前者 /run/svnserve 已经存在,后者是空目录。
真正的重启测试要重启服务器,代价比较大。这里有一个等价且零成本的办法: 手动删掉 /run/svnserve ,然后再启动服务 。这精确复现了重启后 /run 被清空的状态:
#!/bin/bash
echo "--- 当前 /run/svnserve ---"
ls -ld /run/svnserve
echo "--- 删除,等价于重启后 tmpfs 被清空 ---"
rm -rf /run/svnserve
ls -ld /run/svnserve 2>/dev/null || echo "已删除"
echo "--- 走 systemd 启动路径 ---"
systemctl restart svnserve
sleep 2
echo "is-active: $(systemctl is-active svnserve)"
echo "--- /run/svnserve 是否被自动重建 ---"
ls -ld /run/svnserve 2>/dev/null || echo "!! 未创建"
echo "--- 进程与端口 ---"
pgrep -a svnserve
ss -tlnp | grep ':3690'结果:
--- /run/svnserve 是否被自动重建 ---
drwxr-xr-x 2 root root 60 Sep 23 11:59 /run/svnserve <-- 自动重建成功
--- 进程与端口 ---
32374 /usr/bin/svnserve --daemon --pid-file=/run/svnserve/svnserve.pid -r /svn
LISTEN 0 7 *:3690 *:* users:(("svnserve",pid=32374,fd=3))目录被自动重建、服务正常拉起、端口正常监听,说明 开机自启路径已经打通 。
7.3 修复 realm 与文件权限
realm 改成自身路径:
sed -i -E 's|^([[:space:]]*realm[[:space:]]*=).*|\1 /svn/myCode|' \
/svn/myCode/conf/svnserve.conf文件权限收紧:
chmod 600 /svn/*/conf/passwd
stat -c '%A %n' /svn/*/conf/passwd
# -rw------- /svn/myblog/conf/passwd
# -rw------- /svn/myCode/conf/passwd
# -rw------- /svn/p2pSpider/conf/passwd
# -rw------- /svn/videoViewer/conf/passwd这两项都是即时生效的 - svnserve 在每次连接时读取这些配置,不需要重启服务。
7.4 验证时的另一个坑:解析 passwd 不能图省事
修复之后要验证认证是否正常,得从 passwd 里取出真实密码。第一版我用了很随意的写法:
# 错误:会把注释里的示例条目也匹配进来
PW=$(grep -oE '=[[:space:]]*.*' /svn/myCode/conf/passwd | tail -1)结果四个仓库的认证 全部失败 。原因是 svnserve 的 passwd 模板里带了一大段注释,注释中的示例条目同样包含 = ,被一并匹配了进来。正确的做法是按 [users] 段来解析:
# 正确:只取 [users] 段内的真实条目
extract_pw() {
awk '
/^[[:space:]]*\[users\]/ { inu = 1; next }
/^[[:space:]]*\[/ { inu = 0 }
inu && /^[[:space:]]*[^#[:space:]]/ && /=/ {
sub(/^[^=]*=[[:space:]]*/, "")
sub(/[[:space:]]+$/, "")
print
}
' "$1" | tail -1
}
export HOME=/tmp/svn_verify_$$; rm -rf "$HOME"; mkdir -p "$HOME"
for r in myblog myCode p2pSpider videoViewer; do
PW=$(extract_pw "/svn/$r/conf/passwd")
out=$(svn info --non-interactive --no-auth-cache \
--username <USER> --password "$PW" "svn://127.0.0.1/$r" 2>&1)
if echo "$out" | grep -q '^Revision:'; then
echo "[$r] OK $(echo "$out" | grep '^Revision:')"
else
echo "[$r] FAIL $(echo "$out" | tail -1)"
fi
done
rm -rf "$HOME"改用正确解析后,四个仓库全部通过:
[myblog] OK Revision: 396
[myCode] OK Revision: 54
[p2pSpider] OK Revision: 225
[videoViewer] OK Revision: 257.5 回归检查
改动完成后,对整条链路做一次回归:
| 检查项 | 结果 |
|---|---|
| svnserve(3690) | active + enabled,运行在 system.slice 下 |
| 认证读取四个仓库 | 全部通过 |
| 匿名访问 | 仍被拒(三个入口均 E170001 ) |
| svnweb(37245) | active,各页面 200 |
| 写方法探测 | POST/PUT/DELETE 仍返回 405 |
| nginx(80/443) | 未受影响 |
| httpd | 仍为 inactive + disabled |
八、最终状态
改造完成后的整体形态:
┌─────────────────────────────────────────────────────┐
│ 阿里云 ECS / CentOS 7.9 │
│ │
│ nginx :80 :443 ← 业务,全程未改动 │
│ │
│ svnweb :37245 ← 自研 GitHub 风格界面 │
│ └─ Flask + Pygments + waitress │
│ 直读 file:///svn/<repo>(只读) │
│ │
│ svnserve :3690 ← SVN 协议服务 │
│ └─ systemd 托管,开机自启已修复 │
│ svn://<SERVER_IP>/<repo> 读写提交入口 │
│ │
│ /svn/ myblog myCode p2pSpider videoViewer │
│ passwd 权限 600,realm 各自对应 │
└─────────────────────────────────────────────────────┘职责划分很清晰: Web 界面负责 看,svnserve 负责 写 。浏览代码走网页,提交代码继续走 svn:// ,两条路径互不干扰。
九、回顾
这次改造最有价值的部分,其实不在 把界面做出来 ,而在后面那两个环节。
第一个是 验证 这件事本身的难度。 Web 端只读这件事,如果只看代码,结论是显然的;但真正的结论来自于六项交叉验证 - 尤其是"向每个页面发写方法"这类行为层面的探测。同样地,匿名访问测试差点因为客户端的凭据缓存得出完全相反的结论。这两件事说明, 权限类的问题必须用行为去验证,而不能用配置或代码去推断 。
第二个是 配置过就等于配置对 的陷阱。 svnserve.service 是 enabled 的, /etc/sysconfig/svnserve 里也确实有 -r 参数 - 从任何静态检查的角度看,这台服务器的 SVN 都"配了开机自启"。但真实情况是:参数指向了一个不存在的目录,服务每次都启动失败,而当前运行的是一个不受管控的手工进程。这个隐患在服务器重启之前不会暴露,而重启往往发生在最不方便的时候。
从技术细节上看,绕过的那些坑也各有各的教益: svn CLI 把本地路径理解为工作副本(必须用 file:// URL); svn list --xml 的嵌套结构(用 iter() 而非 findall() );Flask 的 <path:path> 不匹配空串; /run 是 tmpfs 所以 --pid-file 的目录需要重建机制;CentOS 7 的 mod_dav_svn 是独立包;以及 EOL 系统的 yum 源需要整体切换到镜像(而且 SCLo 是另一个独立的仓库)。
最终交付的这套东西,占用的是一个额外端口、一套独立的 venv 和一个 systemd 单元,nginx 从头到尾没有被动过一行配置。这种"新能力挂在旁边、不动在跑的东西"的做法,在维护一台已经承载业务的服务器时,是最省心的路径。






