diff --git a/content/en/post/vanity-url-go-import-nginx/index.md b/content/en/post/vanity-url-go-import-nginx/index.md deleted file mode 100644 index 66760cb..0000000 --- a/content/en/post/vanity-url-go-import-nginx/index.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -title: "Serving custom Go import paths for many modules with nginx" -date: !!timestamp '2026-04-23 08:44: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 deleted file mode 100644 index 482963b..0000000 --- a/content/fr/post/vanity-url-go-import-nginx/index.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -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 `` 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.