merge go-vanity-imports: go get-able repos under a configured baseurl
commit 199e3974a0507f1c1d538467ceb969812a120ec9 · parent ef8eddc0ca · parent ff73f22ec0
· browse this tree
Ximianth, 2026-10-27 17:09:03 UTC
A configured 'baseurl' (canonical outside address) turns on answers to the go toolchain's ?go-get=1 probes -- go-import plus go-source meta tags -- and becomes the displayed clone URL. Unset, all of it stays inert: fail closed, no import-path claims by accident.
diff --git a/README.md b/README.md index cedc064..7daf147 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ socle takes exactly one config file, via the required `-config` flag The format is YAML: ```yaml +baseurl: https://code.example repos: parnass: path: "%" @@ -28,6 +29,12 @@ repos: description: "deploy scripts" ``` +- `baseurl` (optional) is the canonical outside address of this socle: + an absolute http(s) URL naming only a host. + When set, it becomes the displayed clone URL + (otherwise that's derived from each request, which a rewriting proxy can fool) + and enables serving Go vanity imports (see Go modules below). + - `repos` maps URL names to local git repositories. A name may contain slashes, so the URL space can have depth. Name segments are limited to `[A-Za-z0-9._-]`, with no leading dot or dash, @@ -84,6 +91,25 @@ a trailing `.git` on the URL also works, since git clients and habits both produ There is no push: socle never runs anything but read-only git subcommands, and a push attempt is refused explicitly. +Go modules +---------- + +With `baseurl` configured, repos are `go get`-able under their socle URLs as vanity import paths: + +``` +go get code.example/name +``` + +socle answers the toolchain's `?go-get=1` probes with the `go-import` meta tag +(pointing at its own clone URL, so the fetch is the ordinary smart-HTTP transport above) +and a `go-source` tag, so pkg.go.dev links source views back to socle's tree pages. +The repo's `go.mod` must of course declare the matching module path, `code.example/name`. + +Without `baseurl` this is all inert: an import prefix is a public claim +that ends up in module caches and trackers, so socle only makes it on purpose. +Note the go toolchain requires https on the probe (barring `GOINSECURE`), +so in practice this means socle behind a TLS-terminating proxy. + Rendering and theming --------------------- @@ -102,7 +128,8 @@ Known limits - Branch and tag names containing `/` aren't addressable in URLs (they still work by sha), and the accepted ref charset is narrower than git's own rules. - The log page shows the newest 100 commits; there is no pagination past that. -- The clone URL on a repo's home page reflects the scheme and host of the direct request; +- Without `baseurl` configured, the clone URL on a repo's home page reflects + the scheme and host of the direct request; behind a rewriting or TLS-terminating proxy it would be wrong. Deployment diff --git a/config.go b/config.go index 11e9040..7422224 100644 --- a/config.go +++ b/config.go @@ -23,6 +23,13 @@ type repoConfig struct { // config is the top-level schema of the socle config file. type config struct { + // BaseURL is the canonical outside address of this socle, e.g. "https://code.example": + // scheme and host only, since socle's URL space is rooted at "/". + // When set, it becomes the displayed clone URL and enables the go-get vanity-import + // answers (see serveGoGetMeta). When empty, clone URLs derive from each request + // and go-get is deliberately not served: socle should never claim an import path + // for a domain nobody chose. + BaseURL string `yaml:"baseurl"` // Repos maps URL names (which may contain slashes) to the repositories they serve. Repos map[string]repoConfig `yaml:"repos"` } @@ -47,6 +54,13 @@ func loadConfig(path string) (*config, error) { return nil, fmt.Errorf("config %s: %w", path, err) } + if cfg.BaseURL != "" { + normalized, err := validateBaseURL(cfg.BaseURL) + if err != nil { + return nil, fmt.Errorf("config %s: %w", path, err) + } + cfg.BaseURL = normalized + } if len(cfg.Repos) == 0 { return nil, fmt.Errorf("config %s: no repos configured; a socle with nothing on display is a misconfiguration", path) } diff --git a/config_test.go b/config_test.go index 7255cfd..cca67d5 100644 --- a/config_test.go +++ b/config_test.go @@ -66,6 +66,9 @@ func describeLoad(cfgPath, base string) string { return "error: " + anonymize(err.Error()) + "\n" } var sb strings.Builder + if cfg.BaseURL != "" { + fmt.Fprintf(&sb, "baseurl=%s\n", cfg.BaseURL) + } for _, name := range sortedNames(cfg.Repos) { rc := cfg.Repos[name] fmt.Fprintf(&sb, "repo %s: path=%s", name, anonymize(rc.Path)) diff --git a/lang_test.go b/lang_test.go index 07ef146..d5803e8 100644 --- a/lang_test.go +++ b/lang_test.go @@ -120,7 +120,7 @@ func TestLangCacheBounds(t *testing.T) { func TestLangStatsCached(t *testing.T) { t.Parallel() s := fixtureServer(t) - rp := (*s.repos.Load())["fixturama"] + rp := s.snap.Load().repos["fixturama"] planted := []langStat{{"Ada", 42}} s.langs.put(langCacheKey(rp.name, fixtureShaMain), planted) got, err := s.langStats(rp, fixtureShaMain) diff --git a/main.go b/main.go index bf0cd42..94d6528 100644 --- a/main.go +++ b/main.go @@ -37,7 +37,7 @@ func main() { for _, w := range warnings { log.Print("socle: ", w) } - srv := newServer(repos, display.NewTheme(seed)) + srv := newServer(repos, cfg.BaseURL, display.NewTheme(seed)) // Config changes apply live: reloads are fail-stable // (any problem logs loudly and the old repo set keeps serving), @@ -69,6 +69,6 @@ func reloadConfig(srv *server, path string) { log.Printf("socle: config reload REJECTED, keeping previous repos: no configured repo could be opened") return } - srv.setRepos(repos) + srv.publish(repos, cfg.BaseURL) log.Printf("socle: config reloaded: %d repos", len(repos)) } diff --git a/pages.go b/pages.go index 10889eb..722ba75 100644 --- a/pages.go +++ b/pages.go @@ -209,15 +209,21 @@ func langSegments(stats []langStat) []langSeg { return segs } -func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo) { - scheme := "http" - if r.TLS != nil { - scheme = "https" +func (s *server) serveHome(w http.ResponseWriter, r *http.Request, rp *repo, baseURL string) { + // The clone URL: the configured canonical address when there is one, + // else derived from the request (right whenever no rewriting proxy intervenes). + cloneURL := baseURL + "/" + rp.name + if baseURL == "" { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + cloneURL = scheme + "://" + r.Host + "/" + rp.name } db := rp.defaultBranch() d := homeData{ Description: rp.description, - CloneURL: scheme + "://" + r.Host + "/" + rp.name, + CloneURL: cloneURL, Ref: db, } branches, tags, err := rp.refs() diff --git a/serve.go b/serve.go index dcf0d3f..5a23982 100644 --- a/serve.go +++ b/serve.go @@ -13,45 +13,55 @@ import ( "vitrine/display" ) +// snapshot is the server's immutable view of one loaded config: +// the opened repo set, plus the canonical base URL (empty when unconfigured; +// see config.BaseURL for what each state means). +// The two travel together so a reload can never pair old repos with a new address. +type snapshot struct { + repos map[string]*repo + baseURL string +} + // server serves the opened repos over HTTP: browse pages, raw blobs, and smart-HTTP fetch. -// The repo set lives behind an atomic pointer so a config reload can swap the whole set +// The config snapshot lives behind an atomic pointer so a reload can swap the whole thing // at once while requests read it lock-free: each request loads the pointer exactly once // (at the top of ServeHTTP) and works with that immutable snapshot for its whole duration. -// A published map is never mutated. +// A published snapshot is never mutated. type server struct { - repos atomic.Pointer[map[string]*repo] + snap atomic.Pointer[snapshot] theme display.Theme // fixed for the process lifetime; see display/theme.go sketch map[string][]byte // the generated sketch SVGs, by filename; see sketch.go style []byte // the assembled /-/style.css body; see buildStyle langs *langCache // language composition per (repo, sha); see lang.go } -func newServer(repos map[string]*repo, th display.Theme) *server { +func newServer(repos map[string]*repo, baseURL string, th display.Theme) *server { s := &server{ theme: th, sketch: sketchAssets(th.Name), style: buildStyle(th), langs: newLangCache(langCacheCap), } - s.setRepos(repos) + s.publish(repos, baseURL) return s } -// setRepos publishes a new repo set atomically; in-flight requests keep the one they loaded. +// publish swaps in a new config snapshot atomically; in-flight requests keep the one they loaded. // The old set needs no teardown: a repo holds only strings (git runs as a subprocess per // operation), so the garbage collector reclaims it once the last such request drops it. // The language-stats cache deliberately survives the swap: it is keyed (repo-name, commit-sha) // and a sha content-addresses the commit's tree, so an entry can be orphaned by a reload // but never wrong, and the cache's cap already bounds orphans. -func (s *server) setRepos(repos map[string]*repo) { - s.repos.Store(&repos) +func (s *server) publish(repos map[string]*repo, baseURL string) { + s.snap.Store(&snapshot{repos: repos, baseURL: baseURL}) } // ServeHTTP dispatches: the two server-wide paths first, then longest-prefix repo matching // (matchRepo), then the first path segment after the repo name selects the verb. func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { p := strings.TrimPrefix(r.URL.Path, "/") - repos := *s.repos.Load() // this request's snapshot; a concurrent reload cannot alter it + snap := s.snap.Load() // this request's snapshot; a concurrent reload cannot alter it + repos := snap.repos // Fetch RPC is the only POST in the whole URL space; everything else is GET (or HEAD). isRPC := false @@ -85,6 +95,16 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) return } + // The go toolchain's vanity-import probe outranks all other dispatch under a repo: + // go queries the full package path (so this must answer on subpaths, not just the home), + // and it must run before the trailing-slash redirect below, which would drop the query. + // Gated on a configured baseurl: the import prefix is a public claim that ends up in + // module caches and trackers, so socle only makes it on purpose -- without a baseurl + // the probe is inert and the URL means whatever it meant before. + if snap.baseURL != "" && r.URL.Query().Get("go-get") == "1" { + serveGoGetMeta(w, rp, snap.baseURL) + return + } if rest == "" { // Canonicalize the repo home to a trailing slash, so relative URLs in the // rendered README resolve under the repo's own prefix. @@ -92,7 +112,7 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, r.URL.EscapedPath()+"/", http.StatusMovedPermanently) return } - s.serveHome(w, r, rp) + s.serveHome(w, r, rp, snap.baseURL) return } @@ -115,6 +135,25 @@ func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } +// serveGoGetMeta answers the vanity-import probe: a minimal page whose go-import meta tag +// maps the import prefix onto this repo's clone URL, plus a go-source tag teaching +// pkg.go.dev to deep-link into socle's own tree pages. +// (The go-source file template carries no #L{line} fragment: file views have no line +// anchors, and an anchor that lands nowhere would be a small lie.) +// Written with Fprintf, no templating or escaping: both inputs are config-validated into +// HTML-inert charsets (baseURL to scheme://host, repo names to segments of [A-Za-z0-9._-]). +func serveGoGetMeta(w http.ResponseWriter, rp *repo, baseURL string) { + prefix := strings.TrimPrefix(strings.TrimPrefix(baseURL, "https://"), "http://") + "/" + rp.name + repoURL := baseURL + "/" + rp.name + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, `<!DOCTYPE html> +<html><head> +<meta name="go-import" content="%s git %s"> +<meta name="go-source" content="%s %s/ %s/tree/HEAD{/dir} %s/tree/HEAD{/dir}/{file}"> +</head><body><a href="/%s/">%s</a></body></html> +`, prefix, repoURL, prefix, repoURL, repoURL, repoURL, rp.name, prefix) +} + // httpError maps a plumbing-layer error onto an HTTP response: // errInvalid is the client's fault (400), errNotFound speaks for itself (404), // and anything else is internal (500) -- logged in full, reported generically. diff --git a/serve_test.go b/serve_test.go index 852927d..ea7c364 100644 --- a/serve_test.go +++ b/serve_test.go @@ -17,7 +17,7 @@ import ( // with a pinned theme seed so output stays deterministic. func fixtureServer(t *testing.T) *server { t.Helper() - return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, display.NewTheme("test")) + return newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "", display.NewTheme("test")) } // do runs one request through the handler and returns the recorded response. @@ -208,7 +208,7 @@ func TestFileViewModes(t *testing.T) { if err != nil { t.Fatal(err) } - s := newServer(map[string]*repo{"media": rp}, display.NewTheme("test")) + s := newServer(map[string]*repo{"media": rp}, "", display.NewTheme("test")) for _, c := range []struct { target string want string @@ -252,7 +252,7 @@ func TestCommitPageShapes(t *testing.T) { if err != nil { t.Fatal(err) } - s := newServer(map[string]*repo{"merger": rp}, display.NewTheme("test")) + s := newServer(map[string]*repo{"merger": rp}, "", display.NewTheme("test")) rec := do(s, "/merger/commit/main", nil, nil) if rec.Code != 200 { t.Fatalf("status = %d", rec.Code) @@ -272,3 +272,50 @@ func TestCommitPageShapes(t *testing.T) { t.Errorf("merge commit shows %d parent links, want 2", got) } } + +// TestGoGetMeta covers the vanity-import answers: with a configured baseurl, any GET +// under a repo carrying ?go-get=1 gets the meta-tag page (exact content asserted, +// since the go toolchain parses it); without one, the probe is inert -- fail closed, +// so socle never claims an import path for a domain nobody chose. +func TestGoGetMeta(t *testing.T) { + t.Parallel() + wantImport := `<meta name="go-import" content="code.example/fixturama git https://code.example/fixturama">` + wantSource := `<meta name="go-source" content="code.example/fixturama https://code.example/fixturama/ https://code.example/fixturama/tree/HEAD{/dir} https://code.example/fixturama/tree/HEAD{/dir}/{file}">` + + s := newServer(map[string]*repo{"fixturama": fixtureRepo(t)}, "https://code.example", display.NewTheme("test")) + for _, target := range []string{ + "/fixturama?go-get=1", // the slashless home: no redirect detour (it would drop the query) + "/fixturama/?go-get=1", // the home itself + "/fixturama/sub/pkg?go-get=1", // go queries the full package path, not just the module root + "/fixturama/tree/main?go-get=1", // even paths that mean something else to socle answer the probe + } { + rec := do(s, target, nil, nil) + if rec.Code != 200 { + t.Fatalf("GET %s: status %d, want 200", target, rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, wantImport) { + t.Errorf("GET %s: body lacks the go-import tag\nwant: %s\ngot body:\n%s", target, wantImport, body) + } + if !strings.Contains(body, wantSource) { + t.Errorf("GET %s: body lacks the go-source tag\nwant: %s", target, wantSource) + } + } + // The configured address also becomes the displayed clone URL. + if body := do(s, "/fixturama/", nil, nil).Body.String(); !strings.Contains(body, "git clone https://code.example/fixturama") { + t.Errorf("home page clone line does not use the configured baseurl") + } + + // Without a baseurl: the probe must change nothing. + bare := fixtureServer(t) + if rec := do(bare, "/fixturama?go-get=1", nil, nil); rec.Code != 301 { + t.Errorf("no baseurl: slashless home with go-get=1: status %d, want the usual 301", rec.Code) + } + rec := do(bare, "/fixturama/?go-get=1", nil, nil) + if rec.Code != 200 { + t.Fatalf("no baseurl: home with go-get=1: status %d, want 200", rec.Code) + } + if body := rec.Body.String(); strings.Contains(body, "go-import") { + t.Errorf("no baseurl: home page must not carry a go-import tag") + } +} diff --git a/testdata/config.md b/testdata/config.md index 9210cfc..3eac772 100644 --- a/testdata/config.md +++ b/testdata/config.md @@ -61,6 +61,87 @@ repo tidy: path=$BASE/sub/dir ``` +The base URL +------------ + +`baseurl` names the canonical outside address of this socle: +it becomes the displayed clone URL and enables the go-get vanity-import answers. +It is optional; without it, clone URLs derive from each request and go-get is not served. + +[testmark]:# (good-baseurl/config.yaml) +```yaml +baseurl: https://code.example +repos: + parnass: + path: "%" +``` + +[testmark]:# (good-baseurl/result) +```text +baseurl=https://code.example +repo parnass: path=$BASE/parnass +``` + +One trailing slash is tolerated and trimmed. + +[testmark]:# (good-baseurl-trailing-slash/config.yaml) +```yaml +baseurl: https://code.example/ +repos: + parnass: + path: "%" +``` + +[testmark]:# (good-baseurl-trailing-slash/result) +```text +baseurl=https://code.example +repo parnass: path=$BASE/parnass +``` + +It must be an absolute http(s) URL naming only a host: +no bare hostnames, no other schemes, and no path -- +socle's URL space is rooted at `/`, so a path prefix would promise routing it can't deliver. + +[testmark]:# (err-baseurl-relative/config.yaml) +```yaml +baseurl: code.example +repos: + parnass: + path: "%" +``` + +[testmark]:# (err-baseurl-relative/result) +```text +error: config $BASE/config.yaml: baseurl "code.example": must be an absolute URL with scheme http or https +``` + +[testmark]:# (err-baseurl-scheme/config.yaml) +```yaml +baseurl: ssh://code.example +repos: + parnass: + path: "%" +``` + +[testmark]:# (err-baseurl-scheme/result) +```text +error: config $BASE/config.yaml: baseurl "ssh://code.example": must be an absolute URL with scheme http or https +``` + +[testmark]:# (err-baseurl-path/config.yaml) +```yaml +baseurl: https://code.example/git +repos: + parnass: + path: "%" +``` + +[testmark]:# (err-baseurl-path/result) +```text +error: config $BASE/config.yaml: baseurl "https://code.example/git": must name only a host, with no path, query, fragment, or userinfo +``` + + Bad configs ----------- diff --git a/validate.go b/validate.go index 1fc805c..6813ecb 100644 --- a/validate.go +++ b/validate.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/url" "strings" ) @@ -14,6 +15,22 @@ import ( // so what these guard against is git misparsing an argument: // options (leading dash), rev-parse operators (.. @{} ^ ~ :), and path traversal. +// validateBaseURL checks (and normalizes) the configured canonical base URL: +// an absolute http(s) URL naming only a host. One trailing slash is tolerated and trimmed; +// a path is refused rather than trimmed, because socle's URL space is rooted at "/" +// and a path prefix would promise routing it can't deliver. +// Returns the normalized form, scheme://host with no trailing slash. +func validateBaseURL(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "http" && u.Scheme != "https" || u.Host == "" { + return "", fmt.Errorf("baseurl %q: must be an absolute URL with scheme http or https", raw) + } + if u.Path != "" && u.Path != "/" || u.RawQuery != "" || u.Fragment != "" || u.User != nil { + return "", fmt.Errorf("baseurl %q: must name only a host, with no path, query, fragment, or userinfo", raw) + } + return u.Scheme + "://" + u.Host, nil +} + // validateRepoName checks a configured repo name (which is also its URL prefix). // Names are slash-separated segments of [A-Za-z0-9._-], with no segment // empty, starting with a dot or a dash, or ending in ".git" diff --git a/watch_test.go b/watch_test.go index f5d1887..58b9557 100644 --- a/watch_test.go +++ b/watch_test.go @@ -51,7 +51,7 @@ func newWatchFixture(t *testing.T, pollInterval time.Duration) *watchFixture { if len(warnings) > 0 { t.Fatalf("fixture repos should all open: %v", warnings) } - fx.srv = newServer(repos, display.NewTheme("test")) + fx.srv = newServer(repos, cfg.BaseURL, display.NewTheme("test")) watchConfig(fx.cfgPath, pollInterval, func() { reloadConfig(fx.srv, fx.cfgPath) fx.checked.Add(1)