| 1 | // Package instanceupdates is the control socket's read-only view of an |
| 2 | // instance's update settings (instance update settings spec §3.3, §5.1). It |
| 3 | // is mounted beside schedule.Mount's verbs with the same bearer and the same |
| 4 | // fixed-host rule. Writes ship with #155 step 6's instance-bound credential, |
| 5 | // through the console, never through this socket. |
| 6 | package instanceupdates |
| 7 | |
| 8 | import ( |
| 9 | "crypto/sha256" |
| 10 | "crypto/subtle" |
| 11 | "encoding/json" |
| 12 | "errors" |
| 13 | "net/http" |
| 14 | |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // Report is what GET /updates answers: the box's copy of the settings, what |
| 20 | // the instance may change, and the installed, serving and desired builds with |
| 21 | // root's reason they differ. |
| 22 | type Report struct { |
| 23 | Host string `json:"host"` |
| 24 | Settings release.UpdateSettings `json:"settings"` |
| 25 | MayChange []string `json:"may_change"` |
| 26 | Installed string `json:"installed,omitempty"` |
| 27 | Serving string `json:"serving,omitempty"` |
| 28 | Desired string `json:"desired,omitempty"` |
| 29 | Reason string `json:"reason,omitempty"` |
| 30 | } |
| 31 | |
| 32 | // Mount adds GET /updates. host is fixed here and never read from a request; |
| 33 | // token is read on every call (the env file can change under a running |
| 34 | // instance), and an empty token refuses everything. |
| 35 | func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error)) { |
| 36 | mux.HandleFunc("GET /updates", func(w http.ResponseWriter, r *http.Request) { |
| 37 | want, err := token() |
| 38 | if err != nil || want == "" { |
| 39 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 40 | return |
| 41 | } |
| 42 | got := sha256.Sum256([]byte(r.Header.Get("Authorization"))) |
| 43 | exp := sha256.Sum256([]byte("Bearer " + want)) |
| 44 | if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 { |
| 45 | http.Error(w, "unauthorized", http.StatusUnauthorized) |
| 46 | return |
| 47 | } |
| 48 | rep, err := read(host) |
| 49 | if err != nil { |
| 50 | http.Error(w, "update settings unavailable", http.StatusServiceUnavailable) |
| 51 | return |
| 52 | } |
| 53 | rep.Host = host |
| 54 | if rep.MayChange == nil { |
| 55 | rep.MayChange = []string{} |
| 56 | } |
| 57 | w.Header().Set("Content-Type", "application/json") |
| 58 | _ = json.NewEncoder(w).Encode(rep) |
| 59 | }) |
| 60 | } |
| 61 | |
| 62 | // errNoRoute is what RegistryReader answers for a host the registry has no |
| 63 | // row for: the verb turns it into a 503, never a 200 with an empty report. |
| 64 | var errNoRoute = errors.New("instanceupdates: no route for host") |
| 65 | |
| 66 | // RegistryReader answers from the box's registry copy: the settings column |
| 67 | // (with the route's channel when the column names none), the installed |
| 68 | // build and the serving build. Root's desired build and reason |
| 69 | // are added by the host's reader, which wraps this one. |
| 70 | func RegistryReader(reg interface { |
| 71 | Get(host string) (*registry.Route, error) |
| 72 | }) func(string) (Report, error) { |
| 73 | return func(host string) (Report, error) { |
| 74 | rt, err := reg.Get(host) |
| 75 | if err != nil { |
| 76 | return Report{}, err |
| 77 | } |
| 78 | if rt == nil { |
| 79 | return Report{}, errNoRoute |
| 80 | } |
| 81 | // Lenient, as the box's passes read the column: a Control name a newer |
| 82 | // console added must not make the whole report unavailable. |
| 83 | u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings) |
| 84 | if err != nil { |
| 85 | return Report{}, err |
| 86 | } |
| 87 | // Default settings are stored as "" (Column), which names no channel: |
| 88 | // the instance follows its route's. A set column is reconcile's |
| 89 | // effective copy and its channel wins; one with no channel falls back |
| 90 | // to the route's too. Only the report is filled, never the column. |
| 91 | if u.Channel == "" { |
| 92 | u.Channel = rt.Channel |
| 93 | } |
| 94 | return Report{Settings: u, MayChange: u.Control, Installed: rt.Version, Serving: rt.ServingVersion}, nil |
| 95 | } |
| 96 | } |
| 97 | |