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:
MHSanaei
2026-09-27 18:07:33 +02:00
parent 3308c816db
commit 4df570b3b0
3 changed files with 125 additions and 116 deletions
+1 -14
View File
@@ -5,7 +5,6 @@ import (
"crypto/rand" "crypto/rand"
"embed" "embed"
"math/big" "math/big"
"net/http"
"net/url" "net/url"
"os" "os"
"regexp" "regexp"
@@ -48,8 +47,7 @@ var (
EventBus *eventbus.Bus EventBus *eventbus.Bus
// Performance improvements // Performance improvements
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
// Simple cache for frequently accessed data // Simple cache for frequently accessed data
statusCache struct { 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) // Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
messageWorkerPool = make(chan struct{}, 10) 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() t.SetHostname()
// Get Telegram bot token // Get Telegram bot token
+35 -102
View File
@@ -2,13 +2,11 @@ package tgbot
import ( import (
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"html" "html"
"io" "net/url"
"net/http"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -248,76 +246,42 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
t.SendMsgToTgbot(chatId, msg, inlineKeyboard) 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) { 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) subURL, _, err := t.buildSubscriptionURLs(email)
if err != nil { if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return return
} }
links, err := t.clientSubLinks(email, subURL)
// Try to fetch raw subscription links. Prefer plain text response.
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil)
if err != nil { if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
return return
} }
// Force plain text to avoid HTML page; controller respects Accept header if len(links) == 0 {
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 {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult")) t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
return return
} }
// Send in chunks to respect message length; use monospace formatting // Send in chunks to respect message length; use monospace formatting
const maxPerMessage = 50 const maxPerMessage = 50
for i := 0; i < len(cleaned); i += maxPerMessage { for i := 0; i < len(links); i += maxPerMessage {
j := min(i+maxPerMessage, len(cleaned)) j := min(i+maxPerMessage, len(links))
chunk := cleaned[i:j] chunk := links[i:j]
var msg strings.Builder var msg strings.Builder
msg.WriteString(t.I18nBot("subscription.individualLinks")) msg.WriteString(t.I18nBot("subscription.individualLinks"))
msg.WriteString(":\r\n") 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) // Also generate a few individual links' QRs (first up to 5)
subPageURL := subURL if links, err := t.clientSubLinks(email, subURL); err == nil {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil) max := min(len(links), 5)
if err == nil { for i := range max {
req.Header.Set("Accept", "text/plain, */*;q=0.1") if png, err := createQR(links[i], 320); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // Use the email as filename for individual link QR
defer cancel() filename := email + ".png"
req = req.WithContext(ctx) document := tu.Document(
if resp, err := optimizedHTTPClient.Do(req); err == nil { tu.ID(chatId),
body, _ := io.ReadAll(resp.Body) tu.FileFromBytes(png, filename),
_ = resp.Body.Close() )
encoded, _ := t.settingService.GetSubEncrypt() _, _ = bot.SendDocument(context.Background(), document)
var content string // Reduced delay for better performance
if encoded { if i < max-1 { // Only delay between documents, not after the last one
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil { time.Sleep(50 * time.Millisecond)
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)
}
}
} }
} }
} }
@@ -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)
}
}