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

10 KiB

title date slug translationKey tags
Servir des chemins d'import Go personnalisés pour de nombreux modules avec nginx 2026-04-23 08:44:00 vanity-url-go-import-nginx vanity-url-go-import-nginx
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 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.

Cette technique est décrite depuis longtemps sur le blog de Julien Vehent, 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.

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 :

<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, 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, 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 :

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