298 lines
9.1 KiB
Go
298 lines
9.1 KiB
Go
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
||
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
||
// مدتزمان، IP، هدرها، payloadِ درخواست، پاسخ و شمارهی موبایلِ کاربر. بدونِ
|
||
// وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||
package telescope
|
||
|
||
import (
|
||
"bufio"
|
||
"bytes"
|
||
"encoding/json"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"regexp"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست (فقط برای نمایش)
|
||
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
|
||
// سقفِ خواندنِ بدنهی غیرِ multipart (JSON): کامل بازگردانده میشود تا هندلر
|
||
// همه را ببیند؛ فقط برای امنیت در برابرِ بدنهی بسیار بزرگ محدود شده است.
|
||
maxCaptureBody = 4 << 20 // ۴ مگابایت
|
||
)
|
||
|
||
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
||
type Entry struct {
|
||
ID int64 `json:"id"`
|
||
Time time.Time `json:"time"`
|
||
Method string `json:"method"`
|
||
Path string `json:"path"`
|
||
Status int `json:"status"`
|
||
Duration int64 `json:"duration_ms"`
|
||
IP string `json:"ip"`
|
||
Bytes int `json:"bytes"`
|
||
UserID int64 `json:"user_id,omitempty"`
|
||
Mobile string `json:"mobile,omitempty"` // در زمانِ نمایش resolve میشود
|
||
Headers string `json:"headers,omitempty"`
|
||
ReqBody string `json:"req_body,omitempty"` // payloadِ درخواست
|
||
RespBody string `json:"resp_body,omitempty"` // بدنهی پاسخ
|
||
}
|
||
|
||
// Telescope رینگبافرِ همزمانامنِ درخواستها.
|
||
type Telescope struct {
|
||
mu sync.Mutex
|
||
buf []Entry
|
||
size int
|
||
next int64 // شناسهی افزایشی
|
||
full bool
|
||
head int // اندیسِ نوشتنِ بعدی
|
||
|
||
// decodeUID شناسهی کاربر را از توکنِ درخواست (بدونِ DB، فقط HMAC) درمیآورد.
|
||
decodeUID func(*http.Request) int64
|
||
// resolveMobile شمارهی موبایل را از شناسه (با DB) میگیرد؛ فقط در زمانِ نمایش.
|
||
resolveMobile func(int64) string
|
||
mobileCache map[int64]string
|
||
}
|
||
|
||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱). decodeUID و resolveMobile
|
||
// میتوانند nil باشند (مثلاً در تستها).
|
||
func New(size int, decodeUID func(*http.Request) int64, resolveMobile func(int64) string) *Telescope {
|
||
if size < 1 {
|
||
size = 200
|
||
}
|
||
return &Telescope{
|
||
buf: make([]Entry, size),
|
||
size: size,
|
||
decodeUID: decodeUID,
|
||
resolveMobile: resolveMobile,
|
||
mobileCache: map[int64]string{},
|
||
}
|
||
}
|
||
|
||
func (t *Telescope) add(e Entry) {
|
||
t.mu.Lock()
|
||
t.next++
|
||
e.ID = t.next
|
||
t.buf[t.head] = e
|
||
t.head = (t.head + 1) % t.size
|
||
if t.head == 0 {
|
||
t.full = true
|
||
}
|
||
t.mu.Unlock()
|
||
}
|
||
|
||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند و در همینجا
|
||
// شمارهی موبایلِ هر کاربر را (با کش) resolve میکند.
|
||
func (t *Telescope) Entries() []Entry {
|
||
t.mu.Lock()
|
||
n := t.head
|
||
if t.full {
|
||
n = t.size
|
||
}
|
||
out := make([]Entry, 0, n)
|
||
for i := 0; i < n; i++ {
|
||
idx := (t.head - 1 - i + t.size*2) % t.size
|
||
out = append(out, t.buf[idx])
|
||
}
|
||
t.mu.Unlock()
|
||
|
||
// resolveِ موبایل بیرون از قفلِ اصلی (ممکن است به DB بزند).
|
||
for i := range out {
|
||
out[i].Mobile = t.mobileFor(out[i].UserID)
|
||
// درخواستهای ورود (بدونِ توکن) شماره را در payload دارند.
|
||
if out[i].Mobile == "" {
|
||
out[i].Mobile = mobileFromBody(out[i].ReqBody)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (t *Telescope) mobileFor(id int64) string {
|
||
if id == 0 || t.resolveMobile == nil {
|
||
return ""
|
||
}
|
||
t.mu.Lock()
|
||
if m, ok := t.mobileCache[id]; ok {
|
||
t.mu.Unlock()
|
||
return m
|
||
}
|
||
t.mu.Unlock()
|
||
m := t.resolveMobile(id)
|
||
t.mu.Lock()
|
||
t.mobileCache[id] = m
|
||
t.mu.Unlock()
|
||
return m
|
||
}
|
||
|
||
// statusRecorder کدِ وضعیت، حجم و بخشی از بدنهی پاسخ را میگیرد.
|
||
type statusRecorder struct {
|
||
http.ResponseWriter
|
||
status int
|
||
bytes int
|
||
buf *bytes.Buffer
|
||
}
|
||
|
||
func (r *statusRecorder) WriteHeader(code int) {
|
||
r.status = code
|
||
r.ResponseWriter.WriteHeader(code)
|
||
}
|
||
|
||
func (r *statusRecorder) Write(b []byte) (int, error) {
|
||
if r.status == 0 {
|
||
r.status = http.StatusOK
|
||
}
|
||
if r.buf != nil && r.buf.Len() < maxRespBody {
|
||
remain := maxRespBody - r.buf.Len()
|
||
if remain >= len(b) {
|
||
r.buf.Write(b)
|
||
} else {
|
||
r.buf.Write(b[:remain])
|
||
}
|
||
}
|
||
n, err := r.ResponseWriter.Write(b)
|
||
r.bytes += n
|
||
return n, err
|
||
}
|
||
|
||
// Hijack برای WebSocket لازم است تا میدلور مانعِ ارتقاء نشود.
|
||
func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||
if h, ok := r.ResponseWriter.(http.Hijacker); ok {
|
||
return h.Hijack()
|
||
}
|
||
return nil, nil, http.ErrNotSupported
|
||
}
|
||
|
||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ،
|
||
// WebSocket و فایلهای استاتیک).
|
||
func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||
p := req.URL.Path
|
||
if p == "/ws" || strings.HasPrefix(p, "/admin/telescope") ||
|
||
strings.HasPrefix(p, "/cards/") || strings.HasPrefix(p, "/carpets/") {
|
||
next.ServeHTTP(w, req)
|
||
return
|
||
}
|
||
|
||
var reqBody string
|
||
if req.Body != nil && req.Method != http.MethodGet {
|
||
// آپلودِ فایل (multipart) را دست نزن؛ بدنه بزرگ است و باید کامل به
|
||
// هندلر برسد (وگرنه ParseMultipartForm شکست میخورد ⇒ «bad upload»).
|
||
if strings.HasPrefix(req.Header.Get("Content-Type"), "multipart/form-data") {
|
||
reqBody = "[multipart upload]"
|
||
} else {
|
||
// بدنه را کامل بخوان و کامل بازگردان (تا هندلر همهی آن را ببیند)،
|
||
// اما فقط بخشِ ابتدایی را برای نمایش در تلسکوپ نگهدار.
|
||
b, _ := io.ReadAll(io.LimitReader(req.Body, maxCaptureBody))
|
||
req.Body = io.NopCloser(bytes.NewReader(b))
|
||
n := len(b)
|
||
if n > maxReqBody {
|
||
n = maxReqBody
|
||
}
|
||
reqBody = maskSecrets(string(b[:n]))
|
||
}
|
||
}
|
||
|
||
var uid int64
|
||
if t.decodeUID != nil {
|
||
uid = t.decodeUID(req) // فقط HMAC، بدون DB
|
||
}
|
||
|
||
rec := &statusRecorder{ResponseWriter: w, buf: &bytes.Buffer{}}
|
||
start := time.Now()
|
||
next.ServeHTTP(rec, req)
|
||
dur := time.Since(start)
|
||
|
||
t.add(Entry{
|
||
Time: start,
|
||
Method: req.Method,
|
||
Path: p,
|
||
Status: rec.status,
|
||
Duration: dur.Milliseconds(),
|
||
IP: req.RemoteAddr,
|
||
Bytes: rec.bytes,
|
||
UserID: uid,
|
||
Headers: formatHeaders(req.Header),
|
||
ReqBody: reqBody,
|
||
RespBody: maskSecrets(rec.buf.String()),
|
||
})
|
||
})
|
||
}
|
||
|
||
// formatHeaders هدرهای درخواست را (با پنهانکردنِ مقادیرِ حساس) به رشته تبدیل میکند.
|
||
func formatHeaders(h http.Header) string {
|
||
keys := make([]string, 0, len(h))
|
||
for k := range h {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
var sb strings.Builder
|
||
for _, k := range keys {
|
||
val := strings.Join(h[k], ", ")
|
||
switch strings.ToLower(k) {
|
||
case "authorization", "cookie", "x-access-token":
|
||
val = "***"
|
||
}
|
||
sb.WriteString(k)
|
||
sb.WriteString(": ")
|
||
sb.WriteString(val)
|
||
sb.WriteString("\n")
|
||
}
|
||
return sb.String()
|
||
}
|
||
|
||
var mobileRe = regexp.MustCompile(`"mobile"\s*:\s*"([^"]+)"`)
|
||
|
||
// mobileFromBody شمارهی موبایل را از payloadِ درخواست (مثلِ ورود) درمیآورد.
|
||
func mobileFromBody(body string) string {
|
||
if m := mobileRe.FindStringSubmatch(body); len(m) == 2 {
|
||
return m[1]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد، امضا) را در JSON پنهان میکند.
|
||
func maskSecrets(s string) string {
|
||
for _, k := range []string{"password", "token", "code", "otp", "signature", "purchase_data"} {
|
||
s = maskField(s, k)
|
||
}
|
||
return s
|
||
}
|
||
|
||
func maskField(s, key string) string {
|
||
needle := `"` + key + `"`
|
||
i := strings.Index(s, needle)
|
||
if i < 0 {
|
||
return s
|
||
}
|
||
j := strings.Index(s[i:], ":")
|
||
if j < 0 {
|
||
return s
|
||
}
|
||
rest := s[i+j+1:]
|
||
rest = strings.TrimLeft(rest, " ")
|
||
if strings.HasPrefix(rest, `"`) {
|
||
end := strings.Index(rest[1:], `"`)
|
||
if end >= 0 {
|
||
return s[:i+j+1] + `"***"` + rest[end+2:]
|
||
}
|
||
}
|
||
return s
|
||
}
|
||
|
||
// Data آخرین درخواستها را بهصورتِ JSON برمیگرداند.
|
||
func (t *Telescope) Data(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
_ = json.NewEncoder(w).Encode(t.Entries())
|
||
}
|
||
|
||
// Page یک صفحهی HTML سبک برای مرورِ درخواستها میدهد.
|
||
func (t *Telescope) Page(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_, _ = io.WriteString(w, pageHTML)
|
||
}
|