New article about go-get + nginx
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
nemunaire 2026-04-23 15:43:59 +07:00
commit d01928119a
2 changed files with 353 additions and 0 deletions

View file

@ -0,0 +1,176 @@
---
title: "Serving custom Go import paths for many modules with nginx"
date: !!timestamp '2026-04-23 10:00:00'
translationKey: vanity-url-go-import-nginx
tags:
- golang
- nginx
- hosting
---
When publishing Go code, the usual practice is to distribute the sources directly from the forge that hosts them: `github.com/org/project`, `framagit.org/org/project`, and so on.
It is convenient, but it durably ties the import path to the chosen provider.
The day you migrate from one forge to another, all your users have to update their imports, and every historical fork keeps pointing at the old address.
Fortunately, the [`go-import`](https://go.dev/ref/mod#vcs-find) mechanism lets you decouple the import path from the actual hosting location of the code.
All it takes is exposing, on a domain of your choosing, an HTML page containing a `<meta name="go-import">` tag that describes where the sources live.
<!-- more -->
This technique has [long been described on Julien Vehent's blog](https://jve.linuxwall.info/blog/index.php?post/2015/08/26/Hosting_Go_code_on_Github_with_custom_import_path), where he shows how to configure HAProxy to serve a static HTML file returning the appropriate tag for a single module.
But as soon as you manage a handful of modules — let alone a whole collection — maintaining one file per module quickly becomes tedious.
The historical approach also fails to handle the module's major versions, introduced [with Go modules](https://go.dev/ref/mod#major-version-suffixes).
My solution was to let `nginx` handle everything, through a single `location` using a regular expression.
## Why use a custom import path
When a user runs `go get git.happydns.org/happydomain`, the Go client first queries the URL `https://git.happydns.org/happydomain?go-get=1` and looks for a tag such as:
```html
<meta name="go-import" content="git.happydns.org/happydomain git https://framagit.org/happyDomain/happydomain.git">
```
This tag tells Go:
1. the prefix that identifies the module;
2. the version control system in use (here `git`);
3. the actual URL to clone the repository from.
As long as you control the `git.happydns.org` domain, it does not matter where the source code actually lives.
You can move from GitHub to GitLab, from GitLab to Codeberg, or even host your own forge: the import paths stay identical, and no user has to change an `import` statement or a `go.mod` file.
Since these platforms may change their terms of service unilaterally, it is always preferable to stay independent and avoid further empowering centralised platforms.
Keeping a hold on the identity of the modules I publish, without having to run the forge that hosts them myself, matters to me.
## Limitations of the per-file HTML approach
The original article suggests serving, for each module, a static HTML file containing the right tag.
It works, but has two drawbacks as soon as you go beyond a single module.
First, every new module or rename implies creating or updating an additional file on the web server.
For the [happyDomain](https://www.happydomain.org/) project, I host around thirty modules (the core, the client SDK, checker libraries, and so on): as many files to keep in sync.
Second, since the arrival of Go modules and their *semantic import versioning* rule, any module whose major version exceeds 1 must be imported with a `/v2`, `/v3`, etc. suffix.
The path `git.happydns.org/happydomain/v2` must then still resolve correctly, returning the same `go-import` tag as the base module.
Serving one HTML file per module *and* per major version quickly becomes unmanageable.
## A generic `location` in `nginx`
Rather than multiplying files, I have `nginx` generate the HTML response directly, from a regular expression that captures both the module name and its optional version suffix.
Here is the configuration running on `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>';
}
}
```
Let us break down what is happening.
## Dispatching requests
The first `location` block redirects any request that does not match anything else to the equivalent URL on Framagit.
In other words, `git.happydns.org/happydomain/-/issues/42` sends the user straight to the right ticket on the forge.
Full consistency is preserved between the canonical address and the underlying forge's interface, without duplicating anything.
The last `location`, finally, is the one we care about: it triggers the generation of the HTML response for the recognised import paths.
## The regular expression
The pattern `^/(golang-sdk|happydomain|happyDomain/happyDeliver|...)(/v[0-9])?$` recognises the full set of modules I distribute.
The explicit enumeration is deliberate: it acts as an allowlist and prevents any unknown path from triggering the generation of a `go-import` tag pointing at a non-existent repository.
Two capture groups are defined:
- `$1` matches the module name;
- `$3` matches the version suffix (`/v2`, `/v3`, ...) when present, or an empty string otherwise. Group `$2` is implicitly used by the inner alternatives of the second `checker-(...)` sub-pattern.
An HTML page is then built inside the [`return 200`](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html#return) directive, with elements that vary depending on the captures.
## The generated response
The returned page contains three useful elements.
The `go-import` tag tells the Go client where to fetch the sources:
```html
<meta name="go-import" content="git.happydns.org/$1$3 git https://framagit.org/happyDomain/$1.git">
```
Note that the announced path includes the version suffix (`$3`) when present, so that `git.happydns.org/happydomain/v2` indeed returns `git.happydns.org/happydomain/v2` as the module identifier.
The repository URL itself does not carry it: Go will resolve the correct branch or tag from the contents of the repository.
A [`meta refresh`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta#http-equiv) directive redirects human browsers to the project's page on Framagit.
This way, when a developer pastes an import path found in a `go.mod` into their browser, they land straight on the repository page instead of facing a blank page.
Finally, a `rel="me"` link associates the domain with the project's Mastodon account, for identity verification on the *fediverse*.
## Testing the result
You can verify that the configuration works with `curl`, passing the `?go-get=1` parameter that Go systematically appends:
```
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">
[...]
```
To make sure the Go client accepts the response, the `-x` option of `go get` (or the `GOFLAGS="-x"` variable) prints in detail all the requests performed during resolution.
{{% card color="info" title="An allowlist, not a catch-all" %}}
It would be tempting to replace the explicit enumeration with a generic pattern such as `^/([a-z0-9-]+)(/v[0-9])?$`.
I prefer to avoid it: a typo in an `import` would produce a `200 OK` response with a `go-import` tag pointing at a non-existent repository, and the resulting error message would be far less explicit than an honest `404`.
{{% /card %}}
{{% card color="danger" title="Beware of HTML injection" %}}
Be especially careful with how tight your regular expression is: the captured content is injected as-is into the body of the HTML response, without any escaping on `nginx`'s side.
A pattern as permissive as `^/(.+)(/v[0-9])?$` would open the door to HTML injection, or even cross-site scripting (XSS), by sneaking code into the URL.
Always restrict the accepted characters to the bare minimum (`[a-zA-Z0-9_/-]` at most), and prefer an allowlist whenever possible.
{{% /card %}}
## Adding a new module
Whenever I add a module to the project, the procedure boils down to two changes in the `nginx` configuration file:
1. add the module name to the regular expression;
2. reload the configuration with `nginx -s reload`.
The module becomes immediately importable under `git.happydns.org/new-module`, and its major version `/v2` works without anything else to add.
---
This technique has been running in production for several years on [git.happydns.org](https://git.happydns.org/) without me ever having to worry about it.
It guarantees that, should I one day decide to leave Framagit for another forge, I only need to update a single URL in my `nginx` configuration for every current and past user of these modules to keep accessing the code, without changing a single line on their side.
A project's identity deserves to be managed independently of the tool that hosts it.
A few lines of `nginx` are enough to give yourself that.

View file

@ -0,0 +1,177 @@
---
title: "Servir des chemins d'import Go personnalisés pour de nombreux modules avec nginx"
date: !!timestamp '2026-04-23 10:00: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.