package service import ( "encoding/json" "errors" "html/template" "net/http" "strings" ) type userContext struct { Name string Groups []string } type pageData struct { State PageState AllowedGroups []string DefaultMessage string BootJSON template.JS } // Handler returns the Metis HTTP handler. func (a *App) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", a.handleHealth) mux.HandleFunc("/metrics", a.handleMetrics) mux.HandleFunc("/internal/sentinel/snapshot", a.handleInternalSnapshot) mux.HandleFunc("/internal/sentinel/watch", a.handleInternalWatch) mux.HandleFunc("/api/state", a.withUIAuth(a.handleState)) mux.HandleFunc("/api/devices", a.withUIAuth(a.handleDevices)) mux.HandleFunc("/api/jobs/build", a.withUIAuth(a.handleBuild)) mux.HandleFunc("/api/jobs/flash", a.withUIAuth(a.handleFlash)) mux.HandleFunc("/api/jobs/replace", a.withUIAuth(a.handleReplace)) mux.HandleFunc("/api/sentinel/watch", a.withUIAuth(a.handleWatch)) mux.HandleFunc("/", a.withUIAuth(a.handleIndex)) return mux } func (a *App) handleHealth(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "metis"}) } func (a *App) handleMetrics(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") a.metrics.Render(w) } func (a *App) handleInternalSnapshot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var record SnapshotRecord if err := json.NewDecoder(r.Body).Decode(&record); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := a.StoreSnapshot(record); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } func (a *App) handleInternalWatch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } event, err := a.WatchSentinel() if err != nil { a.metrics.RecordWatch("error") http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, event) } func (a *App) handleState(w http.ResponseWriter, r *http.Request) { host := r.URL.Query().Get("host") writeJSON(w, http.StatusOK, a.State(host)) } func (a *App) handleDevices(w http.ResponseWriter, r *http.Request) { host := r.URL.Query().Get("host") devices, err := a.RefreshDevices(host) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } writeJSON(w, http.StatusOK, map[string]any{"devices": devices}) } func (a *App) handleBuild(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } values := requestValues(r) node := values["node"] job, err := a.Build(node) if err != nil { http.Error(w, err.Error(), statusForJobError(err)) return } writeJSON(w, http.StatusAccepted, job) } func (a *App) handleFlash(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } values := requestValues(r) node := values["node"] host := values["host"] device := values["device"] job, err := a.Flash(node, host, device) if err != nil { http.Error(w, err.Error(), statusForJobError(err)) return } writeJSON(w, http.StatusAccepted, job) } func (a *App) handleReplace(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } values := requestValues(r) node := values["node"] host := values["host"] device := values["device"] job, err := a.Replace(node, host, device) if err != nil { http.Error(w, err.Error(), statusForJobError(err)) return } writeJSON(w, http.StatusAccepted, job) } func statusForJobError(err error) int { var conflict *activeNodeJobError if errors.As(err, &conflict) { return http.StatusConflict } return http.StatusBadRequest } func (a *App) handleWatch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } event, err := a.WatchSentinel() if err != nil { a.metrics.RecordWatch("error") http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, event) } func (a *App) handleIndex(w http.ResponseWriter, r *http.Request) { state := a.State(a.settings.DefaultFlashHost) payload, _ := json.Marshal(state) data := pageData{ State: state, AllowedGroups: append([]string{}, a.settings.AllowedGroups...), BootJSON: template.JS(payload), } _ = metisPage.Execute(w, data) } func (a *App) withUIAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user, ok := a.authorize(r) if !ok { http.Error(w, "forbidden", http.StatusForbidden) return } if user.Name != "" { w.Header().Set("X-Metis-User", user.Name) } next(w, r) } } func (a *App) authorize(r *http.Request) (userContext, bool) { user := firstNonEmptyHeader(r, "X-Auth-Request-User", "X-Forwarded-User", "X-Auth-Request-Email", "X-Forwarded-Email") groups := splitHeaderList(firstNonEmptyHeader(r, "X-Auth-Request-Groups", "X-Forwarded-Groups")) if len(groups) == 0 { return userContext{Name: user, Groups: groups}, false } for _, group := range groups { for _, allowed := range a.settings.AllowedGroups { if normalizeGroupValue(group) == normalizeGroupValue(allowed) { return userContext{Name: user, Groups: groups}, true } } } return userContext{Name: user, Groups: groups}, false } func firstNonEmptyHeader(r *http.Request, keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(r.Header.Get(key)); value != "" { return value } } return "" } func splitHeaderList(raw string) []string { if strings.TrimSpace(raw) == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, part := range parts { part = strings.TrimSpace(part) if part != "" { out = append(out, part) } } return out } func normalizeGroupValue(raw string) string { value := strings.ToLower(strings.TrimSpace(raw)) return strings.TrimPrefix(value, "/") } func requestValues(r *http.Request) map[string]string { values := map[string]string{} if err := r.ParseForm(); err == nil { for key, rawValues := range r.Form { for _, raw := range rawValues { if value := strings.TrimSpace(raw); value != "" { values[key] = value break } } } } var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err == nil { for key, raw := range payload { if value, ok := raw.(string); ok && strings.TrimSpace(value) != "" { values[key] = strings.TrimSpace(value) } } } return values } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) }