// Package instanceupdates is the control socket's read-only view of an // instance's update settings (instance update settings spec §3.3, §5.1). It // is mounted beside schedule.Mount's verbs with the same bearer and the same // fixed-host rule. Writes ship with #155 step 6's instance-bound credential, // through the console, never through this socket. package instanceupdates import ( "crypto/sha256" "crypto/subtle" "encoding/json" "errors" "net/http" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // Report is what GET /updates answers: the box's copy of the settings, what // the instance may change, and the installed, serving and desired builds with // root's reason they differ. type Report struct { Host string `json:"host"` Settings release.UpdateSettings `json:"settings"` MayChange []string `json:"may_change"` Installed string `json:"installed,omitempty"` Serving string `json:"serving,omitempty"` Desired string `json:"desired,omitempty"` Reason string `json:"reason,omitempty"` } // Mount adds GET /updates. host is fixed here and never read from a request; // token is read on every call (the env file can change under a running // instance), and an empty token refuses everything. func Mount(mux *http.ServeMux, host string, token func() (string, error), read func(host string) (Report, error)) { mux.HandleFunc("GET /updates", func(w http.ResponseWriter, r *http.Request) { want, err := token() if err != nil || want == "" { http.Error(w, "unauthorized", http.StatusUnauthorized) return } got := sha256.Sum256([]byte(r.Header.Get("Authorization"))) exp := sha256.Sum256([]byte("Bearer " + want)) if subtle.ConstantTimeCompare(got[:], exp[:]) != 1 { http.Error(w, "unauthorized", http.StatusUnauthorized) return } rep, err := read(host) if err != nil { http.Error(w, "update settings unavailable", http.StatusServiceUnavailable) return } rep.Host = host if rep.MayChange == nil { rep.MayChange = []string{} } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(rep) }) } // errNoRoute is what RegistryReader answers for a host the registry has no // row for: the verb turns it into a 503, never a 200 with an empty report. var errNoRoute = errors.New("instanceupdates: no route for host") // RegistryReader answers from the box's registry copy: the settings column // (with the route's channel when the column names none), the installed // build and the serving build. Root's desired build and reason // are added by the host's reader, which wraps this one. func RegistryReader(reg interface { Get(host string) (*registry.Route, error) }) func(string) (Report, error) { return func(host string) (Report, error) { rt, err := reg.Get(host) if err != nil { return Report{}, err } if rt == nil { return Report{}, errNoRoute } // Lenient, as the box's passes read the column: a Control name a newer // console added must not make the whole report unavailable. u, err := release.ParseUpdateSettingsLenient(rt.UpdateSettings) if err != nil { return Report{}, err } // Default settings are stored as "" (Column), which names no channel: // the instance follows its route's. A set column is reconcile's // effective copy and its channel wins; one with no channel falls back // to the route's too. Only the report is filled, never the column. if u.Channel == "" { u.Channel = rt.Channel } return Report{Settings: u, MayChange: u.Control, Installed: rt.Version, Serving: rt.ServingVersion}, nil } }