feat: add telescope
This commit is contained in:
+22
-3
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,'<')+'</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>`
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user