Le problème de départ #
QFieldCloud auto-hébergé [E05] : l'instance de base, prête pour la production
Le contexte : une instance QFieldCloud a été remontée à neuf, à partir d’un dossier vide. L’idée était de resynchroniser le même projet QGIS dessus et de retrouver ses photos. Ça paraît raisonnable. C’est faux, et comprendre pourquoi est tout l’objet de cette annexe.
Les fichiers qu’on touche #
Aucun. Cette annexe n’écrit pas une ligne dans le dépôt de déploiement : elle lit une base, un .gpkg et un bucket ressuscité. Les deux fichiers qu’elle ouvre — le projet QGIS et le géopackage — appartiennent à la donnée, pas à l’instance, et chaque bloc de code plus bas le rappelle en première ligne.
Ce que QFieldCloud sait, et ce qu’il ignore #
Premier réflexe, et première erreur : chercher les photos dans QFieldCloud. La base de l’ancienne instance connaît onze fichiers pour ce projet :
$ psql -d qfieldcloud_db -tAc "SELECT name FROM filestorage_file ORDER BY name"
38160_38232_acca_entente.gpkg
38160_38232_acca_entente_lizmap_cloud.qgs
38160_38232_acca_entente_lizmap_cloud_attachments.zip
auth/minio-s3.xml
DCIM/9c9e9d77-…-20251014171549468.jpg
fonts/Lato-Black.ttf
…
Une seule photo, dans DCIM/ — celle-là est bien gérée par QFieldCloud. Les quarante-cinq autres n’apparaissent nulle part. Elles vivent dans un bucket séparé, qfieldcloud-attachments, que la base ne mentionne jamais.
Lire un stockage objet dont le serveur est mort #
Le volume de l’ancien stockage est toujours sur le disque. Mais on ne lit pas un bucket avec cat :
$ find …/qfieldcloud-local/…/38160_38232_acca_entente.gpkg/v20260901145800-08094c12 -type f
…/xl.meta
…/5db6150a-…/part.1
…/5db6150a-…/part.2
…
Le format interne découpe chaque objet en parties précédées de métadonnées. Concaténer les part.N produit un fichier de la bonne taille à mille octets près — et une empreinte fausse, donc un GeoPackage illisible. La leçon est simple : on ne lit pas un stockage objet à la main, on lui redonne un serveur.
$ docker run -d --name oldrustfs -v /copie/du/volume:/data \
-e RUSTFS_ACCESS_KEY=… -e RUSTFS_SECRET_KEY=… rustfs/rustfs:1.0.0-beta.11
$ mc alias set old http://127.0.0.1:9000 "$RUSTFS_ACCESS_KEY" "$RUSTFS_SECRET_KEY" --api S3v4
$ mc ls old
[2026-09-01 14:26:19 UTC] 0B qfieldcloud-attachments/
[2026-09-01 14:26:17 UTC] 0B qfieldcloud-local/
$ mc du old/qfieldcloud-attachments
217MiB 43 objects qfieldcloud-attachments
Deux précautions valent d’être notées. On travaille sur une copie du volume, jamais sur l’original — un serveur objet écrit dès qu’il démarre. Et l’image utilisée doit être celle qui a écrit les données : un format interne n’est pas un format d’échange.
Une fois le serveur debout, le GeoPackage se récupère normalement, et l’empreinte le confirme :
$ mc cp old/qfieldcloud-local/projects/1ae2804d-…/files/38160_38232_acca_entente.gpkg/v20260901145800-08094c12 acca.gpkg
$ md5sum acca.gpkg
d2355267b83713e929fe6dea8fa30527 # identique à celle enregistrée en base
Ce qui relie une photo à son objet #
Le projet QGIS, extrait du même bucket, contient la réponse. Le champ photo de la table de liaison est configuré ainsi :
<!-- 38160_38232_acca_entente_lizmap_cloud.qgs — le projet QGIS, pas un fichier de la stack -->
<field name="photo">
<editWidget type="ExternalResource">
<config>
<Option name="StorageType" type="QString" value="AWSS3"/>
<Option name="StorageUrl" type="QString"
value="https://s3-api.example.org/qfieldcloud-attachments/entente_acca_chasse/photos/"/>
<Option name="StorageAuthConfigId" type="QString" value="6lie345"/>
<Option name="RelativeStorage" type="int" value="0"/>
</config>
</editWidget>
</field>RelativeStorage=0 est le détail qui commande tout : la valeur écrite dans la donnée est l’URL absolue. Vérification dans le GeoPackage :
$ sqlite3 acca.gpkg "SELECT * FROM tj_acca_battue_poste_photo LIMIT 2"
1|110e925d-…|https://s3-api.example.org/qfieldcloud-attachments/entente_acca_chasse/photos/IMG_20251010_155429991.jpg
2|d8b4bf3b-…|https://s3-api.example.org/qfieldcloud-attachments/entente_acca_chasse/photos/IMG_20251010_155351187.jpg
Le lien entre un objet et sa photo, c’est une chaîne de caractères dans une table du GeoPackage. Pas une référence QFieldCloud, pas un chemin relatif, pas une pièce jointe : une URL, gravée dans la donnée au moment de la prise de vue.
L’inventaire, et ce qu’il révèle #
lignes en base : 46
fichiers distincts : 45 # une photo référencée deux fois, normal
objets dans le bucket : 43
hôtes distincts : {'s3-api.example.org'}
Trois références n’ont pas d’objet correspondant :
=== références SANS objet ===
IMG_20251102_103748356.jpg
IMG_20251102_103811015.jpg
IMG_20251102_103955553.jpg
Toutes du 2 novembre 2025, à quelques minutes d’intervalle. Elles ne sont dans aucune des deux copies du stockage — ni le volume MinIO d’origine, ni le miroir fait lors de la migration. Elles n’ont jamais atteint le serveur.
Attention : c’est la faiblesse structurelle du stockage externe, et elle mérite d’être connue avant de le choisir. QGIS écrit la référence dans l’attribut, puis envoie le fichier. Si l’envoi échoue — terrain hors réseau, serveur momentanément absent, session fermée trop tôt — la référence survit sans le fichier. Rien ne le signale : ni la couche, ni le projet, ni un journal. On le découvre en ouvrant la fiche, des mois plus tard.
Le contrôle tient en une requête sur le GeoPackage comparée à la liste des objets du bucket, et il devrait être périodique. Concrètement :
# script d'enquête, à lancer sur une COPIE du .gpkg — rien à installer
# les noms de fichiers référencés par la donnée
sqlite3 acca.gpkg \
"SELECT photo FROM tj_acca_battue_poste_photo WHERE photo IS NOT NULL" \
| xargs -n1 basename | sort -u > /tmp/references
# les objets réellement présents dans le bucket
mc ls --recursive old/qfieldcloud-attachments \
| awk '{print $NF}' | xargs -n1 basename | sort -u > /tmp/objets
# ce que la donnée réclame et que le stockage n'a pas
comm -23 /tmp/references /tmp/objetscomm -23 ne garde que les lignes de gauche : les références orphelines. Une sortie vide est le seul résultat acceptable. L’inverse, comm -13, liste les objets que plus aucune donnée ne désigne — moins grave, mais utile avant de facturer du stockage.
Dernier point relevé : le bucket était privé.
$ curl -o /dev/null -w '%{http_code}' http://…/qfieldcloud-attachments/…/IMG_20251010_155429991.jpg
403
$ mc anonymous get old/qfieldcloud-attachments
Access permission for `old/qfieldcloud-attachments` is `private`
Or les URL stockées sont nues, sans signature. L’accès reposait donc entièrement sur la configuration d’authentification QGIS 6lie345, présente sur chaque poste et sur chaque client QField. C’est un point à trancher avant de remettre le service en route, pas après.
Le plan de remise en service #
Deux routes, et elles ne se valent pas.
| Ce qu’il faut faire | Donnée touchée | |
|---|---|---|
| A. Reconstruire l’endpoint | Un bucket au même nom sur le stockage objet actuel, le miroir des 43 objets, et le nom d’hôte historique repointé dessus | aucune |
| B. Réécrire les URL | Modifier les 46 valeurs du GeoPackage et le StorageUrl du projet, puis reverser le projet |
46 lignes et le .qgs |
La route A est la bonne, pour une raison qui dépasse le confort : elle ne touche pas à la donnée de terrain. Une réécriture d’URL en masse sur un GeoPackage synchronisé avec des clients QField, c’est un conflit de synchronisation en puissance sur chaque appareil. Reconstruire un endpoint, c’est une ligne de configuration sur un frontal.
Elle répare aussi le chemin d’écriture : le StorageUrl du projet pointe au même endroit, donc les prochaines photos repartent dans le bon bucket sans qu’on rediffuse quoi que ce soit.
Restait la question de l’authentification. Elle est tranchée — et pas par le confort : Garage n’implémente ni ACL ni bucket policy, et n’a donc aucun équivalent du mc anonymous set download de MinIO. Sur son API S3, toute requête est signée, sans exception. Il le dit lui-même, à qui frappe sans signature :
<Error>
<Code>AccessDenied</Code>
<Message>Forbidden: Garage does not support anonymous access yet</Message>
<Resource>/</Resource>
<Region>garage</Region>
</Error>Cette réponse est aussi le meilleur test de branchement qui soit — on y revient à l’étape 1. Notez au passage la balise <Region> : le serveur annonce spontanément la valeur que la signature devra reprendre. La lecture anonyme existe bien chez Garage, mais elle passe par son endpoint web (port 3902), qui sert un bucket comme un site statique — donc sur des URL sans nom de bucket dans le chemin, incompatibles avec les 46 valeurs déjà gravées dans le GeoPackage.
La cible est celle posée par l’annexe A07 de Perfect Homelab : un bucket qfieldcloud-attachments sur le nœud Garage 10.10.20.21, doté de sa propre clé et d’un quota de 8 Gio. Le chemin complet, du formulaire QGIS jusqu’au dataset ZFS :
graph LR
subgraph "Poste QGIS / client QField"
W["Widget ExternalResource
StorageType AWSS3"]
A["Config d'authentification
6lie345 · méthode AWS S3"]
end
subgraph "Frontal"
T["Traefik
s3-api.example.org:443"]
end
subgraph "LXC 121"
G["Garage · API S3
10.10.20.21:3900"]
B[("Bucket
qfieldcloud-attachments")]
end
W -->|"GET / PUT signé SigV4"| T
A -.->|"signe la requête"| W
T -->|"HTTP · passHostHeader"| G
G --> B
style W fill:#bbdefb,stroke:#1565c0,stroke-width:2px,color:#000
style A fill:#e1bee7,stroke:#6a1b9a,stroke-width:2px,color:#000
style T fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
style G fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style B fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
Quatre étapes, dans cet ordre : l’adresse, la clé, les objets, l’authentification. La dernière ne sert à rien tant que les trois premières ne sont pas en place.
🔌 Étape 1 — rendre son adresse au bucket #
s3-api.example.org est une donnée métier : il est écrit 46 fois dans le GeoPackage. Il ne change pas — c’est sa destination qui change.
Côté frontal, l’ancienne configuration désignait encore les deux services MinIO de la stack d’origine, sur la machine 10.10.20.70 : la console web et l’API. Les deux ont disparu avec le passage au stockage externe de l’annexe A02, et leurs routeurs se suppriment sans regret.
Mais la manœuvre n’est même pas une création : le nœud Garage est déjà publié, sous son propre nom, depuis l’annexe A02 de Perfect Homelab. Ce qui manque n’est pas un service — c’est un nom d’hôte de plus sur un routeur qui existe. Un Host() supplémentaire dans la règle, et l’affaire est close :
# rules/1xx-webgis.yml — configuration dynamique de Traefik (rechargée à chaud)
http:
routers:
qfieldcloud-app:
entryPoints:
- websecure
rule: Host(`qfieldcloud.example.org`) || Host(`qfieldcloud.home.example.org`)
service: qfieldcloud-app
garage-s3-api:
entryPoints:
- websecure
# 1er nom : l'endpoint S3 du homelab, publié avec Garage.
# 2e nom : celui qui est gravé 46 fois dans le GeoPackage.
# Il ne change JAMAIS ; seule sa cible a changé.
rule: Host(`s3-api.homelab.example.org`) || Host(`s3-api.example.org`)
service: garage-s3
# SUPPRIMÉS : qfieldcloud-minio (console 8010) et qfieldcloud-minio-api (8009).
# Le stockage embarqué de la stack n'existe plus depuis l'annexe A02.
services:
qfieldcloud-app:
loadBalancer:
servers:
- url: https://10.10.20.70:8443
garage-s3:
loadBalancer:
passHostHeader: true # impératif : la signature SigV4 porte sur l'en-tête Host
servers:
- url: http://10.10.20.21:3900Que deux noms servent le même endpoint n’est pas un provisoire à nettoyer plus tard. Le premier est le nom du service — celui qu’on donne à un nouveau projet, celui qui suivra Garage s’il déménage. Le second est une donnée : il appartient au GeoPackage autant que les coordonnées des objets, et il vivra aussi longtemps que ce projet. Les confondre, c’est précisément ce qui a produit cette annexe.
La vérification tient en une commande, et la réponse attendue n’est pas un 200 :
$ curl -s https://s3-api.example.org/qfieldcloud-attachments/
<?xml version="1.0" encoding="UTF-8"?><Error><Code>AccessDenied</Code>
<Message>Forbidden: Garage does not support anonymous access yet</Message>
<Resource>/qfieldcloud-attachments/</Resource><Region>garage</Region></Error>
Ce 403 est le meilleur résultat possible : il prouve d’un coup que le DNS résout, que le certificat est bon, que Traefik route, et que c’est bien Garage au bout — les trois autres réponses possibles ne veulent pas dire la même chose du tout. Il valide même un quatrième point, gratuitement : le <Resource> reprend /qfieldcloud-attachments/, donc le nom du bucket a bien été lu dans le chemin. Le path-style est confirmé avant même d’avoir signé quoi que ce soit.
| Réponse | Où chercher |
|---|---|
<Error> XML de Garage, 403 |
rien à chercher : la route est bonne |
404 page not found en texte brut |
Traefik répond mais aucune règle ne matche : le Host() n’a pas été ajouté, ou le fichier n’est pas dans le dossier surveillé |
Un 404 en HTML, une autre signature |
le nom ne tombe pas sur Traefik : c’est le DNS |
502 |
Traefik route, mais n’atteint pas 10.10.20.21:3900 |
passHostHeader: true n’est pas une précaution de style. La signature AWS SigV4 couvre trois en-têtes, dont host — le client signe s3-api.example.org, et Garage recalcule la signature à partir du Host qu’il reçoit. Un frontal qui réécrit cet en-tête vers 10.10.20.21 produit un SignatureDoesNotMatch que rien, dans le message d’erreur, ne rattache au proxy. C’est le défaut de Traefik, mais il se désactive par service : autant l’écrire.
Deux propriétés de la cible rendent ce branchement possible sans rien réécrire d’autre :
- Le path-style est le mode natif. Le
garage.tomlde ce nœud ne définit pas deroot_domaindans[s3_api]— Garage lit donc le nom du bucket dans le chemin, exactement comme l’URL historique…/qfieldcloud-attachments/entente_acca_chasse/photos/…le suppose. Unroot_domaindéfini ferait interpréters3-apicomme un nom de bucket, et toutes les requêtes tomberaient enNoSuchBucket. - TLS s’arrête au frontal. Traefik présente le certificat de
s3-api.example.orgpuis parle à Garage en HTTP clair sur le VLAN interne. Le schéma d’URL n’entre pas dans la signature :httpsdehors,httpdedans, la signature reste valide.
🔑 Étape 2 — la clé, et rien qu’elle #
Le bucket et sa clé existent déjà — ils ont été créés avec la politique de quotas de l’annexe A07 de Perfect Homelab. Il n’y a donc rien à créer, seulement un secret à relire :
# sur la LXC 121, hôte de Garage
[allfab@storage:storage-stack] $ docker exec -ti garage /garage key info --show-secret qfieldcloud-attachments-appSi le bucket n’existe pas encore de votre côté, les quatre commandes tiennent en un bloc :
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket create qfieldcloud-attachments
[allfab@storage:storage-stack] $ docker exec -ti garage /garage key create qfieldcloud-attachments-app
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket allow \
--read --write --owner qfieldcloud-attachments --key qfieldcloud-attachments-app
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket set-quotas qfieldcloud-attachments --max-size 8GiBPuis on relit la fiche du bucket avant d’aller plus loin :
[allfab@storage:storage-stack] $ docker exec -ti garage /garage bucket info qfieldcloud-attachmentsLe bloc KEYS FOR THIS BUCKET doit montrer un RWO en face de la clé. S’il est vide, le bucket et la clé existent chacun de leur côté sans avoir jamais été reliés — c’est le bucket allow qui manque, et c’est une panne parfaitement silencieuse : garage bucket list et garage key list affichent l’un et l’autre exactement ce qu’on attend. Elle ne se révèle qu’au premier accès, sous une forme qui n’aide pas :
$ mc mirror old/qfieldcloud-attachments garage/qfieldcloud-attachments
mc: <ERROR> Unable to list comparison retrying.. Forbidden: Operation is not allowed for this key.
$ mc diff old/qfieldcloud-attachments garage/qfieldcloud-attachments
mc: <ERROR> Unable to stat 'garage/qfieldcloud-attachments'. Access Denied.
Deux messages différents pour la même cause, et aucun des deux ne prononce le mot « permission de bucket ». Le réflexe est d’aller vérifier le secret, puis l’endpoint, puis l’horloge — trois pistes coûteuses avant la bonne. Un bucket info de dix secondes les évite toutes.
📦 Étape 3 — rapatrier les 43 objets #
L’ancienne stack n’a pas été détruite : elle a été mise de côté, avec ses volumes, au moment du déploiement maintenable de l’épisode 3.
# sur la LXC webgis — l'ancienne stack, archivée telle quelle
$ ls /opt/docker/archives/_qfieldcloud-stack/
docker-compose.yml .env migrate/ src/ _src/ volumes/ README.md
$ ls /opt/docker/archives/_qfieldcloud-stack/volumes/rustfs/
qfieldcloud-attachments qfieldcloud-local
# les identifiants de ce stockage n'ont pas bougé : ils sont dans le .env archivé
$ grep OBJECT_STORAGE_ROOT /opt/docker/archives/_qfieldcloud-stack/.env
# on travaille sur une COPIE : un serveur objet écrit dès qu'il démarre
$ cp -a /opt/docker/archives/_qfieldcloud-stack/volumes/rustfs /var/tmp/old-s3
L’arborescence sous ce dossier confirme au passage tout ce que disait la première moitié de l’annexe. Chaque objet y est un répertoire, portant le nom du fichier, qui contient à son tour ses versions et ses métadonnées :
$ ls /opt/docker/archives/_qfieldcloud-stack/volumes/rustfs/qfieldcloud-attachments/entente_acca_chasse/photos/
IMG_20251005_171711819.jpg/ … (42 répertoires IMG_*) 'vca - pics.png'/
Quarante-deux photos, plus un fichier isolé. L’inventaire du début se referme alors exactement : 45 références distinctes − 3 jamais envoyées = 42 objets utilisés, et le quarante-troisième objet du bucket est ce vca - pics.png que plus aucune donnée ne désigne. Les deux écarts relevés à l’aveugle en début d’enquête ont chacun leur nom.
Deux alias mc ensuite, l’ancien stockage et le nouveau, puis un mirror :
mc n’était installé nulle part sur cette machine, et il n’y a aucune raison de l’y installer pour un transfert unique. Une fonction de shell suffit à le prendre en conteneur, avec sa configuration montée pour que les alias survivent au --rm :
$ mkdir -p /var/tmp/mcconf
$ mcx() { docker run --rm --network host -v /var/tmp/mcconf:/root/.mc minio/mc "$@"; }
# l'ancien, remonté sur la copie (voir plus haut)
$ mcx alias set old http://127.0.0.1:9010 'minioadmin' '<MOT_DE_PASSE>' --api S3v4
Added `old` successfully.
# le nouveau, en direct sur le nœud Garage — inutile de passer par le frontal pour un transfert
$ mcx alias set garage http://10.10.20.21:3900 'GK…' '<SECRET_GARAGE>' --api S3v4
Added `garage` successfully.
--network host est ce qui permet à un même conteneur d’atteindre à la fois le 127.0.0.1:9010 du serveur ressuscité et le 10.10.20.21:3900 de Garage. Et si le secret contient des / ou des = — c’est le cas ici — les guillemets simples ne sont pas facultatifs.
Le transfert lui-même tient en une commande et une seconde :
$ mcx mirror --overwrite old/qfieldcloud-attachments garage/qfieldcloud-attachments
`old/…/photos/IMG_20251005_171711819.jpg` -> `garage/…/photos/IMG_20251005_171711819.jpg`
`old/…/photos/IMG_20251010_155351187.jpg` -> `garage/…/photos/IMG_20251010_155351187.jpg`
…
`old/…/photos/vca - pics.png` -> `garage/…/photos/vca - pics.png`
┌────────────┬─────────────┬──────────┬──────────────┐
│ Total │ Transferred │ Duration │ Speed │
│ 217.38 MiB │ 217.38 MiB │ 00m01s │ 154.39 MiB/s │
└────────────┴─────────────┴──────────┴──────────────┘
$ mcx diff old/qfieldcloud-attachments garage/qfieldcloud-attachments
$ mcx du garage/qfieldcloud-attachments
217MiB 43 objects qfieldcloud-attachments
mc mirror recopie les clés à l’identique, préfixe compris : entente_acca_chasse/photos/IMG_20251010_155429991.jpg reste entente_acca_chasse/photos/IMG_20251010_155429991.jpg. C’est précisément ce qui rend les URL du GeoPackage valides sans les toucher. Le mc diff muet et les 43 objets du mc du sont les deux seules sorties acceptables — et les 217 Mio d’origine se retrouvent au mébioctet près.
Reste à repasser le contrôle d’intégrité de la première moitié, cette fois contre le nouveau bucket :
# les noms référencés par la donnée, sur une COPIE du .gpkg
$ sqlite3 acca.gpkg \
"SELECT photo FROM tj_acca_battue_poste_photo WHERE photo IS NOT NULL" \
| xargs -n1 basename | sort -u > /tmp/references
# les objets réellement présents dans Garage
$ mc ls --recursive garage/qfieldcloud-attachments \
| awk '{print $NF}' | xargs -n1 basename | sort -u > /tmp/objets
$ comm -23 /tmp/references /tmp/objetsLe résultat attendu n’est pas une sortie vide : ce sont les trois photos du 2 novembre 2025, celles qui n’ont jamais atteint le serveur d’origine. Aucun miroir ne les fera apparaître, et c’est la preuve que le miroir est complet — il a transporté exactement ce qui existait. Ces trois références restent des liens morts dans la donnée ; les traiter est une décision métier, pas une opération de stockage.
Le contrôle inverse, comm -13, ne doit sortir qu’une seule ligne : le vca - pics.png orphelin. Son nom porte deux espaces — inoffensif ici, mais bon à savoir : la signature SigV4 de QGIS est calculée sur le chemin encodé (vca%20-%20pics.png), et Garage recalcule la sienne sur le chemin brut qu’il reçoit. Les deux se rejoignent, mais c’est le genre de nom qui mérite un essai avant d’être adopté comme convention de nommage sur le terrain.
🔐 Étape 4 — refaire la configuration d’authentification #
Le projet réclame nommément la configuration 6lie345 — c’est écrit dans le .qgs, à côté du StorageUrl. QGIS répond, sur chaque poste : La configuration '6lie345' n'est pas dans la base de données. La tentation est de créer une configuration neuve et de mettre le projet à jour. Ce serait rouvrir la route B par la fenêtre : le .qgs modifié doit être reversé, puis retiré par chaque terminal.
On force l’identifiant à sa valeur d’origine. Dans la fenêtre Authentification, le champ Id est en lecture seule tant qu’on n’a pas cliqué sur le cadenas placé à sa droite ; une fois déverrouillé, il accepte les sept caractères de votre choix.
Les valeurs à saisir :
| Champ | Valeur | Pourquoi |
|---|---|---|
Id |
6lie345 |
celui que le projet réclame — déverrouiller le cadenas pour l’imposer |
| Nom | libre (ALLFABOX-GARAGE-WEBGIS) |
n’intervient jamais dans la signature |
| Méthode | AWS S3 | et surtout pas Basic authentication |
| Ressource | https://s3-api.example.org |
informatif : la méthode AWS S3 ne le lit pas |
| Username | l’identifiant de clé Garage (GK…) |
l’AWS AccessKeyId |
| Password | le secret de la clé | l’AWS SecretAccessKey |
| Region | garage |
le s3_region déclaré par le serveur |
Authorization: Basic … là où S3 attend une signature AWS4-HMAC-SHA256. C’est un piège d’interface : le champ ne s’appelle pas « mot de passe S3 », et le dialogue accepte la saisie sans rien signaler. Le type de la configuration doit être AWS S3 — la méthode dédiée, apparue avec QGIS 3.30.
Le champ Region mérite un mot, parce qu’il est la source d’échec la plus probable. La configuration historique, écrite pour MinIO, y avait mis… le nom du bucket :
<!-- auth/minio-s3.xml — l'ancienne configuration, telle qu'elle était diffusée -->
<AuthMethodConfig uri="https://s3.example.org" name="ALLFABOX-MINIO-WEBGIS"
id="6lie345" method="AWSS3" version="4">
<Config password="…" region="qfieldcloud-attachments" username="…"/>
</AuthMethodConfig>Ça marchait, parce que MinIO ne vérifie pas la région quand aucune ne lui est imposée. Garage, lui, la vérifie : la région entre dans la dérivation de la clé de signature et dans le credential scope de l’en-tête Authorization. Une région fausse produit un rejet dont le message ne parle jamais de région.
region_name documenté à l’annexe A02 pour boto3, rencontré une seconde fois par une autre porte. La valeur est la même des deux côtés — garage, le s3_region du garage.toml — et il n’y a aucune raison de la deviner : elle est écrite dans la configuration du serveur.
Diffuser la configuration aux clients QField #
Sur un terminal, rien de tout cela ne se saisit à la main. QGIS exporte une configuration en XML — Préférences → Options → Authentification, sélectionner la ligne, Utilitaires → exporter les configurations d’authentification sélectionnées, en laissant le mot de passe vide — et QField relit ce fichier depuis le dossier auth de son répertoire applicatif.
Le fichier obtenu est du texte, et il se relit :
<!-- auth/garage-s3.xml — remplace auth/minio-s3.xml, mêmes id et méthode -->
<!DOCTYPE qgis_authentication>
<qgis_authentication>
<configurations>
<AuthMethodConfig uri="https://s3-api.example.org" name="ALLFABOX-GARAGE-WEBGIS"
id="6lie345" method="AWSS3" version="4">
<Config password="LE_SECRET_GARAGE" region="garage" username="GK…"/>
</AuthMethodConfig>
</configurations>
</qgis_authentication>Trois attributs seulement changent par rapport à l’ancien : username, password et region. L’id et la method sont conservés — c’est ce qui permet au projet de retrouver sa configuration sans être modifié.
auth/minio-s3.xml, visible dans l’inventaire du début de cette annexe) — pratique pour la diffusion, mais cela revient à donner la clé du bucket à tout collaborateur du projet. Deux règles : ne le versionnez pas dans un dépôt Git, et remplacez l’ancien fichier plutôt que d’en ajouter un second — un minio-s3.xml oublié à côté d’un garage-s3.xml diffuse indéfiniment un secret périmé.
« Et un secret QFieldCloud, plutôt ? » #
La question vient naturellement, parce que QFieldCloud sait garder des secrets. Elle mérite une réponse nette : non, pas pour ce cas-là, et la raison éclaire le mécanisme.
Les secrets QFieldCloud sont des variables d’environnement et un pg_service.conf, injectés dans le conteneur QGIS pendant l’exécution d’un job, sur le serveur. Ils ne descendent jamais sur un terminal. C’est exactement ce qu’il faut pour une couche PostGIS en offline editing : le serveur ouvre la connexion, le client ne reçoit que le GeoPackage qui en sort.
Ici, le serveur n’ouvre rien. C’est le client qui signe. QGIS calcule l’empreinte du fichier sur le poste, dérive la clé SigV4 sur le poste, et dépose l’objet dans Garage sans que QFieldCloud soit dans la boucle une seule seconde. Un client qui ne détient pas la clé est un client qui ne peut pas écrire — c’est le prix du stockage externe, et il est indissociable de son avantage : les photos ne transitent jamais par l’application.
graph LR
subgraph "Ce que le serveur porte"
S["Secret QFieldCloud
(pg_service, variables)"]
J["Conteneur de job QGIS"]
end
subgraph "Ce que le client porte"
C["Config d'auth 6lie345
clé S3"]
P["PUT signé"]
end
S --> J
C --> P
P --> B[("Bucket
qfieldcloud-attachments")]
J -.->|"n'y touche jamais"| B
style S fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style J fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
style C fill:#e1bee7,stroke:#6a1b9a,stroke-width:2px,color:#000
style P fill:#e1bee7,stroke:#6a1b9a,stroke-width:2px,color:#000
style B fill:#ffe0b2,stroke:#e65100,stroke-width:2px,color:#000
Restent trois leviers, et le premier est le plus utile parce qu’il ne coûte rien.
Une clé par personne, sous le même identifiant. L’Id 6lie345 n’est qu’une clé de recherche dans la base d’authentification locale de chaque appareil : rien n’oblige deux appareils à ranger les mêmes identifiants derrière. Créez une clé Garage par personne ou par terminal, exportez un XML par destinataire, tous portant id="6lie345". Le projet ne bouge pas, et un terminal perdu se révoque seul.
Lecture seule pour qui ne saisit pas. Un bucket allow --read sans --write produit une clé qui affiche les photos et n’en dépose aucune. Les postes de bureau qui consultent n’ont aucune raison de porter une clé d’écriture.
Le fichier XML est un consommable. Il n’est en clair que le temps du transfert : après import, la base QGIS le rechiffre derrière son mot de passe maître. Livrez-le, importez-le, supprimez-le.
✅ Vérifier, et lire les échecs #
En lecture : ouvrir la fiche d’un objet photographié. La photo s’affiche dans le widget, et c’est tout le chemin qui est validé d’un coup — l’URL écrite dans le GeoPackage il y a onze mois, la résolution DNS, le certificat, le routeur Traefik, la signature SigV4, le bucket.
En écriture : attacher une nouvelle photo. QGIS calcule l’empreinte SHA-256 du fichier, la place dans l’en-tête X-Amz-Content-SHA256, signe et envoie un PUT. L’objet apparaît côté serveur :
$ mcx ls --recursive garage/qfieldcloud-attachments | tail -3C’est là que la manœuvre se juge, et le résultat mérite d’être formulé pour ce qu’il est : le projet QGIS n’a pas été modifié, le GeoPackage non plus. Le stockage objet a pourtant changé trois fois de nature — MinIO, RustFS, Garage — et deux fois de machine. Ce qui a bougé, en tout et pour tout : trois attributs dans une configuration d’authentification, et un Host() dans une règle de routage.
Si l’un des deux tests échoue, le diagnostic est court — chaque symptôme n’a qu’une cause plausible :
flowchart TD
S["La photo ne s'affiche pas"] --> C1{"Message QGIS ?"}
C1 -->|"'6lie345' n'est pas
dans la base"| R1["Config absente ou id non forcé
→ recréer avec le cadenas"]
C1 -->|"403 SignatureDoesNotMatch"| R2["Region ≠ garage,
ou Host réécrit par le proxy"]
C1 -->|"400 Unsupported
Authorization Type"| R3["Méthode Basic au lieu d'AWS S3,
ou QGIS < 3.40 (en-tête malformé)"]
C1 -->|"404 NoSuchBucket"| R4["root_domain défini côté Garage
→ vhost-style au lieu de path-style"]
C1 -->|"404 NoSuchKey"| R5["Objet absent : une des 3 photos
jamais envoyées"]
style S fill:#ffcdd2,stroke:#c62828,stroke-width:2px,color:#000
style R1 fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000
style R2 fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000
style R3 fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000
style R4 fill:#fff9c4,stroke:#f9a825,stroke-width:2px,color:#000
style R5 fill:#c8e6c9,stroke:#2e7d32,stroke-width:2px,color:#000
400 Unsupported Authorization Type mérite une note de version. Jusqu’à QGIS 3.38 inclus, la méthode AWS S3 construisait son en-tête Authorization sans l’espace attendu après AWS4-HMAC-SHA256 — un défaut corrigé depuis (issue 58643). Si le poste est resté sur une version ancienne, aucune valeur saisie dans le dialogue ne le sauvera : c’est la version de QGIS qu’il faut reprendre.
Reste le cas des terminaux. La configuration se diffuse par le XML décrit plus haut, mais l’ensemble n’a été validé ici que sur un poste QGIS de bureau : la lecture d’un projet hors-ligne côté QField mérite son propre essai avant d’être annoncée à des utilisateurs de terrain. Le mécanisme est le même, l’environnement ne l’est pas.
Un dernier détail, sans gravité mais bon à connaître : le PUT de QGIS ne pose pas d’en-tête Content-Type. Les objets se retrouvent donc typés en application/octet-stream dans le bucket. Le widget s’en moque — il lit le contenu, pas le type — mais un navigateur pointé directement sur l’URL téléchargera le fichier au lieu de l’afficher.
Ce qu’il faut retenir #
- Un stockage externe QGIS n’a rien à voir avec QFieldCloud. Resynchroniser un projet ne rattache aucune photo : le lien est une URL absolue écrite dans la donnée.
-
RelativeStorage=0est le réglage qui décide de tout. Il grave l’URL dans l’attribut, donc l’adresse du serveur devient une donnée métier. - On ne lit pas un stockage objet à la main. On remonte un serveur de la bonne version sur une copie du volume.
- Une référence peut survivre à son fichier. L’attribut est écrit avant l’envoi ; si l’envoi échoue, plus rien ne le signale. Comparez périodiquement la base et le bucket.
- Réparez l’adresse, pas la donnée. Le nom d’hôte se repointe en trois lignes de configuration Traefik ; réécrire les URL crée un conflit de synchronisation par client.
- L’identifiant de configuration d’authentification se force. Le cadenas du champ
Idtransforme une modification de projet en simple ré-import de configuration — c’est lui qui rend la route A intégralement gratuite. - La région n’est pas décorative. MinIO l’ignorait, Garage la vérifie : elle entre dans la signature SigV4, et doit valoir exactement le
s3_regiondu serveur. - Un nom d’hôte peut être une donnée. Celui-ci vit dans le GeoPackage, pas dans une configuration : il se traite avec les égards qu’on accorde à la donnée, pas à une URL de service.
Image de couverture : Scott Blake sur Unsplash.