From 5fad1f89cf625380415d55104193eb74d19dde39 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 20:37:03 +0200 Subject: [PATCH 1/4] route-proxy: a second authority for internal names, and a target a route names the scheme of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal aliases were served over plain HTTP only — correctly refused a public certificate (no public CA can validate a private name), and then left with nothing. The mesh has two authorities for its two name spaces (08-connectivity §2), so the proxy now takes an optional internal ACME directory and dispatches at the handshake by the same question HostPolicy already answers: which authority may certify this name at all. A route may also say its target speaks https, with insecure for a backend whose own certificate nothing would trust — the shape Mailu's webmail front needs, and the exception: everything else the mesh hands this proxy stays plain http on the private network. --- examples/route-proxy/main.go | 163 ++++++++++++++++++++++------ examples/route-proxy/routes_test.go | 87 +++++++++++++++ 2 files changed, 219 insertions(+), 31 deletions(-) diff --git a/examples/route-proxy/main.go b/examples/route-proxy/main.go index 78f52f7..c8f16a3 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,81 @@ 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. Each manager's own + // HTTPHandler answers only the tokens it is itself expecting and falls through otherwise — for + // a token neither authority recognises, plain routing takes over, which is what lets a + // consumer's own ACME client — Mailu's, certifying its own name for a protocol this proxy never + // proxies — go on answering its own challenge through an ordinary path-scoped route. + port80 := publicManager.HTTPHandler(handler(held)) + if internalManager != nil { + port80 = publicManager.HTTPHandler(internalManager.HTTPHandler(handler(held))) + } + 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("", "") +} + +// 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 +455,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 +464,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 +681,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() From 6ac9013d6ed934d59f628ca75570b045aa768eb6 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 25 Sep 2026 21:47:32 +0200 Subject: [PATCH 2/4] builder: a clone may offer the forge's credential, through git's own store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A private repository could not be built: the builder clones anonymously, and had no way to say who it is. It already holds exactly one credential to exactly the right place — the package-registry binding and its sealed secret, one gitea user whose password answers npm and git alike — so a clone now offers that, and nothing new is minted or carried. Offered, never pushed: the credential is written as a git credential-store file (0600, in the workspace, never argv) and named with -c credential.helper, 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; a repository on any other host is never shown it. The same store rides along on an artifact's own context clone, so a private module with a private context builds too. --- cmd/mesh-builder/main.go | 49 ++++++++++++ cmd/mesh-builder/once.go | 1 + internal/builder/builder.go | 52 ++++++++++-- internal/builder/builder_test.go | 129 +++++++++++++++++++++++++++--- internal/builder/bundle_test.go | 8 +- internal/builder/packages_test.go | 10 +-- 6 files changed, 221 insertions(+), 28 deletions(-) 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/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 { From f145d17fc888e32bd13098c194010c223393481e Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:21:52 +0200 Subject: [PATCH 3/4] route-proxy: the challenge path falls through for real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autocert's HTTPHandler answers 404 itself for a token it does not hold and never consults its fallback on the challenge path — the predecessor's exact fault, rediscovered live when Mailu's renewal died behind this proxy on cutover day. tokenOrRoute probes each authority against a buffered writer and hands a token none of them holds to plain routing, so a consumer's own ACME client answers its own challenge through an ordinary path-scoped route. Four tests pin it, including the cache-key shape a restart-surviving token actually has. --- examples/route-proxy/challenge_test.go | 86 +++++++++++++++++++++++++ examples/route-proxy/main.go | 88 ++++++++++++++++++++++++-- 2 files changed, 167 insertions(+), 7 deletions(-) create mode 100644 examples/route-proxy/challenge_test.go diff --git a/examples/route-proxy/challenge_test.go b/examples/route-proxy/challenge_test.go new file mode 100644 index 0000000..6919f24 --- /dev/null +++ b/examples/route-proxy/challenge_test.go @@ -0,0 +1,86 @@ +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 ( + "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 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 c8f16a3..1e775f0 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -392,14 +392,18 @@ func run() error { // 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. Each manager's own - // HTTPHandler answers only the tokens it is itself expecting and falls through otherwise — for - // a token neither authority recognises, plain routing takes over, which is what lets a - // consumer's own ACME client — Mailu's, certifying its own name for a protocol this proxy never - // proxies — go on answering its own challenge through an ordinary path-scoped route. - port80 := publicManager.HTTPHandler(handler(held)) + // 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 = publicManager.HTTPHandler(internalManager.HTTPHandler(handler(held))) + port80 = tokenOrRoute(handler(held), publicManager, internalManager) } go func() { if err := http.ListenAndServe(listen, port80); err != nil { @@ -430,6 +434,76 @@ func run() error { 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) + if buffered.status == http.StatusNotFound { + continue // not this manager's token + } + 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. // From dad0a153ffc5f00d5f48721f5dd8f35499cd6604 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 26 Sep 2026 14:26:01 +0200 Subject: [PATCH 4/4] route-proxy: a policy refusal is also not-my-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit autocert checks the host policy before the token and answers 403 — the internal authority does this for every public name, so mail.novox.be's challenge died on the internal manager's probe one commit after it stopped dying on the public one's 404. Both shapes of refusal now fall through to routing; a fifth test pins the 403 case with a refusing policy. --- examples/route-proxy/challenge_test.go | 23 +++++++++++++++++++++++ examples/route-proxy/main.go | 7 +++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/examples/route-proxy/challenge_test.go b/examples/route-proxy/challenge_test.go index 6919f24..3afd141 100644 --- a/examples/route-proxy/challenge_test.go +++ b/examples/route-proxy/challenge_test.go @@ -7,6 +7,8 @@ package main // three behaviours tokenOrRoute exists for. import ( + "context" + "fmt" "net/http" "net/http/httptest" "os" @@ -73,6 +75,27 @@ func TestASecondAuthorityIsProbedBeforeRouting(t *testing.T) { } } +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) diff --git a/examples/route-proxy/main.go b/examples/route-proxy/main.go index 1e775f0..38d9308 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -458,8 +458,11 @@ func tokenOrRoute(routes http.Handler, managers ...*autocert.Manager) http.Handl for _, probe := range probes { buffered := &probedResponse{header: make(http.Header)} probe.ServeHTTP(buffered, r) - if buffered.status == http.StatusNotFound { - continue // not this manager's token + // 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