Files
mesh-controller/internal/inventory/secrets.go
T
jschoubben c37d368f65 A module may need a secret of its own, and the provisioner watches
Two things, both found by trying to write a real postgres module and
discovering it could not be said.

A database has a superuser password, a broker an administrator, a
registry an account. None of them is *for* anybody — they are not the
credential a consumer is given, and the mechanism that hands those out
has a consumer in the middle of it. So a module may declare what it needs
and where to put it, and the mesh generates one per node, seals it, and
reads it no more than it reads any other.

Per node, deliberately: a module running on three machines has three
passwords. One in the manifest instead would put the same secret on every
machine that ever runs it, in a file anybody can read, for ever. Made
once and kept, or a running database would be handed a password it was
not started with; remade when the machine's sealing key changes, like
everything else sealed here.

A need declared and not made is refused rather than skipped, because a
module whose own credential is silently absent starts, fails to
authenticate, and the reason is three layers from the machine reporting
it.

And the provisioner can watch. That is what lets it be a module rather
than a binary somebody places: run once, it needs invoking after every
declaration by a timer or a unit wired to a file; watching, it is an
ordinary long-running service the host already supervises. It polls
rather than watching the filesystem, because the host writes atomically —
the file is replaced, so a watch on the path stops seeing anything after
the first replacement, and a watcher that silently stops working is worse
than a poll. Credentials are compared by digest and never held: this runs
for as long as the machine is up.
2026-08-30 18:22:05 +02:00

183 lines
6.4 KiB
Go

package inventory
import (
"context"
"fmt"
"github.com/novox/mesh-control/internal/secrets"
)
// Where sealed secrets live.
//
// The table holds nothing usable — see the migration and internal/secrets for why that is the
// design rather than an inconvenience.
// Secret is one provision's credential, sealed to each end.
type Secret struct {
Name string
Consumer string
Provider string
ForConsumer string
ForProvider string
ConsumerKey string
ProviderKey string
}
// SecretFor is the credential for one provision between two nodes, making one the first time.
//
// **Made once and kept**, rather than regenerated whenever it is asked for. A secret that changed
// on every declaration would restart both ends on every push and would mean the password a
// provider was told to create never matches the one a consumer was given — which is a mesh that
// reports success and cannot connect.
//
// **Remade when either end's sealing key changes.** A node that rejoined generated a new key and
// can no longer open what was sealed to the old one, so keeping the blob would deliver something
// unreadable for ever. The new secret reaches both ends in the same push, which is the only
// moment they can be changed together.
func (i *Inventory) SecretFor(ctx context.Context, name, consumer, provider string) (Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return Secret{}, err
}
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return Secret{}, err
}
var held Secret
err = i.store.Pool().QueryRow(ctx,
`select for_consumer, for_provider, consumer_key, provider_key from secret
where name = $1 and consumer = $2 and provider = $3`,
name, consumerNode.ID, providerNode.ID).
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey)
if err == nil && held.ConsumerKey == consumerKey && held.ProviderKey == providerKey {
held.Name, held.Consumer, held.Provider = name, consumer, provider
return held, nil
}
made, err := secrets.Make(consumerKey, providerKey)
if err != nil {
return Secret{}, err
}
_, err = i.store.Pool().Exec(ctx,
`insert into secret (name, consumer, provider, for_consumer, for_provider,
consumer_key, provider_key)
values ($1, $2, $3, $4, $5, $6, $7)
on conflict (name, consumer, provider) do update set
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
created_at = now()`,
name, consumerNode.ID, providerNode.ID,
made.ForConsumer, made.ForProvider, made.ConsumerKey, made.ProviderKey)
if err != nil {
return Secret{}, err
}
return Secret{Name: name, Consumer: consumer, Provider: provider,
ForConsumer: made.ForConsumer, ForProvider: made.ForProvider,
ConsumerKey: made.ConsumerKey, ProviderKey: made.ProviderKey}, nil
}
// RotateSecret discards what was there, so the next declaration carries a new one.
//
// Only a delete. Nothing reads the old value first, because nothing can — and making the
// replacement here rather than on the next read would be a second path to the same act, which is
// how two ends come to hold different passwords.
//
// The new secret then reaches both ends on the same push, together, which is what makes rotation
// a single event rather than a fanout with a window where half the mesh holds a dead credential.
func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, provider string) error {
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`delete from secret where name = $1 and consumer = $2 and provider = $3`,
name, consumerNode.ID, providerNode.ID)
return err
}
// SecretsFrom is every credential a provider node was issued, so it can be told what to create.
func (i *Inventory) SecretsFrom(ctx context.Context, provider string) ([]Secret, error) {
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return nil, err
}
rows, err := i.store.Pool().Query(ctx,
`select s.name, c.name, s.for_provider from secret s
join node c on c.id = s.consumer
where s.provider = $1 order by s.name, c.name`, providerNode.ID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Secret
for rows.Next() {
s := Secret{Provider: provider}
if err := rows.Scan(&s.Name, &s.Consumer, &s.ForProvider); err != nil {
return nil, err
}
out = append(out, s)
}
return out, rows.Err()
}
// SecretForModule is a secret a module needs in order to be itself, on one machine.
//
// Not the credential a consumer is given: a superuser password is not *for* anybody. Made once
// and kept, because regenerating it on every declaration would change the password a running
// database has already been started with — and remade when the node's sealing key changes, for
// the same reason as everything else sealed here.
func (i *Inventory) SecretForModule(ctx context.Context, node, module, name string) (string, error) {
key, err := i.SealingKeyOf(ctx, node)
if err != nil {
return "", err
}
if key == "" {
return "", fmt.Errorf(
"%s needs a secret and %s has no sealing key, so nothing can be sealed to it",
module, node)
}
record, err := i.NodeByName(ctx, node)
if err != nil {
return "", err
}
var sealed, against string
err = i.store.Pool().QueryRow(ctx,
`select sealed, node_key from module_secret where node = $1 and module = $2 and name = $3`,
record.ID, module, name).Scan(&sealed, &against)
if err == nil && against == key {
return sealed, nil
}
made, err := secrets.Make(key, key)
if err != nil {
return "", err
}
// Sealed once, to one recipient. Make seals to two ends because a provision has two; here
// both are the same machine, and only one copy is kept.
if _, err := i.store.Pool().Exec(ctx,
`insert into module_secret (node, module, name, sealed, node_key)
values ($1, $2, $3, $4, $5)
on conflict (node, module, name) do update set
sealed = excluded.sealed, node_key = excluded.node_key, made_at = now()`,
record.ID, module, name, made.ForConsumer, key); err != nil {
return "", err
}
return made.ForConsumer, nil
}