hybrids3: du Stockage Objet Compatible S3 Qui n’Exige Pas un Doctorat

Mets nginx devant MinIO sur un préfixe de chemin et essaie d’utiliser des URL présignées. Vas-y, j’attends.
Le problème, c’est AWS Signature V4. La signature couvre le chemin de la requête. nginx sur /storage/ retire ce préfixe avant de transmettre, donc boto3 signe /storage/bucket/key, le serveur voit /bucket/key, le HMAC ne correspond pas, tu prends un 403. Ce n’est pas une erreur de configuration, c’est comme ça que marche le proxy par préfixe de chemin de nginx et comme ça que marche la vérification SigV4. Ils sont structurellement incompatibles, sauf si tu fais le malin avec ta config de proxy ou que tu passes à un serveur qui gère ça autrement.
docker-hybrids3 gère ça autrement. Il a une option de config path_prefix, tu la mets sur /storage et toutes les routes passent nativement sous ce préfixe. Aucun retrait de chemin au niveau du proxy. boto3 signe /storage/bucket/key, nginx transmet /storage/bucket/key, le serveur voit /storage/bucket/key. Les signatures correspondent. C’est aussi pour ça que je fais tourner tout derrière une seule passerelle nginx (aigate fait ça) sans me battre avec la config du proxy.
L’autre truc qui m’a poussé à le construire: quand un agent IA doit écrire un fichier et te rendre une URL, tu n’as pas envie de monter un stockage objet distribué pour ça. Tu veux un truc qui démarre en deux minutes et qui te laisse tranquille. HybridS3 a un serveur MCP intégré, l’agent appelle upload_object, récupère une URL, terminé.

Ce Que C’est

SQLite pour les métadonnées, des fichiers plats sur disque. boto3 fonctionne dessus. Du HTTP tout simple avec curl fonctionne. Auth par bearer token, pas de cérémonie SigV4 pour les requêtes HTTP simples. Trois interfaces, un container, pas de console web, pas d’IAM, pas de mode distribué.
Les buckets sont définis dans la config, pas créés via l’API. Tu sais toujours exactement ce qui existe. Tu veux un bucket? Tu l’ajoutes au YAML, tu redémarres. Tu veux une expiration TTL? Tu mets ttl: 24h sur le bucket et les objets se suppriment tout seuls après leur dernière écriture. Pas de politiques de cycle de vie, pas de tâches cron.

Le Lancer

docker run -d --name hybrids3 
    -p 8080:8080 
    -v ./config.yaml:/config/config.yaml:ro 
    -v hybrids3-data:/data 
    psyb0t/hybrids3

Config dans /config/config.yaml, données dans /data, port 8080. Tourne en UID 1000.

Config

master_key: "change-me-to-something-secret"
master_public_key: "master"
cleanup_interval: 1m
# path_prefix: /storage
buckets:
  uploads:
    public: true
    key: "uploads-secret"
    public_key: "uploads-id"
    ttl: 24h
    max_file_size: 50MB
  permanent:
    public: false
    key: "perm-secret"
    public_key: "permanent-id"
    ttl: 0
    max_file_size: 100MB

Chaque bucket a une clé privée key (jamais transmise, utilisée pour vérifier les HMAC) et une public_key (l’ID de clé d’accès S3, sans danger dans une URL). C’est cette séparation qui fait marcher les URL présignées: la clé publique peut apparaître dans l’URL, la privée jamais.
public: true veut dire que GET, HEAD et LIST ne demandent aucune auth. PUT et DELETE ont toujours besoin d’une clé. public: false veut dire que tout demande une auth.

API HTTP

Simple bearer token dans l’en-tête Authorization:

# upload
curl -X PUT https://ciprian.51k.eu80/uploads/file.txt 
  -H "Authorization: Bearer uploads-secret" 
  -d "hello"
# read from public bucket — no auth
curl https://ciprian.51k.eu80/uploads/file.txt
# read from private bucket
curl https://ciprian.51k.eu80/permanent/doc.pdf 
  -H "Authorization: Bearer perm-secret"
# list objects with prefix filter
curl "https://ciprian.51k.eu80/uploads?prefix=images/&max-keys=50" 
  -H "Authorization: Bearer uploads-secret"

Les requêtes avec un en-tête Authorization AWS Sig V4 reçoivent des réponses XML compatibles S3. Tout le reste reçoit du JSON.

boto3

import boto3
from botocore.config import Config
s3 = boto3.client(
    "s3",
    endpoint_url="https://ciprian.51k.eu80",
    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"),
)
s3.put_object(Bucket="uploads", Key="file.txt", Body=b"hello")
s3.get_object(Bucket="uploads", Key="file.txt")
s3.list_objects_v2(Bucket="uploads", Prefix="images/")

