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/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 98dbf38..582b04c 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -46,25 +46,35 @@ func buildCommand(ctx context.Context, args []string) error { // retype each repository is asking them to be the loop. Naming a repository and asking which // ones need building are different requests, so they are not combined. behind := set.Bool("behind", false, "every module the mesh holds older than its source has") + // A repository on the mesh's own forge, named by its path there (novox/hq ADR 0111). Without it + // the repository is external, cloned exactly as given — see source.go. + self := set.Bool("self", false, "the repository is a path on the forge holding the git seat") positionals, err := parseAround(set, args) if err != nil { return err } if *behind { - if len(positionals) != 0 { + if len(positionals) != 0 || *self { return errors.New("build or build --behind, not both: one names a " + "repository and the other asks which need building") } return buildBehind(ctx, *wait) } if len(positionals) != 1 { - return errors.New("build [--ref R] [--wait D] [--dry-run]") + return errors.New("build [--self] [--path P] [--ref R] [--wait D] [--dry-run]") + } + source := buildSource{Repository: positionals[0]} + if *self { + if err := onASeat(source.Repository); err != nil { + return err + } + source.Seat = gitSeat } if *dryRun { - return buildAndShow(ctx, positionals[0], *path, *ref, *wait) + return buildAndShow(ctx, source, *path, *ref, *wait) } - return buildOne(ctx, positionals[0], *path, *ref, *wait) + return buildOne(ctx, source, *path, *ref, *wait) } // buildFrom turns what a builder said into what the mesh keeps. @@ -325,7 +335,8 @@ func buildBehind(ctx context.Context, wait time.Duration) error { // Its own recorded ref, not its head commit: a module tracking a branch should be built // from that branch, and pinning to the commit the mesh happened to notice would quietly // turn a tracked branch into a pin. - if err := buildOne(ctx, e.Source.Repository, e.Source.Path, e.Source.Ref, wait); err != nil { + source := buildSource{Repository: e.Source.Repository, Seat: e.Source.Seat} + if err := buildOne(ctx, source, e.Source.Path, e.Source.Ref, wait); err != nil { fmt.Printf(" %v\n", err) failed = append(failed, e.Manifest.Module) } @@ -343,7 +354,14 @@ func buildBehind(ctx context.Context, wait time.Duration) error { // buildOne asks a build machine for one repository and records everything that came back. // // Separated from the command so `--behind` can walk a list without a second path to the same act. -func buildOne(ctx context.Context, repository, path, ref string, wait time.Duration) error { +func buildOne(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { + // Before anything is asked of a builder: a source on a seat nobody holds is refused here, with + // the reason, rather than sent to a machine to fail at `git clone`. + repository, err := cloneFrom(ctx, source) + if err != nil { + return err + } + ident, err := openIdentity(ctx) if err != nil { return err @@ -365,7 +383,10 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat Ref: ref, Held: heldBy(ctx), } - fmt.Printf("asked for %s", request.Repository) + fmt.Printf("asked for %s", source) + if source.Seat != "" { + fmt.Printf(" (%s)", repository) + } if path != "" { fmt.Printf(" at %s", path) } @@ -414,11 +435,18 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat } // Recorded with where it came from, so "is this current?" is answerable without building it - // again (novox/hq ADR 0009). - if err := inv.RegisterModule(ctx, manifest, inventory.Source{ + // again (novox/hq ADR 0009). **For a source on a seat, as the path and the seat, never the URL + // just cloned** (ADR 0111): the URL is where the forge runs today, and recording it would put + // the forge's address back into every module built from it. The build log above keeps the URL, + // because that is what was cloned. + recorded := inventory.Source{ Repository: result.Repository, Path: result.Path, Ref: result.Ref, BuiltFrom: result.Commit, Head: result.Commit, - }); err != nil { + } + if source.Seat != "" { + recorded.Repository, recorded.Seat = source.Repository, source.Seat + } + if err := inv.RegisterModule(ctx, manifest, recorded); err != nil { return err } fmt.Printf("\n%s %s, built on %s from %s\n", @@ -428,7 +456,11 @@ func buildOne(ctx context.Context, repository, path, ref string, wait time.Durat } // buildAndShow builds and prints the manifest without recording anything. -func buildAndShow(ctx context.Context, repository, path, ref string, wait time.Duration) error { +func buildAndShow(ctx context.Context, source buildSource, path, ref string, wait time.Duration) error { + repository, err := cloneFrom(ctx, source) + if err != nil { + return err + } ident, err := openIdentity(ctx) if err != nil { return err diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 8442f42..3bcf2c1 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -114,6 +114,8 @@ func run() error { return planCommand(ctx, args[1:]) case "push": return pushCommand(ctx, args[1:]) + case "seats": + return seatsCommand(ctx, args[1:]) case "status": return statusCommand(ctx, args[1:]) case "version": @@ -157,6 +159,7 @@ func usage() { upgrade roll-out [--together] ...send it to the machines running it upgrade record ...record that they are behind, and send nothing status [--json] what is wrong, what is quiet, and what is out of date + seats [--json] every seat this mesh defines, what it delivers, and who holds it board [--listen ADDR] the same three questions, as a page that holds nothing api --issuer URL [--listen A] assign and unassign over http, for a surface that is not here assign put a module on a node diff --git a/cmd/mesh-controller/plan.go b/cmd/mesh-controller/plan.go index 23a97fa..e744e2d 100644 --- a/cmd/mesh-controller/plan.go +++ b/cmd/mesh-controller/plan.go @@ -217,6 +217,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, } offered := map[string][]catalogue.Provider{} + var firstHeld []catalogue.Held for _, o := range others { got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true}) if err != nil { @@ -224,6 +225,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, // report, and nothing of theirs is running, so it offers nothing. continue } + firstHeld = append(firstHeld, got.Claims...) for _, m := range got.Modules { for _, name := range m.OffersAt(catalogue.ScopeMesh) { // What that module says a consumer needs to know, with that node's settings on @@ -234,7 +236,7 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, return catalogue.World{}, err } offered[name] = append(offered[name], catalogue.Provider{ - Node: o.node.Name, At: o.node.At, Serves: serves}) + Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module}) } } } @@ -244,14 +246,20 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory, }) } - world := catalogue.World{Offered: offered} + // **The second pass is given the first pass's holdings.** A seat's holder answers a requirement + // with several providers (novox/hq ADR 0110), so a node consuming one resolves only once the + // holder is known. Without them its set is refused here, and a refused node's own claims drop + // out of what the mesh holds — so a second holder of one of its seats would pass unrefused. + world := catalogue.World{Offered: offered, Held: firstHeld} + var held []catalogue.Held for _, o := range others { got, err := catalogue.Resolve(shelf, o.assigned, o.node, world) if err != nil { continue } - world.Held = append(world.Held, got.Claims...) + held = append(held, got.Claims...) } + world.Held = held return world, nil } @@ -800,6 +808,22 @@ func grantsFor(ctx context.Context, open *stores, node string) ([]catalogue.Gran return out, nil } +// listensLines is what a person is told about what this module would open, and why — the same +// `why` every listens entry already carries for the firewall it also feeds (novox/hq ADR 0007), so +// deciding whether to assign a module can see what it would open before it opens it, not only +// after. A module with nothing to listen on prints nothing extra, same as today. +func listensLines(m catalogue.Manifest) []string { + var out []string + for _, l := range m.Listens { + if l.Why == "" { + out = append(out, fmt.Sprintf(" listens %d/%s from %s", l.Port, l.At(), l.From)) + continue + } + out = append(out, fmt.Sprintf(" listens %d/%s from %s — %s", l.Port, l.At(), l.From, l.Why)) + } + return out +} + func planCommand(ctx context.Context, args []string) error { set := flag.NewFlagSet("plan", flag.ContinueOnError) // Because "one resource" does not tell you whether the settings landed. Being able to read @@ -853,6 +877,9 @@ func planCommand(ctx context.Context, args []string) error { fmt.Printf("%s would run:\n", args[0]) for _, m := range plan.Modules { fmt.Printf(" %-20s %s\n", m.Module, plan.Because[m.Module]) + for _, line := range listensLines(m) { + fmt.Println(line) + } } // What was assigned here and cannot run here. Said with the rest rather than as a refusal: it is // one module on the wrong machine, the others still run, and the remedy is to move this one. diff --git a/cmd/mesh-controller/plan_test.go b/cmd/mesh-controller/plan_test.go new file mode 100644 index 0000000..35a86b8 --- /dev/null +++ b/cmd/mesh-controller/plan_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// `plan` tells a person what a module would open and why, from the same `why` every listens +// entry already carries for the firewall (novox/hq ADR 0007) — so deciding whether to assign a +// module does not need reading its manifest first. +func TestListensLinesShowWhatAModuleWouldOpenAndWhy(t *testing.T) { + m := catalogue.Manifest{Module: "minio", Listens: []catalogue.Listening{ + {Port: 9000, From: catalogue.FromMesh, Why: "the S3 endpoint"}, + {Port: 9001, From: catalogue.FromMesh}, + }} + got := listensLines(m) + if len(got) != 2 { + t.Fatalf("two listens entries, got %d: %v", len(got), got) + } + if !strings.Contains(got[0], "9000/tcp") || !strings.Contains(got[0], "the S3 endpoint") { + t.Errorf("the port and its why did not both appear: %q", got[0]) + } + if strings.Contains(got[1], "—") { + t.Errorf("a listens entry with no why should not print a dash: %q", got[1]) + } + if !strings.Contains(got[1], "9001/tcp") { + t.Errorf("the port still appears without a why: %q", got[1]) + } +} + +func TestListensLinesAreEmptyForAModuleWithNothingToListenOn(t *testing.T) { + if got := listensLines(catalogue.Manifest{Module: "board"}); len(got) != 0 { + t.Errorf("a module with no listens should print nothing, got %v", got) + } +} diff --git a/cmd/mesh-controller/seats.go b/cmd/mesh-controller/seats.go new file mode 100644 index 0000000..1a21c6b --- /dev/null +++ b/cmd/mesh-controller/seats.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// What this mesh can have one of, and who fills each (novox/hq ADR 0110). +// +// **Derived every time, never stored.** A seat is held by a module assignment, so the answer is +// computed from assignments by the same resolution that decides what every machine runs. A table +// of holders kept beside the assignments would be a second copy of one fact, and the first thing +// to be wrong about it. + +// seatHolder is one assignment holding a seat. +type seatHolder struct { + Node string `json:"node"` + Module string `json:"module"` +} + +// seatRow is one seat and who holds it. Unheld is an answer — "this mesh has no X" — not a fault. +type seatRow struct { + Seat string `json:"seat"` + Scope string `json:"scope"` + Delivers string `json:"delivers,omitempty"` + Decision string `json:"decision"` + Holders []seatHolder `json:"holders"` +} + +// seatsHeld is every seat the mesh defines with its holders, and every claim held that names no +// seat in the set. +// +// **The second list is not empty by construction.** Manifests are held to the set when they are +// registered, and a mesh can hold one registered before the set closed. Leaving its claim out of the +// overview would make the one thing the overview is for — what does this mesh have — quietly +// incomplete. +func seatsHeld(seats []catalogue.Seat, held []catalogue.Held) ([]seatRow, []catalogue.Held) { + defined := map[string]bool{} + rows := make([]seatRow, 0, len(seats)) + for _, s := range seats { + defined[s.Name] = true + row := seatRow{Seat: s.Name, Scope: s.Scope, Delivers: s.Delivers, Decision: s.Decision, + Holders: []seatHolder{}} + seen := map[seatHolder]bool{} + for _, h := range held { + if h.Claim != s.Name || h.Scope != s.Scope { + continue + } + holder := seatHolder{Node: h.Node, Module: h.Module} + if !seen[holder] { + seen[holder] = true + row.Holders = append(row.Holders, holder) + } + } + sort.Slice(row.Holders, func(i, j int) bool { + if row.Holders[i].Node != row.Holders[j].Node { + return row.Holders[i].Node < row.Holders[j].Node + } + return row.Holders[i].Module < row.Holders[j].Module + }) + rows = append(rows, row) + } + var outside []catalogue.Held + for _, h := range held { + if !defined[h.Claim] { + outside = append(outside, h) + } + } + sort.Slice(outside, func(i, j int) bool { + if outside[i].Claim != outside[j].Claim { + return outside[i].Claim < outside[j].Claim + } + return outside[i].Node < outside[j].Node + }) + return rows, outside +} + +func seatsCommand(ctx context.Context, args []string) error { + set := flag.NewFlagSet("seats", flag.ContinueOnError) + asJSON := set.Bool("json", false, "the same, as JSON") + if err := set.Parse(args); err != nil { + return err + } + + open, err := openStores(ctx) + if err != nil { + return err + } + defer open.Close() + inv := open.inventory + shelf, err := inv.Catalogue(ctx) + if err != nil { + return err + } + // Every node, none excluded: the same view of what each machine holds that planning uses. + world, err := theRestOfTheMesh(ctx, inv, shelf, "") + if err != nil { + return err + } + rows, outside := seatsHeld(catalogue.Seats(), world.Held) + + if *asJSON { + out := struct { + Seats []seatRow `json:"seats"` + Outside []catalogue.Held `json:"outside,omitempty"` + }{rows, outside} + body, err := json.MarshalIndent(out, "", " ") + if err != nil { + return err + } + fmt.Println(string(body)) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "SEAT\tSCOPE\tDELIVERS\tHELD BY") + for _, r := range rows { + delivers := r.Delivers + if delivers == "" { + delivers = "—" + } + holders := "unheld" + if len(r.Holders) > 0 { + parts := make([]string, 0, len(r.Holders)) + for _, h := range r.Holders { + parts = append(parts, h.Module+" on "+h.Node) + } + holders = strings.Join(parts, ", ") + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", r.Seat, r.Scope, delivers, holders) + } + if err := w.Flush(); err != nil { + return err + } + if len(outside) > 0 { + fmt.Println("\nheld, and not a seat this mesh defines (registered before the set closed — novox/hq ADR 0110):") + for _, h := range outside { + fmt.Printf(" %s %s on %s\n", h.Claim, h.Module, h.Node) + } + } + return nil +} diff --git a/cmd/mesh-controller/seats_test.go b/cmd/mesh-controller/seats_test.go new file mode 100644 index 0000000..469d3a3 --- /dev/null +++ b/cmd/mesh-controller/seats_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// The overview of what a mesh has (novox/hq ADR 0110). + +func TestEverySeatIsListedIncludingTheOnesNobodyHolds(t *testing.T) { + // An unheld seat is an answer — "this mesh has no forge" — so it is listed rather than omitted. + rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{ + {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "postgres"}, + }) + if len(rows) != len(catalogue.Seats()) { + t.Fatalf("%d seats listed of %d", len(rows), len(catalogue.Seats())) + } + for _, r := range rows { + switch r.Seat { + case "mesh-store": + if len(r.Holders) != 1 || r.Holders[0].Module != "postgres" || r.Holders[0].Node != "anchor" { + t.Errorf("mesh-store is held by %+v", r.Holders) + } + if r.Delivers != "postgres-database" { + t.Errorf("mesh-store does not say what it delivers: %q", r.Delivers) + } + case "git": + if len(r.Holders) != 0 { + t.Errorf("git is held by %+v in a mesh with no forge", r.Holders) + } + } + } +} + +func TestANodeSeatListsEveryMachineHoldingIt(t *testing.T) { + rows, _ := seatsHeld(catalogue.Seats(), []catalogue.Held{ + {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "node2", Module: "nftables"}, + {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, + // Resolved twice, reported once: a machine is one holder however many passes saw it. + {Claim: "the-packet-filter", Scope: catalogue.ScopeNode, Node: "anchor", Module: "nftables"}, + }) + for _, r := range rows { + if r.Seat != "the-packet-filter" { + continue + } + if len(r.Holders) != 2 || r.Holders[0].Node != "anchor" || r.Holders[1].Node != "node2" { + t.Fatalf("the packet filter is held by %+v", r.Holders) + } + return + } + t.Fatal("the packet filter is not listed") +} + +func TestAClaimOutsideTheSetIsShownNotHidden(t *testing.T) { + // A manifest registered before the set closed can still hold one. Leaving it out would make + // the overview quietly incomplete, which is the one thing it may not be. + _, outside := seatsHeld(catalogue.Seats(), []catalogue.Held{ + {Claim: "the-controller", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "mesh-controller"}, + }) + if len(outside) != 1 || outside[0].Claim != "the-controller" { + t.Fatalf("a claim outside the set was not shown: %+v", outside) + } +} diff --git a/cmd/mesh-controller/source.go b/cmd/mesh-controller/source.go new file mode 100644 index 0000000..760dda7 --- /dev/null +++ b/cmd/mesh-controller/source.go @@ -0,0 +1,136 @@ +package main + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// where a build's repository is (novox/hq ADR 0111). +// +// A repository is on the mesh's own forge, or it is anywhere else. The first is recorded as its path +// on the forge holding the git seat, and cloned from wherever that forge runs at the moment of +// building; the second is a URL, recorded and cloned exactly as given. The build machine is not told +// the difference — it is handed a URL either way — because only the control plane knows where the +// seat's holder runs. + +// gitSeat is the seat a self-hosted repository lives on. +const gitSeat = "git" + +// buildSource is where a build's repository is: a URL, or a path on a seat's holder. +type buildSource struct { + Repository string + Seat string +} + +// String is the source as a person reads it, which for one on a seat is not the URL: the URL is a +// fact about where the forge happens to run today. +func (s buildSource) String() string { + if s.Seat == "" { + return s.Repository + } + return fmt.Sprintf("%s on the %s seat", s.Repository, s.Seat) +} + +// onASeat refuses an address given as a path on the forge. +// +// **A URL here would be recorded as a path**, and then composed onto the forge's address as one — +// cloning `http://forge:3000/https://github.com/…`. Refused by what an address plainly looks like, +// not repaired: `--self` promises a path, and something that is not one is a mistake to name. +func onASeat(repository string) error { + if strings.Contains(repository, ":") || strings.HasPrefix(repository, "/") || + strings.Trim(repository, "/") == "" { + return fmt.Errorf("--self takes the repository's path on the forge, such as novox/mesh-catalog, "+ + "and %q is not one — without --self it is built from exactly what is given", repository) + } + return nil +} + +// cloneFrom is the URL a build machine clones for a source. +// +// A URL is itself. A path on a seat is composed from the seat's holder as the mesh sees it now — +// the same view planning takes of every machine, so the forge a build clones from is the forge the +// mesh says holds the seat. +func cloneFrom(ctx context.Context, source buildSource) (string, error) { + if source.Seat == "" { + return source.Repository, nil + } + open, err := openStores(ctx) + if err != nil { + return "", err + } + defer open.Close() + shelf, err := open.inventory.Catalogue(ctx) + if err != nil { + return "", err + } + world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "") + if err != nil { + return "", err + } + return clonedFromSeat(world, source.Seat, source.Repository) +} + +// clonedFromSeat composes the clone URL for a repository on a seat's holder. +// +// **Refused, never defaulted, at every step that has no answer.** Nobody holding the seat is a mesh +// without a forge of its own: it builds from external repositories and must say so rather than fail +// to clone. A holder off the private network cannot be reached by any build machine. A holder that +// serves no scheme or port has nothing to compose from — a default port here would be the forge's +// address guessed, which is the thing this exists to stop. +func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) { + seat, known := catalogue.SeatNamed(seatName) + if !known || seat.Delivers == "" { + return "", fmt.Errorf("%q is not a seat a repository can live on", seatName) + } + var holder *catalogue.Held + for i, h := range world.Held { + if h.Claim == seat.Name && h.Scope == seat.Scope { + holder = &world.Held[i] + break + } + } + if holder == nil { + return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+ + "forge — assign a module that claims it, or build from the repository's URL without --self", + seat.Name, repository) + } + var provider *catalogue.Provider + for i, p := range world.Offered[seat.Delivers] { + if p.Node == holder.Node && p.Module == holder.Module { + provider = &world.Offered[seat.Delivers][i] + } + } + if provider == nil { + return "", fmt.Errorf("%s on %s holds the %s seat and offers no %q to clone from", + holder.Module, holder.Node, seat.Name, seat.Delivers) + } + if provider.At == "" { + return "", fmt.Errorf("%s on %s holds the %s seat and is not on the private network, so no "+ + "build machine can reach it", holder.Module, holder.Node, seat.Name) + } + scheme, _ := provider.Serves["scheme"].(string) + port := servedPort(provider.Serves["port"]) + if scheme == "" || port == "" { + return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q", + holder.Module, holder.Node, seat.Name, seat.Delivers) + } + path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git") + return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil +} + +// servedPort is a served port as text, however the manifest and the node's settings carried it. +func servedPort(v any) string { + switch p := v.(type) { + case float64: + return strconv.Itoa(int(p)) + case int: + return strconv.Itoa(p) + case string: + return p + } + return "" +} diff --git a/cmd/mesh-controller/source_test.go b/cmd/mesh-controller/source_test.go new file mode 100644 index 0000000..cd0030b --- /dev/null +++ b/cmd/mesh-controller/source_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "strings" + "testing" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// Defends novox/hq ADR 0111: a build source is on the git seat, or it is external. + +func forgeHolding(port any) catalogue.World { + return catalogue.World{ + Held: []catalogue.Held{{Claim: "git", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "gitea"}}, + Offered: map[string][]catalogue.Provider{"git": { + // A second forge that does not hold the seat, so taking the first one found would be wrong. + {Node: "archive", At: "archive.internal", Module: "gitea-mirror", + Serves: map[string]any{"scheme": "http", "port": float64(3000)}}, + {Node: "anchor", At: "anchor.internal", Module: "gitea", + Serves: map[string]any{"scheme": "http", "port": port}}, + }}, + } +} + +func TestARepositoryOnTheSeatIsClonedFromItsHolder(t *testing.T) { + got, err := clonedFromSeat(forgeHolding(float64(3000)), "git", "novox/mesh-catalog") + if err != nil { + t.Fatal(err) + } + if got != "http://anchor.internal:3000/novox/mesh-catalog.git" { + t.Fatalf("cloned from %s", got) + } +} + +func TestAMovedForgeIsFollowedWithoutRewritingAnything(t *testing.T) { + // The whole point: the node gave the forge another port, and the same recorded path clones + // from the new one. Nothing recorded contained the old one to be wrong. + got, err := clonedFromSeat(forgeHolding(float64(3100)), "git", "novox/mesh-catalog") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, ":3100/") { + t.Fatalf("the moved port was not followed: %s", got) + } +} + +func TestWithNobodyHoldingTheSeatASelfHostedBuildIsRefusedAndSaysWhy(t *testing.T) { + _, err := clonedFromSeat(catalogue.World{}, "git", "novox/mesh-catalog") + if err == nil { + t.Fatal("a repository was cloned from a forge the mesh does not have") + } + for _, want := range []string{"nobody holds the git seat", "without --self"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not say %q: %v", want, err) + } + } +} + +func TestAnExternalRepositoryIsClonedExactlyAsGiven(t *testing.T) { + // Unaffected by the seat, held or not: GitHub and GitLab are the ordinary cases. + given := "https://github.com/someone/something.git" + got, err := cloneFrom(t.Context(), buildSource{Repository: given}) + if err != nil { + t.Fatal(err) + } + if got != given { + t.Fatalf("an external repository became %s", got) + } +} + +func TestAHolderOffThePrivateNetworkIsRefused(t *testing.T) { + world := forgeHolding(float64(3000)) + world.Offered["git"][1].At = "" + if _, err := clonedFromSeat(world, "git", "novox/mesh-catalog"); err == nil || + !strings.Contains(err.Error(), "private network") { + t.Fatalf("a forge nothing can reach was cloned from: %v", err) + } +} + +func TestAHolderServingNoPortIsRefusedRatherThanGuessed(t *testing.T) { + // A default port would be the forge's address guessed, which is what this exists to stop. + if _, err := clonedFromSeat(forgeHolding(nil), "git", "novox/mesh-catalog"); err == nil { + t.Fatal("a port was guessed for a forge that serves none") + } +} + +func TestAnAddressGivenAsAPathOnTheForgeIsRefused(t *testing.T) { + for _, bad := range []string{ + "https://github.com/someone/something.git", + "git@anchor:novox/mesh-catalog.git", + "/srv/git/mesh-catalog", + "", + } { + if err := onASeat(bad); err == nil { + t.Errorf("--self accepted %q as a path on the forge", bad) + } + } + if err := onASeat("novox/mesh-catalog"); err != nil { + t.Errorf("a path on the forge was refused: %v", err) + } +} + +func TestASourceOnTheSeatReadsAsAPathNotAnAddress(t *testing.T) { + s := buildSource{Repository: "novox/mesh-catalog", Seat: "git"} + if got := s.String(); got != "novox/mesh-catalog on the git seat" { + t.Fatalf("read as %q", got) + } +} 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 2962a85..38d9308 100644 --- a/examples/route-proxy/main.go +++ b/examples/route-proxy/main.go @@ -10,10 +10,31 @@ // program. What lives here is that contract, written as something that runs so it can be read // rather than described. // +// **A route also carries what a request arriving at it may do** (novox/hq ADR 0108). The grant used +// to say only where to send traffic, so this proxy applied nothing; the four things the ingress it +// replaces actually relies on are now part of the contribution. The set is closed at four, because +// an open middleware surface recreates the thing being replaced and is far harder to narrow later +// than a closed one is to widen. +// // What it is given, written by the host from an ordinary declaration: // // $ROUTES every consumer, the name it asked for, and where the mesh says that machine is // +// Each contribution's values carry the name and port as before, and optionally: +// +// path the path prefix this rule is scoped to; absent means every path +// priority which rule wins where two match; higher first, and the order is total +// deny refuse the request outright — the shape an incident mitigation needs +// redirect answer with a permanent redirect to this name, keeping the path and query +// auth the *path of a secret* holding `user:hash` lines, never the credential itself +// +// A host may appear more than once, which is what path scoping means: one rule refusing a path +// while another serves everything else on the same name. +// +// **`auth` names a secret and never holds one.** A declaration carrying a credential is refused +// outright rather than served unprotected, and a secret that cannot be read makes the route refuse +// rather than open — a gate that cannot check is not a gate that opens. +// // It re-reads on change rather than being restarted, for the same reason the provisioner does: // a route arriving or leaving is an ordinary event and must not drop the connections of every // other workload. @@ -23,6 +44,7 @@ import ( "bytes" "context" "crypto/sha256" + "crypto/subtle" "crypto/tls" "crypto/x509" "encoding/hex" @@ -42,6 +64,7 @@ import ( "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" + "golang.org/x/crypto/bcrypt" ) // Where public certificates come from when nothing says otherwise. @@ -74,10 +97,23 @@ func issuer() string { // to what it may serve. func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy { return func(_ context.Context, host string) error { - if _, known := held.find(host); known { + if held.eligibleForACME(host) { return nil } - return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host) + return fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for", host) + } +} + +// 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) } } @@ -95,48 +131,184 @@ type contribution struct { Values map[string]any `json:"values"` } +// policy is what a rule does with a request that matched it. +// +// **Decided by the mesh, not here** (novox/hq ADR 0108). A route grant used to hand back a name and +// say nothing about what the name admitted, so this proxy admitted everything. The set is closed at +// four — authentication, refusal, path scoping, redirect — because an open middleware surface +// recreates the thing being replaced and is far harder to narrow later than a closed one is to widen. +type policy struct { + // deny refuses the request outright, whatever it is. + deny bool + // redirectTo answers with a permanent redirect instead of proxying. The request's own path and + // query are carried across, which is what canonicalising one public name onto another means. + redirectTo string + // users is what a request must present, read at load time from the secret the declaration + // *named*. A declaration never carries the credential itself. + users map[string]string + // sealed is set when authentication was declared and the secret could not be read. The rule then + // refuses everything and says why. + // + // **Fail closed.** The alternative — serve the route unauthenticated because the gate is + // missing — turns an unreadable file into a silently public admin surface, which is the exact + // outcome ADR 0108 exists to prevent. A gate that cannot check is not a gate that opens. + sealed string +} + +// rule is one way a host may be routed. A host may have several, which is what path scoping means. +type rule struct { + path string // "" matches every path + priority int + 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. // // Replaced rather than merged: the file is the whole truth about who has a route, so merging // would keep serving a name whose module was unassigned — which is the stale-route fault // 08-connectivity lists as open, reintroduced one level down. +// +// Keyed by host to an *ordered* list rather than to one target, because two of the four policies +// need a single host routed more than one way: a refusal on a path the ordinary route also matches, +// and a certificate-challenge path on a host that otherwise serves a workload. type table struct { - mu sync.RWMutex - to map[string]*httputil.ReverseProxy - targets map[string]string + mu sync.RWMutex + to map[string][]rule + // public is which routed hosts are eligible for a real certificate — every host reached as a + // route's own `name`, never one reached only as its `internal-name`. A private alias can never + // pass ACME's own validation (it has no public DNS to prove it against), so asking for it is + // not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent. + public map[string]bool } -func (t *table) set(routes map[string]string) { - made := map[string]*httputil.ReverseProxy{} - for name, target := range routes { - where, err := url.Parse(target) - if err != nil { - log.Printf("route %s points at %q, which is not a URL: %v", name, target, err) +func (t *table) set(routes map[string][]rule, public map[string]bool) { + made := map[string][]rule{} + for host, rules := range routes { + kept := make([]rule, 0, len(rules)) + for _, r := range rules { + // A rule that only refuses or only redirects has nowhere to send anything, and needs + // nowhere: it answers by itself. + if r.policy.deny || r.policy.redirectTo != "" { + kept = append(kept, r) + continue + } + where, err := url.Parse(r.target) + if err != nil { + log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err) + 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 { continue } - made[name] = httputil.NewSingleHostReverseProxy(where) + inOrder(kept) + made[host] = kept } t.mu.Lock() - t.to, t.targets = made, routes + t.to = made + t.public = public t.mu.Unlock() } -func (t *table) find(host string) (*httputil.ReverseProxy, bool) { - // The port is not part of the name. A request to app.example:8080 is for app.example. +// inOrder puts the rules for one host into the order they are matched in, and does so totally. +// +// **Equal priorities must resolve identically every time** (ADR 0108). Sorting only by priority +// leaves rules that share one in whatever order the map produced, so the same declaration would +// serve differently between restarts — a proxy that is not reproducible. Longest path first within a +// priority is also the intuitive reading: the more specific rule wins. The last two keys exist only +// to make the order total. +func inOrder(rules []rule) { + sort.SliceStable(rules, func(i, j int) bool { + a, b := rules[i], rules[j] + if a.priority != b.priority { + return a.priority > b.priority + } + if len(a.path) != len(b.path) { + return len(a.path) > len(b.path) + } + if a.path != b.path { + return a.path < b.path + } + return a.target < b.target + }) +} + +// find is the rule that answers this request, or nothing if the host is not routed here at all. +func (t *table) find(host, path string) (rule, bool) { + t.mu.RLock() + defer t.mu.RUnlock() + for _, r := range t.to[bareHost(host)] { + if r.path == "" || strings.HasPrefix(path, r.path) { + return r, true + } + } + return rule{}, false +} + +// routed says whether this proxy serves the name at all, whatever the path. +// +// Separate from find because certificate issuance is a question about the *name*: a host whose only +// rules are path-scoped is still a name this proxy answers to, and still needs a certificate. +// eligibleForACME says whether this proxy may ask a certificate authority for this name — every +// host reached as a route's own public `name`, never one reached only as its `internal-name` +// alias, which no public CA can ever validate. +func (t *table) eligibleForACME(host string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + bare := bareHost(host) + return len(t.to[bare]) > 0 && t.public[bare] +} + +func (t *table) routed(host string) bool { + t.mu.RLock() + defer t.mu.RUnlock() + 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 +// because a Host header is not case-sensitive, and a route that only answers the spelling in the +// manifest answers half the requests made to it. +func bareHost(host string) string { if h, _, err := net.SplitHostPort(host); err == nil { host = h } - t.mu.RLock() - defer t.mu.RUnlock() - p, ok := t.to[strings.ToLower(host)] - return p, ok + return strings.ToLower(host) } func (t *table) names() []string { t.mu.RLock() defer t.mu.RUnlock() - out := make([]string, 0, len(t.targets)) - for name := range t.targets { + out := make([]string, 0, len(t.to)) + for name := range t.to { out = append(out, name) } sort.Strings(out) @@ -162,7 +334,7 @@ func run() error { held := newTable() read := func() { - routes, err := routesFrom(path) + routes, public, err := routesFrom(path) if err != nil { // Kept serving what it had. A file being rewritten is momentarily unreadable, and // dropping every route because one read landed mid-write would turn an ordinary @@ -170,7 +342,7 @@ func run() error { log.Printf("cannot read %s, keeping what is already served: %v", path, err) return } - held.set(routes) + held.set(routes, public) log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", ")) } read() @@ -197,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 @@ -218,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, @@ -227,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. @@ -285,61 +584,279 @@ func forThisAuthority(cache, directory string, root []byte) string { // newTable is an empty routing table. func newTable() *table { - return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}} + return &table{to: map[string][]rule{}} } // handler is the proxy itself, separated so it can be driven by a test without a listener. func handler(held *table) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - proxy, known := held.find(r.Host) + matched, known := held.find(r.Host, r.URL.Path) if !known { // **Named, not a bare 404.** A route that was withdrawn and a name that never existed // are different things, and a proxy that says only "not found" makes an operator go // and read the mesh to tell them apart. What it is serving is the answer to both. + // + // And since a host may now be routed only on some paths, those are a third thing: + // saying "no route for this name" while listing that very name as served is a + // contradiction an operator would have to disbelieve the proxy to get past. w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusNotFound) + if held.routed(r.Host) { + fmt.Fprintf(w, "%s is served here, but no route covers %q.\n", + bareHost(r.Host), r.URL.Path) + return + } fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n", r.Host, strings.Join(held.names(), ", ")) return } - proxy.ServeHTTP(w, r) + + switch { + case matched.policy.sealed != "": + // Declared a gate, cannot check it. Refused, and says why — an operator reading this + // learns the secret is missing, rather than wondering why a protected name is 503. + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusServiceUnavailable) + fmt.Fprintf(w, "this route requires authentication and its credentials cannot be read: %s\n", + matched.policy.sealed) + return + + case matched.policy.deny: + http.Error(w, "this path is not served to you", http.StatusForbidden) + return + + case matched.policy.redirectTo != "": + http.Redirect(w, r, canonical(matched.policy.redirectTo, r.URL), http.StatusMovedPermanently) + return + + case len(matched.policy.users) > 0 && !allowed(matched.policy.users, r): + // The realm is the name asked for, so a browser's prompt says which route it is for. + w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", bareHost(r.Host))) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + matched.to.ServeHTTP(w, r) }) } -// routesFrom reads what the mesh wrote and turns it into name → target. -func routesFrom(path string) (map[string]string, error) { +// canonical is where a redirect sends this request. +// +// The declaration names the destination *name*; the request keeps its own path and query. That is +// what canonicalising one public name onto another means — a link to a page under the old name has +// to arrive at the same page under the new one, or the redirect silently loses every deep link. +func canonical(to string, from *url.URL) string { + where, err := url.Parse(to) + if err != nil { + return to + } + if where.Path == "" || where.Path == "/" { + where.Path = from.Path + } + if where.RawQuery == "" { + where.RawQuery = from.RawQuery + } + return where.String() +} + +// allowed says whether the request presented credentials this route accepts. +// +// **Every path costs one bcrypt comparison**, including an unknown user, which is why the miss +// compares against a fixed hash rather than returning early. Returning early would make an unknown +// user measurably faster than a known one with a wrong password, and that difference is a way to +// enumerate the users of a route from outside it. +func allowed(users map[string]string, r *http.Request) bool { + // A hash of nothing anybody knows. Its only job is to cost what a real comparison costs. + const absent = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" + user, password, ok := r.BasicAuth() + if !ok { + return false + } + want, known := users[user] + if !known { + want = absent + } + if err := bcrypt.CompareHashAndPassword([]byte(want), []byte(password)); err != nil { + return false + } + // `known` is checked after the comparison, not instead of it, so the timing is the same either + // way. subtle.ConstantTimeByteEq keeps the branch from being the thing that differs. + return subtle.ConstantTimeByteEq(boolByte(known), 1) == 1 +} + +func boolByte(b bool) byte { + if b { + return 1 + } + return 0 +} + +// routesFrom reads what the mesh wrote and turns it into host → the rules for that host, and +// which of those hosts is a public name — the second is `name`, ACME-eligible; a host reached +// only through `internal-name` never appears there. +func routesFrom(path string) (map[string][]rule, map[string]bool, error) { raw, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, nil, err } var said given if err := json.Unmarshal(raw, &said); err != nil { - return nil, err + return nil, nil, err } - out := map[string]string{} + out := map[string][]rule{} + public := map[string]bool{} for _, c := range said.Given { name, _ := c.Values["name"].(string) if name == "" { log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node) continue } - port, ok := asPort(c.Values["port"]) - if !ok { - log.Printf("%s on %s asked for route %q and gave no usable port; skipped", - c.From, c.Node, name) + host := strings.ToLower(name) + public[host] = true + + made := rule{path: asPath(c.Values["path"])} + if p, ok := asWhole(c.Values["priority"]); ok { + made.priority = p + } + made.policy.deny, _ = c.Values["deny"].(bool) + made.policy.redirectTo, _ = c.Values["redirect"].(string) + + if named, carried := c.Values["auth"].(string); carried && strings.TrimSpace(named) != "" { + // **A declaration names a secret; it never holds one** (ADR 0108). Refused rather than + // tolerated, and the whole rule is dropped rather than served unprotected — the + // rejected option cannot come back by accident, which is the failure this check exists + // to make impossible. + if looksLikeACredential(named) { + log.Printf("%s on %s declared route %q with a credential in the declaration rather "+ + "than the name of a secret; the whole route is refused (novox/hq ADR 0108)", + c.From, c.Node, name) + continue + } + users, err := usersFrom(named) + if err != nil { + // Fail closed: the rule is kept so the name stays routed and answers, and it + // answers by refusing. Dropping it instead would make the name 404 and read as a + // withdrawn route rather than an unreadable secret. + made.policy.sealed = err.Error() + } + made.policy.users = users + } + + // Only a rule that actually proxies needs somewhere to send the request. + if !made.policy.deny && made.policy.redirectTo == "" { + port, ok := asPort(c.Values["port"]) + if !ok { + log.Printf("%s on %s asked for route %q and gave no usable port; skipped", + c.From, c.Node, name) + continue + } + // Where the mesh says that machine is. Empty means it is this one — a workload beside + // the proxy is ordinary, and reaching it over loopback is both correct and the only + // thing that works when there is no private network. + at := c.At + if at == "" { + at = "127.0.0.1" + } + // 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) + + // The internal-network alias, the same rule under a second host — a predecessor proxy + // answered both for one route, as a convenience (reaching a service over the VPN without a + // public TLS round trip), not as an access boundary; composing it here restores exactly + // that, nothing more. Absent whenever the node composed no internal name (novox/hq ADR + // 0056's internalDomain half) — the same "nothing to join a label to" case the public name + // already has. + if internal, _ := c.Values["internal-name"].(string); strings.TrimSpace(internal) != "" { + out[strings.ToLower(internal)] = append(out[strings.ToLower(internal)], made) + } + } + return out, public, nil +} + +// asWhole is any whole number the mesh wrote, whatever its magnitude. +// +// **Not asPort.** Priority was read with the port reader first, which caps at 65535 — so a rule +// declared at a priority above that silently became priority 0 and stopped shadowing the route it +// exists to shadow. The one real rule this has to reproduce is declared at 100000, so the bug was +// exactly load-bearing. A priority is an ordering, not a port: it has no range. +func asWhole(v any) (int, bool) { + switch n := v.(type) { + case float64: + // JSON makes a float of every number, so a non-integral one was not meant as a priority. + if n != float64(int(n)) { + return 0, false + } + return int(n), true + case int: + return n, true + } + return 0, false +} + +// asPath is the path prefix a rule is scoped to, or "" for every path. +func asPath(v any) string { + p, _ := v.(string) + p = strings.TrimSpace(p) + if p == "" { + return "" + } + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return p +} + +// looksLikeACredential is the check that keeps a secret out of a declaration. +// +// It errs towards refusing: a value holding a `:` (the htpasswd separator) or opening with a bcrypt +// identifier is a credential, not a path, and no filesystem path the mesh writes needs either. A +// false refusal is a loud log and a route that does not serve; a false accept is a credential +// committed to a declaration, which is the thing being prevented. +func looksLikeACredential(v string) bool { + v = strings.TrimSpace(v) + return strings.Contains(v, ":") || strings.HasPrefix(v, "$2") +} + +// usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is. +func usersFrom(path string) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("cannot read the secret named for this route: %w", err) + } + users := map[string]string{} + for _, line := range strings.Split(string(raw), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { continue } - // Where the mesh says that machine is. Empty means it is this one — a workload beside the - // proxy is ordinary, and reaching it over loopback is both correct and the only thing - // that works when there is no private network. - at := c.At - if at == "" { - at = "127.0.0.1" + user, hash, ok := strings.Cut(line, ":") + if !ok || user == "" || hash == "" { + continue } - out[strings.ToLower(name)] = fmt.Sprintf("http://%s:%d", at, port) + users[user] = hash } - return out, nil + if len(users) == 0 { + return nil, fmt.Errorf("the secret named for this route holds no usable credentials") + } + return users, nil } // asPort accepts what JSON makes of a number, which is a float even when it was written 8080. diff --git a/examples/route-proxy/policy_test.go b/examples/route-proxy/policy_test.go new file mode 100644 index 0000000..192af8d --- /dev/null +++ b/examples/route-proxy/policy_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +// What a route carries about the requests arriving at it — novox/hq ADR 0108. +// +// Each test here is one of the four capabilities that record closed the set at, plus the negative +// case it promised would be refused. The negative case is the one that rots quietly: nothing fails +// if it stops working, so nothing tells you it has. + +// served starts a workload and gives back the host and port the mesh would have recorded for it. +func served(t *testing.T, body string) (string, int) { + t.Helper() + workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(workload.Close) + host, port, _ := strings.Cut(strings.TrimPrefix(workload.URL, "http://"), ":") + n, err := strconv.Atoi(port) + if err != nil { + t.Fatal(err) + } + return host, n +} + +// ask makes one request through the proxy for a given name and path, without following redirects. +func ask(t *testing.T, proxy, name, path string, auth [2]string) *http.Response { + t.Helper() + req, err := http.NewRequest(http.MethodGet, proxy+path, nil) + if err != nil { + t.Fatal(err) + } + req.Host = name + if auth[0] != "" { + req.SetBasicAuth(auth[0], auth[1]) + } + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + answer, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = answer.Body.Close() }) + return answer +} + +func proxyFor(t *testing.T, routesJSON string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "routes.json") + if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil { + t.Fatal(err) + } + routes, public, err := routesFrom(path) + if err != nil { + t.Fatal(err) + } + held := newTable() + held.set(routes, public) + server := httptest.NewServer(handler(held)) + t.Cleanup(server.Close) + return server.URL +} + +// A refusal on a path shadows the ordinary route for that path and leaves every other path alone. +// +// **This is why path scoping is a prerequisite and not a sibling capability.** The rule being +// reproduced matches a path on a host that is already routed to a workload, so a table mapping a +// host to one target cannot express it at all — no amount of authentication or source filtering +// would have helped. +func TestARefusedPathShadowsTheRouteAndLeavesTheRestServed(t *testing.T) { + at, port := served(t, "the workload") + proxy := proxyFor(t, `{"given":[ + {"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}}, + {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100,"deny":true}} + ]}`) + + if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden { + t.Fatalf("the refused path answered %d, so the block that was put in front of it during an "+ + "incident is not in front of it any more", got) + } + if got := ask(t, proxy, "forge.example", "/", [2]string{}).StatusCode; got != http.StatusOK { + t.Fatalf("refusing one path took the whole route with it: %d", got) + } +} + +// A redirect answers with the redirect, and the request keeps its own path and query. +// +// Losing the path would turn canonicalising one name onto another into "every deep link now lands +// on the front page", which is the kind of breakage that produces no error anywhere. +func TestARedirectKeepsThePathAndQuery(t *testing.T) { + proxy := proxyFor(t, `{"given":[ + {"from":"site","node":"anchor","values":{"name":"www.example","redirect":"https://example/"}} + ]}`) + + answer := ask(t, proxy, "www.example", "/deep/page?ref=1", [2]string{}) + if answer.StatusCode != http.StatusMovedPermanently { + t.Fatalf("a declared redirect answered %d", answer.StatusCode) + } + where := answer.Header.Get("Location") + if !strings.Contains(where, "/deep/page") || !strings.Contains(where, "ref=1") { + t.Fatalf("the redirect dropped the path or the query: %q", where) + } +} + +// Authentication refuses a request with no credentials, admits one with the right ones, and refuses +// the wrong ones — with the credentials read from the secret the declaration *named*. +func TestAuthenticationAdmitsOnlyWhatTheSecretSays(t *testing.T) { + at, port := served(t, "the console") + hash, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost) + if err != nil { + t.Fatal(err) + } + secret := filepath.Join(t.TempDir(), "console-auth") + if err := os.WriteFile(secret, []byte("# a comment\nadmin:"+string(hash)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + proxy := proxyFor(t, `{"given":[ + {"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+secret+`"}} + ]}`) + + if got := ask(t, proxy, "console.example", "/", [2]string{}).StatusCode; got != http.StatusUnauthorized { + t.Fatalf("an admin surface with no login of its own answered %d without credentials", got) + } + if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "wrong"}).StatusCode; got != http.StatusUnauthorized { + t.Fatalf("the wrong password answered %d", got) + } + if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "correct horse"}).StatusCode; got != http.StatusOK { + t.Fatalf("the right password answered %d", got) + } +} + +// The negative case ADR 0108 promised would be refused: a credential in the declaration. +// +// **Refused whole, not tolerated and not served unprotected.** A hash carried in a declaration was +// the rejected option; nothing in the running system should quietly accept it later, because the +// precedent is far easier to set than to withdraw. If this test is deleted the option returns and +// nothing else notices. +func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) { + inline := []string{ + `{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"admin:$2a$10$abcdefghijklmnopqrstuv"}}]}`, + `{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"$2a$10$abcdefghijklmnopqrstuv"}}]}`, + } + for _, body := range inline { + path := filepath.Join(t.TempDir(), "routes.json") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + routes, _, err := routesFrom(path) + if err != nil { + t.Fatal(err) + } + if len(routes) != 0 { + t.Fatalf("a declaration carrying a credential was served anyway: %v", routes) + } + } +} + +// Authentication declared, secret unreadable: the route refuses. It does not serve unprotected. +// +// **Fail closed.** The alternative turns a missing file into a silently public admin surface, which +// is the outcome the whole record exists to prevent. It answers rather than 404s, so an operator +// sees "cannot read the credentials" instead of concluding the route was withdrawn. +func TestAnUnreadableSecretFailsClosed(t *testing.T) { + at, port := served(t, "the console") + missing := filepath.Join(t.TempDir(), "not-mounted") + + proxy := proxyFor(t, `{"given":[ + {"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+missing+`"}} + ]}`) + + answer := ask(t, proxy, "console.example", "/", [2]string{}) + if answer.StatusCode == http.StatusOK { + t.Fatal("a route whose credentials could not be read served the workload unprotected") + } + if answer.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("expected the route to say it cannot check, got %d", answer.StatusCode) + } +} + +// Equal priorities resolve the same way every time, so the same declaration serves the same way +// after a restart. +// +// Sorting only by priority leaves rules that share one in whatever order the map produced. The +// proxy would still work, and would work differently between restarts — which is the hardest kind +// of fault to believe when it is reported. +func TestRulesThatShareAPriorityAreStillTotallyOrdered(t *testing.T) { + first := []rule{ + {path: "/a", priority: 10, target: "http://x:1"}, + {path: "/bb", priority: 10, target: "http://y:2"}, + {path: "", priority: 10, target: "http://z:3"}, + } + second := []rule{ + {path: "", priority: 10, target: "http://z:3"}, + {path: "/bb", priority: 10, target: "http://y:2"}, + {path: "/a", priority: 10, target: "http://x:1"}, + } + inOrder(first) + inOrder(second) + for i := range first { + if first[i].path != second[i].path || first[i].target != second[i].target { + t.Fatalf("two orderings of the same rules disagree at %d: %q vs %q", + i, first[i].path, second[i].path) + } + } + // And the more specific rule is matched first, which is the intuitive reading. + if first[0].path != "/bb" { + t.Fatalf("the longest path is not matched first: %q", first[0].path) + } +} + +// Priority decides before path length does, so a rule can be made to win regardless of specificity. +func TestPriorityOutranksPathLength(t *testing.T) { + rules := []rule{ + {path: "/very/long/path", priority: 1, target: "http://x:1"}, + {path: "", priority: 100, target: "http://y:2"}, + } + inOrder(rules) + if rules[0].priority != 100 { + t.Fatalf("a higher priority did not win: %+v", rules[0]) + } +} + +// A priority above a port number survives, because a priority is an ordering and not a port. +// +// **Found by review, and it was load-bearing.** Priority was first read with the port reader, which +// caps at 65535 — so a rule declared above that silently became priority 0 and stopped shadowing the +// route it exists to shadow. The one real rule this has to reproduce is declared at 100000, so the +// capability would have shipped looking complete and doing nothing. +func TestAPriorityAboveAPortNumberSurvives(t *testing.T) { + at, port := served(t, "the workload") + proxy := proxyFor(t, `{"given":[ + {"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}}, + {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100000,"deny":true}} + ]}`) + + if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden { + t.Fatalf("a rule declared at priority 100000 answered %d instead of refusing", got) + } +} + +// A host routed only on some paths says so, rather than claiming the name is not served here. +// +// Saying "no route for this name" while listing that very name as served is a contradiction an +// operator has to disbelieve the proxy to get past — and path scoping makes it reachable, because a +// host can now have rules that none of this request's paths match. +func TestAHostRoutedOnlyOnSomePathsSaysSo(t *testing.T) { + proxy := proxyFor(t, `{"given":[ + {"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","deny":true}} + ]}`) + + answer := ask(t, proxy, "forge.example", "/elsewhere", [2]string{}) + if answer.StatusCode != http.StatusNotFound { + t.Fatalf("an uncovered path answered %d", answer.StatusCode) + } + body := make([]byte, 256) + n, _ := answer.Body.Read(body) + said := string(body[:n]) + if !strings.Contains(said, "is served here") || !strings.Contains(said, "/elsewhere") { + t.Fatalf("the refusal does not distinguish an uncovered path from an unserved name: %q", said) + } +} diff --git a/examples/route-proxy/routes_test.go b/examples/route-proxy/routes_test.go index 6740569..36d49a5 100644 --- a/examples/route-proxy/routes_test.go +++ b/examples/route-proxy/routes_test.go @@ -19,10 +19,37 @@ func write(t *testing.T, body string) string { return path } +// plain is the table an ordinary set of routes makes: one host, one target, no policy. +func plain(routes map[string]string) map[string][]rule { + out := map[string][]rule{} + for host, target := range routes { + out[host] = []rule{{target: target}} + } + return out +} + +// allPublic is every host in a routes map, ACME-eligible — the ordinary case for a test with no +// internal-name alias of its own to distinguish. +func allPublic(routes map[string][]rule) map[string]bool { + out := map[string]bool{} + for host := range routes { + out[host] = true + } + return out +} + +// targetOf is where a host's first matching rule sends a request. +func targetOf(routes map[string][]rule, host string) string { + if rules := routes[host]; len(rules) > 0 { + return rules[0].target + } + return "" +} + // A route is a grant: the consumer supplies a target, and where that machine is comes from the // mesh rather than from a naming convention the proxy has to know. func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { - routes, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[ + routes, _, err := routesFrom(write(t, `{"contributions":1,"requirement":"route","given":[ {"from":"app","node":"laptop","at":"laptop.internal","values":{"name":"App.Example","port":8080}} ]}`)) if err != nil { @@ -30,28 +57,67 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) { } // Lower-cased, because a Host header is not case-sensitive and a route that only answers the // spelling in the manifest answers half the requests made to it. - if routes["app.example"] != "http://laptop.internal:8080" { + if targetOf(routes, "app.example") != "http://laptop.internal:8080" { t.Fatalf("the route does not point at the consumer: %v", routes) } } +// A route with an internal-name alias is reachable under both hostnames, pointed at the same +// target — the same convenience a predecessor proxy gave for reaching a service over the VPN +// without a public TLS round trip. +func TestARouteWithAnInternalNameIsReachableUnderBoth(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) + } + if targetOf(routes, "app.example") != "http://anchor.internal:8080" { + t.Fatalf("the public name does not point at the consumer: %v", routes) + } + if targetOf(routes, "app.anchor.internal") != "http://anchor.internal:8080" { + t.Fatalf("the internal alias does not point at the same consumer: %v", routes) + } + if !public["app.example"] { + t.Errorf("the public name is not eligible for a certificate: %v", public) + } + if public["app.anchor.internal"] { + t.Errorf("the internal alias is eligible for a certificate no public CA could ever issue: %v", + public) + } +} + +// A route with no internal-name composed gets no second host — the ordinary case, unchanged. +func TestARouteWithNoInternalNameGetsNoAlias(t *testing.T) { + routes, _, err := routesFrom(write(t, `{"given":[ + {"from":"app","node":"anchor","values":{"name":"app.example","port":8080}} + ]}`)) + if err != nil { + t.Fatal(err) + } + if len(routes) != 1 { + t.Fatalf("a route with no internal-name grew a second host: %v", routes) + } +} + // A workload beside the proxy is ordinary, and reaching it over loopback is both correct and the // only thing that works when there is no private network. func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) { - routes, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(write(t, `{"given":[ {"from":"app","node":"anchor","values":{"name":"app.example","port":9000}} ]}`)) if err != nil { t.Fatal(err) } - if routes["app.example"] != "http://127.0.0.1:9000" { + if targetOf(routes, "app.example") != "http://127.0.0.1:9000" { t.Fatalf("a workload on this machine was not reachable: %v", routes) } } // Skipped rather than served wrongly. A route with no port would proxy to :0. func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { - routes, err := routesFrom(write(t, `{"given":[ + routes, _, err := routesFrom(write(t, `{"given":[ {"from":"a","node":"n","at":"n.internal","values":{"name":"no-port.example"}}, {"from":"b","node":"n","at":"n.internal","values":{"port":8080}}, {"from":"c","node":"n","at":"n.internal","values":{"name":"fine.example","port":8080}} @@ -59,11 +125,73 @@ func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) { if err != nil { t.Fatal(err) } - if len(routes) != 1 || routes["fine.example"] == "" { + if len(routes) != 1 || targetOf(routes, "fine.example") == "" { t.Fatalf("an unusable contribution was served: %v", routes) } } +// 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) { @@ -75,7 +203,7 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { host, port, _ := strings.Cut(target, ":") held := newTable() - held.set(map[string]string{"app.example": "http://" + host + ":" + port}) + held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}), allPublic(plain(map[string]string{"app.example": "http://" + host + ":" + port}))) proxy := httptest.NewServer(handler(held)) defer proxy.Close() @@ -120,16 +248,17 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) { // nothing fails more visibly than a stale grant, which is exactly why it must not survive. func TestWithdrawingARouteStopsServingIt(t *testing.T) { held := newTable() - held.set(map[string]string{ + initial := plain(map[string]string{ "going.example": "http://a.internal:80", "staying.example": "http://b.internal:80", }) - held.set(map[string]string{"staying.example": "http://b.internal:80"}) + held.set(initial, allPublic(initial)) + held.set(plain(map[string]string{"staying.example": "http://b.internal:80"}), allPublic(plain(map[string]string{"staying.example": "http://b.internal:80"}))) - if _, still := held.find("going.example"); still { + if _, still := held.find("going.example", "/"); still { t.Fatal("a route whose module was unassigned is still served") } - if _, kept := held.find("staying.example"); !kept { + if _, kept := held.find("staying.example", "/"); !kept { t.Fatal("withdrawing one route took another with it") } } @@ -137,8 +266,8 @@ func TestWithdrawingARouteStopsServingIt(t *testing.T) { // A Host header carries a port and the name does not. func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) { held := newTable() - held.set(map[string]string{"app.example": "http://a.internal:8080"}) - if _, found := held.find("app.example:8080"); !found { + held.set(plain(map[string]string{"app.example": "http://a.internal:8080"}), allPublic(plain(map[string]string{"app.example": "http://a.internal:8080"}))) + if _, found := held.find("app.example:8080", "/"); !found { t.Fatal("a request to app.example:8080 did not find the route for app.example") } } @@ -168,7 +297,7 @@ func TestTheIssuerIsStagingUnlessNamed(t *testing.T) { // rate limit — and the proxy would look healthy throughout. func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { held := newTable() - held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"}) + held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))) policy := onlyWhatTheMeshSaid(held) if err := policy(context.Background(), "photos.example"); err != nil { @@ -181,16 +310,64 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) { } } +// A certificate is asked for on a route's public name, never on its internal-network alias — no +// public CA can validate a private name, and asking anyway would only spend the account's rate +// limit on an order that can never succeed. +func TestNoCertificateIsAskedForOnAnInternalAlias(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 := onlyWhatTheMeshSaid(held) + + if err := policy(context.Background(), "app.example"); err != nil { + t.Errorf("the route's public name was refused a certificate: %v", err) + } + if err := policy(context.Background(), "app.anchor.internal"); err == nil { + t.Error("a certificate was ordered for the internal alias, which no public CA can validate") + } +} + +// 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() - held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"}) + held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}), allPublic(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))) policy := onlyWhatTheMeshSaid(held) if err := policy(context.Background(), "photos.example"); err != nil { t.Fatal(err) } - held.set(nil) + held.set(nil, nil) if err := policy(context.Background(), "photos.example"); err == nil { t.Fatal("a withdrawn route can still order certificates, so the policy read a copy taken " + "once rather than what is served now") diff --git a/internal/builder/builder.go b/internal/builder/builder.go index f78ef31..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, 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 @@ -210,6 +233,43 @@ 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, 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", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil { + return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err) + } + if from.Ref != "" { + if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil { + return "", fmt.Errorf("%s has no %s: %w", from.Repository, from.Ref, err) + } + } + say("context", "done") + 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 "" @@ -333,7 +393,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool { } func one(ctx context.Context, run Runner, publish Publisher, - module, tree, 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 { @@ -405,7 +465,27 @@ func one(ctx context.Context, run Runner, publish Publisher, "and start FROM ${} (novox/hq ADR 0097)", module, a.From, strings.Join(bases, ", ")) } - invocation := append([]string{"build", "-f", a.From, "-t", local}, args...) + // The recipe is always read from this module's own tree, at this module's own commit — only + // the context docker build's final argument names can come from somewhere else, when the + // artifact says so. + recipePath := a.From + buildDir := tree + if a.Context != nil { + 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) + } + // docker build accepts -f outside the context it is given; the recipe stays exactly + // where it was read from and validated against, absolute so the working directory + // switching to the cloned context does not change which file that is. + absRecipe, err := filepath.Abs(filepath.Join(tree, a.From)) + if err != nil { + return catalogue.Built{}, fmt.Errorf("%s: %s's recipe: %w", module, a.Name, err) + } + recipePath = absRecipe + buildDir = cloned + } + invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...) if a.Target != "" { invocation = append(invocation, "--target", a.Target) } @@ -417,8 +497,8 @@ func one(ctx context.Context, run Runner, publish Publisher, invocation = append(invocation, "--network", "host") } invocation = append(invocation, ".") - say("image", "docker build -f %s", a.From) - if _, err := run(ctx, tree, "docker", invocation...); err != nil { + say("image", "docker build -f %s", recipePath) + if _, err := run(ctx, buildDir, "docker", invocation...); err != nil { return catalogue.Built{}, fmt.Errorf("%s: building %s failed: %w", module, a.Name, err) } say("image", "built, publishing") diff --git a/internal/builder/builder_test.go b/internal/builder/builder_test.go index c04ba87..0dbd714 100644 --- a/internal/builder/builder_test.go +++ b/internal/builder/builder_test.go @@ -18,13 +18,19 @@ import ( // tree, that two builds of one commit produce one digest. Running docker here would test docker. type recorded struct { - ran []string + ran []string + // dirs is the directory each entry in ran was run from, same index — so a test can ask not + // only what ran but where. + dirs []string images map[string]string archives map[string]string failPush bool // contents is what a clone of this repository lands, so the fake clone can restore the tree // Build deliberately removes first. contents map[string]string + // secondary is what a clone of a repository OTHER than the one under test lands, keyed by + // that repository's URL — an artifact's own build context, cloned apart from the module. + secondary map[string]map[string]string // stamped is the modification time the clone gives every file. Set differently between two // builds of one commit, because otherwise both land in the same second and a packer that // carried timestamps would still produce one digest — which is a test that passes for a @@ -35,13 +41,28 @@ type recorded struct { func (r *recorded) run(_ context.Context, dir, name string, args ...string) (string, error) { 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 { return "", err } - for path, body := range r.contents { + lands := r.contents + if by, is := r.secondary[repository]; is { + lands = by + } + for path, body := range lands { full := filepath.Join(tree, path) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return "", err @@ -59,7 +80,6 @@ func (r *recorded) run(_ context.Context, dir, name string, args ...string) (str case name == "git" && len(args) > 0 && args[0] == "rev-parse": return "c0ffeec0ffeec0ffeec0ffeec0ffeec0ffeec0ff\n", nil } - _ = dir return "", nil } @@ -108,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) } @@ -134,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) } @@ -154,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") } @@ -166,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") } @@ -179,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) } @@ -209,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 { @@ -222,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") } } @@ -236,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) } @@ -302,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) } @@ -321,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) } @@ -331,3 +351,168 @@ func TestAPathThatLeavesTheRepositoryIsRefused(t *testing.T) { } } } + +// **Packaging and source are allowed to live apart** — a module that ships only the recipe for +// source that lives in a second repository (the reference route-proxy, packaged in the catalogue +// but built from mesh-controller's own repository) names where that source actually is, rather +// than vendoring a second copy the two could drift from. +func TestAnArtifactWithItsOwnContextIsBuiltFromThere(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, + // The recipe lives with the packaging, not the source — read from here regardless of + // where the build context comes from. FROM scratch declares no base, so what is under + // test — where the context comes from — is not entangled with ADR 0097's own checks. + "Dockerfile": "FROM scratch\nCOPY go.mod ./\n", + }, + secondary: map[string]map[string]string{ + // go.mod exists only in the second repository. A build context taken from the wrong + // place would never find it, which a real docker build would refuse on — the fake + // does not read files, so what is checked below is that the build was even pointed + // at the right place, not that COPY would have succeeded. + "https://forge.invalid/source.git": {"go.mod": "module route-proxy\n"}, + }, + } + _, err := Build(context.Background(), r.run, r, + "https://forge.invalid/catalogue.git", "", "", t.TempDir(), nil, Npmrc{}, GitCredential{}, nil) + if err != nil { + t.Fatal(err) + } + + var clonedSource bool + for _, line := range r.ran { + if strings.HasPrefix(line, "git clone") && strings.Contains(line, "https://forge.invalid/source.git") { + clonedSource = true + } + } + if !clonedSource { + t.Fatalf("the artifact's own context was never cloned: %v", r.ran) + } + + buildIndex := -1 + for i, line := range r.ran { + if strings.HasPrefix(line, "docker build ") { + buildIndex = i + } + } + if buildIndex == -1 { + t.Fatal("no docker build was run") + } + build := r.ran[buildIndex] + buildDir := r.dirs[buildIndex] + + if !strings.Contains(buildDir, "context-server") { + t.Errorf("docker build ran from %q, not the artifact's own cloned context", buildDir) + } + recipe := strings.SplitN(strings.SplitN(build, "-f ", 2)[1], " ", 2)[0] + if !filepath.IsAbs(recipe) { + t.Errorf("the recipe %q is not an absolute path, so it is read relative to whatever "+ + "directory the build context moved to rather than where it actually is", recipe) + } + if !strings.HasSuffix(recipe, string(filepath.Separator)+"Dockerfile") { + t.Errorf("the recipe is not the module's own Dockerfile: %q", recipe) + } + if !strings.HasSuffix(build, " .") { + 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 { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index fefd2ff..9fbee3a 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -911,30 +911,53 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant, if err != nil { return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) } - composeName(values, r.PublicDomain) + composeName(values, r.PublicDomain, r.At) out[to] = append(out[to], Contribution{From: m.Module, Values: values}) } + // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an + // object store's data API and its console are two different public names from one module, + // not one. Never in `granted` — a route names a host, not a credential — so every local + // name always reaches the provider from here. + for _, to := range sortedKeys(m.ContributesMany) { + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := settle(m.ContributesMany[to][local], settings[m.Module], nil, + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err) + } + composeName(values, r.PublicDomain, r.At) + out[to] = append(out[to], Contribution{From: m.Module, Values: values}) + } + } } return out, nil } -// composeName joins a contribution's label with a node's public domain, in place (novox/hq ADR -// 0056). +// composeName joins a contribution's label with a node's public domain, and separately with its +// private one, in place (novox/hq ADR 0056). // -// **The whole of what the mesh does with a route's name: join two given strings.** A contribution -// carries a `label` — the subdomain its operator chose — and the node carries its public domain; -// the granted name is `