internal/server/store/trustedcas.go
Ref: Size: 5.1 KiB History
package store
import (
"encoding/json"
"fmt"
"log/slog"
"golang.org/x/crypto/ssh"
)
// backfillTrustedCAs freezes a CA set onto every VM row that has none, and is
// the upgrade half of "a guest trusts the set it was created against".
//
// A row written before vms.trusted_cas existed records nothing, and the
// snapshot answers that by serving the tenant's LIVE set instead (see
// syncsvc.buildSnapshot). Left alone that is permanent: such a VM would keep
// receiving whatever the tenant has registered today for the rest of its life,
// the console could never mark it stale because there is no recorded set to
// compare against, and "frozen at create" would stay false for every guest that
// predates the column. One fact would have two authorities.
//
// Freezing here, at the upgrade, is the closest approximation of freeze-at-
// create those rows can still be given: the set they get is the one they were
// already being served, and from now on it is written down. A tenant with no
// registered CA gives its VMs an EMPTY set rather than leaving them unrecorded —
// "this guest trusts nothing" is a true statement about a guest whose tenant has
// registered nothing, and it is what the live fallback was serving anyway. It is
// also rare: create refuses a tenant with no CA, so only a VM whose tenant
// removed its last CA afterwards can be in that position.
//
// This runs on every Open rather than once ever, and is idempotent by
// construction (a row with a set is never touched). Running it every time is
// deliberate: v0.0.5 writes no trusted_cas at all, so a plane that rolls back
// and forward again mints fresh unrecorded rows, and a one-shot marker would
// leave exactly those rows unfrozen forever — the bug this fixes.
func (s *Store) backfillTrustedCAs() error {
unrecorded, err := s.unrecordedVMs()
if err != nil {
return err
}
if len(unrecorded) == 0 {
return nil
}
// Tombstoned rows are included on purpose: a tombstone can be restored, and
// a restored VM must not come back with a moving CA set.
frozen := map[string]string{} // tenant -> JSON, one read per tenant
for _, r := range unrecorded {
enc, ok := frozen[r.tenant]
if !ok {
cas, err := s.ListTenantUserCAs(r.tenant)
if err != nil {
return fmt.Errorf("list user cas for %s: %w", r.tenant, err)
}
b, err := json.Marshal(FreezeCAs(cas))
if err != nil {
return fmt.Errorf("marshal frozen cas for %s: %w", r.tenant, err)
}
enc = string(b)
frozen[r.tenant] = enc
}
// IS NULL again in the predicate so a row that acquired a set between
// the scan and here keeps the one it was given.
if _, err := s.db.Exec(
`UPDATE vms SET trusted_cas=? WHERE id=? AND trusted_cas IS NULL`, enc, r.id); err != nil {
return fmt.Errorf("freeze trusted cas on %s: %w", r.id, err)
}
}
slog.Info("froze the trusted CA set of VMs that had no record", "vms", len(unrecorded))
return nil
}
// unrecordedVM is one VM row carrying no trusted_cas, and its tenant — the only
// two things the backfill needs to decide what to write.
type unrecordedVM struct{ id, tenant string }
// unrecordedVMs reads the rows the backfill has work to do on, whole, before it
// writes anything: the write is an UPDATE on the same table this reads.
func (s *Store) unrecordedVMs() ([]unrecordedVM, error) {
rows, err := s.db.Query(`SELECT id, tenant FROM vms WHERE trusted_cas IS NULL`)
if err != nil {
return nil, fmt.Errorf("scan for unrecorded trusted cas: %w", err)
}
defer rows.Close()
var out []unrecordedVM
for rows.Next() {
var v unrecordedVM
if err := rows.Scan(&v.id, &v.tenant); err != nil {
return nil, fmt.Errorf("scan unrecorded vm: %w", err)
}
out = append(out, v)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("scan for unrecorded trusted cas: %w", err)
}
return out, nil
}
// FreezeCAs turns a tenant's registered CA set into the record that goes onto a
// VM row at create — the set that guest will trust for the rest of its life,
// whatever the tenant registers or removes afterwards.
//
// It carries the authorized_keys line as well as the SHA256 fingerprint
// OpenSSH would print, because the row has to SERVE this trust to the agent
// that bakes it and a fingerprint is one-way. The fingerprint is computed once,
// here, so a console re-reading these rows on a tick does not parse a key per
// read forever.
//
// A CA whose stored line will not parse still contributes its line, with an
// empty fingerprint. The line is what reaches the guest, so dropping the entry
// would quietly narrow the trust the tenant asked for; an empty fingerprint
// says "we could not name this one", a display problem, not a reason to change
// what the guest trusts.
//
// Exported so the create path (internal/server/api) freezes exactly what the
// snapshot path does, from one source — the API package already imports store.
func FreezeCAs(cas []TenantUserCA) []TrustedCA {
out := make([]TrustedCA, 0, len(cas))
for _, c := range cas {
fp := ""
if pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(c.Pubkey)); err == nil {
fp = ssh.FingerprintSHA256(pub)
}
out = append(out, TrustedCA{Label: c.Label, Fingerprint: fp, AuthorizedKey: c.Pubkey})
}
return out
}