SVN Web 界面搭建实战:CentOS 7 下从 svnserve 到 GitHub 风格浏览器

2026-09-23 67 浏览 0 评论 0 字

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.so

mod_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 扩展
Python3.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 url

quote() 这一步不能省 - 仓库里的路径包含中文和空格(比如 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.target
systemctl 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           # 无

把四步串起来,结论就清楚了:

  1. /etc/sysconfig/svnserve 里写的是 -r /var/svn (不存在),且单元是 enabled 状态;
  2. 所以 每次开机,systemd 都会尝试启动并一定失败 ;
  3. 当前之所以能用,是因为有个 手工启动 的进程撑着,但它不在 systemd 管控之下;
  4. 也没有任何 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/passwd

644 ,即 所有本地用户都可读 。而 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
EOF

RuntimeDirectory=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: 25

7.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 从头到尾没有被动过一行配置。这种"新能力挂在旁边、不动在跑的东西"的做法,在维护一台已经承载业务的服务器时,是最省心的路径。


发布评论

发布评论前请先 登录。
0 评论
点赞
收藏

评论列表 0

暂无评论