package main import ( "bytes" "compress/gzip" "crypto/subtle" "errors" "io" "log/slog" "net/http" "os" "path/filepath" "regexp" "sort" "strings" "sync" "time" "unicode/utf8" ) // Logs keeps the text files the car sends from its Logs tab (signal recordings) on a volume, // so they reach the owner without a USB stick. The car sends with its ingest token; reading // needs LOGS_TOKEN, a token of its own, so neither the car's token can read them nor this one // write anything. // // PUT /v1/logs/{name} one file from the car (ingest token); the same name again replaces it // GET /v1/logs the files, newest first (LOGS_TOKEN) // GET /v1/logs/{name} one file (LOGS_TOKEN) // // The newest maxLogFiles files younger than maxLogAge are kept. A file's contents never go to // the server log, only its name and size. type Logs struct { Dir string // ReadToken is LOGS_TOKEN; empty switches reading off (the car can still send). ReadToken string mu sync.Mutex now func() time.Time } const ( maxLogWireBytes = 2 << 20 // as sent, gzip or not maxLogBytes = 8 << 20 // as stored maxLogFiles = 200 maxLogAge = 180 * 24 * time.Hour ) // logName is what a file may be called: the app's own names, e.g. signal-recording-20261027-085240.txt. var logName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,79}\.txt$`) // NewLogs keeps the files in dir, which must exist. func NewLogs(dir, readToken string) *Logs { return &Logs{Dir: dir, ReadToken: readToken, now: time.Now} } func (a *API) logRoutes(mux *http.ServeMux) { if a.Logs == nil { return } mux.HandleFunc("PUT /v1/logs/{name}", a.putLog) mux.HandleFunc("GET /v1/logs", a.Logs.list) mux.HandleFunc("GET /v1/logs/{name}", a.Logs.get) } func (a *API) putLog(w http.ResponseWriter, r *http.Request) { if _, ok := a.authenticate(r); !ok { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } name := r.PathValue("name") if !logName.MatchString(name) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name must be lower-case letters, digits and dashes, ending in .txt"}) return } text, status, msg := readLogBody(r) if status != 0 { writeJSON(w, status, map[string]string{"error": msg}) return } if err := a.Logs.save(name, text); err != nil { slog.Error("log not stored", "name", name, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } slog.Info("log received", "name", name, "bytes", len(text)) w.WriteHeader(http.StatusNoContent) } // readLogBody is the request's text, un-gzipped when it says so; a status and message when it // is too large, not gzip after all, or not UTF-8 text. func readLogBody(r *http.Request) ([]byte, int, string) { wire, err := io.ReadAll(io.LimitReader(r.Body, maxLogWireBytes+1)) if err != nil { return nil, http.StatusBadRequest, "body could not be read" } if len(wire) > maxLogWireBytes { return nil, http.StatusRequestEntityTooLarge, "body is too large" } text := wire switch strings.ToLower(r.Header.Get("Content-Encoding")) { case "", "identity": case "gzip": zr, err := gzip.NewReader(bytes.NewReader(wire)) if err != nil { return nil, http.StatusBadRequest, "body is not gzip" } text, err = io.ReadAll(io.LimitReader(zr, maxLogBytes+1)) if err != nil { return nil, http.StatusBadRequest, "body is not gzip" } default: return nil, http.StatusUnsupportedMediaType, "only gzip or no encoding" } if len(text) > maxLogBytes { return nil, http.StatusRequestEntityTooLarge, "body is too large" } if !utf8.Valid(text) { return nil, http.StatusBadRequest, "body is not UTF-8 text" } return text, 0, "" } // save writes the file whole (a temporary file renamed into place), then prunes. func (l *Logs) save(name string, text []byte) error { l.mu.Lock() defer l.mu.Unlock() tmp, err := os.CreateTemp(l.Dir, ".incoming-*") if err != nil { return err } defer os.Remove(tmp.Name()) // gone after the rename; cleans up after a failure if _, err := tmp.Write(text); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Rename(tmp.Name(), filepath.Join(l.Dir, name)); err != nil { return err } l.prune() return nil } type logEntry struct { Name string `json:"name"` Bytes int64 `json:"bytes"` Received time.Time `json:"received"` } // entries are the stored files, newest first. func (l *Logs) entries() ([]logEntry, error) { dir, err := os.ReadDir(l.Dir) if err != nil { return nil, err } var out []logEntry for _, e := range dir { if !e.Type().IsRegular() || !logName.MatchString(e.Name()) { continue } info, err := e.Info() if err != nil { continue } out = append(out, logEntry{Name: e.Name(), Bytes: info.Size(), Received: info.ModTime().UTC()}) } sort.Slice(out, func(i, j int) bool { if !out[i].Received.Equal(out[j].Received) { return out[i].Received.After(out[j].Received) } return out[i].Name > out[j].Name }) return out, nil } // prune removes all but the newest maxLogFiles and anything older than maxLogAge. func (l *Logs) prune() { entries, err := l.entries() if err != nil { return } cutoff := l.now().Add(-maxLogAge) for i, e := range entries { if i >= maxLogFiles || e.Received.Before(cutoff) { if err := os.Remove(filepath.Join(l.Dir, e.Name)); err != nil && !errors.Is(err, os.ErrNotExist) { slog.Warn("old log not removed", "name", e.Name, "error", err) } } } } // mayRead accepts LOGS_TOKEN only, compared in constant time; main refuses a short one. func (l *Logs) mayRead(w http.ResponseWriter, r *http.Request) bool { if l.ReadToken == "" { writeJSON(w, http.StatusNotFound, map[string]string{"error": "reading logs is off (no LOGS_TOKEN)"}) return false } supplied, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !ok || subtle.ConstantTimeCompare([]byte(supplied), []byte(l.ReadToken)) != 1 { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return false } return true } func (l *Logs) list(w http.ResponseWriter, r *http.Request) { if !l.mayRead(w, r) { return } l.mu.Lock() entries, err := l.entries() l.mu.Unlock() if err != nil { slog.Error("logs not listed", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } writeJSON(w, http.StatusOK, map[string]any{"logs": append([]logEntry{}, entries...)}) } func (l *Logs) get(w http.ResponseWriter, r *http.Request) { if !l.mayRead(w, r) { return } name := r.PathValue("name") if !logName.MatchString(name) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such log"}) return } l.mu.Lock() text, err := os.ReadFile(filepath.Join(l.Dir, name)) l.mu.Unlock() if errors.Is(err, os.ErrNotExist) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such log"}) return } if err != nil { slog.Error("log not read", "name", name, "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal server error"}) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-Content-Type-Options", "nosniff") w.WriteHeader(http.StatusOK) _, _ = w.Write(text) }