New article about go-get + nginx
This commit is contained in:
parent
c878b3da7f
commit
ac433560b5
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 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 `<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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue