mirror of
https://github.com/octoleo/syncthing.git
synced 2024-12-23 11:28:59 +00:00
4b17c511f9
Seeing thousands of reports is no use when we don't know if they represent one poor user or thousands.
186 lines
4.9 KiB
Go
186 lines
4.9 KiB
Go
// Copyright (C) 2019 The Syncthing Authors.
|
|
//
|
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
|
|
// You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
|
// Command stcrashreceiver is a trivial HTTP server that allows two things:
|
|
//
|
|
// - uploading files (crash reports) named like a SHA256 hash using a PUT request
|
|
// - checking whether such file exists using a HEAD request
|
|
//
|
|
// Typically this should be deployed behind something that manages HTTPS.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/syncthing/syncthing/lib/sha256"
|
|
)
|
|
|
|
const maxRequestSize = 1 << 20 // 1 MiB
|
|
|
|
func main() {
|
|
dir := flag.String("dir", ".", "Directory to store reports in")
|
|
dsn := flag.String("dsn", "", "Sentry DSN")
|
|
listen := flag.String("listen", ":22039", "HTTP listen address")
|
|
flag.Parse()
|
|
|
|
cr := &crashReceiver{
|
|
dir: *dir,
|
|
dsn: *dsn,
|
|
}
|
|
|
|
log.SetOutput(os.Stdout)
|
|
if err := http.ListenAndServe(*listen, cr); err != nil {
|
|
log.Fatalln("HTTP serve:", err)
|
|
}
|
|
}
|
|
|
|
type crashReceiver struct {
|
|
dir string
|
|
dsn string
|
|
}
|
|
|
|
func (r *crashReceiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|
// The final path component should be a SHA256 hash in hex, so 64 hex
|
|
// characters. We don't care about case on the request but use lower
|
|
// case internally.
|
|
reportID := strings.ToLower(path.Base(req.URL.Path))
|
|
if len(reportID) != 64 {
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
for _, c := range reportID {
|
|
if c >= 'a' && c <= 'f' {
|
|
continue
|
|
}
|
|
if c >= '0' && c <= '9' {
|
|
continue
|
|
}
|
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// The location of the report on disk, compressed
|
|
fullPath := filepath.Join(r.dir, r.dirFor(reportID), reportID) + ".gz"
|
|
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
r.serveGet(fullPath, w, req)
|
|
case http.MethodHead:
|
|
r.serveHead(fullPath, w, req)
|
|
case http.MethodPut:
|
|
r.servePut(reportID, fullPath, w, req)
|
|
default:
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
// serveGet responds to GET requests by serving the uncompressed report.
|
|
func (r *crashReceiver) serveGet(fullPath string, w http.ResponseWriter, _ *http.Request) {
|
|
fd, err := os.Open(fullPath)
|
|
if err != nil {
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
defer fd.Close()
|
|
gr, err := gzip.NewReader(fd)
|
|
if err != nil {
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
_, _ = io.Copy(w, gr) // best effort
|
|
}
|
|
|
|
// serveHead responds to HEAD requests by checking if the named report
|
|
// already exists in the system.
|
|
func (r *crashReceiver) serveHead(fullPath string, w http.ResponseWriter, _ *http.Request) {
|
|
if _, err := os.Lstat(fullPath); err != nil {
|
|
http.Error(w, "Not found", http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
// servePut accepts and stores the given report.
|
|
func (r *crashReceiver) servePut(reportID, fullPath string, w http.ResponseWriter, req *http.Request) {
|
|
// Ensure the destination directory exists
|
|
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
|
log.Println("Creating directory:", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Read at most maxRequestSize of report data.
|
|
log.Println("Receiving report", reportID)
|
|
lr := io.LimitReader(req.Body, maxRequestSize)
|
|
bs, err := ioutil.ReadAll(lr)
|
|
if err != nil {
|
|
log.Println("Reading report:", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Compress the report for storage
|
|
buf := new(bytes.Buffer)
|
|
gw := gzip.NewWriter(buf)
|
|
_, _ = gw.Write(bs) // can't fail
|
|
gw.Close()
|
|
|
|
// Create an output file with the compressed report
|
|
err = ioutil.WriteFile(fullPath, buf.Bytes(), 0644)
|
|
if err != nil {
|
|
log.Println("Saving report:", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Send the report to Sentry
|
|
if r.dsn != "" {
|
|
// Remote ID
|
|
user := userIDFor(req)
|
|
|
|
go func() {
|
|
// There's no need for the client to have to wait for this part.
|
|
if err := sendReport(r.dsn, reportID, bs, user); err != nil {
|
|
log.Println("Failed to send report:", err)
|
|
}
|
|
}()
|
|
}
|
|
}
|
|
|
|
// 01234567890abcdef... => 01/23
|
|
func (r *crashReceiver) dirFor(base string) string {
|
|
return filepath.Join(base[0:2], base[2:4])
|
|
}
|
|
|
|
// userIDFor returns a string we can use as the user ID for the purpose of
|
|
// counting affected users. It's the truncated hash of a salt, the user
|
|
// remote IP, and the current month.
|
|
func userIDFor(req *http.Request) string {
|
|
addr := req.RemoteAddr
|
|
if fwd := req.Header.Get("x-forwarded-for"); fwd != "" {
|
|
addr = fwd
|
|
}
|
|
if host, _, err := net.SplitHostPort(addr); err == nil {
|
|
addr = host
|
|
}
|
|
now := time.Now().Format("200601")
|
|
salt := "stcrashreporter"
|
|
hash := sha256.Sum256([]byte(salt + addr + now))
|
|
return fmt.Sprintf("%x", hash[:8])
|
|
}
|