package console import ( "fmt" "os" "regexp" "strings" "testing" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/status" ) // The receipt database identity also names the EC2 instance the console runs // on. A disk image or snapshot keeps the nonce, the filesystem UUID and the // inode, so without it a clone could fence or commit the original's SQL // effects. Root's enrollment file says which instance this disk was enrolled // on, but the image carries that file too, so it is only believed when the // running machine says the same; OpenGated refuses a database whose stored // instance is another machine's. const ( sqlHostActorPath = "/etc/carlos/host-actor.json" // On Nitro instances the hypervisor sets the board asset tag to the // instance ID; a disk copy cannot carry it. sqlDMIAssetTagPath = "/sys/devices/virtual/dmi/id/board_asset_tag" ) var sqlInstanceIdentity = readSQLInstanceIdentity // The sources are vars so tests can stand in for /etc, /sys and IMDS. var ( sqlEnrolledInstance = func() (string, error) { e, err := hostfloor.ReadEnrollment(sqlHostActorPath) if err != nil { return "", err } return e.InstanceID, nil } sqlDMIInstance = func() string { b, err := os.ReadFile(sqlDMIAssetTagPath) if err != nil { return "" } return strings.TrimSpace(string(b)) } // status.InstanceID is IMDSv2 bounded to two seconds; it says "" off EC2. sqlIMDSInstance = status.InstanceID ) var ec2InstanceID = regexp.MustCompile(`^i-[0-9a-f]{8,17}$`) // sqlLiveInstance is the running machine's instance ID, or "". The Nitro // asset tag is read first because it needs no network; other hardware puts // arbitrary text there, so only an instance-shaped tag counts. func sqlLiveInstance() string { if id := sqlDMIInstance(); ec2InstanceID.MatchString(id) { return id } return sqlIMDSInstance() } func readSQLInstanceIdentity() (string, error) { enrolled, err := sqlEnrolledInstance() if err != nil { return "", err } live := sqlLiveInstance() if live == "" { return "", fmt.Errorf("%w: cannot read this machine's instance ID", hostops.ErrIdentity) } if live != enrolled { return "", fmt.Errorf("%w: enrolled on instance %q but running on %q", hostops.ErrIdentity, enrolled, live) } return live, nil } // sqlInstance is the validated per-instance component. A ':' would make the // database ID ambiguous, so it is refused with an empty one. func sqlInstance() (string, error) { id, err := sqlInstanceIdentity() if err != nil { return "", err } if id == "" || strings.ContainsAny(id, ":\n") { return "", hostops.ErrIdentity } return id, nil } func useSQLInstanceIdentity(fn func() (string, error)) (restore func()) { old := sqlInstanceIdentity sqlInstanceIdentity = fn return func() { sqlInstanceIdentity = old } } // UseSQLInstanceIdentityForTest makes OpenGated see id as this machine's // instance until restore runs. Other packages' tests open gated databases on // hosts with no enrollment file; production binaries panic here. func UseSQLInstanceIdentityForTest(id string) (restore func()) { if !testing.Testing() { panic("console: UseSQLInstanceIdentityForTest outside a test binary") } return useSQLInstanceIdentity(func() (string, error) { return id, nil }) }