package main import ( "bufio" "context" "encoding/json" "errors" "fmt" "github.com/carlosframework/platform/internal/awsinv" "github.com/carlosframework/platform/internal/hostfence" "github.com/carlosframework/platform/internal/hostresolve" "io" "log" "net/url" "os" "strings" "time" boxops "github.com/carlosframework/platform/internal/ops" ) func hostClaimEnabled(raw string) bool { return strings.TrimSpace(raw) != "" } type hostResolveOptions struct { Host string Action string Box string Yes bool Console string } // runOps leaves the existing box-side ops surface byte-for-byte alone while // the claim feature is off. The remote host family only exists behind the // explicit deployment flag. func runOps(args []string) { if hostClaimEnabled(os.Getenv("CARLOS_HOST_CLAIM")) && len(args) > 0 && args[0] == "host" { runOpsHost(args[1:]) return } boxops.Run(args) } func parseHostResolve(args []string) (hostResolveOptions, error) { var o hostResolveOptions if len(args) == 0 || args[0] != "resolve" { return o, errors.New("usage: carlos ops host resolve --declare-lost [--yes] [--console ]") } args = args[1:] for i := 0; i < len(args); i++ { a := args[i] next := func() (string, error) { if i+1 >= len(args) { return "", fmt.Errorf("%s needs a value", a) } i++ return args[i], nil } switch { case a == "--yes": o.Yes = true case a == "--declare-lost" || a == "--readmit": if o.Action != "" { return o, errors.New("--declare-lost and --readmit are mutually exclusive") } v, err := next() if err != nil { return o, err } o.Action, o.Box = strings.TrimPrefix(a, "--"), v case a == "--console": v, err := next() if err != nil { return o, err } o.Console = v case strings.HasPrefix(a, "--console="): o.Console = strings.TrimPrefix(a, "--console=") case strings.HasPrefix(a, "-"): return o, fmt.Errorf("unknown option %q", a) case o.Host == "": o.Host = a default: return o, fmt.Errorf("unexpected argument %q", a) } } if o.Host == "" || o.Action == "" || o.Box == "" { return o, errors.New("resolve needs a host and exactly one of --declare-lost or --readmit ") } return o, nil } func runOpsHost(args []string) { o, err := parseHostResolve(args) if err != nil { log.Fatal(err) } sess := requireDeployAPISession(o.Console) if err := sess.resolveHost(context.Background(), o); err != nil { log.Fatal(err) } } func (s *apiSession) inspectHostResolve(ctx context.Context, o hostResolveOptions) error { status, body, err := s.postJSON(ctx, "/api/cli/ops/hosts/"+url.PathEscape(o.Host)+"/resolve/inspect", map[string]any{ "action": o.Action, "box": o.Box, "confirmed": o.Yes, }) if err != nil { return err } if status != 200 { return apiErrorFromBody(status, body) } return nil } // hostResolveSeams are where resolveHost reads the operator's confirmation, // writes what it shows, and reads the box from AWS: os.Stdin, os.Stdout and // awsinv.NewEC2Reader, swapped only by tests. type hostResolveSeams struct { stdin io.Reader stdout io.Writer reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error) } var hostResolveIO = hostResolveSeams{stdin: os.Stdin, stdout: os.Stdout, reader: awsinv.NewEC2Reader} func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) error { base := "/api/cli/ops/hosts/" + url.PathEscape(o.Host) + "/resolve" status, body, err := s.postJSON(ctx, base+"/inspect", map[string]any{"action": o.Action, "box": o.Box}) if err != nil { return err } if status != 200 { return apiErrorFromBody(status, body) } if o.Action == "readmit" { var request map[string]any if err = json.Unmarshal(body, &request); err != nil { return err } fmt.Fprintln(hostResolveIO.stdout, string(body)) if !o.Yes { fmt.Fprint(hostResolveIO.stdout, "Readmit this exact enrollment and revision? Type readmit: ") line, _ := bufio.NewReader(hostResolveIO.stdin).ReadString('\n') if strings.TrimSpace(line) != "readmit" { return errors.New("readmission not confirmed") } } request["action"] = "readmit" status, body, err = s.postJSON(ctx, base, request) } else { // The declare-lost half is shared with carlos move resolve // --declare-lost (declareHostLoss); this door's inspection is the // body above, its confirmation the declaration ID. door := hostLossDoor{ inspect: func(context.Context) (hostresolve.Inspection, error) { var inspection hostresolve.Inspection if err := json.Unmarshal(body, &inspection); err != nil { return inspection, err } // TLS-authenticated console response; the console verifies its // unchanged signed envelope on submission. The client does not // call this AWS proof. fmt.Fprintln(hostResolveIO.stdout, string(body)) return inspection, nil }, confirm: func(inspection hostresolve.Inspection) bool { fmt.Fprintln(hostResolveIO.stdout, "Confirm that compute remains fenced, any restart will be cold through quarantine, and the listed storage was destroyed if asserted. Type the declaration ID:") line, _ := bufio.NewReader(hostResolveIO.stdin).ReadString('\n') // --yes does not silently attest facts about compute or disks. return strings.TrimSpace(line) == inspection.Details.ID }, reader: hostResolveIO.reader, declare: func(ctx context.Context, submission hostfence.Submission) error { status, body, err = s.postJSON(ctx, base, map[string]any{"action": o.Action, "submission": submission}) return err }, now: time.Now, } if _, err := declareHostLoss(ctx, door); err != nil { return err } } if err != nil { return err } if status != 200 { return apiErrorFromBody(status, body) } fmt.Fprintln(hostResolveIO.stdout, string(body)) return nil } // hostLossDoor is one door's declare-lost conversation (claim Plan 10, Paul's // ruling J4: inspect, then declare). The ops host door and the move door // take different requests at different paths, and each says its own // sentences, so each brings its own calls; declareHostLoss is the sequence // they share. type hostLossDoor struct { // inspect asks the door for the signed challenge of the loss. inspect func(ctx context.Context) (hostresolve.Inspection, error) // confirm shows the operator what the declaration costs and reads the // confirmation the door asks for. confirm func(hostresolve.Inspection) bool // reader is the operator's own read-only EC2 seam in the challenge's // region (awsinv.NewEC2Reader, which loads through rootaws). reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error) // declare submits the declaration. declare func(ctx context.Context, sub hostfence.Submission) error now func() time.Time } // hostLossStage names the step at which declareHostLoss stopped. type hostLossStage int const ( hostLossInspect hostLossStage = iota + 1 hostLossConfirm hostLossReader hostLossObserve hostLossDeclare ) var errHostLossNotConfirmed = errors.New("fencing attestation not confirmed") // declareHostLoss is the declare-lost half both doors share: inspect, have // the operator confirm, observe the box through their own EC2 credentials, // and declare the inspected challenge unchanged, confirmed by its ID, with // the standing attestation: compute stays fenced, any restart is cold // through quarantine, and the challenge's own destroyed-storage assertion. // It returns the stage that stopped it, 0 when the declaration was sent. func declareHostLoss(ctx context.Context, d hostLossDoor) (hostLossStage, error) { inspection, err := d.inspect(ctx) if err != nil { return hostLossInspect, err } c := inspection.Details if !d.confirm(inspection) { return hostLossConfirm, errHostLossNotConfirmed } api, err := d.reader(ctx, c.Cloud.Region) if err != nil { return hostLossReader, err } observation, err := hostfence.ObserveForChallenge(ctx, api, c, d.now) if err != nil { return hostLossObserve, err } submission := hostfence.Submission{Challenge: inspection.Challenge, Observation: observation, RequestID: observation.RequestID, Confirmation: c.ID, Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: c.Consequences.DestroyedStorageAssertion}} if err := d.declare(ctx, submission); err != nil { return hostLossDeclare, err } return 0, nil }