Derrière Nginx

La partie qui compte vraiment. Mets path_prefix: /storage dans la config, puis:

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;
}

Deux choses à ne pas rater: pas de slash final sur proxy_pass (un slash final dit à nginx de retirer le préfixe de la location, ce qui casse SigV4), et $http_host et pas $host ($host retire le port, ce qui provoque des signatures qui ne correspondent plus sur les ports non standard).
Avec path_prefix défini, boto3 pointe sur http://yourdomain/storage et tout fonctionne.

MCP

Un serveur MCP tourne sur /mcp/. Sept outils: upload_object, download_object, delete_object, list_objects, list_buckets, object_info, presign_url. Branche n’importe quel client compatible MCP:

{
  "mcpServers": {
    "hybrids3": {
      "type": "streamable-http",
      "url": "https://ciprian.51k.eu80/mcp/"
    }
  }
}

Auth au niveau de l’endpoint (en-tête Authorization ou paramètre de query ?auth=) ou par outil via le paramètre auth_key. Utilise la clé maître pour un accès complet, une clé de bucket pour limiter la connexion à un seul bucket.

URL Présignées

Pour les buckets privés, ça génère une vraie URL présignée AWS Sig V4 signée avec la clé privée du bucket:

curl -X POST "https://ciprian.51k.eu80/presign/permanent/doc.pdf?expires=3600" 
  -H "Authorization: Bearer perm-secret"

Pour les buckets publics ça renvoie l’URL simple, aucune signature nécessaire puisque GET ne demande de toute façon aucune auth. Plage d’expiration d’une seconde à sept jours, 3600 par défaut. Les URL expirées ou trafiquées renvoient 403.
PUT présigné. Depuis la v0.2.0, ?method=PUT tend à quelqu’un une URL qui lui permet d’uploader une clé précise sans jamais voir ta clé de bucket. Les buckets publics ne sont pas un raccourci ici: les lectures anonymes sont permises, les écritures anonymes jamais, donc un PUT présigné est toujours signé, même sur un bucket public.

# generate an upload URL
curl -X POST "https://ciprian.51k.eu80/presign/uploads/inbox/report.pdf?method=PUT&expires=600" 
  -H "Authorization: Bearer uploads-secret"
# → {"url": "...X-Amz-Signature=...", "method": "PUT", "expires": 600}
# upload with the URL alone — no Authorization header
curl -X PUT "<url>" --data-binary @report.pdf

Une URL présignée est liée à son verbe. Une URL de GET ne peut pas servir à faire un PUT et inversement, la méthode fait partie de la requête canonique que la signature couvre. Et c’est tout l’intérêt: tu peux tendre à un tiers une boîte de dépôt pour exactement une clé, pendant exactement dix minutes, et il ne peut toujours rien lire d’autre ni uploader ailleurs.
L’outil MCP presign_url prend le même paramètre method, donc un agent peut fabriquer des URL d’upload lui aussi.

Le build est verrouillé

La chaîne d’approvisionnement est verrouillée par hash sur uv avec une barrière d’âge à date fixe, donc une dépendance publiée hier ne peut pas se glisser dans le build d’aujourd’hui. Dockerfile de production multi-stage, image de base et uv épinglées par digest plutôt que suivies par tag.
Le Makefile est le point d’entrée unique, et tout ce qui est dev tourne dans un container de dev isolé. Rien ne s’installe sur ton host. 165 tests, 17 unitaires, 148 d’intégration.
Le repo livre aussi un skill d’agent et un plugin OpenClaw sous .agents/, publiés sur ClawHub par la CI aux pushs de tags.

Limitations

Pas d’upload multipart, pas de versionnage d’objets, pas d’ACL au-delà du public/privé au niveau du bucket, pas de création de bucket via l’API, pas d’en-têtes CORS, pas de réplication, pas de chiffrement au repos. S’il te faut quoi que ce soit de tout ça, prends autre chose.
Récupère-le: github.com/psyb0t/docker-hybrids3. Sous licence WTFPL.

L’Installer Dans Ton Agent

Le même skill, maintenant installable sans s’approcher d’OpenClaw. Tout ce qui est sous .agents/ est catalogué dans un seul marketplace, donc ça fait deux commandes:

claude plugin marketplace add psyb0t/agents
claude plugin install hybrids3@psyb0t

Codex utilise le même marketplace avec un verbe différent, codex plugin add hybrids3@psyb0t, parce qu’il n’existe pas de codex plugin install. Il trouve aussi le skill tout seul dans un checkout du repo, puisqu’il scanne .agents/skills/ nativement sans que rien ne soit installé. Il est aussi listé sur le MCP Registry officiel maintenant, donc un client qui résout ses serveurs depuis là peut le trouver sans qu’on lui donne une URL.