关于 HybridS3
HybridS3 是面向服务和 AI Agent 的轻量级可自托管对象存储服务。它整合兼容 S3 的接口、HTTP 和 MCP,使用配置文件定义存储桶,并支持对象自动过期。
主要功能
- 01兼容 boto3 的 S3 接口
- 02通过 HTTP 操作对象
- 03由 YAML 配置存储桶
- 04对象 TTL 自动过期
- 05支持预签名 URL
适用场景
- 01供服务存储和读取对象
- 02为 AI Agent 提供结构化存储工具
- 03自动清理临时存储桶内容
MCP 相关
HybridS3 在 `/mcp/` 通过 Streamable HTTP 提供 MCP 服务。MCP 客户端可调用带类型定义的工具操作存储桶,并使用可选的连接认证和单工具访问密钥。
配置
本地服务的配置在各客户端中通用,复制后粘贴到客户端的 MCP 配置文件即可。
工具
本地服务需要在你的电脑上运行后才能读取工具,维护者暂未提供工具列表。
详细介绍
HybridS3
轻量级对象存储,支持 S3(兼容 boto3/AWS SDK)、纯 HTTP 和 MCP。使用 SQLite 存储元数据,磁盘上存储普通文件。
目录
- 为何选择
- 快速开始
- 运行
- 配置
- 密钥与访问
- HTTP API
- S3 接口
- MCP 接口
- Agent 集成
- 预签名 URL
- MIME 类型检测
- TTL 与过期
- 锁定
- 日志记录
- 位于反向代理之后
- 安全
- 测试
- 开发
- 架构
- 限制
- 许可证
为何选择
**大多数可自托管的兼容 S3 存储都面向大规模部署。**分布式纠删码、IAM 策略、WORM 合规、完整的 Web 控制台——如果你在运行云服务,这些都很实用;但如果你只是想找个地方存放文件,供各种服务和 AI Agent 读写,那就太复杂了。
**AWS Sig V4 在反向代理路径前缀后无法正常工作。**大多数实现都会使用完整的原始上游路径验证签名。将它们放在 nginx 的 /storage/ 后面时,nginx 会剥离前缀,服务端看到的是 /bucket/key 而不是 /storage/bucket/key,签名验证就会失败。HybridS3 提供了 path_prefix 配置选项——将其设为 /storage,所有路由都会移至该前缀下。无需剥离路径,也无需特殊代理标头。boto3 签名的路径与服务端看到的路径一致。
**三个接口,一个服务。**boto3 开箱即用。纯 HTTP 配合 curl 即可使用。AI Agent 通过 MCP 连接,并获取结构化的工具定义。无需为不同客户端运行独立服务。
**存储桶是配置,而非状态。**没有创建或删除存储桶的 API。存储桶定义在 YAML 配置文件中。你始终清楚具体有哪些存储桶,配置受版本控制,也不会有意外存储桶不断累积垃圾数据。
**内置 TTL 过期机制。**在存储桶上设置 ttl: 24h,对象就会在最后一次写入后自动过期。无需生命周期策略、cron 作业或独立进程。
**易于阅读和修改。**体量小,一个下午就能看懂。
快速开始
# get the example config
wget -O config.yaml https://raw.githubusercontent.com/psyb0t/docker-hybrids3/master/config.example.yaml
# edit it — set your keys and define your buckets
vi config.yaml
# run
docker run -d --name hybrids3 \
-p 8080:8080 \
-v ./config.yaml:/config/config.yaml:ro \
-v hybrids3-data:/data \
psyb0t/hybrids3
# verify
curl http://localhost:8080/health
运行
容器需要:
- 配置文件位于
/config/config.yaml - 数据目录位于
/data - 端口为
8080
以 UID 1000 运行。
docker run:
docker run -d --name hybrids3 \
-p 8080:8080 \
-v ./config.yaml:/config/config.yaml:ro \
-v hybrids3-data:/data \
psyb0t/hybrids3
docker-compose:
services:
hybrids3:
image: psyb0t/hybrids3
ports:
- "8080:8080"
volumes:
- ./config.yaml:/config/config.yaml:ro
- hybrids3-data:/data
restart: unless-stopped
volumes:
hybrids3-data:
配置
# Cross-bucket god key. Works on every bucket for every operation.
# Only credential that can list all buckets. Keep secret.
master_key: "change-me-to-something-secret"
# Non-secret identifier paired with master_key in S3 auth (aws_access_key_id).
# Safe to share — it grants nothing without the master_key.
master_public_key: "master"
# How often the background loop runs to delete expired objects and orphan files.
# Accepts human-readable durations: 30s, 5m, 1h, or raw seconds: 60
cleanup_interval: 1m
# How long a request may wait to acquire a lock on an object before giving up with 503.
# Accepts seconds as a float. Applies per object key, globally across all buckets.
lock_acquire_timeout: 30
# How long a request may hold a lock before it is forcibly released and the request gets 503.
# Protects against slow or stalled uploads blocking writes on the same key indefinitely.
lock_hold_timeout: 300
# Maximum number of requests that may queue for the same object key at once.
# Once the queue is full, new requests are rejected immediately with 503.
lock_max_waiters: 100
# Serve all routes under this path prefix.
# Set this when running behind a reverse proxy at a subpath (e.g. /storage).
# Accepts "/storage" or "/storage/" — both are normalized.
# Leave empty or omit to serve at the root.
# path_prefix: /storage
# TTL formats: 30s 5m 1h 1h30m12s 1d 2d12h 0 (never expire)
# Size formats: 500B 50KB 10MB 1GB 0 (no limit)
buckets:
uploads:
# true — anyone can read (GET/HEAD/LIST) without authentication
# false — authentication required for all operations
public: true
# Private key for this bucket.
# Used as the Bearer token value and as aws_secret_access_key in S3 auth.
# Never transmitted — only used to sign or verify request signatures.
# Keep secret.
key: "uploads-secret"
# Public identifier for this bucket in S3 auth (aws_access_key_id).
# Also appears in presigned URL Credential= fields.
# Safe to share — it identifies the bucket but grants no access.
# Defaults to the bucket name if not set.
public_key: "uploads-id"
# Objects expire this long after their last write. Overwriting resets the clock.
# 0 means objects never expire.
ttl: 24h
# Maximum upload size. Requests over this limit are rejected with 413.
# 0 means no limit.
max_file_size: 50MB
permanent:
public: false
key: "perm-secret"
public_key: "permanent-id"
ttl: 0
max_file_size: 100MB
密钥与访问
密钥对模型
每个存储桶在配置中都有两个密钥:
| 配置字段 | 作用 | 是否需要保密? |
|---|---|---|
key | 私钥。用于验证 Bearer 请求和签署 S3 签名。不会传输——只在本地用于计算或验证 HMAC。 | 是 |
public_key | 公共标识符。在 S3 身份验证中用作 aws_access_key_id,并显示在预签名 URL 的 Credential= 字段中。单独使用它不会授予任何权限。 | 否——可安全分享 |
这种分离使预签名 URL 能够安全地工作。预签名 URL 必须在 Credential= 字段中嵌入一个标识符,以便服务知道要使用哪个密钥进行验证——该标识符就是 public_key。由于它不是机密信息,将其放在 URL 中没有问题。私钥会在服务端对 URL 签名,且绝不会出现在 URL 中。
主密钥
master_key 是一个跨存储桶凭据,无需单独的存储桶密钥,即可对每个存储桶执行所有操作。以下两种情况需要使用它:
- 你需要通过单个客户端操作多个存储桶
- 你需要列出所有已配置的存储桶——存储桶密钥在
list_buckets中只能看到各自的存储桶
master_public_key 是与 S3 身份验证中的 master_key 配对的非机密标识符(用作 aws_access_key_id)。
不要将主密钥嵌入面向客户端的代码中。请改用各存储桶的密钥——它们只能访问一个存储桶。
存储桶可见性
| 设置 | GET / HEAD / LIST | PUT | DELETE / 预签名 |
|---|---|---|---|
public: true | 无需身份验证 | 存储桶密钥、主密钥或有效的预签名 PUT | 存储桶密钥或主密钥 |
public: false | 存储桶密钥、主密钥或有效的预签名 GET | 存储桶密钥、主密钥或有效的预签名 PUT | 存储桶密钥或主密钥 |
HTTP API
身份验证
HTTP 请求通过 Authorization 标头中的 Bearer 令牌进行身份验证。传入存储桶的私钥 key,或用于跨存储桶操作的主密钥。
Authorization: Bearer <private_key>
- 公共存储桶读取(GET、HEAD、LIST)——无需身份验证。
- 所有写入操作(PUT、DELETE)和对私有存储桶的读取操作——需要存储桶密钥或主密钥。
- 列出存储桶(
GET /)——主密钥可列出所有存储桶;存储桶密钥只能列出自己的存储桶。 - 预签名端点(
POST /presign/...)——需要存储桶密钥或主密钥。
# write to a bucket
curl -X PUT http://localhost:8080/uploads/file.txt \
-H "Authorization: Bearer uploads-secret" \
-d "hello"
# read from a public bucket — no auth needed
curl http://localhost:8080/uploads/file.txt
# read from a private bucket
curl http://localhost:8080/permanent/doc.pdf \
-H "Authorization: Bearer perm-secret"
# list all buckets — master key sees all
curl http://localhost:8080/ \
-H "Authorization: Bearer your-master-key"
# list buckets — bucket key sees only its own bucket
curl http://localhost:8080/ \
-H "Authorization: Bearer uploads-secret"
端点
| 方法 | 路径 | 认证 | 描述 |
|---|---|---|---|
GET | /health | 无 | 返回 {"status":"ok"} |
GET | / | 主密钥或存储桶密钥 | 列出存储桶——主密钥列出全部,存储桶密钥仅列出其对应的存储桶 |
HEAD | /{bucket} | 读取 | 检查存储桶是否存在——返回 200 或 404 |
PUT | /{bucket} | 写入 | S3 兼容空操作:若存储桶存在于配置中则返回 200,否则返回 404 |
GET | /{bucket} | 读取 | 列出存储桶中的对象 |
PUT | /{bucket}/{key} | 写入 | 上传对象 |
GET | /{bucket}/{key} | 读取 | 下载对象 |
HEAD | /{bucket}/{key} | 读取 | 对象元数据——不包含响应正文 |
DELETE | /{bucket}/{key} | 写入 | 删除对象——即使对象不存在也返回 204 |
POST | /presign/{bucket}/{key} | 写入 | 生成预签名 URL(GET 或 PUT,参见 method 查询参数) |
POST | /mcp/ | 按工具 | MCP Streamable HTTP 端点 |
PUT /{bucket} 仅用于兼容 S3 客户端。boto3 会在执行任何操作前发送 create_bucket 调用,该调用会映射到此端点。HybridS3 会将其视为空操作——不会创建或修改任何存储桶。
列出对象时接受 prefix 和 max-keys 查询参数:
curl "http://localhost:8080/uploads?prefix=images/&max-keys=50" \
-H "Authorization: Bearer uploads-secret"
上传会返回 ETag 标头(文件内容的 MD5)。GET 和 HEAD 也会返回 ETag、Last-Modified 和 Content-Length。
对象键
键支持使用 / 表示嵌套路径。写入时会自动创建父目录,删除时会在目录为空后将其清理。
curl -X PUT http://localhost:8080/uploads/reports/2024/january.pdf \
-H "Authorization: Bearer uploads-secret" \
--data-binary @january.pdf
响应格式
包含 AWS Sig V4 Authorization 标头的请求会收到兼容 S3 的 XML 响应。其他所有请求都会收到 JSON 响应。错误响应包含 "error" 字段和 "request_id" 字段。
每个响应都包含 X-Request-Id,用于关联日志,并包含 X-Content-Type-Options: nosniff。
S3 接口
认证
S3 客户端使用 AWS Signature V4 进行认证。客户端使用存储桶的 public_key 作为 aws_access_key_id,并使用存储桶的私有 key 作为 aws_secret_access_key 对每个请求进行签名。生成的 Authorization 标头会在 Credential= 字段中以明文包含访问密钥 ID,并在 Signature= 字段中包含计算得出的 HMAC。私钥不会传输——它仅在本地用于计算签名。
Authorization: AWS4-HMAC-SHA256 Credential=uploads-id/20240101/us-east-1/s3/aws4_request, SignedHeaders=host;x-amz-date, Signature=<hmac>
HybridS3 从 Credential= 中读取访问密钥 ID,找到具有该 public_key 的存储桶,然后使用该存储桶的私钥重新计算预期签名,并将其与标头中的签名进行比较。仅有访问密钥 ID 不会授予任何权限——签名必须匹配。
import boto3
from botocore.config import Config
# per-bucket client — access is limited to the "uploads" bucket
s3 = boto3.client(
"s3",
endpoint_url="http://localhost:8080",
aws_access_key_id="uploads-id", # public_key from config
aws_secret_access_key="uploads-secret", # key from config
region_name="us-east-1",
config=Config(signature_version="s3v4"),
)
# master key client — access to all buckets, can list them
s3_admin = boto3.client(
"s3",
endpoint_url="http://localhost:8080",
aws_access_key_id="master", # master_public_key from config
aws_secret_access_key="your-master-key", # master_key from config
region_name="us-east-1",
config=Config(signature_version="s3v4"),
)
操作
s3.put_object(Bucket="uploads", Key="file.txt", Body=b"hello")
s3.get_object(Bucket="uploads", Key="file.txt")
s3.head_object(Bucket="uploads", Key="file.txt")
s3.delete_object(Bucket="uploads", Key="file.txt")
s3.list_objects_v2(Bucket="uploads", Prefix="images/")
s3_admin.list_buckets() # master key: returns all buckets
s3.list_buckets() # bucket key: returns only the "uploads" bucket
# generate a presigned URL for a private bucket object
url = s3.generate_presigned_url(
"get_object",
Params={"Bucket": "permanent", "Key": "doc.pdf"},
ExpiresIn=3600,
)
# presigned URL someone else can use to upload a single key
put_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "uploads", "Key": "inbox/report.pdf"},
ExpiresIn=600,
)
MCP 接口
MCP 服务使用 Streamable HTTP 传输协议运行于 /mcp/。AI Agent 可通过任何兼容 MCP 的客户端连接,并获得包含类型化输入和输出的结构化工具定义。
连接
{
"mcpServers": {
"hybrids3": {
"type": "streamable-http",
"url": "http://localhost:8080/mcp/"
}
}
}
认证
端点级身份验证
/mcp/ 端点接受可选令牌,用于在调用任何工具之前验证连接。使用主密钥可获得完整访问权限;使用存储桶密钥可将连接限制在该存储桶的范围内。令牌会与主密钥和所有存储桶密钥进行验证。
支持以下两种方式:
Authorization 标头 — 适用于支持自定义标头的客户端(例如 Claude Code):
Authorization: Bearer <master_key_or_bucket_key>
查询参数 — 适用于无法设置自定义标头的客户端(例如 ChatGPT):
http://localhost:8080/mcp/?auth=<master_key_or_bucket_key>
如果提供的令牌与任何已知密钥都不匹配,请求将被拒绝,并返回 401。如果完全未提供令牌,则请求会通过,随后应用各工具的身份验证。
各工具的身份验证
每个操作存储桶的工具都接受一个 auth_key 参数,即存储桶的私有 key 或主密钥。此验证独立于端点级身份验证,并控制每次单独的工具调用可以执行的操作。
# public bucket read — no auth_key
download_object(bucket="uploads", key="file.txt")
# public bucket write — auth_key required
upload_object(bucket="uploads", key="file.txt", content="hello", auth_key="uploads-secret")
# private bucket — auth_key required for all operations
download_object(bucket="permanent", key="doc.pdf", auth_key="perm-secret")
upload_object(bucket="permanent", key="doc.pdf", content="...", auth_key="perm-secret")
# master key lists all buckets; bucket key lists only that bucket
list_buckets(auth_key="your-master-key")
list_buckets(auth_key="uploads-secret") # returns only the uploads bucket
工具
| 工具 | 是否需要身份验证 | 描述 |
|---|---|---|
upload_object | 存储桶密钥或主密钥 | 上传文本或 Base64 编码的二进制数据。如果未指定内容类型,则自动检测。 |
download_object | 存储桶密钥或主密钥(仅限私有存储桶) | 下载对象内容。返回文本或 Base64 编码的二进制数据。超过 50 MB 的对象无法通过 MCP 下载 — 请直接使用 HTTP API。 |
delete_object | 存储桶密钥或主密钥 | 删除对象。 |
list_objects | 存储桶密钥或主密钥(仅限私有存储桶) | 列出对象,可选前缀筛选。默认返回 100 个结果,最多 1000 个。 |
list_buckets | 主密钥或存储桶密钥 | 主密钥可列出所有存储桶。存储桶密钥只能列出对应的存储桶。 |
object_info | 存储桶密钥或主密钥(仅限私有存储桶) | 获取对象元数据(大小、内容类型、ETag、过期时间),而不下载内容。 |
presign_url | 存储桶密钥或主密钥 | 生成可分享的 URL。传入 method="GET"(默认值)或 method="PUT"。对公共存储桶执行 GET 会返回普通 URL;其他情况则会返回带签名的限时 URL。 |
所有工具都会为支持结构化输出的客户端返回结构化输出(structuredContent),并提供纯文本回退。
Agent 集成
技能可用于任何会读取 .agents/skills/ 的 Agent(例如 peen),并且可以原生安装到以下客户端中。
Claude Code
claude plugin marketplace add psyb0t/agents
claude plugin install hybrids3@psyb0t
Claude Code 会提示输入 HybridS3 URL;如果端点要求连接级身份验证,还会提示输入存储桶密钥或主密钥 — 密钥会存储在你的操作系统钥匙串中。
Codex
codex plugin marketplace add psyb0t/agents
codex plugin add hybrids3@psyb0t
通过 marketplace 安装后,该技能会以 $hybrids3:hybrids3 的形式调用。Codex 也会自动发现该技能,无需安装;只要仓库中包含 .agents/skills/,就会以 $hybrids3 的形式直接调用。
OpenClaw
每次发布时,都会将该技能发布到 ClawHub:
openclaw skills install @psyb0t/hybrids3
对于使用本地 stdio 通信的 MCP 客户端,@psyb0t/hybrids3 插件可桥接到服务的 /mcp/ 端点:
openclaw plugins install clawhub:@psyb0t/hybrids3
然后设置 HYBRIDS3_URL(如果你的端点要求连接级身份验证,还需设置 HYBRIDS3_KEY)。
预签名 URL
预签名 URL 允许任何持有链接的人在有限时间内读取或写入特定对象,无需发送 Authorization 标头。/presign/{bucket}/{key} 端点通过 method 查询参数支持两种方法:
method=GET(默认)——接收者可以下载对象。method=PUT——接收者可以上传(覆盖)对象。
预签名 URL 与其 HTTP 方法绑定。GET URL 不能用于 PUT,反之亦然——签名会将方法包含在其规范请求中。
有效期范围:1 秒至 604800 秒(7 天)。默认值:3600。过期或遭篡改的 URL 会返回 403。
预签名 GET
私有存储桶——生成 AWS Sig V4 预签名 URL。服务器使用存储桶的私钥对 URL 签名,私钥不会出现在 URL 中。URL 的 Credential= 字段中包含 public_key,X-Amz-Signature 中包含 HMAC。
# generate via HTTP endpoint
curl -X POST "http://localhost:8080/presign/permanent/doc.pdf?expires=3600" \
-H "Authorization: Bearer perm-secret"
# response
{"url": "http://localhost:8080/permanent/doc.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&X-Amz-Signature=...", "method": "GET", "expires": 3600}
公共存储桶——返回不带签名且没有有效期的普通 URL,因为公共存储桶上的 GET 操作本身无需身份验证。
curl -X POST "http://localhost:8080/presign/uploads/photo.jpg" \
-H "Authorization: Bearer uploads-secret"
# response
{"url": "http://localhost:8080/uploads/photo.jpg", "method": "GET", "expires": null}
预签名 PUT
使用 method=PUT,即可将 URL 交给他人,让对方上传到指定键,而无需看到你的存储桶密钥。公共存储桶在这里也不能走捷径——允许匿名读取,但绝不允许匿名写入,因此即使是公共存储桶,预签名 PUT URL 也始终会经过签名。
# generate a presigned upload URL
curl -X POST "http://localhost:8080/presign/uploads/inbox/report.pdf?method=PUT&expires=600" \
-H "Authorization: Bearer uploads-secret"
# response
{"url": "http://localhost:8080/uploads/inbox/report.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&...&X-Amz-Signature=...", "method": "PUT", "expires": 600}
# upload using only the URL — no Authorization header
curl -X PUT "<url>" --data-binary @report.pdf
上传过程中,服务器会强制执行存储桶的 max_file_size;无论请求通过何种方式进行身份验证,超大请求体都会被拒绝并返回 413。
使用 boto3:
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "uploads", "Key": "inbox/report.pdf"},
ExpiresIn=600,
)
# hand `url` to a client; they PUT their bytes to it
生成预签名 URL 需要存储桶的私钥或主密钥。生成的 URL 只授予对单个键执行单项操作(GET 或 PUT)的权限。
/presign/ HTTP 端点和 presign_url MCP 工具遵循相同逻辑——向 MCP 工具传入 method="PUT" 即可获取上传 URL。
MIME 类型检测
每次上传时都会自动检测内容类型。无需提供 Content-Type 标头。检测使用 libmagic 检查文件内容的前 8 KB;如果 libmagic 返回通用类型,则回退到根据文件名扩展名判断。检测到的类型会存储在元数据中,并在 GET 和 HEAD 响应的 Content-Type 中返回。
若要覆盖自动检测结果,请在上传请求中显式设置 Content-Type。
TTL 和过期时间
在存储桶上设置 ttl 后,对象会在最后一次写入后的指定时长后自动过期。覆盖对象会重置其过期计时。设置 ttl: 0 表示对象永不过期。
buckets:
staging:
ttl: 1h # objects expire 1 hour after last write
后台清理循环每隔 cleanup_interval 秒运行一次,并执行两项任务:
- TTL 过期清理——查找所有
expires_at已过期的对象,并从磁盘和元数据中删除。 - 孤立文件清理——扫描磁盘上每个存储桶的目录,并删除没有对应元数据记录的文件。这用于清理因上传中断而遗留的文件。
每次删除时,都会向上移除空的父目录,直至存储桶根目录。
锁定
每个对象键都有自己的异步读写锁。多个读取者可以同时持有该锁。写入者获得独占访问权,并阻止该键上的所有并发读取者和写入者。锁会在操作开始前获取,并在响应完全发送后释放。
这可以防止并发访问时发生读取数据撕裂、写入内容部分可见和数据损坏。
以下三种情况会导致 503 Service Unavailable:
| 条件 | 触发条件 |
|---|---|
| 过载 | 同一键上排队的请求超过 lock_max_waiters |
| 获取超时 | 请求等待 lock_acquire_timeout 秒仍未获取锁 |
| 持有超时 | 请求持有锁的时间超过 lock_hold_timeout 秒 |
持有超时可防止缓慢或停滞的上传无限期持有写锁。超时触发时,锁会被释放,请求会收到 503。
默认值(获取超时 30s、持有超时 300s、最多等待者 100)适用于大型文件上传。对于高吞吐量的小对象工作负载,可调小这些值,以便更快地减轻负载。
日志记录
以结构化 JSON 格式输出到 stdout,每行一条:
{
"ts": "21:05:33",
"level": "INFO",
"src": "routes:put_object:227",
"rid": "4432cd8a4ac1",
"msg": "PUT",
"bucket": "uploads",
"key": "photo.jpg",
"size": 48231,
"content_type": "image/jpeg"
}
| 字段 | 说明 |
|---|---|
ts | 时间戳(HH:MM:SS) |
level | INFO、WARNING 或 ERROR |
src | module:function:line |
rid | 请求 ID — 与响应头 X-Request-Id 相匹配 |
msg | 事件名称 |
| 其他 | 具体上下文:bucket、key、size、content_type 等 |
请求期间生成的每条日志都会包含请求 ID,便于端到端追踪单个请求。异常日志会包含完整的回溯信息。
在反向代理后运行
在配置中设置 path_prefix,使其与代理位置相匹配。HybridS3 原生支持在该前缀下提供所有路由 — nginx 会原样转发路径,无需移除前缀。由于客户端签名的路径与服务端看到的路径一致,SigV4 签名可以正常工作。
# config.yaml
path_prefix: /storage
# ✅ CORRECT — no URI in proxy_pass, nginx forwards full path as-is
location /storage {
proxy_pass http://hybrids3:8080;
proxy_set_header Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ❌ WRONG — trailing slash in proxy_pass is a URI, nginx strips /storage/
# location /storage/ {
# proxy_pass http://hybrids3:8080/; <-- this "/" is a URI, causes stripping
# }
#
# ❌ WRONG — $host strips the port, SigV4 signs Host: yourdom:4000
# but nginx sends Host: yourdom → signature mismatch
# proxy_set_header Host $host; <-- use $http_host instead
关键在于:proxy_pass http://backend:8080;(末尾没有 /)会转发完整路径。proxy_pass http://backend:8080/;(末尾有 /)会让 nginx 替换 location 前缀 — 这会移除 /storage/,导致 SigV4 失效。
使用此配置:
# boto3
s3 = boto3.client("s3", endpoint_url="http://yourdomain/storage", ...)
# curl
curl http://yourdomain/storage/uploads/file.txt
# MCP
{"url": "http://yourdomain/storage/mcp/"}
设置 path_prefix 后,所有端点都会移至该前缀下,包括健康检查端点(/storage/health)。
安全
- AWS Sig V4 验证 — 对请求头认证和预签名 URL 进行完整的签名验证;过期或遭篡改的预签名 URL 会返回 403
- 恒定时间密钥比较 — 所有凭据检查均使用
hmac.compare_digest,以防止计时攻击 - 私钥隔离 — 私有
key永不会出现在任何响应中、不会嵌入预签名 URL,也不会被记录 - 路径遍历防护 — 在访问磁盘前,使用
Path.relative_to验证所有对象键路径;使用遍历键的上传请求会返回 400 - 防止枚举存储桶 —
GET /需要有效密钥;主密钥可以列出所有存储桶,存储桶密钥只能列出自己的存储桶;未授权访问或访问不存在的存储桶时始终返回 404——调用方无法区分“存储桶不存在”和“无权访问” - 防止 SQLite 前缀注入 — 在
LIKE查询中使用用户提供的prefix值之前,会先对其进行转义 - 原子写入 — 对象先写入临时文件,再重命名到目标位置;读取方不会看到不完整的写入内容
- 流式上传 — 请求正文直接流式写入磁盘,不会将整个正文缓存在内存中
- 孤立文件清理 — 后台扫描会删除磁盘上没有元数据记录的文件
- 每个密钥的读写锁 — 过载或超时的请求会收到 503,而不会无限期排队
- MCP 错误信息屏蔽 — 不会向 MCP 工具调用方暴露内部错误详情
- MCP 下载上限 — 超过 50 MB 的对象无法通过 MCP 下载;请直接使用 HTTP API
- 非 root 容器 — 以 UID 1000 运行
X-Content-Type-Options: nosniff— 每个响应均包含此标头
测试
# build prod image + run unit tests in the dev container + run integration suite
make test
# run a single integration test by name
./test.sh test_s3_presigned_private
# list all available test names
./test.sh --help
# unit tests only, inside the dev container
make test-unit
所有测试输出都会自动通过 tee 写入 test.log。
集成测试涵盖:HTTP API、S3/boto3 兼容性、MCP 工具、Bearer 认证、AWS Sig V4 认证、预签名 URL(HTTP 端点和 S3 SDK,GET 和 PUT)、主密钥、跨存储桶密钥隔离、TTL 过期、孤立文件清理、MIME 检测、大小限制、路径遍历尝试、二进制文件、并发读写、单元测试和 HTTP 层面的读写锁行为、请求 ID 以及安全标头。
开发
所有开发工具都在沙盒化的开发容器中运行,以保持宿主机整洁并限制供应链风险的影响范围。宿主机只需安装 docker、make、git 和 shell——无需 Python 解释器、pip、uv,也无需在全局安装项目依赖。
# one-time: build the dev container (also rebuilds when uv.lock changes)
make dev-image
# drop into a shell with the full dev env on PATH
make shell
# lint + format + unit tests — all run in the dev container
make lint
make format
make test-unit
依赖管理
项目使用 uv,并采用哈希锁定的 uv.lock(已提交)和供应链时效门限(pyproject.toml 中的 [tool.uv] exclude-newer),拒绝安装在固定日期之后发布的任何软件包版本。每次变更依赖项时,都会在同一提交中将该日期更新为3 天前,然后重新锁定依赖。
| 目标 | 功能 | 是否更新时效门限? |
|---|---|---|
make pkg-lock | 根据当前门限刷新 uv.lock | 否 |
make pkg-add PKG=name[==ver] | 添加软件包 | 是 |
make pkg-remove PKG=name | 移除软件包 | 是 |
make pkg-update PKG=name | 升级一个软件包 | 是 |
make pkg-upgrade | 升级所有软件包 | 是 |
切勿直接运行 uv——所有受支持的变更都有对应的 Make 目标,可确保门限保持固定。
生产镜像
make build 会生成生产镜像。Dockerfile 采用多阶段构建,通过 @sha256 摘要固定 Python 基础镜像和 uv,使用 uv sync --frozen --no-dev 安装依赖(经锁文件验证,不含开发依赖),并且只将生成的 venv 和应用源代码复制到运行时阶段。本项目中没有任何地方调用 pip。
架构
app/
main.py FastAPI app, lifespan management, request ID middleware, MCP mount
config.py YAML config loader — TTL durations, sizes, key pairs, lock settings
routes.py HTTP and S3 endpoint handlers, presign endpoint
lock.py Per-key async read-write lock with acquire/hold timeouts and overload cap
mcp_server.py MCP tool definitions — Streamable HTTP at /mcp/
auth.py Bearer token and AWS Sig V4 authentication (header and presigned)
sigv4.py AWS Signature V4 signing and verification
db.py SQLite metadata store — WAL mode, objects table with TTL tracking
storage.py File I/O, path validation, atomic writes, orphan detection
mimetype.py Content type detection — libmagic content sniff with extension fallback
s3xml.py S3 XML response builders for list, error, and location responses
logger.py JSON structured logging with per-request ID propagation via contextvars
scheduler.py Background loop for TTL expiry and orphan file cleanup
限制
- 不支持分段上传(S3 的分块上传协议)
- 不支持对象版本控制
- 除存储桶级别的公开/私有设置外,不支持 ACL
- 不支持通过 API 创建或删除存储桶——存储桶在配置中定义
- 不提供 CORS 标头——如有需要,请在反向代理层添加
- 不支持复制
- 不支持静态数据加密
License
评论
一个开放协议,规定了 AI 应用如何以统一方式连接外部工具与数据源。客户端负责与模型对话,MCP 服务负责提供工具、资源和提示模板,两者通过标准消息通信,同一个服务可以被不同客户端复用。