activator: a replica launches only with its home's key, verified, or none and a socket its helper refuses on (L21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+937
−9
internal/activator/backend_exec.go+31 −2internal/activator/prepare.go+14 −1internal/activator/replica_instancekey_linux_test.go+106 −0internal/activator/replica_instancekey_test.go+268 −0internal/activator/replicakey.go+122 −0internal/activator/replicakey_race_test.go+94 −0internal/appconfig/instancekey.go+34 −0internal/appconfig/instancekey_test.go+42 −0internal/host/agent.go+7 −0internal/host/replicahomekeys.go+88 −0internal/host/replicahomekeys_test.go+121 −0internal/instancekey/instancekey.go+10 −6
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 57323a46f..8d953eb9b 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -96,6 +96,20 @@ type ExecBackend struct { |
| // STRIPPED when the agent knows nothing — a tenant's config env must not |
| // be able to tell an app it runs somewhere it does not. |
| Region string |
| + // ReplicaHomeKeys answers, for a replica row's host, the SHA-256 of its |
| + // home's effective CARLOS_INSTANCE_KEY (instance-key plan rulings 26 and |
| + // 30). O8 will supply it from the home's published env item; until then |
| + // the agent reads the box's list (/etc/carlos/replica-home-keys), which |
| + // only the pre-roll check writes, again at every call. Nil, or false for |
| + // a host, means the home key cannot be verified, and a replica launch |
| + // that would be handed a key is refused (replicaKeyGate). |
| + ReplicaHomeKeys func(host string) (sha256 string, ok bool) |
| + // ReplicaConfigKey reads rt's configuration from the bucket now and |
| + // returns the SHA-256 of the CARLOS_INSTANCE_KEY it renders, "" for |
| + // none (appconfig.ConfiguredKeyDigest). replicaKeyGate refuses a |
| + // replica whose configured key is not that digest: a configuration |
| + // change is in flight (ruling 27). Nil refuses every configured key. |
| + ReplicaConfigKey func(ctx context.Context, rt registry.Route) (sha256 string, err error) |
| // Cache, when non-nil, adds the purge verb to each spawned instance's |
| // control socket so it can invalidate its own cached responses right |
| // after a write. Nil disables that ONE verb, not the socket: the control |
| @@ -837,10 +851,15 @@ func (b *ExecBackend) seedChild(ctx context.Context, rt registry.Route) error { |
| if _, err := b.ensureInstanceKeyFor(rt, paths); err != nil { |
| return fmt.Errorf("instance key for %s: %w", host, err) |
| } |
| - fileEnv, err := b.instanceEnv(rt) |
| + fileEnv, keyLayer, err := b.instanceEnvLayer(rt) |
| if err != nil { |
| return err |
| } |
| + // A seed has no control socket, so a replica's seed is gated as a |
| + // launch without one (ruling 26), on the layer this same read found. |
| + if err := b.replicaKeyGate(rt, fileEnv, keyLayer, false); err != nil { |
| + return err |
| + } |
| dbPath := paths.dbPath |
| // Whether the database existed BEFORE the seed ran decides whether a |
| @@ -1383,7 +1402,9 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc |
| return nil, "", fmt.Errorf("instance key for %s: %w", host, err) |
| } |
| - fileEnv, err := b.instanceEnv(rt) |
| + // One read gives the env and the layer its CARLOS_INSTANCE_KEY came |
| + // from, for the replica gate below (ruling 29). |
| + fileEnv, keyLayer, err := b.instanceEnvLayer(rt) |
| if err != nil { |
| return nil, "", err |
| } |
| @@ -1429,6 +1450,14 @@ func (b *ExecBackend) spawnChildFrom(ctx context.Context, rt registry.Route, soc |
| log.Printf("activator: control listener for %s: %v (continuing without it)", rt.Host, err) |
| ctl = nil |
| } |
| + // A replica is started only with its home's key, verified, or with |
| + // none and a control socket its helper can refuse on (ruling 26). |
| + if err := b.replicaKeyGate(rt, fileEnv, keyLayer, ctl != nil); err != nil { |
| + if ctl != nil { |
| + ctl.Close() |
| + } |
| + return nil, "", err |
| + } |
| // Both control-channel vars are AGENT-OWNED in BOTH directions: set when the |
| // channel exists, and REMOVED when it does not. The removal half is not |
| // decoration. fileEnv is tenant-supplied — <host>.config.env comes from the |
diff --git a/internal/activator/prepare.go b/internal/activator/prepare.go| index 3ffc0af70..83706e7f9 100644 |
| --- a/internal/activator/prepare.go |
| +++ b/internal/activator/prepare.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/edge" |
| "github.com/carlosframework/platform/internal/instancectl" |
| "github.com/carlosframework/platform/internal/instancefs" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -53,6 +54,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| return p, nil, nil, nil, err |
| } |
| var env map[string]string |
| + var keyLayer instancekey.Layer // the layer env's key came from, read with it (ruling 29) |
| if role == instancectl.RoleRun || role == instancectl.RoleSeed { |
| // p.dataDir is the instance directory here, tenant-writable ground |
| // (design §3.2), so the token file is minted and read through |
| @@ -64,7 +66,7 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| return p, nil, nil, nil, fmt.Errorf("instance key for %s: %w", rt.Host, err) |
| } |
| var err error |
| - env, err = b.instanceEnv(rt) |
| + env, keyLayer, err = b.instanceEnvLayer(rt) |
| if err != nil { |
| return p, nil, nil, nil, err |
| } |
| @@ -105,6 +107,17 @@ func (b *ExecBackend) prepareAt(rt registry.Route, role instancectl.Role, sock s |
| _ = os.Remove(sock) |
| ctl = b.listenControl(rt, sock+".control", p.envFile, gen) |
| } |
| + // A replica's run and seed roles are gated like a flat launch: the |
| + // seed has no control socket, the run role has the one just bound or |
| + // none (ruling 26). The sidecar role reads no instance env. |
| + if role == instancectl.RoleRun || role == instancectl.RoleSeed { |
| + if err := b.replicaKeyGate(rt, env, keyLayer, ctl != nil); err != nil { |
| + if ctl != nil { |
| + ctl.Close() |
| + } |
| + return p, nil, nil, nil, err |
| + } |
| + } |
| logs, err := listenLogs(b.logSocketFor(rt.Host, role, gen), b.logEmitter(rt)) |
| if err != nil { |
| if ctl != nil { |
diff --git a/internal/activator/replica_instancekey_linux_test.go b/internal/activator/replica_instancekey_linux_test.go| new file mode 100644 |
| index 000000000..34c079cc4 |
| --- /dev/null |
| +++ b/internal/activator/replica_instancekey_linux_test.go |
| @@ -0,0 +1,106 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// socketsAllowed reports whether this process may bind a unix socket (the |
| +// sandbox refuses it). |
| +func socketsAllowed(t *testing.T) bool { |
| + dir, err := os.MkdirTemp("", "ik") // short: a test's own temp dir can exceed a socket path's limit |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer os.RemoveAll(dir) |
| + l, err := net.Listen("unix", filepath.Join(dir, "p.sock")) |
| + if err != nil { |
| + return false |
| + } |
| + l.Close() |
| + return true |
| +} |
| + |
| +// Ruling 26 with a control socket: a keyless replica runs, and the app's |
| +// helper refuses at step 3 (ErrNoInstanceKey) instead of minting. |
| +func TestKeylessReplicaWithAControlSocketLeavesTheHelperToRefuse(t *testing.T) { |
| + if !socketsAllowed(t) { |
| + t.Skip("needs unix sockets (run with the sandbox off)") |
| + } |
| + b := testExecBackend(t) |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Socket: tmpSock(t)} |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + out := stageHelperApp(t, b, rt.Host, "v1") |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, _ := b.Start(ctx, rt) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + if got := outcomeOf(out); got != "refused" { |
| + t.Fatalf("app outcome %q, want the helper's refusal", got) |
| + } |
| +} |
| + |
| +// Ruling 26 on the nested layout: the seed role has no control socket, so |
| +// a keyless replica's seed is refused at prepare; a run role handed a key |
| +// that is not the home's is refused too; one handed the home's key is |
| +// prepared with it. |
| +func TestNestedReplicaPrepareIsGated(t *testing.T) { |
| + homeKey := strings.Repeat("H", 43) + "=" |
| + for _, tc := range []struct { |
| + name, config string |
| + role instancectl.Role |
| + refused bool |
| + }{ |
| + {"a keyless seed", "", instancectl.RoleSeed, true}, |
| + {"a run role with another key", InstanceKeyVar + "=configured-B\n", instancectl.RoleRun, true}, |
| + {"a run role with the home's key", InstanceKeyVar + "=" + homeKey + "\n", instancectl.RoleRun, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{}) |
| + rt.Replica = true |
| + b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true } |
| + b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return instancekey.Digest(homeKey), nil } |
| + dir := provision.InstanceDir(b.DataDir, rt.Host) |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc.config != "" { |
| + if err := os.WriteFile(filepath.Join(dir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + _, ctl, logs, env, err := b.prepare(rt, tc.role) |
| + if tc.refused { |
| + if !errors.Is(err, ErrReplicaKey) { |
| + t.Fatalf("prepare = %v, want ErrReplicaKey", err) |
| + } |
| + return |
| + } |
| + if err != nil { |
| + t.Fatalf("prepare: %v", err) |
| + } |
| + logs.Close() |
| + if ctl != nil { |
| + ctl.Close() |
| + } |
| + if env[InstanceKeyVar] != homeKey { |
| + t.Fatal("the prepared env does not carry the home's key") |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/activator/replica_instancekey_test.go b/internal/activator/replica_instancekey_test.go| new file mode 100644 |
| index 000000000..0091b8ee7 |
| --- /dev/null |
| +++ b/internal/activator/replica_instancekey_test.go |
| @@ -0,0 +1,268 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// The app the replica tests launch is this test binary, running |
| +// TestInstanceKeyHelperProcess: rastrillo e3ea08de's carlos.InstanceKey |
| +// and its documented caller, so a test sees what an adopting app does, not |
| +// only which variables it was handed. |
| +const helperOutEnv = "GO_TEST_INSTANCE_KEY_HELPER_OUT" |
| + |
| +var ( |
| + errHelperNoInstanceKey = errors.New("ErrNoInstanceKey") |
| + errHelperNotOnCarlos = errors.New("ErrNotOnCarlos") |
| +) |
| + |
| +// helperInstanceKey is rastrillo e3ea08de's InstanceKey(own) with Running(): |
| +// own, then $CARLOS_INSTANCE_KEY verbatim, then ErrNoInstanceKey when |
| +// $CARLOS_CONTROL_SOCKET is set, else ErrNotOnCarlos. |
| +func helperInstanceKey(own string) (string, error) { |
| + if own != "" { |
| + return own, nil |
| + } |
| + if k := os.Getenv(InstanceKeyVar); k != "" { |
| + return k, nil |
| + } |
| + if os.Getenv("CARLOS_CONTROL_SOCKET") != "" { |
| + return "", errHelperNoInstanceKey |
| + } |
| + return "", errHelperNotOnCarlos |
| +} |
| + |
| +// TestInstanceKeyHelperProcess is the app, not a test: with no output path |
| +// set it does nothing. It resolves its key as an adopting app with no key |
| +// of its own does, records the outcome, and exits: |
| +// - "key <sha256>": it sealed with the key it was handed; |
| +// - "refused": ErrNoInstanceKey, it did not start; |
| +// - "minted": ErrNotOnCarlos, and the documented caller minted its own. |
| +func TestInstanceKeyHelperProcess(t *testing.T) { |
| + out := os.Getenv(helperOutEnv) |
| + if out == "" { |
| + return |
| + } |
| + var outcome string |
| + switch key, err := helperInstanceKey(""); { |
| + case err == nil: |
| + outcome = "key " + instancekey.Digest(key) |
| + case errors.Is(err, errHelperNoInstanceKey): |
| + outcome = "refused" |
| + default: |
| + outcome = "minted" |
| + } |
| + _ = os.WriteFile(out+".tmp", []byte(outcome), 0o600) |
| + _ = os.Rename(out+".tmp", out) |
| + os.Exit(0) |
| +} |
| + |
| +// stageHelperApp makes this test binary host's live build, running only |
| +// TestInstanceKeyHelperProcess, and returns where its outcome lands. |
| +func stageHelperApp(t *testing.T, b *ExecBackend, host, ver string) string { |
| + t.Helper() |
| + self, err := os.Executable() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := filepath.Join(t.TempDir(), "outcome") |
| + t.Setenv(helperOutEnv, out) |
| + vdir := filepath.Join(b.OptDir, "versions", ver) |
| + if err := os.MkdirAll(vdir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + bin := filepath.Join(vdir, "app") |
| + script := fmt.Sprintf("#!/bin/sh\nexec '%s' -test.run='^TestInstanceKeyHelperProcess$'\n", self) |
| + if err := os.WriteFile(bin, []byte(script), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(b.liveLink(host)) |
| + if err := os.Symlink(bin, b.liveLink(host)); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +// outcomeOf waits up to two seconds for the helper's outcome; "" is no app |
| +// ran at all. |
| +func outcomeOf(path string) string { |
| + deadline := time.Now().Add(2 * time.Second) |
| + for time.Now().Before(deadline) { |
| + if raw, err := os.ReadFile(path); err == nil { |
| + return string(raw) |
| + } |
| + time.Sleep(10 * time.Millisecond) |
| + } |
| + return "" |
| +} |
| + |
| +// noControlSocket is a socket path too long to bind, so the launch's |
| +// control listener fails and CARLOS_CONTROL_SOCKET is withheld: the same |
| +// launch a claim box runs before O6, and a flat launch whose bind failed. |
| +func noControlSocket(t *testing.T) string { |
| + return filepath.Join(t.TempDir(), strings.Repeat("s", 120)+".sock") |
| +} |
| + |
| +type replicaCase struct { |
| + name string |
| + replica bool |
| + own, config string // env file bodies ("" = no config file) |
| + homeKey string // the home's effective key the box lists ("" = none listed) |
| + start string // "refused" (no app runs) or the helper outcome wanted |
| + wantKeyFromOwnOK bool // a home row: the outcome is the key minted into <host>.env |
| + // bucket is the key the replica's configuration in the bucket renders |
| + // now (ReplicaConfigKey; "" renders none). bucketDown makes that read |
| + // fail; noBucket leaves ReplicaConfigKey nil. |
| + bucket string |
| + bucketDown, noBucket bool |
| +} |
| + |
| +// freshConfig is ExecBackend.ReplicaConfigKey for one case. |
| +func freshConfig(tc replicaCase) func(context.Context, registry.Route) (string, error) { |
| + if tc.noBucket { |
| + return nil |
| + } |
| + return func(context.Context, registry.Route) (string, error) { |
| + switch { |
| + case tc.bucketDown: |
| + return "", errors.New("bucket unreachable") |
| + case tc.bucket == "": |
| + return "", nil |
| + } |
| + return instancekey.Digest(tc.bucket), nil |
| + } |
| +} |
| + |
| +// Rulings 26 to 28 through a real launch and the helper (Astra rounds 4 and |
| +// 5): a replica runs only with exactly its home's key, listed by the |
| +// pre-roll check and, when configured, still the bucket's configured key; |
| +// or with none and a control socket its helper refuses on. Every launch |
| +// here has no control socket, which is where the helper would otherwise |
| +// mint. |
| +func TestReplicaLaunchWithoutAControlSocket(t *testing.T) { |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| + homeKey := strings.Repeat("H", 43) + "=" |
| + for _, tc := range []replicaCase{ |
| + {name: "a keyless replica is refused by the agent, not left to mint", replica: true, own: tok, start: "refused"}, |
| + {name: "a configured key that is the home's runs and seals with it", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| + {name: "a pre-existing env key that is the home's runs", replica: true, own: tok + InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| + {name: "a configured key that differs is refused", replica: true, own: tok, config: InstanceKeyVar + "=configured-B\n", homeKey: homeKey, bucket: "configured-B", start: "refused"}, |
| + {name: "a configured key with no verified home key is refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", bucket: homeKey, start: "refused"}, |
| + // Ruling 27, Astra round 5 finding 1: the key changed in the bucket |
| + // to B and the home already holds it, while this box's sync is |
| + // behind and the replica's configuration and list entry still say A. |
| + {name: "the home updated, this replica's configuration delayed: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: "configured-B", start: "refused"}, |
| + {name: "the bucket cannot be read: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucketDown: true, start: "refused"}, |
| + {name: "no fresh reader: refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, noBucket: true, start: "refused"}, |
| + // Ruling 27's residual, documented: a change the bucket does not |
| + // hold (the home's own <host>.env edited by hand, or a per-host key |
| + // configured on the home only) is invisible here. The replica's |
| + // list entry and the bucket both still say A, so it runs with A. |
| + // Only the procedure, or O8's fresh evidence, closes this. |
| + {name: "residual: a change only on the home is not seen, and the replica runs with the old key", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, bucket: homeKey, start: "key " + instancekey.Digest(homeKey)}, |
| + // Ruling 28: a home whose key exists only in configuration, with a |
| + // replica that is handed none (its own overlay blanks it). O8 never |
| + // delivers a configured key, so the replica stays keyless and is |
| + // refused here, where it has no control socket. |
| + {name: "a config-only home's keyless replica is refused", replica: true, own: tok, config: InstanceKeyVar + "=\n", homeKey: homeKey, bucket: "", start: "refused"}, |
| + {name: "a home row mints and its app seals with the minted key", own: tok, wantKeyFromOwnOK: true}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b := testExecBackend(t) |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: tc.replica, Socket: noControlSocket(t)} |
| + own := filepath.Join(b.DataDir, rt.Host+".env") |
| + if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc.config != "" { |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if tc.homeKey != "" { |
| + b.ReplicaHomeKeys = func(h string) (string, bool) { |
| + return instancekey.Digest(tc.homeKey), h == rt.Host |
| + } |
| + } |
| + b.ReplicaConfigKey = freshConfig(tc) |
| + out := stageHelperApp(t, b, rt.Host, "v1") |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, err := b.Start(ctx, rt) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + got := outcomeOf(out) |
| + if tc.wantKeyFromOwnOK { |
| + env, _ := readEnvFile(own) |
| + if got != "key "+instancekey.Digest(env[InstanceKeyVar]) { |
| + t.Fatalf("home row: outcome %q, want the minted key", got) |
| + } |
| + return |
| + } |
| + if tc.start == "refused" { |
| + if !errors.Is(err, ErrReplicaKey) || got != "" { |
| + t.Fatalf("Start = %v, app outcome %q; want ErrReplicaKey and no app", err, got) |
| + } |
| + } else if got != tc.start { |
| + t.Fatalf("app outcome %q, want %q (Start: %v)", got, tc.start, err) |
| + } |
| + if raw, _ := os.ReadFile(own); string(raw) != tc.own { |
| + t.Fatal("a replica launch changed its own env file: a replica never mints") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A replica's seed has no control socket: a keyless replica's seed is |
| +// refused before it runs; one handed its home's key runs. |
| +func TestReplicaSeedIsGated(t *testing.T) { |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| + homeKey := strings.Repeat("H", 43) + "=" |
| + for _, tc := range []struct { |
| + name, config string |
| + want string |
| + }{ |
| + {"keyless", "", ""}, |
| + {"the home's key", InstanceKeyVar + "=" + homeKey + "\n", "key " + instancekey.Digest(homeKey)}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + b := testExecBackend(t) |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Seed: `["seed"]`} |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte(tok), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if tc.config != "" { |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), true } |
| + b.ReplicaConfigKey = freshConfig(replicaCase{bucket: homeKey}) |
| + out := stageHelperApp(t, b, rt.Host, "v1") |
| + err := b.Seed(context.Background(), rt) |
| + got := outcomeOf(out) |
| + if tc.want == "" { |
| + if !errors.Is(err, ErrReplicaKey) || got != "" { |
| + t.Fatalf("Seed = %v, outcome %q; want ErrReplicaKey and no seed process", err, got) |
| + } |
| + return |
| + } |
| + if err != nil || got != tc.want { |
| + t.Fatalf("Seed = %v, outcome %q; want %q", err, got, tc.want) |
| + } |
| + }) |
| + } |
| +} |
diff --git a/internal/activator/replicakey.go b/internal/activator/replicakey.go| new file mode 100644 |
| index 000000000..7905b5d9b |
| --- /dev/null |
| +++ b/internal/activator/replicakey.go |
| @@ -0,0 +1,122 @@ |
| +package activator |
| + |
| +// A replica gets its home's key (instance-key plan rulings 26 to 28). A |
| +// replica (#162) mirrors its home's data, sealed with the home's key, so |
| +// the only key it may be handed is exactly the home's effective key. The |
| +// platform never mints one on a replica row (ensureInstanceKeyFor), and |
| +// every replica launch passes replicaKeyGate before anything runs: |
| +// |
| +// - a replica whose env files deliver a key starts only when that key's |
| +// digest is its entry in the box's verified list (ReplicaHomeKeys, which |
| +// only the pre-roll check writes, read again at every launch) and, when |
| +// the key comes from the replica's configuration, the configuration in |
| +// the bucket renders that same key now (ReplicaConfigKey). Where the key |
| +// came from is decided by the same read that composed the env the child |
| +// gets (instanceEnvLayer, ruling 29), never by reading the files again. |
| +// A key that differs from either, or that cannot be checked, refuses |
| +// the launch with a log line. The fresh |
| +// read catches a configuration change that this box has not synced yet |
| +// while the home already holds the new key (ruling 27); |
| +// - a replica whose env files deliver no key starts only with a control |
| +// socket. There an app on rastrillo's InstanceKey with no key of its |
| +// own gets ErrNoInstanceKey and refuses (step 3), and an app that never |
| +// asks for a key runs as before. Without a socket (a seed, a claim |
| +// launch before O6, a control bind that failed) the helper would fall |
| +// to step 4 and mint its own key over the home's data, so the agent |
| +// refuses the launch itself rather than leave that to the helper. |
| +// |
| +// Delivering the home's key to a replica that has none is O8's, for a |
| +// platform-minted home key only (ruling 28): until then, and always for a |
| +// home whose key exists only in configuration, a keyless replica gets |
| +// nothing and is gated as above. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// ErrReplicaKey is a replica launch refused by replicaKeyGate. |
| +var ErrReplicaKey = errors.New("activator: replica launch refused: its instance key is not its home's") |
| + |
| +// replicaConfigTimeout bounds replicaKeyGate's fresh read of the bucket. |
| +const replicaConfigTimeout = 10 * time.Second |
| + |
| +// instanceEnvLayer is instanceEnv returning, from the same read, the layer |
| +// its CARLOS_INSTANCE_KEY came from (ruling 29). mergeEnv delivers exactly |
| +// the key instancekey.Effective names, so env[Var] and the layer always |
| +// describe the same bytes, however the files change afterwards. |
| +func (b *ExecBackend) instanceEnvLayer(rt registry.Route) (map[string]string, instancekey.Layer, error) { |
| + cfgEnv, ownEnv, err := b.instanceEnvParts(rt) |
| + if err != nil { |
| + return nil, instancekey.LayerNone, err |
| + } |
| + _, layer := instancekey.Effective(cfgEnv, ownEnv) |
| + return mergeEnv(cfgEnv, ownEnv), layer, nil |
| +} |
| + |
| +// replicaGateHook runs at the start of every replica gate, after the env |
| +// was read. A test seam: it lets a test change the env files between the |
| +// read and the gate, as a concurrent config sync can. Nil in production. |
| +var replicaGateHook func() |
| + |
| +// replicaKeyGate is rulings 26, 27 and 29's check for a launch of rt with |
| +// env (the composed env files), layer (where env's key came from, decided |
| +// by the same read) and, when haveControl, a bound control socket. It reads |
| +// no env file. It is nil for every row that is not a replica. Its errors and |
| +// log lines name the host and the reason, never a key or a digest. |
| +func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, layer instancekey.Layer, haveControl bool) error { |
| + if !rt.Replica { |
| + return nil |
| + } |
| + if replicaGateHook != nil { |
| + replicaGateHook() |
| + } |
| + refuse := func(why string) error { |
| + log.Printf("activator: replica %s not started: %s", rt.Host, why) |
| + return fmt.Errorf("%w: %s: %s", ErrReplicaKey, rt.Host, why) |
| + } |
| + key := env[instancekey.Var] |
| + if key == "" { |
| + if haveControl { |
| + return nil |
| + } |
| + return refuse("it has no key of its home's and no control socket, so an app on the instance-key helper would mint its own") |
| + } |
| + var home string |
| + var ok bool |
| + if b.ReplicaHomeKeys != nil { |
| + home, ok = b.ReplicaHomeKeys(rt.Host) |
| + } |
| + switch { |
| + case !ok: |
| + return refuse(instancekey.Var + " cannot be checked against its home's key (no entry in CARLOS_REPLICA_HOME_KEYS; O8)") |
| + case instancekey.Digest(key) != home: |
| + return refuse(instancekey.Var + " is not its home's key") |
| + } |
| + switch layer { |
| + case instancekey.LayerOwn: |
| + return nil // the replica's own file: it changes only by hand |
| + case instancekey.LayerConfig: |
| + default: |
| + return refuse("where its " + instancekey.Var + " came from is unknown") |
| + } |
| + if b.ReplicaConfigKey == nil { |
| + return refuse("its configured " + instancekey.Var + " cannot be checked against the bucket's configuration") |
| + } |
| + ctx, cancel := context.WithTimeout(context.Background(), replicaConfigTimeout) |
| + defer cancel() |
| + fresh, err := b.ReplicaConfigKey(ctx, rt) |
| + switch { |
| + case err != nil: |
| + return refuse("the bucket's configuration could not be read: " + err.Error()) |
| + case fresh != instancekey.Digest(key): |
| + return refuse("its configured " + instancekey.Var + " is not the one the bucket's configuration now holds: a key change is in flight (ruling 27)") |
| + } |
| + return nil |
| +} |
diff --git a/internal/activator/replicakey_race_test.go b/internal/activator/replicakey_race_test.go| new file mode 100644 |
| index 000000000..931666206 |
| --- /dev/null |
| +++ b/internal/activator/replicakey_race_test.go |
| @@ -0,0 +1,94 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Ruling 29 (Astra round 6 finding 1), deterministic: the launch reads a |
| +// configured key A, and then a config sync removes the key from this box's |
| +// <host>.config.env (the bucket no longer holds it) before the replica |
| +// gate runs. The gate decides on the layer the launch's own read found, so |
| +// it still consults the bucket, which now renders no key, and refuses. A |
| +// second read of the files would have found no key, skipped the bucket |
| +// and admitted a child that is handed A. |
| +func TestReplicaGateDecidesOnTheLaunchesOwnRead(t *testing.T) { |
| + tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" |
| + keyA := strings.Repeat("A", 43) + "=" |
| + b := testExecBackend(t) |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Socket: noControlSocket(t)} |
| + cfgPath := filepath.Join(b.DataDir, rt.Host+".config.env") |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte(tok), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(cfgPath, []byte(InstanceKeyVar+"="+keyA+"\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(keyA), true } |
| + b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { return "", nil } // the bucket: key removed |
| + syncs := 0 |
| + replicaGateHook = func() { |
| + syncs++ |
| + if err := os.WriteFile(cfgPath, []byte("OTHER=1\n"), 0o600); err != nil { |
| + t.Error(err) |
| + } |
| + } |
| + t.Cleanup(func() { replicaGateHook = nil }) |
| + out := stageHelperApp(t, b, rt.Host, "v1") |
| + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| + defer cancel() |
| + proc, _, err := b.Start(ctx, rt) |
| + if proc != nil { |
| + defer proc.Stop(context.Background()) |
| + } |
| + if syncs != 1 { |
| + t.Fatalf("the sync ran %d times between the read and the gate, want once", syncs) |
| + } |
| + if got := outcomeOf(out); !errors.Is(err, ErrReplicaKey) || got != "" { |
| + t.Fatalf("Start = %v, app outcome %q; want ErrReplicaKey and no app", err, got) |
| + } |
| +} |
| + |
| +// The gate reads no env file: given the env and layer the launch's read |
| +// captured, it decides the same with the files gone. |
| +func TestReplicaGateReadsNoEnvFile(t *testing.T) { |
| + keyA := strings.Repeat("A", 43) + "=" |
| + b := &ExecBackend{DataDir: t.TempDir()} |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true} |
| + if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(InstanceKeyVar+"="+keyA+"\n"), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + env, layer, err := b.instanceEnvLayer(rt) |
| + if err != nil || layer != instancekey.LayerConfig || env[InstanceKeyVar] != keyA { |
| + t.Fatalf("instanceEnvLayer = %v, %v", layer, err) |
| + } |
| + if err := os.RemoveAll(b.DataDir); err != nil { |
| + t.Fatal(err) |
| + } |
| + b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(keyA), true } |
| + for _, tc := range []struct { |
| + bucket string |
| + ok bool |
| + }{{keyA, true}, {"", false}} { |
| + b.ReplicaConfigKey = func(context.Context, registry.Route) (string, error) { |
| + if tc.bucket == "" { |
| + return "", nil |
| + } |
| + return instancekey.Digest(tc.bucket), nil |
| + } |
| + if err := b.replicaKeyGate(rt, env, layer, false); (err == nil) != tc.ok { |
| + t.Fatalf("bucket %q: gate = %v, want ok %v", tc.bucket, err, tc.ok) |
| + } |
| + } |
| + if err := b.replicaKeyGate(rt, env, instancekey.LayerNone, false); !errors.Is(err, ErrReplicaKey) { |
| + t.Fatalf("a key with no known layer = %v, want a refusal", err) |
| + } |
| +} |
diff --git a/internal/appconfig/instancekey.go b/internal/appconfig/instancekey.go| new file mode 100644 |
| index 000000000..c60051498 |
| --- /dev/null |
| +++ b/internal/appconfig/instancekey.go |
| @@ -0,0 +1,34 @@ |
| +package appconfig |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ConfiguredKeyDigest is the SHA-256 of the CARLOS_INSTANCE_KEY rt's |
| +// configuration in the bucket renders now, "" when it renders none: the |
| +// same merge and renderer SyncExec writes <host>.config.env with |
| +// (LoadMergedHostVersions, RenderEnv), read fresh rather than from the |
| +// box's last sync. A replica launch whose configured key is not this |
| +// digest is refused (instance-key plan ruling 27): a configuration change |
| +// is in flight, and the home may already hold the new key. The value |
| +// never leaves this function. |
| +func ConfiguredKeyDigest(ctx context.Context, s release.Store, priv *ecdh.PrivateKey, rt registry.Route) (string, error) { |
| + m, _, err := LoadMergedHostVersions(ctx, s, release.RouteAccount(rt), rt.App, rt.Environment, rt.Host) |
| + if err != nil || m == nil { |
| + return "", err |
| + } |
| + rendered, err := RenderEnv(m, priv, routeBinding(rt)) |
| + if err != nil { |
| + return "", err |
| + } |
| + key, _ := instancekey.Effective(instancekey.ParseEnv(rendered), nil) |
| + if key == "" { |
| + return "", nil |
| + } |
| + return instancekey.Digest(key), nil |
| +} |
diff --git a/internal/appconfig/instancekey_test.go b/internal/appconfig/instancekey_test.go| new file mode 100644 |
| index 000000000..e6d23aba8 |
| --- /dev/null |
| +++ b/internal/appconfig/instancekey_test.go |
| @@ -0,0 +1,42 @@ |
| +package appconfig |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// Ruling 27's fresh check: the digest of the key the bucket's |
| +// configuration renders now, from the app's bundle or the host's overlay, |
| +// plain or sealed; "" when it renders none. |
| +func TestConfiguredKeyDigestReadsTheBucketNow(t *testing.T) { |
| + ctx, s, _, _, priv, pub := execKit(t) |
| + pk, _ := ParsePrivateKey(priv, "test") |
| + pubKey, _ := ParsePublicKey(pub, "test") |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true} |
| + check := func(want string) { |
| + t.Helper() |
| + got, err := ConfiguredKeyDigest(ctx, s, pk, rt) |
| + if err != nil || got != want { |
| + t.Fatalf("ConfiguredKeyDigest = %q, %v; want %q", got, err, want) |
| + } |
| + } |
| + check("") |
| + if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "key-A"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + check(instancekey.Digest("key-A")) |
| + if err := SetEnv(ctx, s, "acct", "shop", map[string]string{"CARLOS_INSTANCE_KEY": "key-B"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + check(instancekey.Digest("key-B")) |
| + sealed, err := Seal(pubKey, Binding{Account: "acct", App: "shop"}, []byte("key-C")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := ApplyInstance(ctx, s, "acct", "shop", rt.Host, map[string]Var{"CARLOS_INSTANCE_KEY": {Kind: "secret", Envelope: sealed}}, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + check(instancekey.Digest("key-C")) |
| +} |
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 7457ed444..8ac99978b 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -360,6 +360,13 @@ func runAgent(args []string, buildClaims ClaimFactory) { |
| if logSink != nil { // a typed-nil in the interface would NOT be nil |
| eb.Logs = logSink |
| } |
| + // The verified home keys of this box's replica rows (L21 rulings 26 |
| + // and 30), read again at every replica launch, until O8 reads them |
| + // from each home's published env item. |
| + eb.ReplicaHomeKeys = replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) }) |
| + // The bucket's configuration, read fresh at each replica launch |
| + // whose key is configured (ruling 27). |
| + eb.ReplicaConfigKey = replicaConfigKey(os.Getenv, openDeployStore) |
| // The root instance-control channel (design §5.3) and the box facts |
| // a nested wake needs beyond the row (design §8.1, Task 6): the |
| // journal check and the peer list both read this SAME registry |
diff --git a/internal/host/replicahomekeys.go b/internal/host/replicahomekeys.go| new file mode 100644 |
| index 000000000..a522fdc57 |
| --- /dev/null |
| +++ b/internal/host/replicahomekeys.go |
| @@ -0,0 +1,88 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "crypto/ecdh" |
| + "errors" |
| + "log" |
| + "os" |
| + "strings" |
| + "sync" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// replicaHomeKeysPath is the box's list of verified home keys for its |
| +// replica rows (the list the plan calls CARLOS_REPLICA_HOME_KEYS): |
| +// "replica-host=sha256" entries, separated by commas or newlines. Only the |
| +// pre-roll check writes it, as root, replacing it whole (write a temporary |
| +// file, then rename it into place); the agent only reads it. |
| +const replicaHomeKeysPath = "/etc/carlos/replica-home-keys" |
| + |
| +// replicaHomeKeys is ExecBackend.ReplicaHomeKeys (instance-key plan rulings |
| +// 26, 27 and 30): it reads the list again at every call, so an entry the |
| +// procedure removes or replaces takes effect at the next replica launch, |
| +// with no agent restart and no cached copy to go stale. Nothing refreshes |
| +// the list from a replica's own configuration. An absent list verifies |
| +// nothing. A list that cannot be read or parsed is logged by the error's |
| +// position and reason, never its text, and verifies nothing, so every |
| +// replica launch that would be handed a key is refused until it is fixed. |
| +func replicaHomeKeys(read func() ([]byte, error)) func(host string) (string, bool) { |
| + return func(host string) (string, bool) { |
| + raw, err := read() |
| + if errors.Is(err, os.ErrNotExist) { |
| + return "", false |
| + } |
| + if err != nil { |
| + log.Printf("agent: replica home keys: the list could not be read (no replica home key verifies): %v", err) |
| + return "", false |
| + } |
| + keys, err := instancekey.ParseHomeKeys(strings.ReplaceAll(string(raw), "\n", ",")) |
| + if err != nil { |
| + log.Printf("agent: replica home keys: %v (no replica home key verifies until it is fixed)", err) |
| + return "", false |
| + } |
| + sha, ok := keys[host] |
| + return sha, ok |
| + } |
| +} |
| + |
| +// replicaConfigKey is ExecBackend.ReplicaConfigKey (ruling 27): the digest |
| +// of the key rt's configuration in the bucket renders now, read with the |
| +// box's secrets key through the deploy store. The store and key are opened |
| +// on first use and kept once both open; a failure is returned, so the |
| +// replica is refused, and is tried again at its next launch. |
| +func replicaConfigKey(getenv func(string) string, open func(context.Context) (release.Store, error)) func(context.Context, registry.Route) (string, error) { |
| + var ( |
| + mu sync.Mutex |
| + store release.Store |
| + priv *ecdh.PrivateKey |
| + ) |
| + return func(ctx context.Context, rt registry.Route) (string, error) { |
| + mu.Lock() |
| + if store == nil { |
| + raw := getenv("CARLOS_SECRETS_KEY") |
| + if raw == "" { |
| + mu.Unlock() |
| + return "", errors.New("CARLOS_SECRETS_KEY is not set") |
| + } |
| + k, err := appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") |
| + if err != nil { |
| + mu.Unlock() |
| + return "", err |
| + } |
| + st, err := open(ctx) |
| + if err != nil { |
| + mu.Unlock() |
| + return "", err |
| + } |
| + store, priv = st, k |
| + } |
| + st, k := store, priv |
| + mu.Unlock() |
| + return appconfig.ConfiguredKeyDigest(ctx, st, k, rt) |
| + } |
| +} |
diff --git a/internal/host/replicahomekeys_test.go b/internal/host/replicahomekeys_test.go| new file mode 100644 |
| index 000000000..3a321272d |
| --- /dev/null |
| +++ b/internal/host/replicahomekeys_test.go |
| @@ -0,0 +1,121 @@ |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Ruling 30 (Astra round 6 finding 2): the list is read again at every |
| +// call, so the procedure's invalidation and its new entries take effect at |
| +// the next replica launch, with no restart. An absent list verifies |
| +// nothing. |
| +func TestReplicaHomeKeysReadsTheListAtEveryLaunch(t *testing.T) { |
| + a, b := instancekey.Digest("home-key-a"), instancekey.Digest("home-key-b") |
| + path := filepath.Join(t.TempDir(), "replica-home-keys") |
| + keys := replicaHomeKeys(func() ([]byte, error) { return os.ReadFile(path) }) |
| + if _, ok := keys("shop-au.example.com"); ok { |
| + t.Fatal("an absent list verified a key") |
| + } |
| + write := func(body string) { |
| + t.Helper() |
| + tmp := path + ".tmp" |
| + if err := os.WriteFile(tmp, []byte(body), 0o640); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Rename(tmp, path); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + write("shop-au.example.com=" + a + "\nshop-uk.example.com=" + a + "\n") |
| + if got, ok := keys("shop-au.example.com"); !ok || got != a { |
| + t.Fatalf("listed replica = %q, %v", got, ok) |
| + } |
| + // The procedure's invalidation: the entry is gone at the next call. |
| + write("shop-uk.example.com=" + a + "\n") |
| + if _, ok := keys("shop-au.example.com"); ok { |
| + t.Fatal("a removed entry still verified: the list was cached") |
| + } |
| + // And its replacement, after the key change and the re-run check. |
| + write("shop-au.example.com=" + b + ",shop-uk.example.com=" + a) |
| + if got, ok := keys("shop-au.example.com"); !ok || got != b { |
| + t.Fatalf("the replaced entry = %q, %v; want the new digest", got, ok) |
| + } |
| + if _, ok := keys("shop-nz.example.com"); ok { |
| + t.Fatal("an unlisted replica verified") |
| + } |
| +} |
| + |
| +// Astra round 5 finding 3: a malformed list is logged by entry position and |
| +// reason, never by its text (a bare digest, with no host before it, would |
| +// otherwise reach the log), and verifies nothing. |
| +func TestReplicaHomeKeysLogsAMalformedListWithoutItsText(t *testing.T) { |
| + sha := instancekey.Digest("home-key") |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + defer log.SetOutput(os.Stderr) |
| + keys := replicaHomeKeys(func() ([]byte, error) { return []byte("shop-au.example.com=" + sha + "\n" + sha + "\n"), nil }) |
| + if _, ok := keys("shop-au.example.com"); ok { |
| + t.Fatal("a malformed list verified a key") |
| + } |
| + line := buf.String() |
| + if !strings.Contains(line, "CARLOS_REPLICA_HOME_KEYS entry 2") || strings.Contains(line, sha[:16]) || strings.Contains(line, "shop-au") { |
| + t.Fatalf("log line %q: want the entry's position and reason, and none of its text", line) |
| + } |
| + buf.Reset() |
| + if _, ok := replicaHomeKeys(func() ([]byte, error) { return nil, errors.New("permission denied") })("shop-au.example.com"); ok { |
| + t.Fatal("an unreadable list verified a key") |
| + } |
| + if !strings.Contains(buf.String(), "could not be read") { |
| + t.Fatalf("log line %q", buf.String()) |
| + } |
| +} |
| + |
| +// Ruling 27's fresh read, as the agent wires it: the bucket's current |
| +// configured key, through the deploy store and the box's secrets key. With |
| +// no secrets key it is an error, so the replica is refused. |
| +func TestReplicaConfigKeyReadsTheBucketNow(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + priv, _, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := appconfig.SetEnv(ctx, s, "acct", "shop", map[string]string{instancekey.Var: "key-A"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", App: "shop", Account: "acct", Replica: true} |
| + opened := 0 |
| + open := func(context.Context) (release.Store, error) { opened++; return s, nil } |
| + read := replicaConfigKey(func(k string) string { |
| + if k == "CARLOS_SECRETS_KEY" { |
| + return priv |
| + } |
| + return "" |
| + }, open) |
| + for i := 0; i < 2; i++ { |
| + if got, err := read(ctx, rt); err != nil || got != instancekey.Digest("key-A") { |
| + t.Fatalf("read %d = %q, %v", i, got, err) |
| + } |
| + } |
| + if opened != 1 { |
| + t.Fatalf("the store was opened %d times, want once", opened) |
| + } |
| + failing := replicaConfigKey(func(string) string { return priv }, func(context.Context) (release.Store, error) { return nil, errors.New("bucket unreachable") }) |
| + if _, err := failing(ctx, rt); err == nil { |
| + t.Fatal("an unreachable bucket was not an error") |
| + } |
| + if _, err := replicaConfigKey(func(string) string { return "" }, open)(ctx, rt); err == nil { |
| + t.Fatal("no secrets key was not an error") |
| + } |
| +} |
diff --git a/internal/instancekey/instancekey.go b/internal/instancekey/instancekey.go| index f99491301..825194d95 100644 |
| --- a/internal/instancekey/instancekey.go |
| +++ b/internal/instancekey/instancekey.go |
| @@ -200,12 +200,16 @@ func ValidDigest(s string) bool { |
| return true |
| } |
| -// ParseHomeKeys reads CARLOS_REPLICA_HOME_KEYS, the box's list of verified |
| -// home keys for its replica rows (instance-key plan rulings 26 and 27): |
| -// "replica-host=sha256,replica-host=sha256", each digest the home's |
| -// effective key as the pre-roll check computed it. Only the pre-roll check |
| -// writes it; nothing refreshes it from a replica's own configuration. |
| -// Whitespace around entries is ignored and "" is an empty list. A |
| +// ParseHomeKeys parses the box's list of verified home keys for its replica |
| +// rows, the file /etc/carlos/replica-home-keys (the list the plan calls |
| +// CARLOS_REPLICA_HOME_KEYS; instance-key plan rulings 26, 27 and 30): |
| +// "replica-host=sha256" entries, each digest the home's effective key as |
| +// the pre-roll check computed it. Only the pre-roll check writes the file; |
| +// nothing refreshes it from a replica's own configuration. The file |
| +// separates entries with commas or newlines, but this splits on commas |
| +// only, so its reader turns newlines into commas first (the agent's |
| +// replicaHomeKeys). Whitespace around entries is ignored and "" is an |
| +// empty list. A |
| // malformed entry, or a host named twice, fails the whole value: a list |
| // that may be wrong is not a list to verify keys against. Its errors name |
| // the entry by position and the reason, never the entry's text, which may |