diff --git a/content/en/post/vanity-url-go-import-nginx/index.md b/content/en/post/vanity-url-go-import-nginx/index.md new file mode 100644 index 0000000..965a6a6 --- /dev/null +++ b/content/en/post/vanity-url-go-import-nginx/index.md @@ -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 `` tag that describes where the sources live. + + + +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 + +``` + +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 '
+ + + + Nothing to see here; move + along.'; + } +} +``` + +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 + +``` + +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' + + + [...] + +42sh$ curl -s 'https://git.happydns.org/happydomain/v2?go-get=1' + + + [...] +``` + +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. diff --git a/content/fr/post/vanity-url-go-import-nginx/index.md b/content/fr/post/vanity-url-go-import-nginx/index.md new file mode 100644 index 0000000..20ab55e --- /dev/null +++ b/content/fr/post/vanity-url-go-import-nginx/index.md @@ -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 `` décrivant où se trouvent les sources. + + + +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 + +``` + +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 ' + + + + Nothing to see here; move + along.'; + } +} +``` + +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 + +``` + +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' + + + [...] + +42sh$ curl -s 'https://git.happydns.org/happydomain/v2?go-get=1' + + + [...] +``` + +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.