carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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
22func hostClaimEnabled(raw string) bool { return strings.TrimSpace(raw) != "" }
23
24type 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.
35func 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
43func 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
92func 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
103func (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.
119type hostResolveSeams struct {
120 stdin io.Reader
121 stdout io.Writer
122 reader func(ctx context.Context, region string) (awsinv.ExactEC2API, error)
123}
124
125var hostResolveIO = hostResolveSeams{stdin: os.Stdin, stdout: os.Stdout, reader: awsinv.NewEC2Reader}
126
127func (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.
199type 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.
214type hostLossStage int
215
216const (
217 hostLossInspect hostLossStage = iota + 1
218 hostLossConfirm
219 hostLossReader
220 hostLossObserve
221 hostLossDeclare
222)
223
224var 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.
232func 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