nemunai.re/content/fr/post/vanity-url-go-import-nginx/index.md
Pierre-Olivier Mercier ac433560b5
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/cron/woodpecker Pipeline is running
New article about go-get + nginx
2026-04-23 15:54:33 +07:00

177 lines
10 KiB
Markdown

---
title: "Servir des chemins d'import Go personnalisés pour de nombreux modules avec nginx"
date: !!timestamp '2026-04-23 08:44:00'
slug: vanity-url-go-import-nginx
translationKey: vanity-url-go-import-nginx
tags:
- golang
- nginx
- hosting
---
Lorsque l'on publie du code Go, l'habitude est d'en distribuer les sources directement depuis la forge qui l'héberge : `github.com/org/projet`, `framagit.org/org/projet`, etc.
C'est pratique, mais cela lie durablement le chemin d'import au prestataire choisi.
Le jour où l'on migre d'une forge à une autre, tous les utilisateurs doivent modifier leurs imports, et chaque fork historique continue de pointer vers l'ancienne adresse.
Heureusement, le mécanisme [`go-import`](https://go.dev/ref/mod#vcs-find) permet de dissocier le chemin d'import du lieu d'hébergement réel du code.
Il suffit pour cela d'exposer, sur le domaine de son choix, une page HTML contenant une balise `<meta name="go-import">` décrivant où se trouvent les sources.
<!-- more -->
Cette technique est [décrite depuis longtemps sur le blog de Julien Vehent](https://jve.linuxwall.info/blog/index.php?post/2015/08/26/Hosting_Go_code_on_Github_with_custom_import_path), qui montre comment configurer HAProxy pour servir un fichier HTML statique renvoyant la balise adéquate pour un module unique.
Mais dès que l'on gère une poignée de modules, voire une collection entière, maintenir un fichier par module devient vite fastidieux.
Et l'approche historique ne répond pas non plus à la gestion des versions majeures du module, introduite [avec Go modules](https://go.dev/ref/mod#major-version-suffixes).
Ma solution a consisté à tout faire porter par `nginx`, au moyen d'une seule `location` utilisant une expression rationnelle.
## L'intérêt d'un chemin d'import personnalisé
Quand un utilisateur exécute `go get git.happydns.org/happydomain`, le client Go interroge d'abord l'URL `https://git.happydns.org/happydomain?go-get=1` et y cherche une balise :
```html
<meta name="go-import" content="git.happydns.org/happydomain git https://framagit.org/happyDomain/happydomain.git">
```
Cette balise indique à Go :
1. le préfixe qui sert d'identifiant pour ce module ;
2. le type de gestionnaire de versions (ici `git`) ;
3. l'URL réelle où cloner le dépôt.
Tant que l'on contrôle le domaine `git.happydns.org`, peu importe où se trouve réellement le code source.
On peut passer de GitHub à GitLab, de GitLab à Codeberg, ou même héberger sa propre forge : les chemins d'import restent identiques, et aucun utilisateur n'a besoin de modifier ses `import` ou son `go.mod`.
Ces plateformes pouvant changer leur conditions d'utilisation unilatéralement, il vaut toujours mieux être indépendant, et ne pas accroître la puissance de plateforme centralisées...
Il est donc important pour moi de garder la main sur l'identité des modules que je mets à disposition, sans avoir à gérer moi-même la forge qui les héberge.
## Limitations de l'approche par fichiers HTML
L'article initial propose de servir, pour chaque module, un fichier HTML statique contenant la bonne balise.
Cela fonctionne, mais présente deux inconvénients dès que l'on dépasse le module unique.
D'une part, chaque nouveau module ou renommage implique de créer ou mettre à jour un fichier supplémentaire sur le serveur web.
Pour le projet [happyDomain](https://www.happydomain.org/), j'héberge une trentaine de modules (le cœur, le SDK client, des bibliothèques de vérification, etc.) : autant de fichiers à synchroniser.
D'autre part, depuis l'arrivée des modules Go et de leur règle du *semantic import versioning*, tout module dont la version majeure dépasse 1 doit être importé avec un suffixe `/v2`, `/v3`, etc.
Le chemin `git.happydns.org/happydomain/v2` doit alors continuer à résoudre correctement, en renvoyant la même balise `go-import` que le module de base.
Servir un fichier HTML par module *et* par version majeure devient vite ingérable.
## Une `location` générique dans `nginx`
Plutôt que de multiplier les fichiers, je fais générer la réponse HTML directement par `nginx`, à partir d'une expression rationnelle qui capture à la fois le nom du module et son éventuel suffixe de version.
Voici la configuration en place sur `git.happydns.org` :
```
server {
# [...]
location / {
rewrite ^(.*)$ https://framagit.org/happyDomain$1;
}
location ~ ^/(golang-sdk|happydomain|happyDomain/happyDeliver|happydeliver|checker-sdk-go|checker-(caa|dav|delegation|dummy|email-autoconfig|kerberos|ldap|matrix|ns-restrictions|pgp|ping|smtp|ssh|sip|srv|stun-turn|tls|zonemaster))(/v[0-9])?$ {
default_type text/html;
return 200 '<!DOCTYPE html><html><head>
<meta name="go-import" content="git.happydns.org/$1$3 git https://framagit.org/happyDomain/$1.git">
<meta http-equiv="refresh" content="0; url=https://framagit.org/happyDomain/$1">
<link rel="me" href="https://floss.social/@happyDomain">
</head><body>Nothing to see here; <a href="https://framagit.org/happyDomain/$1">move
along</a>.</body></html>';
}
}
```
Décortiquons ce qui se passe.
## Dispatch des requêtes
La première instruction `location` renvoie toute requête qui ne correspond à rien d'autre vers l'URL équivalente sur Framagit.
Autrement dit, `git.happydns.org/happydomain/-/issues/42` renvoie l'utilisateur directement vers le bon ticket de la forge.
On conserve ainsi une cohérence totale entre l'adresse canonique et l'interface de la forge sous-jacente, sans avoir à dupliquer quoi que ce soit.
La dernière `location`, enfin, est celle qui nous intéresse : elle déclenche la génération de la réponse HTML pour les chemins d'import reconnus.
## L'expression rationnelle
Le motif `^/(golang-sdk|happydomain|happyDomain/happyDeliver|...)(/v[0-9])?$` reconnaît l'ensemble des modules que je distribue.
L'énumération explicite est volontaire : elle sert de liste blanche et évite que n'importe quel chemin inconnu déclenche la génération d'une balise `go-import` qui pointerait vers un dépôt inexistant.
Deux groupes de capture sont définis :
- `$1` correspond au nom du module ;
- `$3` correspond au suffixe de version (`/v2`, `/v3`, ...) lorsqu'il est présent, ou à une chaîne vide sinon. Le groupe `$2` est implicitement utilisé par les alternatives internes du second sous-motif `checker-(...)`.
On construit ensuite, dans la directive [`return 200`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return), une page HTML dont les éléments varient en fonction des captures.
## La réponse générée
La page renvoyée contient trois éléments utiles.
La balise `go-import` déclare au client Go où aller chercher les sources :
```html
<meta name="go-import" content="git.happydns.org/$1$3 git https://framagit.org/happyDomain/$1.git">
```
Notez que le chemin annoncé inclut le suffixe de version (`$3`) lorsqu'il existe, de sorte que `git.happydns.org/happydomain/v2` renvoie bien `git.happydns.org/happydomain/v2` comme identifiant de module.
L'URL du dépôt, elle, ne le comporte pas : Go se chargera de résoudre la bonne branche ou le bon tag à partir du contenu du dépôt.
Une directive [`meta refresh`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv) redirige les navigateurs humains vers la page du projet sur Framagit.
Ainsi, lorsqu'un développeur colle dans son navigateur un chemin d'import qu'il a vu dans un `go.mod`, il arrive directement sur la page du dépôt, sans se retrouver face à une page blanche.
Enfin, un lien `rel="me"` associe le domaine au compte Mastodon du projet, pour la vérification d'identité sur le *fediverse*.
## Tester le résultat
On peut vérifier le bon fonctionnement de la configuration avec `curl`, en passant le paramètre `?go-get=1` que Go ajoute systématiquement :
```
42sh$ curl -s 'https://git.happydns.org/happydomain?go-get=1'
<!DOCTYPE html><html><head>
<meta name="go-import" content="git.happydns.org/happydomain git https://framagit.org/happyDomain/happydomain.git">
[...]
42sh$ curl -s 'https://git.happydns.org/happydomain/v2?go-get=1'
<!DOCTYPE html><html><head>
<meta name="go-import" content="git.happydns.org/happydomain/v2 git https://framagit.org/happyDomain/happydomain.git">
[...]
```
Pour s'assurer que le client Go accepte bien la réponse, l'option `-x` de `go get` (ou la variable `GOFLAGS="-x"`) affiche en détail toutes les requêtes effectuées lors de la résolution.
{{% card color="info" title="Une liste blanche, pas un catch-all" %}}
Il serait tentant de remplacer l'énumération explicite par un motif générique du type `^/([a-z0-9-]+)(/v[0-9])?$`.
Je préfère l'éviter : une faute de frappe dans un `import` produirait une réponse `200 OK` avec une balise `go-import` pointant vers un dépôt inexistant, et le message d'erreur renvoyé à l'utilisateur serait beaucoup moins explicite qu'un honnête `404`.
{{% /card %}}
{{% card color="danger" title="Attention à l'injection HTML" %}}
Soyez particulièrement vigilant sur la régularité de votre expression rationnelle : le contenu capturé est injecté tel quel dans le corps de la réponse HTML, sans aucun échappement de la part de `nginx`.
Un motif trop permissif comme `^/(.+)(/v[0-9])?$` ouvrirait la porte à des injections HTML, voire à du cross-site scripting (XSS) en glissant du code dans l'URL.
Restreignez toujours les caractères acceptés au strict nécessaire (`[a-zA-Z0-9_/-]` au plus), et préférez toujours une liste blanche lorsque c'est possible.
{{% /card %}}
## Ajouter un nouveau module
Lorsque j'ajoute un module au projet, la procédure se résume à deux modifications dans le fichier de configuration `nginx` :
1. insérer le nom du module dans l'expression rationnelle ;
2. recharger la configuration avec `nginx -s reload`.
Le module devient immédiatement importable sous `git.happydns.org/nouveau-module`, et la version majeure `/v2` fonctionne sans rien ajouter d'autre.
---
Cette technique est utilisée en production depuis plusieurs années sur [git.happydns.org](https://git.happydns.org/) sans que je ne m'en soucie jamais.
Elle me donne la garantie que, si je décide un jour de quitter Framagit pour une autre forge, il me suffira de modifier une seule URL dans ma configuration `nginx` pour que tous les utilisateurs actuels et passés de ces modules continuent d'accéder au code, sans avoir à modifier la moindre ligne chez eux.
L'identité d'un projet mérite d'être gérée indépendamment de l'outil qui l'héberge.
Quelques lignes d'`nginx` suffisent à se l'offrir.