mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-28 10:12:07 +03:00
fix(tgbot): build a client's individual links in-process
The bot's "individual links" and QR actions fetched the client's own subscription over HTTP from the public URL it builds for display. With no sub or web domain set that URL falls back to the machine's hostname, which usually does not resolve (`lookup exhausted-reply: no such host`), and even a resolvable name fails behind NAT, a firewall or a disabled sub server. Both actions now ask InboundService.GetSubLinks, the in-process provider the panel's links API already uses, with the host taken from that same URL so the link addresses are unchanged. The now-unused pooled HTTP client goes too. Closes #6597
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -48,8 +47,7 @@ var (
|
||||
EventBus *eventbus.Bus
|
||||
|
||||
// Performance improvements
|
||||
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
|
||||
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
|
||||
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
|
||||
|
||||
// Simple cache for frequently accessed data
|
||||
statusCache struct {
|
||||
@@ -306,17 +304,6 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
// Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
|
||||
messageWorkerPool = make(chan struct{}, 10)
|
||||
|
||||
// Initialize optimized HTTP client with connection pooling
|
||||
optimizedHTTPClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.SetHostname()
|
||||
|
||||
// Get Telegram bot token
|
||||
|
||||
@@ -2,13 +2,11 @@ package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -248,76 +246,42 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
|
||||
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
|
||||
// clientSubLinks builds the subscription's links in-process for the host subURL
|
||||
// names; fetching subURL instead fails whenever that host does not resolve here.
|
||||
func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
|
||||
u, err := url.Parse(subURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, client, err := t.inboundService.GetClientByEmail(email)
|
||||
if err != nil || client == nil {
|
||||
return nil, errors.New("client not found")
|
||||
}
|
||||
return t.inboundService.GetSubLinks(u.Hostname(), client.SubID)
|
||||
}
|
||||
|
||||
// sendClientIndividualLinks sends the subscription's individual links to the user
|
||||
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
||||
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch raw subscription links. Prefer plain text response.
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil)
|
||||
links, err := t.clientSubLinks(email, subURL)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
// Force plain text to avoid HTML page; controller respects Accept header
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
|
||||
// Use optimized client with connection pooling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := optimizedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If service is configured to encode (Base64), decode it
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
|
||||
if err != nil {
|
||||
// fallback to raw text
|
||||
content = string(bodyBytes)
|
||||
} else {
|
||||
content = string(decoded)
|
||||
}
|
||||
} else {
|
||||
content = string(bodyBytes)
|
||||
}
|
||||
|
||||
// Normalize line endings and trim
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
if len(links) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
|
||||
// Send in chunks to respect message length; use monospace formatting
|
||||
const maxPerMessage = 50
|
||||
for i := 0; i < len(cleaned); i += maxPerMessage {
|
||||
j := min(i+maxPerMessage, len(cleaned))
|
||||
chunk := cleaned[i:j]
|
||||
for i := 0; i < len(links); i += maxPerMessage {
|
||||
j := min(i+maxPerMessage, len(links))
|
||||
chunk := links[i:j]
|
||||
var msg strings.Builder
|
||||
msg.WriteString(t.I18nBot("subscription.individualLinks"))
|
||||
msg.WriteString(":\r\n")
|
||||
@@ -375,51 +339,20 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
}
|
||||
|
||||
// Also generate a few individual links' QRs (first up to 5)
|
||||
subPageURL := subURL
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
if resp, err := optimizedHTTPClient.Do(req); err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||
content = string(dec)
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
max := min(len(cleaned), 5)
|
||||
for i := range max {
|
||||
if png, err := createQR(cleaned[i], 320); err == nil {
|
||||
// Use the email as filename for individual link QR
|
||||
filename := email + ".png"
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
// Reduced delay for better performance
|
||||
if i < max-1 { // Only delay between documents, not after the last one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if links, err := t.clientSubLinks(email, subURL); err == nil {
|
||||
max := min(len(links), 5)
|
||||
for i := range max {
|
||||
if png, err := createQR(links[i], 320); err == nil {
|
||||
// Use the email as filename for individual link QR
|
||||
filename := email + ".png"
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
// Reduced delay for better performance
|
||||
if i < max-1 { // Only delay between documents, not after the last one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/sub"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// recordingBotServer answers sendMessage and records every text it was asked to send.
|
||||
func recordingBotServer(t *testing.T) func() []string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
var texts []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/bot"+testBotToken+"/sendMessage" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
texts = append(texts, body.Text)
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
|
||||
"message_id": 1, "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"},
|
||||
}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
swapTestBot(t, srv.URL)
|
||||
return func() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), texts...)
|
||||
}
|
||||
}
|
||||
|
||||
// With no sub or web domain set, the only host the bot knows is the machine
|
||||
// name, which need not resolve; the links must not depend on reaching it.
|
||||
func TestIndividualLinksDoNotNeedAResolvableHost(t *testing.T) {
|
||||
sent := recordingBotServer(t)
|
||||
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
|
||||
service.RegisterSubLinkProvider(sub.NewLinkProvider())
|
||||
|
||||
const uuid = "11111111-2222-4333-8444-555555555555"
|
||||
db := database.GetDB()
|
||||
ib := &model.Inbound{
|
||||
UserId: 1, Tag: "in-443", Enable: true, Listen: "203.0.113.5", Port: 443,
|
||||
Protocol: model.VLESS, Remark: "in",
|
||||
Settings: `{"clients":[{"id":"` + uuid + `","email":"` + ownerMail + `","tgId":4242,"subId":"sub-owned","enable":true}],"decryption":"none"}`,
|
||||
}
|
||||
if err := db.Create(ib).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
rec := &model.ClientRecord{Email: ownerMail, SubID: "sub-owned", UUID: uuid, TgID: ownerTgID, Enable: true}
|
||||
if err := db.Create(rec).Error; err != nil {
|
||||
t.Fatalf("seed client: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
|
||||
t.Fatalf("seed client_inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: ownerMail, Enable: true}).Error; err != nil {
|
||||
t.Fatalf("seed traffic: %v", err)
|
||||
}
|
||||
|
||||
origHost, origRunning := hostname, isRunning
|
||||
t.Cleanup(func() { hostname, isRunning = origHost, origRunning })
|
||||
hostname, isRunning = "unresolvable-panel-host.invalid", true
|
||||
|
||||
tapClientLinks(t, &Tgbot{}, ownerTgID, "client_individual_links "+ownerMail)
|
||||
|
||||
got := strings.Join(sent(), "\n")
|
||||
if !strings.Contains(got, "vless://"+uuid+"@203.0.113.5:443") {
|
||||
t.Fatalf("bot sent %q, want the client's vless link", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user