feat: add telescope

This commit is contained in:
2026-07-05 08:42:42 +03:30
parent c6997477b9
commit 37baef565e
9 changed files with 530 additions and 8 deletions
+22 -3
View File
@@ -18,6 +18,7 @@ import (
"hakemsho/internal/httpx" "hakemsho/internal/httpx"
"hakemsho/internal/static" "hakemsho/internal/static"
"hakemsho/internal/store" "hakemsho/internal/store"
"hakemsho/internal/telescope"
"hakemsho/internal/user" "hakemsho/internal/user"
"hakemsho/internal/ws" "hakemsho/internal/ws"
) )
@@ -26,6 +27,9 @@ 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)
@@ -43,9 +47,15 @@ func main() {
// اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافه‌بازار (امضای RSA). // اقتصاد و فروشگاه. تأییدِ خرید: مایکت (X-Access-Token) و کافه‌بازار (امضای RSA).
// اگر کلیدها تنظیم نشده باشند، به تأییدکننده‌ی توسعه برمی‌گردیم (dev fallback). // اگر کلیدها تنظیم نشده باشند، به تأییدکننده‌ی توسعه برمی‌گردیم (dev fallback).
iap := economy.StoreRouter{DevFallback: true} iap := economy.StoreRouter{DevFallback: true}
if cfg.MyketAccessToken != "" && cfg.IAPPackage != "" { // مایکت: تأییدِ آفلاینِ RSA (اگر کلید باشد) و/یا سمت‌سرور با access token.
iap.Myket = economy.NewMyketVerifier(cfg.IAPPackage, cfg.MyketAccessToken) if cfg.MyketRSAKey != "" || (cfg.MyketAccessToken != "" && cfg.IAPPackage != "") {
slog.Info("IAP myket configured", "package", cfg.IAPPackage) mv, err := economy.NewMyketVerifier(cfg.IAPPackage, cfg.MyketAccessToken).WithRSAKey(cfg.MyketRSAKey)
if err != nil {
slog.Error("myket rsa key", "err", err)
os.Exit(1)
}
iap.Myket = mv
slog.Info("IAP myket configured", "package", cfg.IAPPackage, "rsa", cfg.MyketRSAKey != "", "server", cfg.MyketAccessToken != "")
} }
if cfg.BazaarRSAKey != "" { if cfg.BazaarRSAKey != "" {
bv, err := economy.NewBazaarRSAVerifier(cfg.BazaarRSAKey) bv, err := economy.NewBazaarRSAVerifier(cfg.BazaarRSAKey)
@@ -87,6 +97,8 @@ func main() {
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(15 * time.Second)) r.Use(middleware.Timeout(15 * time.Second))
// بازرسِ درخواست‌ها: هر درخواستِ /api و /admin را در رینگ‌بافر ثبت می‌کند.
r.Use(scope.Middleware)
r.Get("/health", func(w http.ResponseWriter, _ *http.Request) { r.Get("/health", func(w http.ResponseWriter, _ *http.Request) {
httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"}) httpx.JSON(w, http.StatusOK, map[string]string{"status": "ok"})
@@ -103,6 +115,13 @@ func main() {
// پنل ادمین (HTML سرور‌ساید، پشت Basic Auth) // پنل ادمین (HTML سرور‌ساید، پشت Basic Auth)
r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass)) r.Mount("/admin", admin.New(st.DB, eco, cfg.CardsDir, cfg.CarpetsDir).Routes(cfg.AdminUser, cfg.AdminPass))
// بازرسِ درخواست‌ها (Telescope-lite) — پشتِ همان Basic Auth ادمین.
r.Group(func(r chi.Router) {
r.Use(middleware.BasicAuth("hakemsho-admin", map[string]string{cfg.AdminUser: cfg.AdminPass}))
r.Get("/admin/telescope", scope.Page)
r.Get("/admin/telescope/data", scope.Data)
})
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
r.Post("/login-otp", authH.LoginOTP) r.Post("/login-otp", authH.LoginOTP)
Executable
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# استقرارِ بکندِ حکم‌شو روی سرور: کراس‌کامپایل لینوکس + آپلود + ری‌استارتِ systemd.
# بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh
set -euo pipefail
HOST="${HAKEM_HOST:-ubuntu@95.38.179.159}"
KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}"
REMOTE_DIR="/home/ubuntu/hakemsho"
cd "$(dirname "$0")"
echo "==> build linux/amd64 (CGO off, pure-Go sqlite)"
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /tmp/hakemsho-linux ./cmd/server
echo "==> upload to $HOST"
scp -i "$KEY" -o StrictHostKeyChecking=no /tmp/hakemsho-linux "$HOST:$REMOTE_DIR/hakemsho.new"
echo "==> swap in + restart (zero-config; data & env untouched)"
ssh -i "$KEY" -o StrictHostKeyChecking=no "$HOST" '
set -e
mv ~/hakemsho/hakemsho.new ~/hakemsho/hakemsho
chmod +x ~/hakemsho/hakemsho
sudo systemctl restart hakemsho
sleep 2
systemctl is-active hakemsho
curl -s -m5 http://127.0.0.1:8080/health && echo
'
echo "==> done: https://hakem.approagency.ir/health"
+1
View File
@@ -36,5 +36,6 @@
<a href="/admin/carpets" class="{{if eq .Nav "carpets"}}active{{end}}">فرش‌ها</a> <a href="/admin/carpets" class="{{if eq .Nav "carpets"}}active{{end}}">فرش‌ها</a>
<a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a> <a href="/admin/chat" class="{{if eq .Nav "chat"}}active{{end}}">چت</a>
<a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a> <a href="/admin/users" class="{{if eq .Nav "users"}}active{{end}}">کاربران</a>
<a href="/admin/telescope">تلسکوپ</a>
</nav> </nav>
{{end}} {{end}}
+45 -3
View File
@@ -9,10 +9,13 @@ import (
"time" "time"
) )
// Kavenegar کلاینت ارسال OTP از طریق verify/lookup (معادل KavenegarService.php). // Kavenegar کلاینت ارسال OTP. اگر Template تنظیم شده باشد از verify/lookup
// (تمپلیتِ تأییدشده) استفاده می‌کند؛ در غیر این صورت پیامکِ متنیِ ساده با
// sms/send می‌فرستد (مثل approagency — بدون نیاز به تأییدِ تمپلیت).
type Kavenegar struct { type Kavenegar struct {
APIKey string APIKey string
Template string // مثل "loginotp" Template string // خالی ⇒ ارسالِ متنیِ ساده
Sender string // خطِ فرستنده (خالی ⇒ خطِ پیش‌فرضِ حساب)
client *http.Client client *http.Client
} }
@@ -24,11 +27,14 @@ func NewKavenegar(apiKey, template string) *Kavenegar {
} }
} }
// SendVerify کد را با تمپلیت verify ارسال می‌کند. // SendVerify کد ورود را ارسال می‌کند (verify/lookup اگر تمپلیت باشد، وگرنه متنِ ساده).
func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error { func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error {
if k.APIKey == "" { if k.APIKey == "" {
return fmt.Errorf("KAVE_API_KEY is null") return fmt.Errorf("KAVE_API_KEY is null")
} }
if k.Template == "" {
return k.sendPlain(ctx, mobile, token)
}
endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/verify/lookup.json", k.APIKey) endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/verify/lookup.json", k.APIKey)
q := url.Values{} q := url.Values{}
q.Set("receptor", mobile) q.Set("receptor", mobile)
@@ -59,3 +65,39 @@ func (k *Kavenegar) SendVerify(ctx context.Context, mobile, token string) error
} }
return nil return nil
} }
// sendPlain پیامکِ متنیِ سادهٔ کد ورود را با sms/send می‌فرستد (بدونِ تمپلیت).
// قالبِ پیام مانند approagency است؛ خطِ پایانی «#کد» به تشخیصِ خودکارِ کد کمک می‌کند.
func (k *Kavenegar) sendPlain(ctx context.Context, mobile, token string) error {
endpoint := fmt.Sprintf("https://api.kavenegar.com/v1/%s/sms/send.json", k.APIKey)
message := fmt.Sprintf("کد ورود به حکم‌شو: %s\n#%s", token, token)
q := url.Values{}
q.Set("receptor", mobile)
q.Set("message", message)
if k.Sender != "" {
q.Set("sender", k.Sender)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+q.Encode(), nil)
if err != nil {
return err
}
resp, err := k.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var out struct {
Return struct {
Status int `json:"status"`
Message string `json:"message"`
} `json:"return"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return fmt.Errorf("kavenegar decode: %w", err)
}
if out.Return.Status != 200 {
return fmt.Errorf("kavenegar sms failed: status=%d msg=%s", out.Return.Status, out.Return.Message)
}
return nil
}
+15 -1
View File
@@ -3,6 +3,7 @@ package config
import ( import (
"bufio" "bufio"
"os" "os"
"strconv"
"strings" "strings"
"time" "time"
) )
@@ -22,9 +23,11 @@ type Config struct {
AdminPass string // پسوردِ پنل ادمین (Basic Auth) AdminPass string // پسوردِ پنل ادمین (Basic Auth)
CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار) CardsDir string // پوشه‌ی دیسکیِ اسکین‌های آپلودی (روی volume ماندگار)
CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار) CarpetsDir string // پوشه‌ی دیسکیِ تصاویرِ فرش (روی volume ماندگار)
TelescopeSize int // تعدادِ درخواستی که بازرسِ حافظه نگه می‌دارد
// خرید درون‌برنامه‌ای (IAP) // خرید درون‌برنامه‌ای (IAP)
IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه) IAPPackage string // نامِ بسته‌ی اپ (برای مایکت/کافه)
MyketAccessToken string // access token مایکت (تأیید سمت‌سرور) MyketAccessToken string // access token مایکت (تأیید سمت‌سرور)
MyketRSAKey string // کلیدِ عمومیِ RSA مایکت (تأیید امضای آفلاین)
BazaarRSAKey string // کلیدِ عمومیِ RSA کافه‌بازار (تأیید امضای آفلاین) BazaarRSAKey string // کلیدِ عمومیِ RSA کافه‌بازار (تأیید امضای آفلاین)
} }
@@ -37,7 +40,7 @@ func Load() Config {
JWTSecret: env("JWT_SECRET", "change-me-in-production"), JWTSecret: env("JWT_SECRET", "change-me-in-production"),
JWTTTL: 30 * 24 * time.Hour, JWTTTL: 30 * 24 * time.Hour,
KaveAPIKey: env("KAVE_API_KEY", ""), KaveAPIKey: env("KAVE_API_KEY", ""),
KaveTemple: env("KAVE_TEMPLATE", "loginotp"), KaveTemple: env("KAVE_TEMPLATE", ""), // خالی ⇒ پیامکِ متنیِ ساده (بدونِ تمپلیت)
OTPTTL: 15 * time.Minute, OTPTTL: 15 * time.Minute,
AdminMobile: env("ADMIN_MOBILE", ""), AdminMobile: env("ADMIN_MOBILE", ""),
AdminOTP: env("ADMIN_OTP", ""), AdminOTP: env("ADMIN_OTP", ""),
@@ -45,8 +48,10 @@ func Load() Config {
AdminPass: env("ADMIN_PANEL_PASS", "admin"), AdminPass: env("ADMIN_PANEL_PASS", "admin"),
CardsDir: env("CARDS_DIR", "carddata"), CardsDir: env("CARDS_DIR", "carddata"),
CarpetsDir: env("CARPETS_DIR", "carpetdata"), CarpetsDir: env("CARPETS_DIR", "carpetdata"),
TelescopeSize: envInt("TELESCOPE_SIZE", 200),
IAPPackage: env("IAP_PACKAGE", ""), IAPPackage: env("IAP_PACKAGE", ""),
MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""), MyketAccessToken: env("MYKET_ACCESS_TOKEN", ""),
MyketRSAKey: env("MYKET_RSA_KEY", ""),
BazaarRSAKey: env("BAZAAR_RSA_KEY", ""), BazaarRSAKey: env("BAZAAR_RSA_KEY", ""),
} }
} }
@@ -83,3 +88,12 @@ func env(key, def string) string {
} }
return def return def
} }
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
+33 -1
View File
@@ -95,16 +95,22 @@ func (t *TapsellAdVerifier) Verify(ctx context.Context, userID int64, token stri
return out.Valid && out.Completed, nil return out.Valid && out.Completed, nil
} }
// --- مایکت: تأیید سمت‌سرور با X-Access-Token --- // --- مایکت: تأیید امضای RSA آفلاین (اولویت) یا سمت‌سرور با X-Access-Token ---
// //
// افزونه‌ی myket_iap مثلِ کافه‌بازار، purchaseData (JSONِ خام) و signature (base64)
// برمی‌گرداند که با کلیدِ عمومیِ RSA و SHA1withRSA به‌صورتِ آفلاین تأیید می‌شوند —
// بدونِ رفت‌وبرگشتِ شبکه به‌ازای هر خرید. اگر کلیدِ عمومی تنظیم نشده باشد یا امضا
// ارسال نشده باشد، به تأییدِ سمت‌سرور برمی‌گردیم:
// GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token} // GET https://developer.myket.ir/api/applications/{pkg}/purchases/products/{sku}/tokens/{token}
// هدر: X-Access-Token: <accessToken> . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد). // هدر: X-Access-Token: <accessToken> . پاسخ purchaseState=0 یعنی معتبر (غیرِ صفر=مسترد).
type MyketVerifier struct { type MyketVerifier struct {
PackageName string PackageName string
AccessToken string AccessToken string
pub *rsa.PublicKey // کلیدِ عمومیِ RSA برای تأییدِ آفلاین (اختیاری)
client *http.Client client *http.Client
} }
// NewMyketVerifier یک تأییدکننده با access token (سمت‌سرور) می‌سازد.
func NewMyketVerifier(pkg, accessToken string) *MyketVerifier { func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
return &MyketVerifier{ return &MyketVerifier{
PackageName: pkg, PackageName: pkg,
@@ -113,7 +119,33 @@ func NewMyketVerifier(pkg, accessToken string) *MyketVerifier {
} }
} }
// WithRSAKey کلیدِ عمومیِ RSA را برای تأییدِ آفلاین ضمیمه می‌کند (اگر خالی نباشد).
func (v *MyketVerifier) WithRSAKey(publicKey string) (*MyketVerifier, error) {
if strings.TrimSpace(publicKey) == "" {
return v, nil
}
pub, err := parseRSAPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("myket rsa: %w", err)
}
v.pub = pub
return v, nil
}
func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) { func (v *MyketVerifier) Verify(ctx context.Context, p IAPProof) (bool, error) {
// مسیرِ ترجیحی: تأییدِ امضای آفلاین (اگر کلید و امضا موجود باشند).
if v.pub != nil && p.PurchaseData != "" && p.Signature != "" {
sig, err := base64.StdEncoding.DecodeString(p.Signature)
if err != nil {
return false, fmt.Errorf("myket: bad signature encoding: %w", err)
}
h := sha1.Sum([]byte(p.PurchaseData))
if err := rsa.VerifyPKCS1v15(v.pub, crypto.SHA1, h[:], sig); err != nil {
return false, nil // امضای نامعتبر ⇒ خریدِ نامعتبر
}
return true, nil
}
// مسیرِ جایگزین: تأییدِ سمت‌سرور با access token.
if v.AccessToken == "" || p.Token == "" { if v.AccessToken == "" || p.Token == "" {
return false, fmt.Errorf("myket: missing access token or purchase token") return false, fmt.Errorf("myket: missing access token or purchase token")
} }
+122
View File
@@ -0,0 +1,122 @@
package telescope
// pageHTML یک صفحه‌ی مستقل (بدونِ وابستگیِ خارجی) که هر ۲ ثانیه داده را می‌گیرد.
const pageHTML = `<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>تلسکوپ بازرسِ درخواست‌ها</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
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; }
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; }
header label { font-size:12px; opacity:.85; display:flex; align-items:center; gap:6px; }
.filters { padding:10px 20px; display:flex; gap:8px; flex-wrap:wrap; }
.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; }
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; }
.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; }
.slow { color:#fbbf24; }
.dim { opacity:.55; }
.empty { padding:40px; text-align:center; opacity:.6; }
</style>
</head>
<body>
<header>
<span class="dot"></span>
<h1>تلسکوپ بازرسِ درخواست‌ها</h1>
<label><input type="checkbox" id="live" checked> زنده</label>
<span class="meta" id="meta"></span>
</header>
<div class="filters">
<input id="q" placeholder="جستجو در مسیر…">
<select id="mf">
<option value="">همه متدها</option>
<option>GET</option><option>POST</option><option>PUT</option><option>DELETE</option>
</select>
<select id="sf">
<option value="">همه وضعیت‌ها</option>
<option value="2">2xx</option><option value="3">3xx</option>
<option value="4">4xx</option><option value="5">5xx</option>
</select>
</div>
<table>
<thead><tr>
<th>زمان</th><th>متد</th><th>مسیر</th><th>وضعیت</th><th>مدت</th><th>حجم</th><th>IP</th>
</tr></thead>
<tbody id="rows"></tbody>
</table>
<div class="empty" id="empty" style="display:none">درخواستی ثبت نشده است</div>
<script>
let data = [];
const $ = s => document.querySelector(s);
function fmtTime(t){ const d = new Date(t); return d.toLocaleTimeString('fa-IR'); }
function stClass(s){ return 's' + Math.floor(s/100); }
function render(){
const q = $('#q').value.trim().toLowerCase();
const mf = $('#mf').value, sf = $('#sf').value;
const rows = data.filter(e =>
(!q || (e.path||'').toLowerCase().includes(q)) &&
(!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');
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="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 {
tb.appendChild(tr);
}
}
$('#meta').textContent = rows.length + ' / ' + data.length + ' درخواست';
}
async function load(){
try{
const r = await fetch('/admin/telescope/data');
data = await r.json();
render();
}catch(e){ $('#meta').textContent = 'خطا در دریافت'; }
}
['q','mf','sf'].forEach(id => $('#'+id).addEventListener('input', render));
load();
setInterval(() => { if($('#live').checked) load(); }, 2000);
</script>
</body>
</html>`
+188
View File
@@ -0,0 +1,188 @@
// 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)
}
+76
View File
@@ -0,0 +1,76 @@
package telescope
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRingOrderAndOverflow(t *testing.T) {
tel := New(3)
for i := 0; i < 5; i++ {
tel.add(Entry{Path: string(rune('a' + i))})
}
got := tel.Entries()
if len(got) != 3 {
t.Fatalf("want 3 entries, got %d", len(got))
}
// جدید-به-قدیم: e, d, c (a و b بازنویسی شده‌اند).
want := []string{"e", "d", "c"}
for i, w := range want {
if got[i].Path != w {
t.Errorf("entry %d: want %q got %q", i, w, got[i].Path)
}
}
// شناسه‌ها باید افزایشی و یکتا باشند.
if got[0].ID != 5 || got[2].ID != 3 {
t.Errorf("ids wrong: %d..%d", got[2].ID, got[0].ID)
}
}
func TestMiddlewareCapturesErrorBodyMasked(t *testing.T) {
tel := New(10)
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
req := httptest.NewRequest(http.MethodPost, "/api/x",
strings.NewReader(`{"password":"secret","kind":"coin"}`))
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)
}
if !strings.Contains(e[0].Body, `"kind":"coin"`) {
t.Errorf("non-secret field lost: %s", e[0].Body)
}
}
func TestMiddlewareOKHasNoBody(t *testing.T) {
tel := New(10)
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"}`))
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)
}
}
func TestMiddlewareSkipsWS(t *testing.T) {
tel := New(10)
h := tel.Middleware(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/ws", nil))
if len(tel.Entries()) != 0 {
t.Error("ws request should be skipped")
}
}