socle

config: optional baseurl field, the canonical outside address

commit 4eed84cb5356d5f47e14d9cab361734d928b253c · parent ef8eddc0ca · browse this tree
Ximianth, 2026-10-27 17:05:35 UTC

Validated at load (absolute http(s) URL, host only) and normalized
to scheme://host with no trailing slash.  Nothing consumes it yet;
the go-get vanity-import answers and the clone-URL display come next.
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/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"