socle

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)