| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "github.com/carlosframework/platform/internal/awsinv" |
| 10 | "github.com/carlosframework/platform/internal/hostfence" |
| 11 | "github.com/carlosframework/platform/internal/hostresolve" |
| 12 | "io" |
| 13 | "log" |
| 14 | "net/url" |
| 15 | "os" |
| 16 | "strings" |
| 17 | "time" |
| 18 | |
| 19 | boxops "github.com/carlosframework/platform/internal/ops" |
| 20 | ) |
| 21 | |
| 22 | func hostClaimEnabled(raw string) bool { return strings.TrimSpace(raw) != "" } |
| 23 | |
| 24 | type hostResolveOptions struct { |
| 25 | Host string |
| 26 | Action string |
| 27 | Box string |
| 28 | Yes bool |
| 29 | Console string |
| 30 | } |
| 31 | |
| 32 | // runOps leaves the existing box-side ops surface byte-for-byte alone while |
| 33 | // the claim feature is off. The remote host family only exists behind the |
| 34 | // explicit deployment flag. |
| 35 | func runOps(args []string) { |
| 36 | if hostClaimEnabled(os.Getenv("CARLOS_HOST_CLAIM")) && len(args) > 0 && args[0] == "host" { |
| 37 | runOpsHost(args[1:]) |
| 38 | return |
| 39 | } |
| 40 | boxops.Run(args) |
| 41 | } |
| 42 | |
| 43 | func parseHostResolve(args []string) (hostResolveOptions, error) { |
| 44 | var o hostResolveOptions |
| 45 | if len(args) == 0 || args[0] != "resolve" { |
| 46 | return o, errors.New("usage: carlos ops host resolve <host> --declare-lost <box> [--yes] [--console <url>]") |
| 47 | } |
| 48 | args = args[1:] |
| 49 | for i := 0; i < len(args); i++ { |
| 50 | a := args[i] |
| 51 | next := func() (string, error) { |
| 52 | if i+1 >= len(args) { |
| 53 | return "", fmt.Errorf("%s needs a value", a) |
| 54 | } |
| 55 | i++ |
| 56 | return args[i], nil |
| 57 | } |
| 58 | switch { |
| 59 | case a == "--yes": |
| 60 | o.Yes = true |
| 61 | case a == "--declare-lost" || a == "--readmit": |
| 62 | if o.Action != "" { |
| 63 | return o, errors.New("--declare-lost and --readmit are mutually exclusive") |
| 64 | } |
| 65 | v, err := next() |
| 66 | if err != nil { |
| 67 | return o, err |
| 68 | } |
| 69 | o.Action, o.Box = strings.TrimPrefix(a, "--"), v |
| 70 | case a == "--console": |
| 71 | v, err := next() |
| 72 | if err != nil { |
| 73 | return o, err |
| 74 | } |
| 75 | o.Console = v |
| 76 | case strings.HasPrefix(a, "--console="): |
| 77 | o.Console = strings.TrimPrefix(a, "--console=") |
| 78 | case strings.HasPrefix(a, "-"): |
| 79 | return o, fmt.Errorf("unknown option %q", a) |
| 80 | case o.Host == "": |
| 81 | o.Host = a |
| 82 | default: |
| 83 | return o, fmt.Errorf("unexpected argument %q", a) |
| 84 | } |
| 85 | } |
| 86 | if o.Host == "" || o.Action == "" || o.Box == "" { |
| 87 | return o, errors.New("resolve needs a host and exactly one of --declare-lost <box> or --readmit <box>") |
| 88 | } |
| 89 | return o, nil |
| 90 | } |
| 91 | |
| 92 | func runOpsHost(args []string) { |
| 93 | o, err := parseHostResolve(args) |
| 94 | if err != nil { |
| 95 | log.Fatal(err) |
| 96 | } |
| 97 | sess := requireDeployAPISession(o.Console) |
| 98 | if err := sess.resolveHost(context.Background(), o); err != nil { |
| 99 | log.Fatal(err) |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | func (s *apiSession) inspectHostResolve(ctx context.Context, o hostResolveOptions) error { |
| 104 | status, body, err := s.postJSON(ctx, "/api/cli/ops/hosts/"+url.PathEscape(o.Host)+"/resolve/inspect", map[string]any{ |
| 105 | "action": o.Action, "box": o.Box, "confirmed": o.Yes, |
| 106 | }) |
| 107 | if err != nil { |
| 108 | return err |
| 109 | } |
| 110 | if status != 200 { |
| 111 | return apiErrorFromBody(status, body) |
| 112 | } |
| 113 | return nil |
| 114 | } |
| 115 | |
| 116 | // hostResolveSeams are where resolveHost reads the operator's confirmation, |
| 117 | // writes what it shows, and reads the box from AWS: os.Stdin, os.Stdout and |
| 118 | // awsinv.NewEC2Reader, swapped only by tests. |
| 119 | type hostResolveSeams struct { |
| 120 | stdin io.Reader |
| 121 | stdout io.Writer |
| 122 | reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error) |
| 123 | } |
| 124 | |
| 125 | var hostResolveIO = hostResolveSeams{stdin: os.Stdin, stdout: os.Stdout, reader: awsinv.NewEC2Reader} |
| 126 | |
| 127 | func (s *apiSession) resolveHost(ctx context.Context, o hostResolveOptions) error { |
| 128 | base := "/api/cli/ops/hosts/" + url.PathEscape(o.Host) + "/resolve" |
| 129 | status, body, err := s.postJSON(ctx, base+"/inspect", map[string]any{"action": o.Action, "box": o.Box}) |
| 130 | if err != nil { |
| 131 | return err |
| 132 | } |
| 133 | if status != 200 { |
| 134 | return apiErrorFromBody(status, body) |
| 135 | } |
| 136 | if o.Action == "readmit" { |
| 137 | var request map[string]any |
| 138 | if err = json.Unmarshal(body, &request); err != nil { |
| 139 | return err |
| 140 | } |
| 141 | fmt.Fprintln(hostResolveIO.stdout, string(body)) |
| 142 | if !o.Yes { |
| 143 | fmt.Fprint(hostResolveIO.stdout, "Readmit this exact enrollment and revision? Type readmit: ") |
| 144 | line, _ := bufio.NewReader(hostResolveIO.stdin).ReadString('\n') |
| 145 | if strings.TrimSpace(line) != "readmit" { |
| 146 | return errors.New("readmission not confirmed") |
| 147 | } |
| 148 | } |
| 149 | request["action"] = "readmit" |
| 150 | status, body, err = s.postJSON(ctx, base, request) |
| 151 | } else { |
| 152 | // The declare-lost half is shared with carlos move resolve |
| 153 | // --declare-lost (declareHostLoss); this door's inspection is the |
| 154 | // body above, its confirmation the declaration ID. |
| 155 | door := hostLossDoor{ |
| 156 | inspect: func(context.Context) (hostresolve.Inspection, error) { |
| 157 | var inspection hostresolve.Inspection |
| 158 | if err := json.Unmarshal(body, &inspection); err != nil { |
| 159 | return inspection, err |
| 160 | } |
| 161 | // TLS-authenticated console response; the console verifies its |
| 162 | // unchanged signed envelope on submission. The client does not |
| 163 | // call this AWS proof. |
| 164 | fmt.Fprintln(hostResolveIO.stdout, string(body)) |
| 165 | return inspection, nil |
| 166 | }, |
| 167 | confirm: func(inspection hostresolve.Inspection) bool { |
| 168 | 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:") |
| 169 | line, _ := bufio.NewReader(hostResolveIO.stdin).ReadString('\n') |
| 170 | // --yes does not silently attest facts about compute or disks. |
| 171 | return strings.TrimSpace(line) == inspection.Details.ID |
| 172 | }, |
| 173 | reader: hostResolveIO.reader, |
| 174 | declare: func(ctx context.Context, submission hostfence.Submission) error { |
| 175 | status, body, err = s.postJSON(ctx, base, map[string]any{"action": o.Action, "submission": submission}) |
| 176 | return err |
| 177 | }, |
| 178 | now: time.Now, |
| 179 | } |
| 180 | if _, err := declareHostLoss(ctx, door); err != nil { |
| 181 | return err |
| 182 | } |
| 183 | } |
| 184 | if err != nil { |
| 185 | return err |
| 186 | } |
| 187 | if status != 200 { |
| 188 | return apiErrorFromBody(status, body) |
| 189 | } |
| 190 | fmt.Fprintln(hostResolveIO.stdout, string(body)) |
| 191 | return nil |
| 192 | } |
| 193 | |
| 194 | // hostLossDoor is one door's declare-lost conversation (claim Plan 10, Paul's |
| 195 | // ruling J4: inspect, then declare). The ops host door and the move door |
| 196 | // take different requests at different paths, and each says its own |
| 197 | // sentences, so each brings its own calls; declareHostLoss is the sequence |
| 198 | // they share. |
| 199 | type hostLossDoor struct { |
| 200 | // inspect asks the door for the signed challenge of the loss. |
| 201 | inspect func(ctx context.Context) (hostresolve.Inspection, error) |
| 202 | // confirm shows the operator what the declaration costs and reads the |
| 203 | // confirmation the door asks for. |
| 204 | confirm func(hostresolve.Inspection) bool |
| 205 | // reader is the operator's own read-only EC2 seam in the challenge's |
| 206 | // region (awsinv.NewEC2Reader, which loads through rootaws). |
| 207 | reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error) |
| 208 | // declare submits the declaration. |
| 209 | declare func(ctx context.Context, sub hostfence.Submission) error |
| 210 | now func() time.Time |
| 211 | } |
| 212 | |
| 213 | // hostLossStage names the step at which declareHostLoss stopped. |
| 214 | type hostLossStage int |
| 215 | |
| 216 | const ( |
| 217 | hostLossInspect hostLossStage = iota + 1 |
| 218 | hostLossConfirm |
| 219 | hostLossReader |
| 220 | hostLossObserve |
| 221 | hostLossDeclare |
| 222 | ) |
| 223 | |
| 224 | var errHostLossNotConfirmed = errors.New("fencing attestation not confirmed") |
| 225 | |
| 226 | // declareHostLoss is the declare-lost half both doors share: inspect, have |
| 227 | // the operator confirm, observe the box through their own EC2 credentials, |
| 228 | // and declare the inspected challenge unchanged, confirmed by its ID, with |
| 229 | // the standing attestation: compute stays fenced, any restart is cold |
| 230 | // through quarantine, and the challenge's own destroyed-storage assertion. |
| 231 | // It returns the stage that stopped it, 0 when the declaration was sent. |
| 232 | func declareHostLoss(ctx context.Context, d hostLossDoor) (hostLossStage, error) { |
| 233 | inspection, err := d.inspect(ctx) |
| 234 | if err != nil { |
| 235 | return hostLossInspect, err |
| 236 | } |
| 237 | c := inspection.Details |
| 238 | if !d.confirm(inspection) { |
| 239 | return hostLossConfirm, errHostLossNotConfirmed |
| 240 | } |
| 241 | api, err := d.reader(ctx, c.Cloud.Region) |
| 242 | if err != nil { |
| 243 | return hostLossReader, err |
| 244 | } |
| 245 | observation, err := hostfence.ObserveForChallenge(ctx, api, c, d.now) |
| 246 | if err != nil { |
| 247 | return hostLossObserve, err |
| 248 | } |
| 249 | submission := hostfence.Submission{Challenge: inspection.Challenge, Observation: observation, RequestID: observation.RequestID, Confirmation: c.ID, |
| 250 | Attestation: hostfence.OperatorAttestation{MaintainComputeFence: true, ColdQuarantineStartOnly: true, Disks: c.Consequences.DestroyedStorageAssertion}} |
| 251 | if err := d.declare(ctx, submission); err != nil { |
| 252 | return hostLossDeclare, err |
| 253 | } |
| 254 | return 0, nil |
| 255 | } |
| 256 | |