New article about go-get + nginx
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
parent
c878b3da7f
commit
d01928119a
2 changed files with 353 additions and 0 deletions
176
content/en/post/vanity-url-go-import-nginx/index.md
Normal file
176
content/en/post/vanity-url-go-import-nginx/index.md
Normal 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.
|
||||
177
content/fr/post/vanity-url-go-import-nginx/index.md
Normal file
177
content/fr/post/vanity-url-go-import-nginx/index.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue