189 lines
5.3 KiB
Go
189 lines
5.3 KiB
Go
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
|
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
|
// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
|
|
// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
|
package telescope
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// 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"`
|
|
Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT
|
|
}
|
|
|
|
// Telescope رینگبافرِ همزمانامنِ درخواستها.
|
|
type Telescope struct {
|
|
mu sync.Mutex
|
|
buf []Entry
|
|
size int
|
|
next int64 // شناسهی افزایشی
|
|
full bool
|
|
head int // اندیسِ نوشتنِ بعدی
|
|
}
|
|
|
|
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱).
|
|
func New(size int) *Telescope {
|
|
if size < 1 {
|
|
size = 200
|
|
}
|
|
return &Telescope{buf: make([]Entry, size), size: size}
|
|
}
|
|
|
|
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 آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
|
|
func (t *Telescope) Entries() []Entry {
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
n := t.head
|
|
if t.full {
|
|
n = t.size
|
|
}
|
|
out := make([]Entry, 0, n)
|
|
// از جدیدترین (درست قبلِ head) عقب میرویم.
|
|
for i := 0; i < n; i++ {
|
|
idx := (t.head - 1 - i + t.size*2) % t.size
|
|
out = append(out, t.buf[idx])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// statusRecorder کدِ وضعیت و حجمِ پاسخ را میگیرد.
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
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
|
|
}
|
|
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 را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
|
|
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 body string
|
|
if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
|
|
b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
|
|
req.Body = io.NopCloser(bytes.NewReader(b))
|
|
body = maskSecrets(string(b))
|
|
}
|
|
|
|
rec := &statusRecorder{ResponseWriter: w}
|
|
start := time.Now()
|
|
next.ServeHTTP(rec, req)
|
|
dur := time.Since(start)
|
|
|
|
e := Entry{
|
|
Time: start,
|
|
Method: req.Method,
|
|
Path: p,
|
|
Status: rec.status,
|
|
Duration: dur.Milliseconds(),
|
|
IP: req.RemoteAddr,
|
|
Bytes: rec.bytes,
|
|
}
|
|
// بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
|
|
if rec.status >= 400 {
|
|
e.Body = body
|
|
}
|
|
t.add(e)
|
|
})
|
|
}
|
|
|
|
// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنهی JSON پنهان میکند.
|
|
func maskSecrets(s string) string {
|
|
for _, k := range []string{"password", "token", "code", "otp", "signature"} {
|
|
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 {
|
|
masked := s[:i+j+1] + `"***"` + rest[end+2:]
|
|
return masked
|
|
}
|
|
}
|
|
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)
|
|
}
|