diff --git a/cmd/mesh-builder/main.go b/cmd/mesh-builder/main.go index 5ec294a..c4fa5e1 100644 --- a/cmd/mesh-builder/main.go +++ b/cmd/mesh-builder/main.go @@ -24,6 +24,7 @@ import ( "encoding/json" "errors" "fmt" + "net/url" "os" "os/signal" "strings" @@ -202,6 +203,7 @@ func answer(ctx context.Context, channel *amqp.Channel, publisher builder.Publis // not after a clone that then fails at npm ci. built, err = builder.Build(ctx, builder.Command, publisher, request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc, + forgeFrom(), func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) }) @@ -367,6 +369,53 @@ func packagesFrom() (builder.Npmrc, error) { return builder.Npmrc{Scope: scope, Registry: registry, Token: secret}, nil } +// forgeFrom is the git credential this builder may offer a clone, composed from the same binding +// and sealed secret its package-registry half already reads: the forge that answers npm is the +// forge that hosts the repositories, and its provisioner applies one password to one user for +// both. Anything missing means no credential, and every clone stays anonymous — which is all a +// mesh of public repositories ever needs. +// +// The URL names the binding's own address — the machine the mesh says the forge is on — so a +// private repository is registered and built by that address, and a clone of anything else is +// never shown this credential (git's credential store matches the whole origin). +func forgeFrom() builder.GitCredential { + path := strings.TrimSpace(os.Getenv("MESH_PACKAGE_BINDING")) + if path == "" { + return builder.GitCredential{} + } + raw, err := os.ReadFile(path) + if err != nil { + return builder.GitCredential{} + } + var told struct { + At string `json:"at"` + As string `json:"as"` + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal(raw, &told); err != nil || told.At == "" || told.As == "" { + return builder.GitCredential{} + } + secret := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN")) + if file := strings.TrimSpace(os.Getenv("MESH_NPM_TOKEN_FILE")); file != "" { + if raw, err := os.ReadFile(file); err == nil { + secret = strings.TrimSpace(string(raw)) + } + } + if secret == "" { + return builder.GitCredential{} + } + scheme := "https" + if s, ok := told.Serves["scheme"]; ok { + scheme = fmt.Sprintf("%v", s) + } + host := told.At + if port, ok := told.Serves["port"]; ok { + host = fmt.Sprintf("%s:%v", told.At, port) + } + made := url.URL{Scheme: scheme, User: url.UserPassword(told.As, secret), Host: host} + return builder.GitCredential{URL: made.String()} +} + func short(commit string) string { if len(commit) > 8 { return commit[:8] diff --git a/cmd/mesh-builder/once.go b/cmd/mesh-builder/once.go index 1ea9da7..97b2ef1 100644 --- a/cmd/mesh-builder/once.go +++ b/cmd/mesh-builder/once.go @@ -89,6 +89,7 @@ func buildOnce(ctx context.Context, args []string) error { return err } built, buildErr := builder.Build(ctx, builder.Command, publisher, repository, *path, *ref, where, bases, npmrc, + forgeFrom(), func(step, message string) { fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message) }) if buildErr != nil { return buildErr diff --git a/examples/route-proxy/challenge_test.go b/examples/route-proxy/challenge_test.go new file mode 100644 index 0000000..3afd141 --- /dev/null +++ b/examples/route-proxy/challenge_test.go @@ -0,0 +1,109 @@ +package main + +// The challenge path falls through for real. autocert's own HTTPHandler answers 404 itself for a +// token it does not hold and never consults its fallback on the challenge path — the +// predecessor's fault, the edge owning /.well-known/acme-challenge outright, rediscovered live +// when Mailu's renewal died behind this proxy on cutover day (2026-09-26). These tests pin the +// three behaviours tokenOrRoute exists for. + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "golang.org/x/crypto/acme/autocert" +) + +func routedTo(t *testing.T, marker string) http.Handler { + t.Helper() + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte(marker)); err != nil { + t.Fatal(err) + } + }) +} + +func TestATokenNoAuthorityHoldsIsRoutedNot404d(t *testing.T) { + m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} + h := tokenOrRoute(routedTo(t, "the workload answered"), m) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/somebody-elses-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" { + t.Fatalf("a token no authority holds must reach plain routing; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestATokenAManagerHoldsIsAnsweredByIt(t *testing.T) { + // autocert reads a token it does not have in memory from its cache, under "+http-01" — + // which is also how a token would survive the manager restarting mid-issuance. + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "held-token+http-01"), []byte("the-key-authorization"), 0o600); err != nil { + t.Fatal(err) + } + m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)} + h := tokenOrRoute(routedTo(t, "must not be reached"), m) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/held-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "the-key-authorization" { + t.Fatalf("the manager holding a token answers it; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestASecondAuthorityIsProbedBeforeRouting(t *testing.T) { + first := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "internal-token+http-01"), []byte("internal-key"), 0o600); err != nil { + t.Fatal(err) + } + second := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(dir)} + h := tokenOrRoute(routedTo(t, "must not be reached"), first, second) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://git.internal/.well-known/acme-challenge/internal-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "internal-key" { + t.Fatalf("the second authority's token is found by probing past the first; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestAnAuthorityWhosePolicyRefusesTheNameIsProbedPast(t *testing.T) { + // autocert checks the host policy before the token and answers 403 — the internal authority + // does this for every public name. A policy refusal is as much "not mine" as a missing token: + // the request must still reach plain routing, where the workload's own ACME client answers. + refusing := &autocert.Manager{ + Prompt: autocert.AcceptTOS, + Cache: autocert.DirCache(t.TempDir()), + HostPolicy: func(ctx context.Context, host string) error { + return fmt.Errorf("no internal-only route for %q in this mesh", host) + }, + } + h := tokenOrRoute(routedTo(t, "the workload answered"), refusing) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://mail.example/.well-known/acme-challenge/mailus-token", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "the workload answered" { + t.Fatalf("a policy refusal must fall through to routing; got %d %q", rec.Code, rec.Body.String()) + } +} + +func TestAnOrdinaryPathNeverTouchesTheChallengeMachinery(t *testing.T) { + m := &autocert.Manager{Prompt: autocert.AcceptTOS, Cache: autocert.DirCache(t.TempDir())} + h := tokenOrRoute(routedTo(t, "routed"), m) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest("GET", "http://site.example/index.html", nil)) + + if rec.Code != http.StatusOK || rec.Body.String() != "routed" { + t.Fatalf("an ordinary path goes straight to routing; got %d %q", rec.Code, rec.Body.String()) + } +} diff --git a/examples/route-proxy/main.go b/examples/route-proxy/main.go index 78f52f7..38d9308 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -104,6 +104,19 @@ func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { } } +// onlyInternalNamesTheMeshSaid is onlyWhatTheMeshSaid's mirror for the internal authority — the +// same quota-spending concern applies even to an authority with no rate limit of its own, because +// an order for a name this proxy does not actually route is a bug worth refusing rather than +// serving. +func onlyInternalNamesTheMeshSaid(held *table) autocert.HostPolicy { + return func(_ context.Context, host string) error { + if held.eligibleForInternalACME(host) { + return nil + } + return fmt.Errorf("no internal-only route for %q in this mesh, so no certificate is asked for", host) + } +} + // what the mesh writes: the contributions file, one entry per consumer. type given struct { Given []contribution `json:"given"` @@ -149,6 +162,11 @@ type rule struct { policy policy to *httputil.ReverseProxy target string + // insecure skips certificate verification when target is reached over https. For a backend + // that terminates TLS with its own certificate this proxy has no reason to trust — Mailu's + // webmail front is the first of these — never for anything reached over plain http, where + // there is nothing to verify in the first place. + insecure bool } // table is what the proxy is currently serving, replaced whole whenever the file changes. @@ -187,6 +205,9 @@ func (t *table) set(routes map[string][]rule, public map[string]bool) { continue } r.to = httputil.NewSingleHostReverseProxy(where) + if r.insecure { + r.to.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} + } kept = append(kept, r) } if len(kept) == 0 { @@ -256,6 +277,21 @@ func (t *table) routed(host string) bool { return len(t.to[bareHost(host)]) > 0 } +// eligibleForInternalACME says whether this proxy may ask its *internal* authority for a +// certificate for this name — every host it routes that is not also a route's public `name`. +// +// **The mesh has two name spaces and two authorities** (novox/hq 03-DESIGN/01-to-be/08-connectivity +// §2): a public name is certified by a public CA, an internal one by the mesh's own. This is +// composed only from `to` and `public`, which routesFrom already builds correctly — a host never +// lands in both a route's own `name` and only its `internal-name`, so nothing new has to be +// tracked to tell the two apart. +func (t *table) eligibleForInternalACME(host string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + bare := bareHost(host) + return len(t.to[bare]) > 0 && !t.public[bare] +} + // bareHost is the name without the port, lower-cased. // // The port is not part of the name: a request to app.example:8080 is for app.example. Lower-cased @@ -333,16 +369,158 @@ func run() error { return fmt.Errorf("TLS_LISTEN is set and ACME_CACHE is not: certificates need somewhere " + "to persist, or every restart orders them again") } - client := &acme.Client{DirectoryURL: issuer()} - // An issuer that is not one of the public ones serves its own API over TLS with a certificate - // nothing trusts yet — the lab's, or an internal step-ca. Trusting it is a deliberate act and - // names a file, rather than the client being told to skip verification: *skip* would also - // apply on the day this points at a public issuer, and nothing would say so. + publicManager, err := newManager(cache, issuer(), strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")), + onlyWhatTheMeshSaid(held)) + if err != nil { + return err + } + log.Printf("issuing public certificates from %s, for whatever the mesh routes here", issuer()) + + // The internal authority is optional: unset means this proxy serves internal-only aliases over + // plain HTTP exactly as it always has, which is the standalone-binary default and a safe one — + // it asks nothing of an authority it was not told about. + var internalManager *autocert.Manager + if directory := strings.TrimSpace(os.Getenv("INTERNAL_ACME_DIRECTORY")); directory != "" { + internalManager, err = newManager(cache, directory, strings.TrimSpace(os.Getenv("INTERNAL_ACME_CA_BUNDLE")), + onlyInternalNamesTheMeshSaid(held)) + if err != nil { + return fmt.Errorf("internal certificate authority: %w", err) + } + log.Printf("issuing internal certificates from %s, for every internal-only alias this routes", + directory) + } + + // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge + // must be answered *at the name being certified*, which is why issuance happens on the node + // that is publicly reachable rather than wherever the workload runs. + // + // **autocert's own HTTPHandler does not fall through on the challenge path.** For a token it + // does not hold it answers 404 itself; its fallback only ever sees non-challenge paths — which + // is exactly the predecessor's fault, the edge owning `/.well-known/acme-challenge` outright, + // rediscovered live when Mailu's renewal died behind this proxy on cutover day. tokenOrRoute + // probes each manager and hands a token neither authority recognises to plain routing, which + // is what lets a consumer's own ACME client — Mailu's, certifying its own name for a protocol + // this proxy never proxies — answer its own challenge through an ordinary path-scoped route. + port80 := tokenOrRoute(handler(held), publicManager) + if internalManager != nil { + port80 = tokenOrRoute(handler(held), publicManager, internalManager) + } + go func() { + if err := http.ListenAndServe(listen, port80); err != nil { + log.Printf("plain HTTP stopped: %v", err) + } + }() + + tlsConfig := publicManager.TLSConfig() + if internalManager != nil { + // Dispatched by which authority may certify this name at all — the same question + // eligibleForInternalACME already answers, asked once more at handshake time rather than + // only when an order is placed, since a cached certificate is served here on every request + // and never goes through HostPolicy again. + fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate + tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + if held.eligibleForInternalACME(hello.ServerName) { + return fromInternal(hello) + } + return fromPublic(hello) + } + } + + server := &http.Server{ + Addr: secure, + Handler: handler(held), + TLSConfig: tlsConfig, + } + return server.ListenAndServeTLS("", "") +} + +// tokenOrRoute serves port 80: each manager answers the challenge tokens it is itself holding, +// and a token none of them holds is routed like any other request instead of being 404'd at the +// edge. +// +// autocert gives no way to ask "is this your token?" — its HTTPHandler both answers and refuses — +// so each manager is probed against a buffered writer and its refusal (404 on the challenge path) +// is discarded in favour of the next candidate. The probe is cheap: the handler answers from +// memory, and the path only carries traffic while an issuance is actually running. +func tokenOrRoute(routes http.Handler, managers ...*autocert.Manager) http.Handler { + const challengePrefix = "/.well-known/acme-challenge/" + // Non-challenge paths never reach a manager at all; autocert's tryHTTP01 switch still has to + // be armed, which HTTPHandler is the only exported way to do. + probes := make([]http.Handler, len(managers)) + for i, m := range managers { + probes[i] = m.HTTPHandler(routes) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, challengePrefix) { + routes.ServeHTTP(w, r) + return + } + for _, probe := range probes { + buffered := &probedResponse{header: make(http.Header)} + probe.ServeHTTP(buffered, r) + // Two shapes of "not mine": 404, a token this manager is not holding — and 403, a + // name its host policy would never certify at all (autocert checks the policy before + // the token, so the internal authority answers 403 for every public name). + if buffered.status == http.StatusNotFound || buffered.status == http.StatusForbidden { + continue + } + buffered.replayTo(w) + return + } + routes.ServeHTTP(w, r) // no authority holds it: the workload behind a routed path may + }) +} + +// probedResponse buffers one handler's answer so a refusal can be discarded unseen. +type probedResponse struct { + header http.Header + status int + body bytes.Buffer +} + +func (p *probedResponse) Header() http.Header { return p.header } + +func (p *probedResponse) WriteHeader(status int) { + if p.status == 0 { + p.status = status + } +} + +func (p *probedResponse) Write(b []byte) (int, error) { + if p.status == 0 { + p.status = http.StatusOK + } + return p.body.Write(b) +} + +func (p *probedResponse) replayTo(w http.ResponseWriter) { + for k, vs := range p.header { + for _, v := range vs { + w.Header().Add(k, v) + } + } + status := p.status + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = w.Write(p.body.Bytes()) +} + +// newManager is one ACME authority's autocert manager: where to ask, what to trust it with, and +// which names it may be asked to certify. +// +// **Trusting an authority names a file rather than skipping verification.** An issuer that is not +// one of the public ones — the lab's, or the mesh's own step-ca — serves its own ACME API over TLS +// with a certificate nothing trusts yet. *Skip* would also apply the day this points at a public +// issuer, and nothing would say so; naming a bundle is a deliberate, visible act instead. +func newManager(cache, directory, bundle string, policy autocert.HostPolicy) (*autocert.Manager, error) { + client := &acme.Client{DirectoryURL: directory} var root []byte - if bundle := strings.TrimSpace(os.Getenv("ACME_CA_BUNDLE")); bundle != "" { + if bundle != "" { read, err := os.ReadFile(bundle) if err != nil { - return fmt.Errorf("ACME_CA_BUNDLE names %s and it cannot be read: %w", bundle, err) + return nil, fmt.Errorf("the CA bundle names %s and it cannot be read: %w", bundle, err) } root = read // An empty bundle means the issuer's root is already in the system trust store — a public @@ -354,7 +532,7 @@ func run() error { if strings.TrimSpace(string(root)) != "" { pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(root) { - return fmt.Errorf("%s holds no certificate this can trust", bundle) + return nil, fmt.Errorf("%s holds no certificate this can trust", bundle) } client.HTTPClient = &http.Client{ Timeout: 30 * time.Second, @@ -363,31 +541,16 @@ func run() error { } } // Where this authority's account and certificates are kept. Per authority, not per proxy — see - // forThisAuthority, which is what makes a re-initialised CA heal itself. - mine := forThisAuthority(cache, issuer(), root) - manager := &autocert.Manager{ + // forThisAuthority, which is what makes a re-initialised CA heal itself, and what lets the + // public and internal authorities share one ACME_CACHE without colliding: they hash to + // different names because their directories differ. + mine := forThisAuthority(cache, directory, root) + return &autocert.Manager{ Cache: autocert.DirCache(mine), Prompt: autocert.AcceptTOS, - HostPolicy: onlyWhatTheMeshSaid(held), + HostPolicy: policy, Client: client, - } - log.Printf("issuing from %s into %s, for whatever the mesh routes here", issuer(), mine) - - // Port 80 answers the HTTP-01 challenge and goes on proxying everything else. The challenge - // must be answered *at the name being certified*, which is why issuance happens on the node - // that is publicly reachable rather than wherever the workload runs. - go func() { - if err := http.ListenAndServe(listen, manager.HTTPHandler(handler(held))); err != nil { - log.Printf("plain HTTP stopped: %v", err) - } - }() - - server := &http.Server{ - Addr: secure, - Handler: handler(held), - TLSConfig: manager.TLSConfig(), - } - return server.ListenAndServeTLS("", "") + }, nil } // forThisAuthority is where one ACME authority's account and certificates are kept. @@ -595,7 +758,22 @@ func routesFrom(path string) (map[string][]rule, map[string]bool, error) { if at == "" { at = "127.0.0.1" } - made.target = fmt.Sprintf("http://%s:%d", at, port) + // http unless the contribution says otherwise. A backend that terminates its own TLS + // with a certificate this proxy has no reason to trust — Mailu's webmail front is the + // first of these — is the reason `insecure` exists, and it stays the exception: every + // other target the mesh hands this proxy is a plain workload on the private network. + scheme, _ := c.Values["scheme"].(string) + scheme = strings.ToLower(strings.TrimSpace(scheme)) + if scheme == "" { + scheme = "http" + } + if scheme != "http" && scheme != "https" { + log.Printf("%s on %s asked for route %q with scheme %q, which is neither http "+ + "nor https; skipped", c.From, c.Node, name, scheme) + continue + } + made.insecure, _ = c.Values["insecure"].(bool) + made.target = fmt.Sprintf("%s://%s:%d", scheme, at, port) } out[host] = append(out[host], made) diff --git a/examples/route-proxy/routes_test.go b/examples/route-proxy/routes_test.go index b4d4bfa..36d49a5 100644 --- a/examples/route-proxy/routes_test.go +++ b/examples/route-proxy/routes_test.go @@ -130,6 +130,68 @@ func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { } } +// A route may name a target reached over https, for a backend that terminates its own TLS — the +// shape Mailu's webmail front needs, which this proxy reaches as a plain workload otherwise. +func TestARouteMayTargetHttps(t *testing.T) { + routes, _, err := routesFrom(write(t, `{"given":[ + {"from":"mail","node":"anchor","at":"anchor.internal", + "values":{"name":"mail.example","port":7443,"scheme":"https","insecure":true}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if targetOf(routes, "mail.example") != "https://anchor.internal:7443" { + t.Fatalf("an https target was not built as one: %v", routes) + } + if !routes["mail.example"][0].insecure { + t.Fatal("insecure was declared and not carried onto the rule") + } +} + +// A scheme that is neither http nor https is refused rather than guessed at. +func TestARouteWithAnUnknownSchemeIsSkipped(t *testing.T) { + routes, _, err := routesFrom(write(t, `{"given":[ + {"from":"a","node":"n","at":"n.internal","values":{"name":"bad.example","port":80,"scheme":"ftp"}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes) != 0 { + t.Fatalf("a route with an unusable scheme was served: %v", routes) + } +} + +// End to end: a backend terminating TLS with a certificate nothing would ordinarily trust is still +// reached when the route declared `insecure`, and the response comes back through unmodified. +func TestTheProxyReachesAnInsecureHttpsBackend(t *testing.T) { + workload := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("the workload, over its own TLS")) + })) + defer workload.Close() + target := strings.TrimPrefix(workload.URL, "https://") + + held := newTable() + routes := map[string][]rule{"mail.example": {{target: "https://" + target, insecure: true}}} + held.set(routes, allPublic(routes)) + + proxy := httptest.NewServer(handler(held)) + defer proxy.Close() + + asked, err := http.NewRequest(http.MethodGet, proxy.URL, nil) + if err != nil { + t.Fatal(err) + } + asked.Host = "mail.example" + answer, err := http.DefaultClient.Do(asked) + if err != nil { + t.Fatal(err) + } + defer answer.Body.Close() + if answer.StatusCode != http.StatusOK { + t.Fatalf("an insecure https backend was not reached: %d", answer.StatusCode) + } +} + // End to end through the proxy itself: a request for the name reaches the workload, and a name // nobody asked for is refused in a way that says what IS served. func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { @@ -271,6 +333,31 @@ func TestNoCertificateIsAskedForOnAnInternalAlias(t *testing.T) { } } +// A certificate is asked of the *internal* authority only for a name that is routed here and is +// not a route's own public name — the internal-network alias, never the route it accompanies. +func TestTheInternalAuthorityOnlyCertifiesInternalOnlyAliases(t *testing.T) { + routes, public, err := routesFrom(write(t, `{"given":[ + {"from":"app","node":"anchor","at":"anchor.internal", + "values":{"name":"app.example","internal-name":"app.anchor.internal","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + held := newTable() + held.set(routes, public) + policy := onlyInternalNamesTheMeshSaid(held) + + if err := policy(context.Background(), "app.anchor.internal"); err != nil { + t.Errorf("the internal alias was refused by its own authority: %v", err) + } + if err := policy(context.Background(), "app.example"); err == nil { + t.Error("the internal authority certified a route's public name, which the public authority already covers") + } + if err := policy(context.Background(), "unrouted.internal"); err == nil { + t.Error("the internal authority certified a name nobody routed here") + } +} + // A route withdrawn stops being certifiable, without the proxy restarting. func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) { held := newTable() diff --git a/internal/builder/builder.go b/internal/builder/builder.go index 222878d..3aa56f3 100644 --- a/internal/builder/builder.go +++ b/internal/builder/builder.go @@ -63,6 +63,19 @@ type Result struct { Built []catalogue.Built } +// GitCredential is the forge credential a clone may present when the server asks for one. +// +// **Offered, never pushed.** It is written as a git credential-store file and named to git with +// `-c credential.helper=store`, so git itself decides when it applies: only on an authentication +// challenge, and only for the URL it was written for — scheme, host and port included. A public +// repository clones exactly as before, and a repository on any other host is never shown it. +type GitCredential struct { + // URL is the credential-store line — scheme://user:password@host[:port] — naming the one + // server this credential belongs to. Empty means the builder holds none and every clone is + // anonymous, as it always was. + URL string +} + // Build clones a repository at a ref, reads its manifest, produces what it declares, publishes // each, and returns the manifest the mesh should hold. // @@ -70,7 +83,8 @@ type Result struct { // archive failed would otherwise leave half of itself in the store under a digest the mesh never // records — reachable, unreferenced, and indistinguishable from something in use. func Build(ctx context.Context, run Runner, publish Publisher, - repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, log Log) (Result, error) { + repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, + forge GitCredential, log Log) (Result, error) { say := logging(log) say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref)) @@ -80,6 +94,15 @@ func Build(ctx context.Context, run Runner, publish Publisher, if err := os.MkdirAll(workspace, 0o755); err != nil { return Result{}, err } + // The credential is a file git reads, never an argument: a URL carrying a password in argv + // would be readable by anything that can list processes for as long as a clone runs. + credentials := "" + if forge.URL != "" { + credentials = filepath.Join(workspace, "git-credentials") + if err := os.WriteFile(credentials, []byte(forge.URL+"\n"), 0o600); err != nil { + return Result{}, err + } + } tree := filepath.Join(workspace, "source") if err := os.RemoveAll(tree); err != nil { return Result{}, err @@ -87,7 +110,7 @@ func Build(ctx context.Context, run Runner, publish Publisher, // A fresh clone every time rather than a fetch into a tree that is already there. A build // that reuses a working tree can succeed because of something a previous build left behind, // and that is a build nobody can reproduce. - if _, err := run(ctx, workspace, "git", "clone", "--quiet", repository, tree); err != nil { + if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", repository, tree)...); err != nil { say("clone", "FAILED: %v", err) return Result{}, fmt.Errorf("cannot clone %s: %w", repository, err) } @@ -177,7 +200,7 @@ func Build(ctx context.Context, run Runner, publish Publisher, sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) for _, a := range artifacts { say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a)) - made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, a, args, held, npmrcPath, say) + made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, say) if err != nil { say("artifact", "%s FAILED: %v", a.Name, err) return Result{}, err @@ -213,14 +236,14 @@ func logging(log Log) func(step, format string, args ...any) { // contextFrom clones an image artifact's own build context, when it names one apart from this // module's own repository — a fresh tree, the same way the module's own is, keyed by artifact // name so two artifacts of one module naming different contexts do not collide. -func contextFrom(ctx context.Context, run Runner, workspace, artifact string, +func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string, from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) { say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact) dir := filepath.Join(workspace, "context-"+artifact) if err := os.RemoveAll(dir); err != nil { return "", err } - if _, err := run(ctx, workspace, "git", "clone", "--quiet", from.Repository, dir); err != nil { + if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil { return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err) } if from.Ref != "" { @@ -232,6 +255,21 @@ func contextFrom(ctx context.Context, run Runner, workspace, artifact string, return dir, nil } +// cloneWith is a git invocation that may offer a stored credential. +// +// The first `-c credential.helper=` clears every helper the environment might carry, so exactly +// one place answers an authentication challenge: the file the builder wrote. Without a file, the +// invocation is exactly what it always was. +func cloneWith(credentials string, rest ...string) []string { + if credentials == "" { + return rest + } + return append([]string{ + "-c", "credential.helper=", + "-c", "credential.helper=store --file=" + credentials, + }, rest...) +} + func describePath(path string) string { if path == "" { return "" @@ -355,7 +393,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool { } func one(ctx context.Context, run Runner, publish Publisher, - module, tree, workspace, commit string, a catalogue.Artifact, args []string, + module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string, held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) { switch a.Kind { @@ -433,7 +471,7 @@ func one(ctx context.Context, run Runner, publish Publisher, recipePath := a.From buildDir := tree if a.Context != nil { - cloned, err := contextFrom(ctx, run, workspace, a.Name, *a.Context, say) + cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say) if err != nil { return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err) } diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index 175295e..0dbd714 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -42,8 +42,17 @@ func (r *recorded) run(_ context.Context, dir, name string, args ...string) (str line := name + " " + strings.Join(args, " ") r.ran = append(r.ran, line) r.dirs = append(r.dirs, dir) + // A clone may carry `-c` configuration in front of the verb — the credential store — so the + // verb is found rather than assumed first. + isClone := false + for _, a := range args { + if a == "clone" { + isClone = true + break + } + } switch { - case name == "git" && len(args) > 0 && args[0] == "clone": + case name == "git" && isClone: repository := args[len(args)-2] tree := args[len(args)-1] if err := os.MkdirAll(tree, 0o755); err != nil { @@ -119,7 +128,7 @@ func TestABuildProducesAManifestThePinsAreIn(t *testing.T) { r, workspace := aRepository(t, withBoth, map[string]string{ "Dockerfile": "FROM scratch", "files/theme.conf": "dark", }) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatal(err) } @@ -145,7 +154,7 @@ func TestTwoBuildsOfOneCommitProduceOneDigest(t *testing.T) { }) // A year apart, so a packer carrying timestamps cannot accidentally agree. r.stamped = time.Date(2020+i, time.March, 3, 4, 5, 6, 0, time.UTC) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatal(err) } @@ -165,7 +174,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) { // unreferenced, and indistinguishable from something in use. r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch"}) // `files` is missing, so packing the archive fails — after the image would have been pushed. - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil) + _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err == nil { t.Fatal("a build with a missing input succeeded") } @@ -177,7 +186,7 @@ func TestNothingIsPublishedUntilEverythingIsBuilt(t *testing.T) { func TestARepositoryWithNoManifestSaysSo(t *testing.T) { workspace := t.TempDir() r := &recorded{contents: map[string]string{"README.md": "nothing to see"}} - _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil) + _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err == nil { t.Fatal("a repository with nothing saying what it is was built") } @@ -190,7 +199,7 @@ func TestAModuleThatBuildsNothingStillProducesAManifest(t *testing.T) { // Most of what a person installs is configuration. r, workspace := aRepository(t, `{"module":"shell","version":"1","resources":[ {"id":"rc","type":"file","path":"/etc/zsh/zshrc","content":"setopt"}]}`, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, nil) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/shell.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatal(err) } @@ -220,7 +229,7 @@ func TestTheTreeIsFreshEveryTime(t *testing.T) { if err := os.WriteFile(leftover, []byte("stale"), 0o644); err != nil { t.Fatal(err) } - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil); err != nil { + if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(leftover); err == nil { @@ -233,7 +242,7 @@ func TestABuildThatCannotPushFails(t *testing.T) { "Dockerfile": "FROM scratch", "files/a": "b", }) r.failPush = true - if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, nil); err == nil { + if _, err := Build(context.Background(), r.run, r, "https://forge.invalid/x.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err == nil { t.Fatal("a build that could publish nothing reported success") } } @@ -247,7 +256,7 @@ func TestAnUpstreamImageIsMirroredRatherThanBuilt(t *testing.T) { "resources":[{"id":"db","type":"container","name":"mesh-postgres","artifact":"store"}]}` r, workspace := aRepository(t, mirrors, nil) - got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, nil) + got, err := Build(context.Background(), r.run, r, "https://forge.invalid/postgres.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatal(err) } @@ -313,7 +322,7 @@ func TestAModuleIsBuiltFromItsPathWithinTheRepository(t *testing.T) { "modules/other/" + ManifestName: `{"module":"other","version":"1"}`, }} got, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, nil) + "https://forge.invalid/catalogue.git", "modules/shell", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatal(err) } @@ -332,7 +341,7 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) { for _, escaping := range []string{"../../etc", "/etc"} { r := &recorded{contents: map[string]string{ManifestName: withBoth}} _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, nil) + "https://forge.invalid/x.git", escaping, "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) if err == nil { t.Fatalf("%q was accepted as a module's path", escaping) } @@ -369,7 +378,7 @@ func TestAnArtifactWithItsOwnContextIsBuiltFromThere(t *testing.T) { }, } _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, nil) + "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatal(err) } @@ -411,3 +420,99 @@ func TestAnArtifactWithItsOwnContextIsBuiltFromThere(t *testing.T) { t.Errorf("the build was not given a context: %s", build) } } + +// The forge credential is offered through git's own credential store — a file, never argv — and +// git decides when it applies. What is checked: the clone names the store, the secret never +// appears in a command line, and the file holds exactly the URL at 0600. +func TestABuildOffersTheForgesCredentialThroughGitsOwnStore(t *testing.T) { + r, workspace := aRepository(t, withBoth, map[string]string{ + "Dockerfile": "FROM scratch", "files/theme.conf": "dark", + }) + _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", + workspace, nil, Npmrc{}, + GitCredential{URL: "http://mesh_novox_builder:sw0rdfi5h@forge.invalid:20000"}, nil) + if err != nil { + t.Fatal(err) + } + stored := filepath.Join(workspace, "git-credentials") + clone := r.ran[0] + if !strings.Contains(clone, "credential.helper=store --file="+stored) { + t.Fatalf("the clone does not name the credential store: %s", clone) + } + for _, line := range r.ran { + if strings.Contains(line, "sw0rdfi5h") { + t.Fatalf("the secret is in a command line, readable by anything that can list processes: %s", line) + } + } + raw, err := os.ReadFile(stored) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(raw)) != "http://mesh_novox_builder:sw0rdfi5h@forge.invalid:20000" { + t.Fatalf("the store does not hold the credential as given: %q", raw) + } + info, err := os.Stat(stored) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("the credential file is readable beyond its owner: %v", info.Mode()) + } +} + +// Without a credential, a clone is exactly the invocation it always was, and no credential file +// appears — the builder a mesh of public repositories runs is unchanged. +func TestABuildWithNoCredentialClonesExactlyAsBefore(t *testing.T) { + r, workspace := aRepository(t, withBoth, map[string]string{ + "Dockerfile": "FROM scratch", "files/theme.conf": "dark", + }) + _, err := Build(context.Background(), r.run, r, "https://forge.invalid/meshboard.git", "", "", + workspace, nil, Npmrc{}, GitCredential{}, nil) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(r.ran[0], "git clone --quiet ") { + t.Fatalf("a credential-less clone grew flags: %s", r.ran[0]) + } + if _, err := os.Stat(filepath.Join(workspace, "git-credentials")); !os.IsNotExist(err) { + t.Fatal("a credential file was written with no credential to put in it") + } +} + +// An artifact's own context is cloned with the same offer: a private module whose context is a +// second private repository on the same forge builds, and the secret still never reaches argv. +func TestAContextCloneCarriesTheSameCredentialStore(t *testing.T) { + const withContext = `{"module":"route-proxy","version":"1", + "build":{"artifacts":[ + {"name":"server","kind":"image","from":"Dockerfile", + "context":{"repository":"https://forge.invalid/source.git","ref":"main"}}]}}` + r := &recorded{ + contents: map[string]string{ + ManifestName: withContext, + "Dockerfile": "FROM scratch\nCOPY go.mod ./\n", + }, + secondary: map[string]map[string]string{ + "https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"}, + }, + } + workspace := t.TempDir() + _, err := Build(context.Background(), r.run, r, + "https://forge.invalid/catalogue.git", "", "", workspace, nil, Npmrc{}, + GitCredential{URL: "https://builder:s3cret@forge.invalid"}, nil) + if err != nil { + t.Fatal(err) + } + stored := filepath.Join(workspace, "git-credentials") + var contextClone string + for _, line := range r.ran { + if strings.Contains(line, "clone") && strings.Contains(line, "source.git") { + contextClone = line + } + } + if contextClone == "" { + t.Fatalf("the context was never cloned: %v", r.ran) + } + if !strings.Contains(contextClone, "credential.helper=store --file="+stored) { + t.Fatalf("the context clone does not name the credential store: %s", contextClone) + } +} diff --git a/internal/builder/bundle_test.go b/internal/builder/bundle_test.go index 5cf8846..da718fd 100644 --- a/internal/builder/bundle_test.go +++ b/internal/builder/bundle_test.go @@ -54,7 +54,7 @@ func TestABundleIsCompiledAndPackedWithNoDockerfile(t *testing.T) { held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} got, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, nil) + "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatalf("a module with a language and no Dockerfile did not build: %v", err) } @@ -91,7 +91,7 @@ func TestABundleWhoseToolchainIsNotHeldIsRefusedFirst(t *testing.T) { r, workspace := aRepository(t, aBundle, map[string]string{"index.ts": "console.log(1)"}) _, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, nil) + "https://forge.invalid/greeter.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err == nil { t.Fatal("a bundle was built with no toolchain to compile it in") } @@ -112,7 +112,7 @@ func TestABundleInAnUnknownLanguageIsRefused(t *testing.T) { _, err := Build(context.Background(), compiling{r}.run, r, "https://forge.invalid/greeter.git", "", "", workspace, - map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, nil) + map[string]string{"mesh-tools/build": "registry.invalid/x@sha256:" + strings.Repeat("c", 64)}, Npmrc{}, GitCredential{}, nil) if err == nil { t.Fatal("a language nothing can compile was accepted") } @@ -140,7 +140,7 @@ func TestTwoBundlesInOneModuleArePackedSeparately(t *testing.T) { held := map[string]string{"mesh-tools/build": "registry.invalid/mesh-tools/build@sha256:" + strings.Repeat("b", 64)} got, err := Build(context.Background(), compiling{r}.run, r, - "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, nil) + "https://forge.invalid/greeter.git", "", "", workspace, held, Npmrc{}, GitCredential{}, nil) if err != nil { t.Fatalf("a module with two bundles did not build: %v", err) } diff --git a/internal/builder/packages_test.go b/internal/builder/packages_test.go index 8a8ae8c..a87f19f 100644 --- a/internal/builder/packages_test.go +++ b/internal/builder/packages_test.go @@ -71,7 +71,7 @@ func TestAnImageBuildGetsTheCredentialInTheContextAndHostNetwork(t *testing.T) { r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY .npmrc ./", "files/x": "y"}) n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"} if _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil { + "https://forge.invalid/meshboard.git", "", "", workspace, nil, n, GitCredential{}, nil); err != nil { t.Fatalf("the build failed: %v", err) } @@ -101,7 +101,7 @@ func TestAnImageBuildGetsTheCredentialInTheContextAndHostNetwork(t *testing.T) { func TestAnImageBuildWithoutACredentialGetsNoHostNetwork(t *testing.T) { r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch", "files/x": "y"}) if _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, nil); err != nil { + "https://forge.invalid/meshboard.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil); err != nil { t.Fatalf("the build failed: %v", err) } for _, line := range r.ran { @@ -127,7 +127,7 @@ func TestAPackageIsBuiltOnAPublicBaseAndPublishedByVersion(t *testing.T) { }) n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"} got, err := Build(context.Background(), r.run, r, - "https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, n, nil) + "https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, n, GitCredential{}, nil) if err != nil { t.Fatalf("the package did not build: %v", err) } @@ -159,7 +159,7 @@ func TestAPackageWithNoRegistryIsRefused(t *testing.T) { "package.json": `{"name":"@novox/mesh-sdk","version":"0.1.0"}`, }) _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, Npmrc{}, nil) + "https://forge.invalid/mesh-sdk.git", "", "", workspace, nil, Npmrc{}, GitCredential{}, nil) if err == nil { t.Fatal("a package built with no registry to publish to, silently") } @@ -206,7 +206,7 @@ func TestAnImageThatDoesNotAskForTheCredentialDoesNotGetIt(t *testing.T) { r, workspace := aRepository(t, withBoth, map[string]string{"Dockerfile": "FROM scratch\nCOPY . .", "files/x": "y"}) n := Npmrc{Scope: "@novox", Registry: "https://forge.invalid/api/packages/novox/npm/", Token: "t"} if _, err := Build(context.Background(), r.run, r, - "https://forge.invalid/meshboard.git", "", "", workspace, nil, n, nil); err != nil { + "https://forge.invalid/meshboard.git", "", "", workspace, nil, n, GitCredential{}, nil); err != nil { t.Fatalf("the build failed: %v", err) } for _, line := range r.ran {