package relay import ( "fmt" "io" "net/http" "strings" "sync" "time" ) // Diagnostics drop-off. // // Getting a report off an awkward machine — a headless NAS, a Windows box in // the middle of driver debugging — is otherwise a matter of copying thousands // of lines by hand. The relay is already reachable from every client, so it // makes a convenient place to leave one. // // Reports are held in memory only, capped in size and count, and expire. The // relay is not a storage service, and treating it like one is how it would // become one. const ( // maxDiagReports bounds how many are kept; the oldest is dropped first. maxDiagReports = 32 // maxDiagSize bounds one report. maxDiagSize = 4 << 20 // 4 MB // diagTTL is how long a report survives. Long enough to fetch and read, // short enough that machine details do not linger. diagTTL = 24 * time.Hour ) // RetentionNote describes the retention policy for the client to print. const RetentionNote = "24 hours" type diagReport struct { data []byte stored time.Time fetched int remoteIP string } type diagStore struct { mu sync.Mutex reports map[string]*diagReport } func newDiagStore() *diagStore { return &diagStore{reports: make(map[string]*diagReport)} } // put stores a report, evicting the oldest if the store is full. func (s *diagStore) put(id string, data []byte, remoteIP string) { s.mu.Lock() defer s.mu.Unlock() s.expireLocked() if len(s.reports) >= maxDiagReports { var oldestID string var oldest time.Time for id, report := range s.reports { if oldestID == "" || report.stored.Before(oldest) { oldestID, oldest = id, report.stored } } delete(s.reports, oldestID) } s.reports[id] = &diagReport{ data: data, stored: time.Now(), remoteIP: remoteIP, } } func (s *diagStore) get(id string) ([]byte, bool) { s.mu.Lock() defer s.mu.Unlock() s.expireLocked() report, ok := s.reports[id] if !ok { return nil, false } report.fetched++ return report.data, true } // expireLocked drops reports past their TTL. Callers must hold the lock. func (s *diagStore) expireLocked() { cutoff := time.Now().Add(-diagTTL) for id, report := range s.reports { if report.stored.Before(cutoff) { delete(s.reports, id) } } } // handleDiag serves the diagnostics endpoint: PUT to store, GET to retrieve. func (s *Server) handleDiag(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/diag/") if id == "" || strings.Contains(id, "/") { http.Error(w, "report ID required: /diag/", http.StatusBadRequest) return } switch r.Method { case http.MethodPut, http.MethodPost: s.storeDiag(w, r, id) case http.MethodGet: s.fetchDiag(w, id) default: http.Error(w, "use PUT to store and GET to retrieve", http.StatusMethodNotAllowed) } } func (s *Server) storeDiag(w http.ResponseWriter, r *http.Request, id string) { // LimitReader rather than trusting Content-Length: a client can lie about // that, and this endpoint takes uploads from anyone who can reach it. data, err := io.ReadAll(io.LimitReader(r.Body, maxDiagSize+1)) if err != nil { http.Error(w, "could not read the report", http.StatusBadRequest) return } if len(data) > maxDiagSize { http.Error(w, fmt.Sprintf("report exceeds the %d byte limit", maxDiagSize), http.StatusRequestEntityTooLarge) return } if len(data) == 0 { http.Error(w, "empty report", http.StatusBadRequest) return } s.diag.put(id, data, clientIP(r)) w.WriteHeader(http.StatusCreated) fmt.Fprintf(w, "stored as %s, kept for %s\n", id, RetentionNote) } func (s *Server) fetchDiag(w http.ResponseWriter, id string) { data, ok := s.diag.get(id) if !ok { http.Error(w, "no such report (wrong ID, or it expired)", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") w.Write(data) }