package host import ( "context" "crypto/ecdh" "errors" "log" "os" "strings" "sync" "github.com/carlosframework/platform/internal/activator" "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" // instanceKeyReaders are the two readers every ExecBackend that launches in // production is given: the box's verified home keys for its replica rows // (rulings 26 and 30) and the bucket's configured key (rulings 5 and 27). // The agent's backend and the claim path's backend (claimExecBackend) are // wired from the same pair, so neither can gate a replica or mint a key // by a rule the other lacks (final review I1). type instanceKeyReaders struct { HomeKeys func(host string) (sha256 string, ok bool) Configured func(context.Context, registry.Route) (sha256 string, err error) } // newInstanceKeyReaders builds the pair from a box: getenv for the secrets // key and the flags, open for the deploy store, list for the home-keys // file. func newInstanceKeyReaders(getenv func(string) string, open func(context.Context) (release.Store, error), list func() ([]byte, error)) instanceKeyReaders { return instanceKeyReaders{HomeKeys: replicaHomeKeys(list), Configured: configuredKey(getenv, open)} } // productionInstanceKeyReaders reads this box: its environment, its deploy // store and /etc/carlos/replica-home-keys. func productionInstanceKeyReaders() instanceKeyReaders { return newInstanceKeyReaders(os.Getenv, openDeployStore, func() ([]byte, error) { return os.ReadFile(replicaHomeKeysPath) }) } // wire gives eb both readers. func (k instanceKeyReaders) wire(eb *activator.ExecBackend) { eb.ReplicaHomeKeys = k.HomeKeys eb.ConfiguredKey = k.Configured } // 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. // Its log lines name the file by replicaHomeKeysPath, the list it reads in // production, so an operator knows what to fix. 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: %s could not be read (no replica home key verifies): %v", replicaHomeKeysPath, err) return "", false } keys, err := instancekey.ParseHomeKeys(strings.ReplaceAll(string(raw), "\n", ",")) if err != nil { log.Printf("agent: replica home keys: %s: %v (no replica home key verifies until it is fixed)", replicaHomeKeysPath, err) return "", false } sha, ok := keys[host] return sha, ok } } // configuredKey is ExecBackend.ConfiguredKey (rulings 5 and 27): the // digest of the key rt's configuration in the bucket renders now, read // through the deploy store with the box's secrets key. Which boxes have a // configuration to read is decided as the config guard decides whether a // box can provide a route's config at all (configguard.go): a box with no // deployment store and CARLOS_NO_SEALED_CONFIG set has no configuration // source and renders no key; one with no store and no such flag is an // error; one with a store and no secrets key renders what it can, and a // sealed value fails the render here as it fails the guard. The store is // opened on first use and kept once open; a failed open is returned, so // the mint is deferred or the replica refused, and is tried again at the // next launch. func configuredKey(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 { var k *ecdh.PrivateKey if raw := getenv("CARLOS_SECRETS_KEY"); raw != "" { var err error k, err = appconfig.ParsePrivateKey(raw, "CARLOS_SECRETS_KEY") if err != nil { mu.Unlock() return "", err } } st, err := open(ctx) if errors.Is(err, errNoDeployStore) && noSealedConfigFlag(getenv) { mu.Unlock() return "", nil // no configuration source: nothing is on its way } if err != nil { mu.Unlock() return "", err } store, priv = st, k } st, k := store, priv mu.Unlock() return appconfig.ConfiguredKeyDigest(ctx, st, k, rt) } }