github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/syz-cluster/dashboard/handler.go (about) 1 // Copyright 2024 syzkaller project authors. All rights reserved. 2 // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file. 3 4 package main 5 6 import ( 7 "embed" 8 "errors" 9 "fmt" 10 "html/template" 11 "io" 12 "io/fs" 13 "net/http" 14 "strconv" 15 "time" 16 17 "github.com/google/syzkaller/pkg/html/urlutil" 18 "github.com/google/syzkaller/syz-cluster/pkg/app" 19 "github.com/google/syzkaller/syz-cluster/pkg/blob" 20 "github.com/google/syzkaller/syz-cluster/pkg/db" 21 ) 22 23 type dashboardHandler struct { 24 title string 25 buildRepo *db.BuildRepository 26 seriesRepo *db.SeriesRepository 27 sessionRepo *db.SessionRepository 28 sessionTestRepo *db.SessionTestRepository 29 findingRepo *db.FindingRepository 30 statsRepo *db.StatsRepository 31 blobStorage blob.Storage 32 templates map[string]*template.Template 33 } 34 35 //go:embed templates/* 36 var templates embed.FS 37 38 func newHandler(env *app.AppEnvironment) (*dashboardHandler, error) { 39 perFile := map[string]*template.Template{} 40 var err error 41 for _, name := range []string{"index.html", "series.html", "graphs.html"} { 42 perFile[name], err = template.ParseFS(templates, 43 "templates/base.html", "templates/templates.html", "templates/"+name) 44 if err != nil { 45 return nil, err 46 } 47 } 48 return &dashboardHandler{ 49 title: env.Config.Name, 50 templates: perFile, 51 blobStorage: env.BlobStorage, 52 buildRepo: db.NewBuildRepository(env.Spanner), 53 seriesRepo: db.NewSeriesRepository(env.Spanner), 54 sessionRepo: db.NewSessionRepository(env.Spanner), 55 sessionTestRepo: db.NewSessionTestRepository(env.Spanner), 56 findingRepo: db.NewFindingRepository(env.Spanner), 57 statsRepo: db.NewStatsRepository(env.Spanner), 58 }, nil 59 } 60 61 //go:embed static 62 var staticFs embed.FS 63 64 func (h *dashboardHandler) Mux() *http.ServeMux { 65 mux := http.NewServeMux() 66 mux.HandleFunc("/sessions/{id}/log", errToStatus(h.sessionLog)) 67 mux.HandleFunc("/sessions/{id}/triage_log", errToStatus(h.sessionTriageLog)) 68 mux.HandleFunc("/sessions/{id}/test_logs", errToStatus(h.sessionTestLog)) 69 mux.HandleFunc("/sessions/{id}/test_artifacts", errToStatus(h.sessionTestArtifacts)) 70 mux.HandleFunc("/series/{id}/all_patches", errToStatus(h.allPatches)) 71 mux.HandleFunc("/series/{id}", errToStatus(h.seriesInfo)) 72 mux.HandleFunc("/patches/{id}", errToStatus(h.patchContent)) 73 mux.HandleFunc("/findings/{id}/{key}", errToStatus(h.findingInfo)) 74 mux.HandleFunc("/builds/{id}/{key}", errToStatus(h.buildInfo)) 75 mux.HandleFunc("/stats", errToStatus(h.statsPage)) 76 mux.HandleFunc("/", errToStatus(h.seriesList)) 77 staticFiles, err := fs.Sub(staticFs, "static") 78 if err != nil { 79 app.Fatalf("failed to parse templates: %v", err) 80 } 81 mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFiles)))) 82 return mux 83 } 84 85 var ( 86 errNotFound = errors.New("not found error") 87 errBadRequest = errors.New("bad request") 88 ) 89 90 type statusOption struct { 91 Key db.SessionStatus 92 Value string 93 } 94 95 func (h *dashboardHandler) seriesList(w http.ResponseWriter, r *http.Request) error { 96 type MainPageData struct { 97 // It's probably not the best idea to expose db entities here, 98 // but so far redefining the entity would just duplicate the code. 99 List []*db.SeriesWithSession 100 Filter db.SeriesFilter 101 Statuses []statusOption 102 // This is very primitive, but better than nothing. 103 FilterFormURL string 104 PrevPageURL string 105 NextPageURL string 106 } 107 const perPage = 100 108 offset, err := h.getOffset(r) 109 if err != nil { 110 return err 111 } 112 baseURL := r.URL.RequestURI() 113 data := MainPageData{ 114 Filter: db.SeriesFilter{ 115 Cc: r.FormValue("cc"), 116 Status: db.SessionStatus(r.FormValue("status")), 117 WithFindings: r.FormValue("with_findings") != "", 118 Limit: perPage, 119 Offset: offset, 120 }, 121 // If the filters are changed, the old offset value is irrelevant. 122 FilterFormURL: urlutil.DropParam(baseURL, "offset", ""), 123 Statuses: []statusOption{ 124 {db.SessionStatusAny, "any"}, 125 {db.SessionStatusWaiting, "waiting"}, 126 {db.SessionStatusInProgress, "in progress"}, 127 {db.SessionStatusFinished, "finished"}, 128 {db.SessionStatusSkipped, "skipped"}, 129 }, 130 } 131 132 data.List, err = h.seriesRepo.ListLatest(r.Context(), data.Filter, time.Time{}) 133 if err != nil { 134 return fmt.Errorf("failed to query the list: %w", err) 135 } 136 if data.Filter.Offset > 0 { 137 data.PrevPageURL = urlutil.SetParam(baseURL, "offset", 138 fmt.Sprintf("%d", max(0, data.Filter.Offset-perPage))) 139 } 140 // TODO: this is not strictly correct (we also need to check whether there actually more rows). 141 // But let's tolerate it for now. 142 if len(data.List) == data.Filter.Limit { 143 data.NextPageURL = urlutil.SetParam(baseURL, "offset", 144 fmt.Sprintf("%d", data.Filter.Offset+len(data.List))) 145 } 146 return h.renderTemplate(w, "index.html", data) 147 } 148 149 func (h *dashboardHandler) getOffset(r *http.Request) (int, error) { 150 val := r.FormValue("offset") 151 if val == "" { 152 return 0, nil 153 } 154 i, err := strconv.Atoi(val) 155 if err != nil || i < 0 { 156 return 0, fmt.Errorf("%w: invalid offset value", errBadRequest) 157 } 158 return i, nil 159 } 160 161 func (h *dashboardHandler) seriesInfo(w http.ResponseWriter, r *http.Request) error { 162 type SessionTest struct { 163 *db.FullSessionTest 164 Findings []*db.Finding 165 } 166 type SessionData struct { 167 *db.Session 168 Tests []SessionTest 169 } 170 type SeriesData struct { 171 *db.Series 172 Patches []*db.Patch 173 Sessions []SessionData 174 TotalPatches int 175 } 176 var data SeriesData 177 var err error 178 ctx := r.Context() 179 data.Series, err = h.seriesRepo.GetByID(ctx, r.PathValue("id")) 180 if err != nil { 181 return fmt.Errorf("failed to query series: %w", err) 182 } else if data.Series == nil { 183 return fmt.Errorf("%w: series", errNotFound) 184 } 185 data.Patches, err = h.seriesRepo.ListPatches(ctx, data.Series) 186 if err != nil { 187 return fmt.Errorf("failed to query patches: %w", err) 188 } 189 data.TotalPatches = len(data.Patches) 190 sessions, err := h.sessionRepo.ListForSeries(ctx, data.Series) 191 if err != nil { 192 return fmt.Errorf("failed to query sessions: %w", err) 193 } 194 for _, session := range sessions { 195 rawTests, err := h.sessionTestRepo.BySession(ctx, session.ID) 196 if err != nil { 197 return fmt.Errorf("failed to query session tests: %w", err) 198 } 199 findings, err := h.findingRepo.ListForSession(ctx, session.ID, db.NoLimit) 200 if err != nil { 201 return fmt.Errorf("failed to query session findings: %w", err) 202 } 203 perName := groupFindings(findings) 204 sessionData := SessionData{ 205 Session: session, 206 } 207 for _, test := range rawTests { 208 sessionData.Tests = append(sessionData.Tests, SessionTest{ 209 FullSessionTest: test, 210 Findings: perName[test.TestName], 211 }) 212 } 213 data.Sessions = append(data.Sessions, sessionData) 214 } 215 return h.renderTemplate(w, "series.html", data) 216 } 217 218 func (h *dashboardHandler) statsPage(w http.ResponseWriter, r *http.Request) error { 219 type StatsPageData struct { 220 Processed []*db.CountPerWeek 221 Findings []*db.CountPerWeek 222 Reports []*db.CountPerWeek 223 Delay []*db.DelayPerWeek 224 Distribution []*db.StatusPerWeek 225 } 226 var data StatsPageData 227 var err error 228 data.Processed, err = h.statsRepo.ProcessedSeriesPerWeek(r.Context()) 229 if err != nil { 230 return fmt.Errorf("failed to query processed series data: %w", err) 231 } 232 data.Findings, err = h.statsRepo.FindingsPerWeek(r.Context()) 233 if err != nil { 234 return fmt.Errorf("failed to query findings data: %w", err) 235 } 236 data.Reports, err = h.statsRepo.ReportsPerWeek(r.Context()) 237 if err != nil { 238 return fmt.Errorf("failed to query reports data: %w", err) 239 } 240 data.Delay, err = h.statsRepo.DelayPerWeek(r.Context()) 241 if err != nil { 242 return fmt.Errorf("failed to query delay data: %w", err) 243 } 244 data.Distribution, err = h.statsRepo.SessionStatusPerWeek(r.Context()) 245 if err != nil { 246 return fmt.Errorf("failed to query distribution data: %w", err) 247 } 248 return h.renderTemplate(w, "graphs.html", data) 249 } 250 251 func groupFindings(findings []*db.Finding) map[string][]*db.Finding { 252 ret := map[string][]*db.Finding{} 253 for _, finding := range findings { 254 ret[finding.TestName] = append(ret[finding.TestName], finding) 255 } 256 return ret 257 } 258 259 func (h *dashboardHandler) renderTemplate(w http.ResponseWriter, name string, data any) error { 260 type page struct { 261 Title string 262 Template string 263 Data any 264 } 265 return h.templates[name].ExecuteTemplate(w, "base.html", page{ 266 Title: h.title, 267 Template: name, 268 Data: data, 269 }) 270 } 271 272 // nolint:dupl 273 func (h *dashboardHandler) sessionLog(w http.ResponseWriter, r *http.Request) error { 274 session, err := h.sessionRepo.GetByID(r.Context(), r.PathValue("id")) 275 if err != nil { 276 return err 277 } else if session == nil { 278 return fmt.Errorf("%w: session", errNotFound) 279 } 280 return h.streamBlob(w, session.LogURI) 281 } 282 283 // nolint:dupl 284 func (h *dashboardHandler) sessionTriageLog(w http.ResponseWriter, r *http.Request) error { 285 session, err := h.sessionRepo.GetByID(r.Context(), r.PathValue("id")) 286 if err != nil { 287 return err 288 } else if session == nil { 289 return fmt.Errorf("%w: session", errNotFound) 290 } 291 return h.streamBlob(w, session.TriageLogURI) 292 } 293 294 // nolint:dupl 295 func (h *dashboardHandler) patchContent(w http.ResponseWriter, r *http.Request) error { 296 patch, err := h.seriesRepo.PatchByID(r.Context(), r.PathValue("id")) 297 if err != nil { 298 return err 299 } else if patch == nil { 300 return fmt.Errorf("%w: patch", errNotFound) 301 } 302 return h.streamBlob(w, patch.BodyURI) 303 } 304 305 // nolint:dupl 306 func (h *dashboardHandler) allPatches(w http.ResponseWriter, r *http.Request) error { 307 ctx := r.Context() 308 series, err := h.seriesRepo.GetByID(ctx, r.PathValue("id")) 309 if err != nil { 310 return fmt.Errorf("failed to query series: %w", err) 311 } else if series == nil { 312 return fmt.Errorf("%w: series", errNotFound) 313 } 314 patches, err := h.seriesRepo.ListPatches(ctx, series) 315 if err != nil { 316 return fmt.Errorf("failed to query patches: %w", err) 317 } 318 for _, patch := range patches { 319 err = h.streamBlob(w, patch.BodyURI) 320 if err != nil { 321 return err 322 } 323 } 324 return nil 325 } 326 327 func (h *dashboardHandler) findingInfo(w http.ResponseWriter, r *http.Request) error { 328 finding, err := h.findingRepo.GetByID(r.Context(), r.PathValue("id")) 329 if err != nil { 330 return err 331 } else if finding == nil { 332 return fmt.Errorf("%w: finding", errNotFound) 333 } 334 switch r.PathValue("key") { 335 case "report": 336 return h.streamBlob(w, finding.ReportURI) 337 case "log": 338 return h.streamBlob(w, finding.LogURI) 339 case "syz_repro": 340 opts, err := blob.ReadAllBytes(h.blobStorage, finding.SyzReproOptsURI) 341 if err != nil { 342 return err 343 } 344 repro, err := blob.ReadAllBytes(h.blobStorage, finding.SyzReproURI) 345 if err != nil { 346 return err 347 } 348 fmt.Fprintf(w, "# %s\n", opts) 349 _, err = w.Write(repro) 350 return err 351 case "c_repro": 352 return h.streamBlob(w, finding.CReproURI) 353 default: 354 return fmt.Errorf("%w: unknown key value", errBadRequest) 355 } 356 } 357 358 func (h *dashboardHandler) buildInfo(w http.ResponseWriter, r *http.Request) error { 359 build, err := h.buildRepo.GetByID(r.Context(), r.PathValue("id")) 360 if err != nil { 361 return err 362 } else if build == nil { 363 return fmt.Errorf("%w: build", errNotFound) 364 } 365 switch r.PathValue("key") { 366 case "log": 367 return h.streamBlob(w, build.LogURI) 368 case "config": 369 return h.streamBlob(w, build.ConfigURI) 370 default: 371 return fmt.Errorf("%w: unknown key value", errBadRequest) 372 } 373 } 374 375 func (h *dashboardHandler) sessionTestLog(w http.ResponseWriter, r *http.Request) error { 376 test, err := h.sessionTestRepo.Get(r.Context(), r.PathValue("id"), r.FormValue("name")) 377 if err != nil { 378 return err 379 } else if test == nil { 380 return fmt.Errorf("%w: test", errNotFound) 381 } 382 return h.streamBlob(w, test.LogURI) 383 } 384 385 func (h *dashboardHandler) sessionTestArtifacts(w http.ResponseWriter, r *http.Request) error { 386 test, err := h.sessionTestRepo.Get(r.Context(), r.PathValue("id"), r.FormValue("name")) 387 if err != nil { 388 return err 389 } else if test == nil { 390 return fmt.Errorf("%w: test", errNotFound) 391 } 392 filename := fmt.Sprintf("%s_%s.tar.gz", test.SessionID, test.TestName) 393 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 394 w.Header().Set("Content-Type", "application/octet-stream") 395 return h.streamBlob(w, test.ArtifactsArchiveURI) 396 } 397 398 func (h *dashboardHandler) streamBlob(w http.ResponseWriter, uri string) error { 399 if uri == "" { 400 return nil 401 } 402 reader, err := h.blobStorage.Read(uri) 403 if err != nil { 404 return err 405 } 406 defer reader.Close() 407 _, err = io.Copy(w, reader) 408 return err 409 } 410 411 func errToStatus(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { 412 return func(w http.ResponseWriter, r *http.Request) { 413 err := f(w, r) 414 if errors.Is(err, errNotFound) { 415 http.Error(w, err.Error(), http.StatusNotFound) 416 } else if errors.Is(err, errBadRequest) { 417 http.Error(w, err.Error(), http.StatusBadRequest) 418 } else if err != nil { 419 // TODO: if the error happened in the template, likely we've already printed 420 // something to w. Unless we're in streamBlob(), it makes sense to first collect 421 // the output in some buffer and only dump it after the exit from the handler. 422 http.Error(w, err.Error(), http.StatusInternalServerError) 423 } 424 } 425 }