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")