feat: add telescope style
This commit is contained in:
+26
-3
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -27,9 +28,6 @@ func main() {
|
|||||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
|
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, nil)))
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
// تلسکوپ: بازرسِ درونبرنامهایِ درخواستها (رینگبافرِ حافظه، بدونِ وابستگی).
|
|
||||||
scope := telescope.New(cfg.TelescopeSize)
|
|
||||||
|
|
||||||
st, err := store.Open(cfg.DBPath)
|
st, err := store.Open(cfg.DBPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("open db", "err", err)
|
slog.Error("open db", "err", err)
|
||||||
@@ -44,6 +42,31 @@ func main() {
|
|||||||
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
|
jwt := auth.NewJWT(cfg.JWTSecret, cfg.JWTTTL)
|
||||||
authH := auth.NewHandler(users, otp, kave, jwt, cfg.AdminMobile, cfg.AdminOTP)
|
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).
|
// اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافهبازار (امضای RSA).
|
||||||
// اگر کلیدها تنظیم نشده باشند، به تأییدکنندهی توسعه برمیگردیم (dev fallback).
|
// اگر کلیدها تنظیم نشده باشند، به تأییدکنندهی توسعه برمیگردیم (dev fallback).
|
||||||
iap := economy.StoreRouter{DevFallback: true}
|
iap := economy.StoreRouter{DevFallback: true}
|
||||||
|
|||||||
+55
-24
@@ -13,7 +13,7 @@ const pageHTML = `<!doctype html>
|
|||||||
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif;
|
body { margin:0; font-family: -apple-system, "Segoe UI", Tahoma, sans-serif;
|
||||||
background:#0E2347; color:#E8EEF7; }
|
background:#0E2347; color:#E8EEF7; }
|
||||||
header { padding:14px 20px; background:#17345C; border-bottom:1px solid #1E5FA8;
|
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 h1 { font-size:17px; margin:0; font-weight:700; }
|
||||||
header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; }
|
header .dot { width:9px; height:9px; border-radius:50%; background:#4ade80; }
|
||||||
header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; }
|
header .meta { margin-inline-start:auto; font-size:12px; opacity:.7; }
|
||||||
@@ -22,24 +22,34 @@ const pageHTML = `<!doctype html>
|
|||||||
.filters input, .filters select {
|
.filters input, .filters select {
|
||||||
background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8;
|
background:#0b1c39; color:#E8EEF7; border:1px solid #1E5FA8;
|
||||||
border-radius:8px; padding:7px 10px; font-size:13px; }
|
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; }
|
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, 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; }
|
th { background:#122c52; font-weight:600; border-bottom:2px solid #1E5FA8; }
|
||||||
tr:hover { background:#132b50; }
|
tr.row { cursor:pointer; }
|
||||||
|
tr.row:hover { background:#132b50; }
|
||||||
.m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; }
|
.m { font-weight:700; font-size:11px; padding:2px 7px; border-radius:6px; }
|
||||||
.GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd}
|
.GET{background:#134e4a;color:#5eead4} .POST{background:#1e3a8a;color:#93c5fd}
|
||||||
.PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5}
|
.PUT{background:#713f12;color:#fcd34d} .DELETE{background:#7f1d1d;color:#fca5a5}
|
||||||
.st { font-weight:700; }
|
.st { font-weight:700; }
|
||||||
.s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171}
|
.s2{color:#4ade80} .s3{color:#60a5fa} .s4{color:#fbbf24} .s5{color:#f87171}
|
||||||
.path { font-family: ui-monospace, monospace; }
|
.path { font-family: ui-monospace, monospace; }
|
||||||
.body { display:none; }
|
.phone { font-family: ui-monospace, monospace; color:#fcd34d; }
|
||||||
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; }
|
|
||||||
.slow { color:#fbbf24; }
|
.slow { color:#fbbf24; }
|
||||||
.dim { opacity:.55; }
|
.dim { opacity:.55; }
|
||||||
.empty { padding:40px; text-align:center; opacity:.6; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -51,6 +61,7 @@ const pageHTML = `<!doctype html>
|
|||||||
</header>
|
</header>
|
||||||
<div class="filters">
|
<div class="filters">
|
||||||
<input id="q" placeholder="جستجو در مسیر…">
|
<input id="q" placeholder="جستجو در مسیر…">
|
||||||
|
<input id="pf" placeholder="شماره موبایل…" inputmode="numeric">
|
||||||
<select id="mf">
|
<select id="mf">
|
||||||
<option value="">همه متدها</option>
|
<option value="">همه متدها</option>
|
||||||
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</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>
|
<option value="4">4xx</option><option value="5">5xx</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr>
|
<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>
|
</tr></thead>
|
||||||
<tbody id="rows"></tbody>
|
<tbody id="rows"></tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
|
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
|
||||||
<script>
|
<script>
|
||||||
let data = [];
|
let data = [];
|
||||||
|
let openId = null;
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
function fmtTime(t){ const d = new Date(t); return d.toLocaleTimeString('fa-IR'); }
|
const esc = s => (s||'').replace(/&/g,'&').replace(/</g,'<');
|
||||||
|
function fmtTime(t){ return new Date(t).toLocaleTimeString('fa-IR'); }
|
||||||
function stClass(s){ return 's' + Math.floor(s/100); }
|
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(){
|
function render(){
|
||||||
const q = $('#q').value.trim().toLowerCase();
|
const q = $('#q').value.trim().toLowerCase();
|
||||||
|
const pf = $('#pf').value.trim();
|
||||||
const mf = $('#mf').value, sf = $('#sf').value;
|
const mf = $('#mf').value, sf = $('#sf').value;
|
||||||
const rows = data.filter(e =>
|
const rows = data.filter(e =>
|
||||||
(!q || (e.path||'').toLowerCase().includes(q)) &&
|
(!q || (e.path||'').toLowerCase().includes(q)) &&
|
||||||
|
(!pf || (e.mobile||'').includes(pf)) &&
|
||||||
(!mf || e.method === mf) &&
|
(!mf || e.method === mf) &&
|
||||||
(!sf || Math.floor((e.status||0)/100) == sf));
|
(!sf || Math.floor((e.status||0)/100) == sf));
|
||||||
const tb = $('#rows'); tb.innerHTML = '';
|
const tb = $('#rows'); tb.innerHTML = '';
|
||||||
$('#empty').style.display = rows.length ? 'none' : 'block';
|
$('#empty').style.display = rows.length ? 'none' : 'block';
|
||||||
for(const e of rows){
|
for(const e of rows){
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
|
tr.className = 'row';
|
||||||
const slow = e.duration_ms > 500 ? ' slow' : '';
|
const slow = e.duration_ms > 500 ? ' slow' : '';
|
||||||
tr.innerHTML =
|
tr.innerHTML =
|
||||||
'<td class="dim">'+fmtTime(e.time)+'</td>'+
|
'<td class="dim">'+fmtTime(e.time)+'</td>'+
|
||||||
'<td><span class="m '+e.method+'">'+e.method+'</span></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="st '+stClass(e.status)+'">'+(e.status||'-')+'</td>'+
|
||||||
'<td class="'+slow.trim()+'">'+e.duration_ms+'ms</td>'+
|
'<td class="'+slow.trim()+'">'+e.duration_ms+'ms</td>'+
|
||||||
'<td class="dim">'+e.bytes+'</td>'+
|
'<td class="phone">'+esc(e.mobile||'')+'</td>'+
|
||||||
'<td class="dim">'+(e.ip||'')+'</td>';
|
'<td class="dim">'+esc(e.ip||'')+'</td>';
|
||||||
if(e.body){
|
tr.onclick = () => { openId = (openId===e.id ? null : e.id); render(); };
|
||||||
tr.style.cursor='pointer';
|
tb.appendChild(tr);
|
||||||
tr.onclick = () => { const b = tr.nextSibling; b.style.display = b.style.display==='table-row'?'none':'table-row'; };
|
if(openId === e.id){
|
||||||
tb.appendChild(tr);
|
const dr = document.createElement('tr');
|
||||||
const b = document.createElement('tr'); b.className='body';
|
dr.className = 'detail';
|
||||||
b.innerHTML = '<td colspan="7">'+e.body.replace(/</g,'<')+'</td>';
|
dr.innerHTML = '<td colspan="7">'+detail(e)+'</td>';
|
||||||
b.style.display='none';
|
tb.appendChild(dr);
|
||||||
tb.appendChild(b);
|
|
||||||
} else {
|
|
||||||
tb.appendChild(tr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
|
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
|
||||||
@@ -114,7 +145,7 @@ async function load(){
|
|||||||
render();
|
render();
|
||||||
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
|
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
|
||||||
}
|
}
|
||||||
['q','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
['q','pf','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
|
||||||
load();
|
load();
|
||||||
setInterval(() => { if($('#live').checked) load(); }, 2000);
|
setInterval(() => { if($('#live').checked) load(); }, 2000);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+122
-28
@@ -1,7 +1,7 @@
|
|||||||
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
// Package telescope یک بازرسِ سبکِ درخواستها است (شبیهِ Laravel Telescope).
|
||||||
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
// آخرین N درخواست را در یک رینگبافرِ حافظه نگه میدارد: متد، مسیر، وضعیت،
|
||||||
// مدتزمان، IP و در صورتِ خطا (>=۴۰۰) بدنهی درخواست. بدونِ هیچ وابستگیِ خارجی
|
// مدتزمان، IP، هدرها، payloadِ درخواست، پاسخ و شمارهی موبایلِ کاربر. بدونِ
|
||||||
// و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
// وابستگیِ خارجی و با سرباری ناچیز؛ برای پایشِ سریع روی سرورِ کممنابع مناسب است.
|
||||||
package telescope
|
package telescope
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,11 +11,18 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxReqBody = 8 << 10 // ۸ کیلوبایت از payloadِ درخواست
|
||||||
|
maxRespBody = 16 << 10 // ۱۶ کیلوبایت از پاسخ
|
||||||
|
)
|
||||||
|
|
||||||
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
// Entry یک درخواستِ ثبتشده را نگه میدارد.
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -26,7 +33,11 @@ type Entry struct {
|
|||||||
Duration int64 `json:"duration_ms"`
|
Duration int64 `json:"duration_ms"`
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Bytes int `json:"bytes"`
|
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 رینگبافرِ همزمانامنِ درخواستها.
|
// Telescope رینگبافرِ همزمانامنِ درخواستها.
|
||||||
@@ -37,14 +48,27 @@ type Telescope struct {
|
|||||||
next int64 // شناسهی افزایشی
|
next int64 // شناسهی افزایشی
|
||||||
full bool
|
full bool
|
||||||
head int // اندیسِ نوشتنِ بعدی
|
head int // اندیسِ نوشتنِ بعدی
|
||||||
|
|
||||||
|
// decodeUID شناسهی کاربر را از توکنِ درخواست (بدونِ DB، فقط HMAC) درمیآورد.
|
||||||
|
decodeUID func(*http.Request) int64
|
||||||
|
// resolveMobile شمارهی موبایل را از شناسه (با DB) میگیرد؛ فقط در زمانِ نمایش.
|
||||||
|
resolveMobile func(int64) string
|
||||||
|
mobileCache map[int64]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱).
|
// New یک بازرس با ظرفیتِ size میسازد (حداقل ۱). decodeUID و resolveMobile
|
||||||
func New(size int) *Telescope {
|
// میتوانند nil باشند (مثلاً در تستها).
|
||||||
|
func New(size int, decodeUID func(*http.Request) int64, resolveMobile func(int64) string) *Telescope {
|
||||||
if size < 1 {
|
if size < 1 {
|
||||||
size = 200
|
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) {
|
func (t *Telescope) add(e Entry) {
|
||||||
@@ -59,28 +83,55 @@ func (t *Telescope) add(e Entry) {
|
|||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند.
|
// Entries آخرین درخواستها را بهترتیبِ جدید-به-قدیم برمیگرداند و در همینجا
|
||||||
|
// شمارهی موبایلِ هر کاربر را (با کش) resolve میکند.
|
||||||
func (t *Telescope) Entries() []Entry {
|
func (t *Telescope) Entries() []Entry {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
|
||||||
n := t.head
|
n := t.head
|
||||||
if t.full {
|
if t.full {
|
||||||
n = t.size
|
n = t.size
|
||||||
}
|
}
|
||||||
out := make([]Entry, 0, n)
|
out := make([]Entry, 0, n)
|
||||||
// از جدیدترین (درست قبلِ head) عقب میرویم.
|
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
idx := (t.head - 1 - i + t.size*2) % t.size
|
idx := (t.head - 1 - i + t.size*2) % t.size
|
||||||
out = append(out, t.buf[idx])
|
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
|
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 {
|
type statusRecorder struct {
|
||||||
http.ResponseWriter
|
http.ResponseWriter
|
||||||
status int
|
status int
|
||||||
bytes int
|
bytes int
|
||||||
|
buf *bytes.Buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *statusRecorder) WriteHeader(code int) {
|
func (r *statusRecorder) WriteHeader(code int) {
|
||||||
@@ -92,6 +143,14 @@ func (r *statusRecorder) Write(b []byte) (int, error) {
|
|||||||
if r.status == 0 {
|
if r.status == 0 {
|
||||||
r.status = http.StatusOK
|
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)
|
n, err := r.ResponseWriter.Write(b)
|
||||||
r.bytes += n
|
r.bytes += n
|
||||||
return n, err
|
return n, err
|
||||||
@@ -105,7 +164,8 @@ func (r *statusRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
|||||||
return nil, nil, http.ErrNotSupported
|
return nil, nil, http.ErrNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ).
|
// Middleware هر درخواستِ /api و /admin را ثبت میکند (بهجز خودِ صفحهی تلسکوپ،
|
||||||
|
// WebSocket و فایلهای استاتیک).
|
||||||
func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
p := req.URL.Path
|
p := req.URL.Path
|
||||||
@@ -115,19 +175,24 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body string
|
var reqBody string
|
||||||
if (req.Method == http.MethodPost || req.Method == http.MethodPut) && req.Body != nil {
|
if req.Body != nil && req.Method != http.MethodGet {
|
||||||
b, _ := io.ReadAll(io.LimitReader(req.Body, 4096))
|
b, _ := io.ReadAll(io.LimitReader(req.Body, maxReqBody))
|
||||||
req.Body = io.NopCloser(bytes.NewReader(b))
|
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()
|
start := time.Now()
|
||||||
next.ServeHTTP(rec, req)
|
next.ServeHTTP(rec, req)
|
||||||
dur := time.Since(start)
|
dur := time.Since(start)
|
||||||
|
|
||||||
e := Entry{
|
t.add(Entry{
|
||||||
Time: start,
|
Time: start,
|
||||||
Method: req.Method,
|
Method: req.Method,
|
||||||
Path: p,
|
Path: p,
|
||||||
@@ -135,18 +200,49 @@ func (t *Telescope) Middleware(next http.Handler) http.Handler {
|
|||||||
Duration: dur.Milliseconds(),
|
Duration: dur.Milliseconds(),
|
||||||
IP: req.RemoteAddr,
|
IP: req.RemoteAddr,
|
||||||
Bytes: rec.bytes,
|
Bytes: rec.bytes,
|
||||||
}
|
UserID: uid,
|
||||||
// بدنه را فقط برای خطاها نگه میداریم تا حافظه/حریمِ خصوصی حفظ شود.
|
Headers: formatHeaders(req.Header),
|
||||||
if rec.status >= 400 {
|
ReqBody: reqBody,
|
||||||
e.Body = body
|
RespBody: maskSecrets(rec.buf.String()),
|
||||||
}
|
})
|
||||||
t.add(e)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
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)
|
s = maskField(s, k)
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
@@ -158,7 +254,6 @@ func maskField(s, key string) string {
|
|||||||
if i < 0 {
|
if i < 0 {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
// دنبالِ ": " و سپس مقدار میگردیم.
|
|
||||||
j := strings.Index(s[i:], ":")
|
j := strings.Index(s[i:], ":")
|
||||||
if j < 0 {
|
if j < 0 {
|
||||||
return s
|
return s
|
||||||
@@ -168,8 +263,7 @@ func maskField(s, key string) string {
|
|||||||
if strings.HasPrefix(rest, `"`) {
|
if strings.HasPrefix(rest, `"`) {
|
||||||
end := strings.Index(rest[1:], `"`)
|
end := strings.Index(rest[1:], `"`)
|
||||||
if end >= 0 {
|
if end >= 0 {
|
||||||
masked := s[:i+j+1] + `"***"` + rest[end+2:]
|
return s[:i+j+1] + `"***"` + rest[end+2:]
|
||||||
return masked
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestRingOrderAndOverflow(t *testing.T) {
|
func TestRingOrderAndOverflow(t *testing.T) {
|
||||||
tel := New(3)
|
tel := New(3, nil, nil)
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
tel.add(Entry{Path: string(rune('a' + i))})
|
tel.add(Entry{Path: string(rune('a' + i))})
|
||||||
}
|
}
|
||||||
@@ -29,43 +29,76 @@ func TestRingOrderAndOverflow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
|
func TestMiddlewareCapturesPayloadHeadersResponseMasked(t *testing.T) {
|
||||||
tel := New(10)
|
tel := New(10, nil, nil)
|
||||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = w.Write([]byte(`{"token":"xyz","message":"bad"}`))
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/x",
|
req := httptest.NewRequest(http.MethodPost, "/api/x",
|
||||||
strings.NewReader(`{"password":"secret","kind":"coin"}`))
|
strings.NewReader(`{"password":"secret","kind":"coin"}`))
|
||||||
|
req.Header.Set("Authorization", "Bearer supersecret")
|
||||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
e := tel.Entries()
|
e := tel.Entries()
|
||||||
if len(e) != 1 || e[0].Status != 400 {
|
if len(e) != 1 || e[0].Status != 400 {
|
||||||
t.Fatalf("expected one 400 entry, got %+v", e)
|
t.Fatalf("expected one 400 entry, got %+v", e)
|
||||||
}
|
}
|
||||||
if !strings.Contains(e[0].Body, `"password":"***"`) {
|
// payload برای هر درخواست ثبت میشود و مقادیرِ حساس ماسک میشوند.
|
||||||
t.Errorf("password not masked: %s", e[0].Body)
|
if !strings.Contains(e[0].ReqBody, `"password":"***"`) {
|
||||||
|
t.Errorf("password not masked: %s", e[0].ReqBody)
|
||||||
}
|
}
|
||||||
if !strings.Contains(e[0].Body, `"kind":"coin"`) {
|
if !strings.Contains(e[0].ReqBody, `"kind":"coin"`) {
|
||||||
t.Errorf("non-secret field lost: %s", e[0].Body)
|
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) {
|
func TestMobileFromLoginBody(t *testing.T) {
|
||||||
tel := New(10)
|
tel := New(10, nil, nil)
|
||||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/y",
|
req := httptest.NewRequest(http.MethodPost, "/api/auth/login-otp",
|
||||||
strings.NewReader(`{"token":"abc"}`))
|
strings.NewReader(`{"mobile":"09120000000"}`))
|
||||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
e := tel.Entries()
|
e := tel.Entries()
|
||||||
if len(e) != 1 || e[0].Body != "" {
|
if len(e) != 1 || e[0].Mobile != "09120000000" {
|
||||||
t.Errorf("2xx should not store body, got %+v", e)
|
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) {
|
func TestMiddlewareSkipsWS(t *testing.T) {
|
||||||
tel := New(10)
|
tel := New(10, nil, nil)
|
||||||
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}))
|
}))
|
||||||
|
|||||||
Reference in New Issue
Block a user