From 20614822dff667a4c30d00cdaa47915cd5e31ce0 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 16:24:14 +0100 Subject: [PATCH] F-host 2, ENG-5: the bootstrap's one-off push credential goes to distribution's account, mode 0600, only once the node-engine made that account and its state directory, so the holder's program can read it (review of issue 1966) The owner is the account the manifest names for the directory that holds the file; the bootstrap no longer makes that directory itself, and a run that reaches it first is refused naming what it waits on. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- internal/bootstrap/bootstrap.go | 7 +-- internal/bootstrap/push_credential.go | 81 +++++++++++++++++++++++---- internal/bootstrap/trust_test.go | 35 ++++++++++-- 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index 2d4b2ff..b95905c 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -159,9 +159,8 @@ type Options struct { // PushCredentialFile is where the bootstrap writes its one-off push credential for the store's holder to find, the // holder's state file `bootstrap-push` on the node that holds it (design 51 *The move*, issue 1966 ENG-5); empty writes - // none. PushCredentialOwner is the user id and group id the file is given when the holder's account is known. - PushCredentialFile string - PushCredentialOwner *[2]int + // none. The file goes to the account that owns that directory in the holder's manifest, once the node-engine made both. + PushCredentialFile string // Host is the `mesh-host` binary on this machine — the program that enrols and then holds the // machine to what the mesh says. The installer runs it; it does not contain it. @@ -826,7 +825,7 @@ func RegistryAndPublish(ctx context.Context, o Options, d Deps, sys system.Syste // The one-off credential the holders' first images are pushed to the store's front with, written for its holder to find // and forgotten when this run ends; the holder discards the file once it answers its first publish (ENG-5). if o.PushCredentialFile != "" { - cred, err := MintPushCredential(o.PushCredentialFile, o.PushCredentialOwner) + cred, err := WritePushCredential(o.Catalogue, o.PushCredentialFile) if err != nil { return Published{}, failed(StepRegistry, err) } diff --git a/internal/bootstrap/push_credential.go b/internal/bootstrap/push_credential.go index 5331609..9134836 100644 --- a/internal/bootstrap/push_credential.go +++ b/internal/bootstrap/push_credential.go @@ -3,9 +3,13 @@ package bootstrap import ( "crypto/rand" "encoding/hex" + "encoding/json" "fmt" "os" + "os/user" "path/filepath" + "strconv" + "syscall" ) // PushCredential is the bootstrap's one-off credential for the holders' first images (design 51 *The move*; issue 1966 ENG-5): @@ -26,26 +30,81 @@ func (c *PushCredential) Token() string { return c.token } // Forget drops the token from this run's memory. The file is the holder's to remove. func (c *PushCredential) Forget() { c.token = "" } -// MintPushCredential makes a token and writes it to file, mode 0600, owned by owner (user id, group id) when given; the -// directory is made if it is not there. A file that is there is replaced: a credential left by an earlier run is never reused. -func MintPushCredential(file string, owner *[2]int) (*PushCredential, error) { +// lookupAccount gives an account's user id and group id. A variable so a test can name an account this machine has. +var lookupAccount = func(name string) (uid, gid int, err error) { + u, err := user.Lookup(name) + if err != nil { + return 0, 0, err + } + if uid, err = strconv.Atoi(u.Uid); err != nil { + return 0, 0, err + } + gid, err = strconv.Atoi(u.Gid) + return uid, gid, err +} + +// WritePushCredential writes the one-off credential for the store's holder, read from its manifest in catalogue: the file +// goes into the holder's directory resource that holds file's path, given to the account that directory names as owner, +// and only once the node-engine has made that account and that directory, owned by it. Until then the holder's program, +// which runs as that account, could not read it, so it is refused rather than written as root's. +func WritePushCredential(catalogue, file string) (*PushCredential, error) { + manifest, err := readManifest(catalogue, RegistryModule) + if err != nil { + return nil, err + } + var m struct { + Resources []struct { + Type string `json:"type"` + Name string `json:"name"` + Path string `json:"path"` + Owner string `json:"owner"` + } `json:"resources"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + return nil, fmt.Errorf("the %s manifest is not readable as JSON: %w", RegistryModule, err) + } + dir, owner, account := filepath.Dir(file), "", false + for _, r := range m.Resources { + if r.Type == "directory" && filepath.Clean(r.Path) == dir { + owner = r.Owner + } + } + for _, r := range m.Resources { + account = account || (owner != "" && r.Type == "user" && r.Name == owner) + } + if !account { + return nil, fmt.Errorf("the push credential %s has no account to go to: the %s manifest declares no directory %s "+ + "owned by an account it declares, so its program could not read the file", file, RegistryModule, dir) + } + uid, gid, err := lookupAccount(owner) + if err != nil { + return nil, fmt.Errorf("the push credential waits on the account %s, which the node-engine has not made: %w", owner, err) + } + st, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("the push credential waits on %s's state directory %s, which the node-engine has not made: %w", owner, dir, err) + } + if sys, ok := st.Sys().(*syscall.Stat_t); !ok || int(sys.Uid) != uid { + return nil, fmt.Errorf("the push credential waits on %s owning its state directory %s, which the node-engine has not yet given it", owner, dir) + } + return MintPushCredential(file, uid, gid) +} + +// MintPushCredential makes a token and writes it to file, mode 0600, owned by the user id and group id given, into a +// directory that must be there. A file that is there is replaced: a credential left by an earlier run is never reused. +func MintPushCredential(file string, uid, gid int) (*PushCredential, error) { raw := make([]byte, 32) if _, err := rand.Read(raw); err != nil { return nil, fmt.Errorf("a push credential cannot be made: %w", err) } token := hex.EncodeToString(raw) - if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { - return nil, fmt.Errorf("the push credential's directory %s cannot be made: %w", filepath.Dir(file), err) - } tmp := file + ".new" if err := os.WriteFile(tmp, []byte(token+"\n"), 0o600); err != nil { return nil, fmt.Errorf("the push credential cannot be written to %s: %w", file, err) } - if owner != nil { - if err := os.Chown(tmp, owner[0], owner[1]); err != nil { - os.Remove(tmp) - return nil, fmt.Errorf("the push credential cannot be given to the store's holder: %w", err) - } + if err := os.Chown(tmp, uid, gid); err != nil { + os.Remove(tmp) + return nil, fmt.Errorf("the push credential cannot be given to the store's holder: %w", err) } if err := os.Rename(tmp, file); err != nil { os.Remove(tmp) diff --git a/internal/bootstrap/trust_test.go b/internal/bootstrap/trust_test.go index 4ca6007..6fd6533 100644 --- a/internal/bootstrap/trust_test.go +++ b/internal/bootstrap/trust_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "regexp" "strings" + "syscall" "testing" "time" @@ -227,8 +228,29 @@ func TestARangeTheRuntimeTrustsCoversAnAddressInIt(t *testing.T) { // moving the trust after the push, or dropping it, fails this test. func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { o, runtime, sys := trustSetup(t, `{"data-root":"/srv/docker"}`) - o.Catalogue = catalogueWith(t, RegistryModule, upstreamRegistryManifest) + // The holder's state directory and its account, as distribution's manifest declares them (ENG-5). + stateDir := filepath.Join(t.TempDir(), "registry") + o.Catalogue = catalogueWith(t, RegistryModule, strings.Replace(upstreamRegistryManifest, + `{"id": "state", "type": "directory", "path": "/var/lib/mesh/registry", "mode": "0700"}`, + `{"id": "account", "type": "user", "name": "distribution"}, + {"id": "state", "type": "directory", "path": "`+stateDir+`", "mode": "0700", "owner": "distribution"}`, 1)) o.Registry = "192.0.2.10:5100" + asked, was := []string{}, lookupAccount + lookupAccount = func(name string) (int, int, error) { + asked = append(asked, name) + return os.Getuid(), os.Getgid(), nil + } + t.Cleanup(func() { lookupAccount = was }) + o.PushCredentialFile = filepath.Join(stateDir, "bootstrap-push") + // Before the node-engine made distribution's state directory, nothing is written: the program could not read it. + if _, err := WritePushCredential(o.Catalogue, o.PushCredentialFile); err == nil { + t.Errorf("the push credential was written before its holder's state directory existed") + } else if _, err := os.Stat(stateDir); err == nil { + t.Errorf("the bootstrap made the holder's state directory itself") + } + if err := os.Mkdir(stateDir, 0o700); err != nil { + t.Fatal(err) + } pushed := false mesh := aMeshThatAgrees(nil) @@ -266,7 +288,6 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { var result Result // And the one-off credential for the holders' first images (ENG-5): written once, mode 0600, one line of 64 hex digits, // named in the result and never put in it. - o.PushCredentialFile = filepath.Join(t.TempDir(), "registry", "bootstrap-push") published, err := RegistryAndPublish(context.Background(), o, d, sys, controlPlane{container: "temp-mesh-controller", run: run, timeout: time.Second}, held, &result, func(string) {}) if err != nil { @@ -293,13 +314,17 @@ func TestNothingIsPushedToTheRegistryBeforeTheRuntimeTrustsIt(t *testing.T) { } if raw, err := os.ReadFile(o.PushCredentialFile); err != nil || !regexp.MustCompile(`^[0-9a-f]{64}\n$`).Match(raw) { t.Errorf("the push credential is %q (%v)", raw, err) - } else if st, _ := os.Stat(o.PushCredentialFile); st.Mode().Perm() != 0o600 { - t.Errorf("the push credential is mode %v", st.Mode().Perm()) + } else if st, _ := os.Stat(o.PushCredentialFile); st.Mode().Perm() != 0o600 || + int(st.Sys().(*syscall.Stat_t).Uid) != os.Getuid() || int(st.Sys().(*syscall.Stat_t).Gid) != os.Getgid() { + t.Errorf("the push credential is mode %v, owner %d:%d", st.Mode().Perm(), st.Sys().(*syscall.Stat_t).Uid, st.Sys().(*syscall.Stat_t).Gid) + } + if fmt.Sprint(asked) != "[distribution distribution]" { + t.Errorf("the push credential's owner was asked as %v, not the account distribution's state directory names", asked) } if result.PushCredentialFile != o.PushCredentialFile { t.Errorf("the result names %q", result.PushCredentialFile) } - if again, err := MintPushCredential(o.PushCredentialFile, nil); err != nil || again.Token() == "" { + if again, err := MintPushCredential(o.PushCredentialFile, os.Getuid(), os.Getgid()); err != nil || again.Token() == "" { t.Errorf("a credential of an earlier run was not replaced: %v", err) } else if again.Forget(); again.Token() != "" { t.Errorf("a forgotten credential still holds its token")