diff --git a/cmd/server/main.go b/cmd/server/main.go index f2d112a..96411ea 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "strconv" + "strings" "time" "github.com/go-chi/chi/v5" @@ -27,9 +28,6 @@ func main() { slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil))) cfg := config.Load() - // تلسکوپ: بازرسِ درون‌برنامه‌ایِ درخواست‌ها (رینگ‌بافرِ حافظه، بدونِ وابستگی). - scope := telescope.New(cfg.TelescopeSize) - st, err := store.Open(cfg.DBPath) if err != nil { slog.Error("open db", "err", err) @@ -44,6 +42,31 @@ func main() { jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL) authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP) + // تلسکوپ: بازرسِ درون‌برنامه‌ایِ درخواست‌ها. شناسه‌ی کاربر را ارزان (فقط HMAC) + // از توکن درمی‌آورد و شماره‌ی موبایل را در زمانِ نمایش (با DB) resolve می‌کند. + scope := telescope.New(cfg.TelescopeSize, + func(r *http.Request) int64 { + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + if tok == "" || tok == r.Header.Get("Authorization") { + tok = r.URL.Query().Get("token") + } + if tok == "" { + return 0 + } + id, err := jwt.Verify(tok) + if err != nil { + return 0 + } + return id + }, + func(id int64) string { + if u, err := users.FindByID(context.Background(), id); err == nil { + return u.Mobile + } + return "" + }, + ) + // اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافه‌بازار (امضای RSA). // اگر کلیدها تنظیم نشده باشند، به تأییدکننده‌ی توسعه برمی‌گردیم (dev fallback). iap := economy.StoreRouter{DevFallback: true} diff --git a/internal/telescope/page.go b/internal/telescope/page.go index ec3763b..c7de2ed 100644 --- a/internal/telescope/page.go +++ b/internal/telescope/page.go @@ -13,7 +13,7 @@ const pageHTML = ` body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif; background:#0E2347; color:#E8EEF7; } header { padding:14px 20px; background:#17345C; border-bottom:1px solid #1E5FA8; - display:flex; align-items:center; gap:14px; position:sticky; top:0; } + display:flex; align-items:center; gap:14px; position:sticky; top:0; z-index:2; } header h1 { font-size:17px; margin:0; font-weight:700; } header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; } header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; } @@ -22,24 +22,34 @@ const pageHTML = ` .filters input, .filters select { background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8; border-radius:8px; padding:7px 10px; font-size:13px; } - .filters input { flex:1; min-width:160px; } + .filters input { flex:1; min-width:140px; } + .wrap { overflow-x:auto; } table { width:100%; border-collapse:collapse; font-size:13px; } th, td { padding:9px 12px; text-align:right; border-bottom:1px solid #16305a; white-space:nowrap; } - th { position:sticky; top:52px; background:#122c52; font-weight:600; z-index:1; } - tr:hover { background:#132b50; } + th { background:#122c52; font-weight:600; border-bottom:2px solid #1E5FA8; } + tr.row { cursor:pointer; } + tr.row:hover { background:#132b50; } .m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; } .GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd} .PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5} .st { font-weight:700; } .s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171} .path { font-family: ui-monospace, monospace; } - .body { display:none; } - tr.open .body { display:table-row; } - .body td { background:#0b1c39; white-space:pre-wrap; font-family: ui-monospace, monospace; - font-size:12px; color:#fca5a5; direction:ltr; text-align:left; } + .phone { font-family: ui-monospace, monospace; color:#fcd34d; } .slow { color:#fbbf24; } .dim { opacity:.55; } .empty { padding:40px; text-align:center; opacity:.6; } + .detail td { background:#0b1c39; padding:0; } + .detail.hidden { display:none; } + .panels { padding:12px 16px; display:grid; gap:12px; } + .panel h4 { margin:0 0 6px; font-size:12px; color:#8ab4e8; font-weight:700; + display:flex; align-items:center; gap:6px; } + .panel pre { margin:0; background:#04101f; border:1px solid #16305a; border-radius:8px; + padding:10px; font-family: ui-monospace, monospace; font-size:12px; line-height:1.6; + color:#cbd5e1; white-space:pre-wrap; word-break:break-word; direction:ltr; text-align:left; + max-height:280px; overflow:auto; } + .kv { display:flex; gap:8px; flex-wrap:wrap; font-size:12px; margin-bottom:4px; } + .kv b { color:#8ab4e8; } @@ -51,6 +61,7 @@ const pageHTML = `
+
+
- +
زمانمتدمسیروضعیتمدتحجمIPزمانمتدمسیروضعیتمدتموبایلIP
+
diff --git a/internal/telescope/telescope.go b/internal/telescope/telescope.go index bb92b28..f68b500 100644 --- a/internal/telescope/telescope.go +++ b/internal/telescope/telescope.go @@ -1,7 +1,7 @@ // Package telescope یک بازرسِ سبکِ درخواست‌ها است (شبیهِ Laravel Telescope). // آخرین N درخواست را در یک رینگ‌بافرِ حافظه نگه می‌دارد: متد، مسیر، وضعیت، -// مدت‌زمان، IP و در صورتِ خطا (>=۴۰۰) بدنه‌ی درخواست. بدونِ هیچ وابستگیِ خارجی -// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کم‌منابع مناسب است. +// مدت‌زمان، IP، هدرها، payloadِ درخواست، پاسخ و شماره‌ی موبایلِ کاربر. بدونِ +// وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کم‌منابع مناسب است. package telescope import ( @@ -11,11 +11,18 @@ import ( "io" "net" "net/http" + "regexp" + "sort" "strings" "sync" "time" ) +const ( + maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست + maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ +) + // Entry یک درخواستِ ثبت‌شده را نگه می‌دارد. type Entry struct { ID int64 `json:"id"` @@ -26,7 +33,11 @@ type Entry struct { Duration int64 `json:"duration_ms"` IP string `json:"ip"` Bytes int `json:"bytes"` - Body string `json:"body,omitempty"` // فقط برای خطاها (>=۴۰۰) و POST/PUT + 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 رینگ‌بافرِ هم‌زمان‌امنِ درخواست‌ها. @@ -37,14 +48,27 @@ type Telescope struct { 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 می‌سازد (حداقل ۱). -func New(size int) *Telescope { +// 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} + return &Telescope{ + buf: make([]Entry, size), + size: size, + decodeUID: decodeUID, + resolveMobile: resolveMobile, + mobileCache: map[int64]string{}, + } } func (t *Telescope) add(e Entry) { @@ -59,28 +83,55 @@ func (t *Telescope) add(e Entry) { t.mu.Unlock() } -// Entries آخرین درخواست‌ها را به‌ترتیبِ جدید-به-قدیم برمی‌گرداند. +// Entries آخرین درخواست‌ها را به‌ترتیبِ جدید-به-قدیم برمی‌گرداند و در همین‌جا +// شماره‌ی موبایلِ هر کاربر را (با کش) resolve می‌کند. 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]) } + 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 } -// statusRecorder کدِ وضعیت و حجمِ پاسخ را می‌گیرد. +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) { @@ -92,6 +143,14 @@ 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 @@ -105,7 +164,8 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) { return nil, nil, http.ErrNotSupported } -// Middleware هر درخواستِ /api و /admin را ثبت می‌کند (به‌جز خودِ صفحه‌ی تلسکوپ). +// 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 @@ -115,19 +175,24 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler { return } - var body string - if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil { - b, _ := io.ReadAll(io.LimitReader(req.Body, 4096)) + var reqBody string + if req.Body != nil && req.Method != http.MethodGet { + b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody)) req.Body = io.NopCloser(bytes.NewReader(b)) - body = maskSecrets(string(b)) + reqBody = maskSecrets(string(b)) } - rec := &statusRecorder{ResponseWriter: w} + 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) - e := Entry{ + t.add(Entry{ Time: start, Method: req.Method, Path: p, @@ -135,18 +200,49 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler { Duration: dur.Milliseconds(), IP: req.RemoteAddr, Bytes: rec.bytes, - } - // بدنه را فقط برای خطاها نگه می‌داریم تا حافظه/حریمِ خصوصی حفظ شود. - if rec.status >= 400 { - e.Body = body - } - t.add(e) + UserID: uid, + Headers: formatHeaders(req.Header), + ReqBody: reqBody, + RespBody: maskSecrets(rec.buf.String()), + }) }) } -// maskSecrets مقادیرِ حساس (توکن، کد، پسورد) را در بدنه‌ی JSON پنهان می‌کند. +// 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"} { + for _, k := range []string{"password", "token", "code", "otp", "signature", "purchase_data"} { s = maskField(s, k) } return s @@ -158,7 +254,6 @@ func maskField(s, key string) string { if i < 0 { return s } - // دنبالِ ": " و سپس مقدار می‌گردیم. j := strings.Index(s[i:], ":") if j < 0 { return s @@ -168,8 +263,7 @@ func maskField(s, key string) string { if strings.HasPrefix(rest, `"`) { end := strings.Index(rest[1:], `"`) if end >= 0 { - masked := s[:i+j+1] + `"***"` + rest[end+2:] - return masked + return s[:i+j+1] + `"***"` + rest[end+2:] } } return s diff --git a/internal/telescope/telescope_test.go b/internal/telescope/telescope_test.go index 52bee6e..6966e3b 100644 --- a/internal/telescope/telescope_test.go +++ b/internal/telescope/telescope_test.go @@ -8,7 +8,7 @@ import ( ) func TestRingOrderAndOverflow(t *testing.T) { - tel := New(3) + tel := New(3, nil, nil) for i := 0; i < 5; i++ { tel.add(Entry{Path: string(rune('a' + i))}) } @@ -29,43 +29,76 @@ func TestRingOrderAndOverflow(t *testing.T) { } } -func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) { - tel := New(10) +func TestMiddlewareCapturesPayloadHeadersResponseMasked(t *testing.T) { + tel := New(10, nil, nil) h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"token":"xyz","message":"bad"}`)) })) req := httptest.NewRequest(http.MethodPost, "/api/x", strings.NewReader(`{"password":"secret","kind":"coin"}`)) + req.Header.Set("Authorization", "Bearer supersecret") h.ServeHTTP(httptest.NewRecorder(), req) e := tel.Entries() if len(e) != 1 || e[0].Status != 400 { t.Fatalf("expected one 400 entry, got %+v", e) } - if !strings.Contains(e[0].Body, `"password":"***"`) { - t.Errorf("password not masked: %s", e[0].Body) + // payload برای هر درخواست ثبت می‌شود و مقادیرِ حساس ماسک می‌شوند. + if !strings.Contains(e[0].ReqBody, `"password":"***"`) { + t.Errorf("password not masked: %s", e[0].ReqBody) } - if !strings.Contains(e[0].Body, `"kind":"coin"`) { - t.Errorf("non-secret field lost: %s", e[0].Body) + if !strings.Contains(e[0].ReqBody, `"kind":"coin"`) { + t.Errorf("non-secret field lost: %s", e[0].ReqBody) + } + // پاسخ ثبت و توکنِ آن ماسک می‌شود. + if !strings.Contains(e[0].RespBody, `"token":"***"`) { + t.Errorf("response token not masked: %s", e[0].RespBody) + } + // هدرِ Authorization در نمایش پنهان می‌شود. + if strings.Contains(e[0].Headers, "supersecret") { + t.Errorf("authorization header leaked: %s", e[0].Headers) + } + if !strings.Contains(e[0].Headers, "Authorization: ***") { + t.Errorf("authorization header not masked: %s", e[0].Headers) } } -func TestMiddlewareOKHasNoBody(t *testing.T) { - tel := New(10) +func TestMobileFromLoginBody(t *testing.T) { + tel := New(10, nil, nil) h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) - req := httptest.NewRequest(http.MethodPost, "/api/y", - strings.NewReader(`{"token":"abc"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/auth/login-otp", + strings.NewReader(`{"mobile":"09120000000"}`)) h.ServeHTTP(httptest.NewRecorder(), req) e := tel.Entries() - if len(e) != 1 || e[0].Body != "" { - t.Errorf("2xx should not store body, got %+v", e) + if len(e) != 1 || e[0].Mobile != "09120000000" { + t.Errorf("mobile not extracted from login body, got %+v", e) + } +} + +func TestResolveMobileByUserID(t *testing.T) { + tel := New(10, + func(*http.Request) int64 { return 42 }, + func(id int64) string { + if id == 42 { + return "09121112233" + } + return "" + }) + h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/api/me", nil)) + e := tel.Entries() + if len(e) != 1 || e[0].Mobile != "09121112233" || e[0].UserID != 42 { + t.Errorf("mobile not resolved by user id, got %+v", e) } } func TestMiddlewareSkipsWS(t *testing.T) { - tel := New(10) + tel := New(10, nil, nil) h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))