feat: add telescope style

This commit is contained in:
2026-07-05 21:55:12 +03:30
parent ac6e8695b1
commit a72cf582d0
4 changed files with 250 additions and 69 deletions
+26 -3
View File
@@ -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}
+54 -23
View File
@@ -13,7 +13,7 @@ const pageHTML = `<!doctype html>
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 = `<!doctype html>
.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; }
</style>
</head>
<body>
@@ -51,6 +61,7 @@ const pageHTML = `<!doctype html>
</header>
<div class="filters">
<input id="q" placeholder="جستجو در مسیر…">
<input id="pf" placeholder="شماره موبایل…" inputmode="numeric">
<select id="mf">
<option value="">همه متدها</option>
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
@@ -61,48 +72,68 @@ const pageHTML = `<!doctype html>
<option value="4">4xx</option><option value="5">5xx</option>
</select>
</div>
<div class="wrap">
<table>
<thead><tr>
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>حجم</th><th>IP</th>
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>موبایل</th><th>IP</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
<script>
let data = [];
let openId = null;
const $ = s => document.querySelector(s);
function fmtTime(t){ const d = new Date(t); return d.toLocaleTimeString('fa-IR'); }
const esc = s => (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;');
function fmtTime(t){ return new Date(t).toLocaleTimeString('fa-IR'); }
function stClass(s){ return 's' + Math.floor(s/100); }
function detail(e){
const sec = (title, body) => body
? '<div class="panel"><h4>'+title+'</h4><pre>'+esc(body)+'</pre></div>' : '';
const kv = '<div class="kv">'+
(e.mobile ? '<span><b>موبایل:</b> '+esc(e.mobile)+'</span>' : '<span class="dim">کاربر مهمان</span>')+
(e.user_id ? '<span><b>شناسه کاربر:</b> '+e.user_id+'</span>' : '')+
'<span><b>حجم پاسخ:</b> '+e.bytes+' بایت</span>'+
'<span><b>مدت:</b> '+e.duration_ms+'ms</span></div>';
return '<div class="panels">'+kv+
sec('هدرها', e.headers)+
sec('Payload درخواست', e.req_body)+
sec('پاسخ', e.resp_body)+
'</div>';
}
function render(){
const q = $('#q').value.trim().toLowerCase();
const pf = $('#pf').value.trim();
const mf = $('#mf').value, sf = $('#sf').value;
const rows = data.filter(e =>
(!q || (e.path||'').toLowerCase().includes(q)) &&
(!pf || (e.mobile||'').includes(pf)) &&
(!mf || e.method === mf) &&
(!sf || Math.floor((e.status||0)/100) == sf));
const tb = $('#rows'); tb.innerHTML = '';
$('#empty').style.display = rows.length ? 'none' : 'block';
for(const e of rows){
const tr = document.createElement('tr');
tr.className = 'row';
const slow = e.duration_ms > 500 ? ' slow' : '';
tr.innerHTML =
'<td class="dim">'+fmtTime(e.time)+'</td>'+
'<td><span class="m '+e.method+'">'+e.method+'</span></td>'+
'<td class="path">'+e.path+'</td>'+
'<td class="path">'+esc(e.path)+'</td>'+
'<td class="st '+stClass(e.status)+'">'+(e.status||'-')+'</td>'+
'<td class="'+slow.trim()+'">'+e.duration_ms+'ms</td>'+
'<td class="dim">'+e.bytes+'</td>'+
'<td class="dim">'+(e.ip||'')+'</td>';
if(e.body){
tr.style.cursor='pointer';
tr.onclick = () => { const b = tr.nextSibling; b.style.display = b.style.display==='table-row'?'none':'table-row'; };
tb.appendChild(tr);
const b = document.createElement('tr'); b.className='body';
b.innerHTML = '<td colspan="7">'+e.body.replace(/</g,'&lt;')+'</td>';
b.style.display='none';
tb.appendChild(b);
} else {
'<td class="phone">'+esc(e.mobile||'')+'</td>'+
'<td class="dim">'+esc(e.ip||'')+'</td>';
tr.onclick = () => { openId = (openId===e.id ? null : e.id); render(); };
tb.appendChild(tr);
if(openId === e.id){
const dr = document.createElement('tr');
dr.className = 'detail';
dr.innerHTML = '<td colspan="7">'+detail(e)+'</td>';
tb.appendChild(dr);
}
}
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
@@ -114,7 +145,7 @@ async function load(){
render();
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
}
['q','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
['q','pf','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
load();
setInterval(() => { if($('#live').checked) load(); }, 2000);
</script>
+122 -28
View File
@@ -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
+47 -14
View File
@@ -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)
}))