diff --git a/internal/apply/apply.go b/internal/apply/apply.go index 2613725..c610a2a 100644 --- a/internal/apply/apply.go +++ b/internal/apply/apply.go @@ -369,6 +369,27 @@ func applyFile(r *declaration.File, previous store.Applied, unseal Unseal) (Outc } content = string(opened) } + // Sealed values into the holes the content left for them. **The host is the only thing that + // ever holds both** — the mesh discarded the value, and the module wrote the document without + // it (novox/hq ADR 0024). + if len(r.Secrets) > 0 { + if unseal == nil { + return out, fmt.Errorf( + "%s needs %d sealed value(s) and this node has no sealing key", + r.Path, len(r.Secrets)) + } + for _, name := range r.SecretsUsed() { + opened, err := unseal(r.Secrets[name]) + if err != nil { + return out, fmt.Errorf("cannot open the secret %q for %s: %w", name, r.Path, err) + } + content = strings.ReplaceAll(content, "${secret:"+name+"}", string(opened)) + } + // A file carrying a credential is not world-readable, whatever else it also carries. + if r.Mode == "" { + fallback = 0o600 + } + } out.wrote = digestOf(content) mode, err := modeOf(r.Mode, fallback) if err != nil { @@ -887,6 +908,9 @@ func applyContainer(ctx context.Context, r *declaration.Container, run Runner) ( } args := []string{"run", "--detach", "--name", r.Name, "--restart", "unless-stopped"} + for _, file := range r.EnvFile { + args = append(args, "--env-file", file) + } if r.Network != "" { args = append(args, "--network", r.Network) } diff --git a/internal/apply/vocabulary_test.go b/internal/apply/vocabulary_test.go index aacf596..1670f49 100644 --- a/internal/apply/vocabulary_test.go +++ b/internal/apply/vocabulary_test.go @@ -13,6 +13,7 @@ import ( "net/http" "net/http/httptest" "os" + "path/filepath" "strings" "testing" @@ -317,3 +318,88 @@ func TestANetworkAlreadyThereIsNotRebuilt(t *testing.T) { "name and not the thing, so it does not tear one down and rebuild it", created) } } + +// A secret inside a configuration file, substituted on the machine. +// +// **The one place a credential and a configuration meet.** A program wanting its token inside a +// JSON document cannot be handed a file that is entirely a token, and the mesh cannot compose the +// document because it discarded the value. So the module supplies the document with a hole, the +// mesh delivers the value sealed, and the host is the only thing that ever holds both. +func TestASealedValueIsPutIntoTheFileThatNamesIt(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "settings.json") + d := declare(t, `{"id":"settings","type":"file","path":"`+path+`",`+ + `"content":"{\"tracking\":\"on\",\"token\":\"${secret:atlassian}\"}",`+ + `"secrets":{"atlassian":"SEALED"}}`) + + open := func(blob string) ([]byte, error) { + if blob != "SEALED" { + return nil, fmt.Errorf("asked to open %q", blob) + } + return []byte("the-real-token"), nil + } + if _, _, err := Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, nil, nil, open); err != nil { + t.Fatal(err) + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), `"token":"the-real-token"`) { + t.Fatalf("the secret was not put in: %s", written) + } + if strings.Contains(string(written), "secret:") { + t.Fatalf("a placeholder survived into the file: %s", written) + } + // The rest of the document is untouched — this is substitution, not replacement. + if !strings.Contains(string(written), `"tracking":"on"`) { + t.Fatalf("the content around the secret was lost: %s", written) + } + // And it carries a credential, so it is not world-readable. + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("a file holding a credential is %v", info.Mode().Perm()) + } +} + +// Defends the reason env-file exists: a credential may not travel in `env`. +// +// A declaration reaches a node over the broker and `env` is plain text in it, so a password there +// is a password the broker sees. A sealed file arrives unreadable, the host writes it, and the +// runtime reads it. +func TestAContainerIsGivenItsEnvironmentFiles(t *testing.T) { + var ran []string + run := func(_ context.Context, name string, args ...string) (string, error) { + ran = append(ran, name+" "+strings.Join(args, " ")) + if len(args) > 0 && args[0] == "inspect" { + return "", fmt.Errorf("no such container") + } + return "", nil + } + d := declare(t, `{"id":"app","type":"container","name":"umami",`+ + `"image":"umami@sha256:0000000000000000000000000000000000000000000000000000000000000000",`+ + `"env-file":["/var/lib/umami/database.env","/var/lib/umami/app.env"]}`) + + _, _, _ = Apply(context.Background(), archHost(t), d, store.State{}, + store.OriginDeclared, run, nil, nil) + + var started string + for _, line := range ran { + if strings.Contains(line, "run ") { + started = line + } + } + for _, want := range []string{ + "--env-file /var/lib/umami/database.env", + "--env-file /var/lib/umami/app.env", + } { + if !strings.Contains(started, want) { + t.Errorf("the container was started without %q:\n%s", want, started) + } + } +} diff --git a/internal/declaration/declaration.go b/internal/declaration/declaration.go index 0d4041e..98355f1 100644 --- a/internal/declaration/declaration.go +++ b/internal/declaration/declaration.go @@ -12,6 +12,8 @@ import ( "fmt" "io" "reflect" + "regexp" + "slices" "sort" "strings" ) @@ -118,6 +120,23 @@ type File struct { // by looking rather than by knowing which field won. Sealed string `json:"sealed,omitempty"` + // Secrets are sealed values put into Content where it says `${secret:name}`. + // + // **The one place a secret and a configuration meet, and it happens on the machine.** A + // program that wants its token inside a JSON document cannot be given a file that is entirely + // a token, and the mesh cannot compose the document itself — it discarded the value + // (novox/hq ADR 0024). So the module supplies the document with a hole in it, the mesh + // delivers the value sealed, and the host is the only thing that ever sees both. + // + // **Substitution is textual and the host learns no formats.** That is deliberate: a mechanism + // that understood JSON would be asked to understand YAML next, and then INI, which is how the + // arrangement this replaces became something nobody could hold in their head. The module knows + // its own format, because it wrote the rest of the file. + // + // The sharp edge, stated rather than discovered: a value containing a quote or a backslash + // will not be escaped for whatever syntax surrounds it. + Secrets map[string]string `json:"secrets,omitempty"` + // Bytes is content that is not text, base64-encoded — a wallpaper, a font, an icon. // // A third way of saying what is in a file, and the three are exclusive. It would have been @@ -137,6 +156,22 @@ func (f *File) Identity() string { return f.ID } func (f *File) Kind() Type { return TypeFile } func (f *File) Target() string { return f.Path } +// placeholder is what Content says where a sealed value belongs: ${secret:name}. +var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`) + +// SecretsUsed are the names Content asks for, in the order they first appear. +func (f *File) SecretsUsed() []string { + var used []string + seen := map[string]bool{} + for _, m := range placeholder.FindAllStringSubmatch(f.Content, -1) { + if !seen[m[1]] { + seen[m[1]] = true + used = append(used, m[1]) + } + } + return used +} + func (f *File) validate(where string, _ bool) []string { var problems []string if f.Path == "" { @@ -157,6 +192,29 @@ func (f *File) validate(where string, _ bool) []string { strings.Join(said, " and ")+ " — otherwise nobody can tell by looking which one landed on the machine") } + // A file whose content names a secret must be given exactly the secrets it names. + // + // **Both directions, and both are refusals rather than warnings.** A placeholder with nothing + // to fill it would write `${secret:x}` into a configuration file, which the program reads as + // a value and fails on somewhere unrelated. A secret nobody uses means whoever wrote this + // believes a credential is in a file where it is not. + if len(f.Secrets) > 0 && f.Content == "" { + problems = append(problems, where+ + ": secrets were given and there is no content to put them in") + } + used := f.SecretsUsed() + for _, name := range used { + if f.Secrets[name] == "" { + problems = append(problems, fmt.Sprintf( + "%s: the content asks for the secret %q and none was given", where, name)) + } + } + for name := range f.Secrets { + if !slices.Contains(used, name) { + problems = append(problems, fmt.Sprintf( + "%s: the secret %q was given and the content never asks for it", where, name)) + } + } return append(problems, checkMode(where, f.Mode)...) } @@ -364,11 +422,22 @@ type Container struct { Type Type `json:"type"` Name string `json:"name"` // Image is pinned by digest (novox/hq ADR 0006) — a tag moves and a digest does not. - Image string `json:"image"` - Env map[string]string `json:"env,omitempty"` - Ports []string `json:"ports,omitempty"` - Volumes []string `json:"volumes,omitempty"` - Args []string `json:"args,omitempty"` + Image string `json:"image"` + Env map[string]string `json:"env,omitempty"` + // EnvFile names files the runtime reads environment from, in order. + // + // **Because a secret may not travel in Env.** A declaration reaches a node over the broker, + // and `env` is plain text in it — so a password there is a password the broker sees, which is + // the transitive trust refused everywhere else (novox/hq ADR 0004). A sealed file reaches the + // machine unreadable, the host writes it, and the runtime reads it: the mesh never holds it + // and neither does anything between them. + // + // It is also simply how third-party software takes credentials. Nothing that ships in a + // container will read a path the mesh invented; every one of them reads its environment. + EnvFile []string `json:"env-file,omitempty"` + Ports []string `json:"ports,omitempty"` + Volumes []string `json:"volumes,omitempty"` + Args []string `json:"args,omitempty"` // Names this container can reach, as `name:address`. // // **Because a container does not inherit the machine's names.** It gets its own `/etc/hosts` diff --git a/internal/declaration/declaration_test.go b/internal/declaration/declaration_test.go index a09df0f..f738002 100644 --- a/internal/declaration/declaration_test.go +++ b/internal/declaration/declaration_test.go @@ -359,3 +359,26 @@ func TestANetworkNameIsRefusedIfItIsNotOne(t *testing.T) { } } } + +// A placeholder with nothing to fill it is refused before anything is written. +// +// The alternative is a configuration file containing the literal `${secret:x}`, which the program +// reads as a value and fails on somewhere with no connection to this. +func TestAFileAskingForASecretItWasNotGivenIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"declaration":1,"resources":[ + {"id":"c","type":"file","path":"/tmp/x","content":"token=${secret:absent}"} + ]}`) + if len(refusal.Problems) == 0 { + t.Fatal("a file naming a secret nobody gave it was accepted") + } +} + +// And a secret nobody asked for, because somebody believes it is in a file where it is not. +func TestASecretTheContentNeverUsesIsRefused(t *testing.T) { + refusal := refusalFor(t, `{"declaration":1,"resources":[ + {"id":"c","type":"file","path":"/tmp/x","content":"nothing here","secrets":{"spare":"S"}} + ]}`) + if len(refusal.Problems) == 0 { + t.Fatal("a secret the content never mentions was accepted") + } +}