精简: 去掉 Telegram 机器人+通知: tgbot 包/stats-notify 与 hash 清理 job/登录通知/backuptotgbot/testTgBot/全部 tg* 设置/locale Bot 本地化/SecretClears 机制; 连带清理 Swagger/外部流量/邮件/2FA 的残留测试
This commit is contained in:
@@ -2,10 +2,10 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"errors"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
@@ -289,13 +289,13 @@ func (a *AgentController) listTokens(c *gin.Context) {
|
|||||||
out := make([]gin.H, 0, len(agents))
|
out := make([]gin.H, 0, len(agents))
|
||||||
for _, ag := range agents {
|
for _, ag := range agents {
|
||||||
out = append(out, gin.H{
|
out = append(out, gin.H{
|
||||||
"id": ag.Id,
|
"id": ag.Id,
|
||||||
"token_prefix": ag.TokenPrefix,
|
"token_prefix": ag.TokenPrefix,
|
||||||
"name": ag.Name,
|
"name": ag.Name,
|
||||||
"node_id": ag.NodeID,
|
"node_id": ag.NodeID,
|
||||||
"last_seen": ag.LastSeen,
|
"last_seen": ag.LastSeen,
|
||||||
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
|
"has_egress": ag.Outbounds != "" || ag.RoutingRules != "",
|
||||||
"created_at": ag.CreatedAt,
|
"created_at": ag.CreatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
jsonObj(c, out, nil)
|
jsonObj(c, out, nil)
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -25,7 +24,6 @@ type APIController struct {
|
|||||||
xraySettingController *XraySettingController
|
xraySettingController *XraySettingController
|
||||||
userService panel.UserService
|
userService panel.UserService
|
||||||
apiTokenService panel.ApiTokenService
|
apiTokenService panel.ApiTokenService
|
||||||
Tgbot tgbot.Tgbot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||||
@@ -219,10 +217,4 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
|
|||||||
NewSubBalancerController(api)
|
NewSubBalancerController(api)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackuptoTgbot sends a backup of the panel data to Telegram bot admins.
|
|
||||||
func (a *APIController) BackuptoTgbot(c *gin.Context) {
|
|
||||||
a.Tgbot.SendBackupToAdmins()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,11 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWithServerBasePath(t *testing.T) {
|
|
||||||
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
|
|
||||||
|
|
||||||
out, err := withServerBasePath(spec, "/test/")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("withServerBasePath: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var doc map[string]any
|
|
||||||
if err := json.Unmarshal(out, &doc); err != nil {
|
|
||||||
t.Fatalf("unmarshal result: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
servers, ok := doc["servers"].([]any)
|
|
||||||
if !ok || len(servers) != 1 {
|
|
||||||
t.Fatalf("servers = %v, want one entry", doc["servers"])
|
|
||||||
}
|
|
||||||
srv, _ := servers[0].(map[string]any)
|
|
||||||
if srv["url"] != "/test" {
|
|
||||||
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
|
|
||||||
}
|
|
||||||
|
|
||||||
if doc["openapi"] != "3.0.3" {
|
|
||||||
t.Errorf("openapi field not preserved: %v", doc["openapi"])
|
|
||||||
}
|
|
||||||
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
|
|
||||||
t.Errorf("paths content not preserved verbatim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithServerBasePathInvalidJSON(t *testing.T) {
|
|
||||||
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
|
|
||||||
t.Errorf("expected error on invalid spec, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) {
|
func TestPWAHeadInjectionUsesRuntimeBasePath(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,7 +26,6 @@ type IndexController struct {
|
|||||||
|
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
userService panel.UserService
|
userService panel.UserService
|
||||||
tgbot tgbot.Tgbot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIndexController creates a new IndexController and initializes its routes.
|
// NewIndexController creates a new IndexController and initializes its routes.
|
||||||
@@ -75,17 +73,9 @@ func (a *IndexController) login(c *gin.Context) {
|
|||||||
|
|
||||||
remoteIP := getRemoteIp(c)
|
remoteIP := getRemoteIp(c)
|
||||||
safeUser := template.HTMLEscapeString(form.Username)
|
safeUser := template.HTMLEscapeString(form.Username)
|
||||||
timeStr := time.Now().Format("2006-01-02 15:04:05")
|
|
||||||
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
|
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
|
||||||
reason := "too many failed attempts"
|
reason := "too many failed attempts"
|
||||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
|
||||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
|
||||||
Username: safeUser,
|
|
||||||
IP: remoteIP,
|
|
||||||
Time: timeStr,
|
|
||||||
Status: tgbot.LoginFail,
|
|
||||||
Reason: reason,
|
|
||||||
})
|
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -99,25 +89,12 @@ func (a *IndexController) login(c *gin.Context) {
|
|||||||
} else {
|
} else {
|
||||||
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
|
logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
|
||||||
}
|
}
|
||||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
|
||||||
Username: safeUser,
|
|
||||||
IP: remoteIP,
|
|
||||||
Time: timeStr,
|
|
||||||
Status: tgbot.LoginFail,
|
|
||||||
Reason: reason,
|
|
||||||
})
|
|
||||||
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
|
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
|
||||||
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
|
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
|
||||||
a.tgbot.UserLoginNotify(tgbot.LoginAttempt{
|
|
||||||
Username: safeUser,
|
|
||||||
IP: remoteIP,
|
|
||||||
Time: timeStr,
|
|
||||||
Status: tgbot.LoginSuccess,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err := session.SetLoginUser(c, user); err != nil {
|
if err := session.SetLoginUser(c, user); err != nil {
|
||||||
logger.Warning("Unable to save session:", err)
|
logger.Warning("Unable to save session:", err)
|
||||||
|
|||||||
@@ -176,9 +176,9 @@ func (a *NodeController) add(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{
|
||||||
"node": view,
|
"node": view,
|
||||||
"agentToken": token,
|
"agentToken": token,
|
||||||
"nodeType": "agent",
|
"nodeType": "agent",
|
||||||
}, nil)
|
}, nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/apiTokens/create", a.createApiToken)
|
g.POST("/apiTokens/create", a.createApiToken)
|
||||||
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
|
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
|
||||||
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
|
||||||
g.POST("/testTgBot", a.testTgBot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *SettingController) validateRegex(c *gin.Context) {
|
func (a *SettingController) validateRegex(c *gin.Context) {
|
||||||
@@ -119,13 +118,7 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
allSetting := &form.AllSetting
|
allSetting := &form.AllSetting
|
||||||
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
|
oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
|
||||||
oldTgEnable, _ := a.settingService.GetTgbotEnabled()
|
err := a.settingService.UpdateAllSetting(allSetting)
|
||||||
oldTgToken, _ := a.settingService.GetTgBotToken()
|
|
||||||
oldTgChatId, _ := a.settingService.GetTgBotChatId()
|
|
||||||
oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
|
|
||||||
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
|
|
||||||
TgBotToken: form.ClearTgBotToken,
|
|
||||||
})
|
|
||||||
if err == nil && form.PanelOutbound != oldPanelOutbound {
|
if err == nil && form.PanelOutbound != oldPanelOutbound {
|
||||||
// The egress bridge lives in the generated config; reconcile the
|
// The egress bridge lives in the generated config; reconcile the
|
||||||
// running core. One SOCKS inbound plus one routing rule — both
|
// running core. One SOCKS inbound plus one routing rule — both
|
||||||
@@ -134,16 +127,6 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
|||||||
logger.Warning("apply panel outbound change failed:", applyErr)
|
logger.Warning("apply panel outbound change failed:", applyErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
|
|
||||||
if err == nil && reloadTgbotFunc != nil {
|
|
||||||
tgChanged := oldTgEnable != allSetting.TgBotEnable ||
|
|
||||||
(allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken ||
|
|
||||||
oldTgChatId != allSetting.TgBotChatId ||
|
|
||||||
oldTgAPIServer != allSetting.TgBotAPIServer))
|
|
||||||
if tgChanged {
|
|
||||||
reloadTgbotFunc()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,33 +239,3 @@ func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabledExpectedScope(id, form.ExpectedScope, form.Enabled))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *SettingController) testTgBot(c *gin.Context) {
|
|
||||||
enabled, err := a.settingService.GetTgbotEnabled()
|
|
||||||
if err != nil || !enabled {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotEnabled"), errors.New("telegram bot disabled"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Import tgbot package would create a circular dependency, so we call
|
|
||||||
// the test through the global function registered at startup.
|
|
||||||
if testTgFunc != nil {
|
|
||||||
if err := testTgFunc(); err != nil {
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestFailed")+": "+err.Error(), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgTestSuccess"), nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.tgBotNotRunning"), errors.New("bot not started"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// testTgFunc is set from web layer to test Telegram sending without circular imports.
|
|
||||||
var testTgFunc func() error
|
|
||||||
|
|
||||||
// SetTestTgFunc registers the function used to test Telegram sending.
|
|
||||||
func SetTestTgFunc(fn func() error) { testTgFunc = fn }
|
|
||||||
|
|
||||||
// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency.
|
|
||||||
var reloadTgbotFunc func()
|
|
||||||
|
|
||||||
func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn }
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,7 +13,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateRegex(t *testing.T) {
|
func TestValidateRegex(t *testing.T) {
|
||||||
@@ -89,84 +87,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GHSA-xqqw-jqqv-99h6: a save that keeps 2FA enabled must not be able to
|
|
||||||
// rebind the authenticator without presenting a current code.
|
|
||||||
func TestUpdateSettingRequiresCodeToReplaceTwoFactorToken(t *testing.T) {
|
|
||||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
|
||||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
||||||
t.Fatalf("InitDB: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
|
|
||||||
settingService := service.SettingService{}
|
|
||||||
if err := settingService.SetTwoFactorToken("ORIGINALSECRET234567"); err != nil {
|
|
||||||
t.Fatalf("seed token: %v", err)
|
|
||||||
}
|
|
||||||
if err := settingService.SetTwoFactorEnable(true); err != nil {
|
|
||||||
t.Fatalf("seed enable: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
post := func(t *testing.T, mutate func(map[string]any)) string {
|
|
||||||
t.Helper()
|
|
||||||
base, err := settingService.GetAllSetting()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllSetting: %v", err)
|
|
||||||
}
|
|
||||||
raw, err := json.Marshal(base)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal: %v", err)
|
|
||||||
}
|
|
||||||
body := map[string]any{}
|
|
||||||
if err := json.Unmarshal(raw, &body); err != nil {
|
|
||||||
t.Fatalf("unmarshal: %v", err)
|
|
||||||
}
|
|
||||||
mutate(body)
|
|
||||||
payload, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("marshal payload: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
router := gin.New()
|
|
||||||
NewSettingController(router.Group("/panel/api"))
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/panel/api/setting/update", strings.NewReader(string(payload)))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(resp, req)
|
|
||||||
return resp.Body.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("rebind without code is rejected", func(t *testing.T) {
|
|
||||||
got := post(t, func(body map[string]any) {
|
|
||||||
body["twoFactorEnable"] = true
|
|
||||||
body["twoFactorToken"] = "ATTACKERSECRET567890"
|
|
||||||
})
|
|
||||||
if !strings.Contains(got, `"success":false`) {
|
|
||||||
t.Fatalf("rebind without a 2FA code was accepted: %s", got)
|
|
||||||
}
|
|
||||||
stored, err := settingService.GetTwoFactorToken()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetTwoFactorToken: %v", err)
|
|
||||||
}
|
|
||||||
if stored != "ORIGINALSECRET234567" {
|
|
||||||
t.Fatalf("stored 2FA secret = %q, want it unchanged", stored)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("ordinary save with redacted token still succeeds", func(t *testing.T) {
|
|
||||||
got := post(t, func(body map[string]any) {
|
|
||||||
body["twoFactorEnable"] = true
|
|
||||||
body["twoFactorToken"] = ""
|
|
||||||
})
|
|
||||||
if !strings.Contains(got, `"success":true`) {
|
|
||||||
t.Fatalf("normal settings save was rejected: %s", got)
|
|
||||||
}
|
|
||||||
stored, err := settingService.GetTwoFactorToken()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetTwoFactorToken: %v", err)
|
|
||||||
}
|
|
||||||
if stored != "ORIGINALSECRET234567" {
|
|
||||||
t.Fatalf("stored 2FA secret = %q, want it preserved", stored)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,31 +5,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckValidSmtpFrom(t *testing.T) {
|
|
||||||
base := func() *AllSetting {
|
|
||||||
return &AllSetting{WebPort: 2053, SubPort: 2096}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range []string{"", "panel@example.com"} {
|
|
||||||
s := base()
|
|
||||||
s.SmtpFrom = v
|
|
||||||
if err := s.CheckValid(); err != nil {
|
|
||||||
t.Errorf("CheckValid with smtpFrom=%q: unexpected error %v", v, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, v := range []string{
|
|
||||||
"not-an-address",
|
|
||||||
"panel@example.com\r\nBcc: evil@example.com",
|
|
||||||
"a@b\nSubject: injected",
|
|
||||||
} {
|
|
||||||
s := base()
|
|
||||||
s.SmtpFrom = v
|
|
||||||
if err := s.CheckValid(); err == nil {
|
|
||||||
t.Errorf("CheckValid with smtpFrom=%q: want error, got nil", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckValidWildcardListenPortConflict(t *testing.T) {
|
func TestCheckValidWildcardListenPortConflict(t *testing.T) {
|
||||||
s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""}
|
s := &AllSetting{WebPort: 2053, SubPort: 2053, WebListen: "0.0.0.0", SubListen: ""}
|
||||||
|
|||||||
@@ -36,18 +36,6 @@ type AllSetting struct {
|
|||||||
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
|
SubShowIdentityOnAllLinks bool `json:"subShowIdentityOnAllLinks" form:"subShowIdentityOnAllLinks"`
|
||||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||||
|
|
||||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
|
|
||||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
|
|
||||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
|
|
||||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
|
|
||||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
|
|
||||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
|
|
||||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
|
|
||||||
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`
|
|
||||||
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"`
|
|
||||||
TgLang string `json:"tgLang" form:"tgLang"`
|
|
||||||
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
|
|
||||||
|
|
||||||
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
|
OutboundDownThreshold int `json:"outboundDownThreshold" form:"outboundDownThreshold" validate:"gte=1,lte=100"`
|
||||||
|
|
||||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||||
@@ -97,7 +85,6 @@ type AllSetting struct {
|
|||||||
type AllSettingView struct {
|
type AllSettingView struct {
|
||||||
AllSetting
|
AllSetting
|
||||||
|
|
||||||
HasTgBotToken bool `json:"hasTgBotToken"`
|
|
||||||
HasApiToken bool `json:"hasApiToken"`
|
HasApiToken bool `json:"hasApiToken"`
|
||||||
HasWarpSecret bool `json:"hasWarpSecret"`
|
HasWarpSecret bool `json:"hasWarpSecret"`
|
||||||
HasNordSecret bool `json:"hasNordSecret"`
|
HasNordSecret bool `json:"hasNordSecret"`
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import "github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
||||||
|
|
||||||
// CheckHashStorageJob periodically cleans up expired hash entries from the Telegram bot's hash storage.
|
|
||||||
type CheckHashStorageJob struct {
|
|
||||||
tgbotService tgbot.Tgbot
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCheckHashStorageJob creates a new hash storage cleanup job instance.
|
|
||||||
func NewCheckHashStorageJob() *CheckHashStorageJob {
|
|
||||||
return new(CheckHashStorageJob)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run removes expired hash entries from the Telegram bot's hash storage.
|
|
||||||
func (j *CheckHashStorageJob) Run() {
|
|
||||||
storage := j.tgbotService.GetHashStorage()
|
|
||||||
if storage == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
storage.RemoveExpiredHashes()
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestCheckHashStorageJob_RunWithoutPanicWhenStorageNil(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("CheckHashStorageJob.Run panicked when storage is nil: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
NewCheckHashStorageJob().Run()
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LoginStatus represents the status of a login attempt.
|
|
||||||
type LoginStatus byte
|
|
||||||
|
|
||||||
const (
|
|
||||||
LoginSuccess LoginStatus = 1 // Successful login
|
|
||||||
LoginFail LoginStatus = 0 // Failed login attempt
|
|
||||||
)
|
|
||||||
|
|
||||||
// StatsNotifyJob sends periodic statistics reports via Telegram bot.
|
|
||||||
type StatsNotifyJob struct {
|
|
||||||
xrayService service.XrayService
|
|
||||||
tgbotService tgbot.Tgbot
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStatsNotifyJob creates a new statistics notification job instance.
|
|
||||||
func NewStatsNotifyJob() *StatsNotifyJob {
|
|
||||||
return new(StatsNotifyJob)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run sends a statistics report via Telegram bot if Xray is running.
|
|
||||||
func (j *StatsNotifyJob) Run() {
|
|
||||||
if !j.xrayService.IsXrayRunning() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
j.tgbotService.SendReport()
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
)
|
|
||||||
|
|
||||||
func stallingListener(t *testing.T) (addr string, release func()) {
|
|
||||||
t.Helper()
|
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("listen: %v", err)
|
|
||||||
}
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
for {
|
|
||||||
conn, err := ln.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
go func(c net.Conn) {
|
|
||||||
<-done
|
|
||||||
_ = c.Close()
|
|
||||||
}(conn)
|
|
||||||
go func(c net.Conn) { _, _ = io.Copy(io.Discard, c) }(conn)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return ln.Addr().String(), func() {
|
|
||||||
_ = ln.Close()
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExternalInformClient_StalledReceiverTimesOut(t *testing.T) {
|
|
||||||
addr, release := stallingListener(t)
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
request := fasthttp.AcquireRequest()
|
|
||||||
defer fasthttp.ReleaseRequest(request)
|
|
||||||
request.Header.SetMethod("POST")
|
|
||||||
request.Header.SetContentType("application/json; charset=UTF-8")
|
|
||||||
request.SetBody([]byte(`{"clientTraffics":[],"inboundTraffics":[]}`))
|
|
||||||
request.SetRequestURI("http://" + addr + "/inform")
|
|
||||||
request.Header.SetConnectionClose()
|
|
||||||
|
|
||||||
response := fasthttp.AcquireResponse()
|
|
||||||
defer fasthttp.ReleaseResponse(response)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
err := externalInformClient.DoTimeout(request, response, externalInformTimeout)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("a receiver that never answers must produce an error, not a completed request")
|
|
||||||
}
|
|
||||||
if !errors.Is(err, fasthttp.ErrTimeout) {
|
|
||||||
t.Errorf("want a timeout error, got %v", err)
|
|
||||||
}
|
|
||||||
if elapsed > externalInformTimeout+2*time.Second {
|
|
||||||
t.Errorf("call took %v, must be bounded by %v", elapsed, externalInformTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExternalInformClient_HasDeadlines(t *testing.T) {
|
|
||||||
if externalInformClient.ReadTimeout <= 0 || externalInformClient.WriteTimeout <= 0 {
|
|
||||||
t.Fatal("the traffic-notify client must carry read and write deadlines")
|
|
||||||
}
|
|
||||||
if externalInformTimeout >= 5*time.Second {
|
|
||||||
t.Errorf("the call budget (%v) must stay under the 5s traffic poll cadence", externalInformTimeout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestInformSkippedWhenNothingToReport(t *testing.T) {
|
|
||||||
j := &XrayTrafficJob{}
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
j.informTrafficToExternalAPI(nil, nil)
|
|
||||||
}()
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("an empty payload must return before touching settings or the network")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,24 +19,17 @@ import (
|
|||||||
var (
|
var (
|
||||||
i18nBundle *i18n.Bundle
|
i18nBundle *i18n.Bundle
|
||||||
LocalizerWeb *i18n.Localizer
|
LocalizerWeb *i18n.Localizer
|
||||||
LocalizerBot *i18n.Localizer
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// I18nType represents the type of interface for internationalization.
|
// I18nType represents the type of interface for internationalization.
|
||||||
type I18nType string
|
type I18nType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Bot I18nType = "bot" // Bot interface type
|
|
||||||
Web I18nType = "web" // Web interface type
|
Web I18nType = "web" // Web interface type
|
||||||
)
|
)
|
||||||
|
|
||||||
// SettingService interface defines methods for accessing locale settings.
|
|
||||||
type SettingService interface {
|
|
||||||
GetTgLang() (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitLocalizer initializes the internationalization system with embedded translation files.
|
// InitLocalizer initializes the internationalization system with embedded translation files.
|
||||||
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
func InitLocalizer(i18nFS embed.FS) error {
|
||||||
// set default bundle to English
|
// set default bundle to English
|
||||||
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
|
||||||
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
|
||||||
@@ -46,11 +39,6 @@ func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// setup bot locale
|
|
||||||
if err := initTGBotLocalizer(settingService); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +59,12 @@ func createTemplateData(params []string, separator ...string) map[string]any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// I18n retrieves a localized message for the given key and type.
|
// I18n retrieves a localized message for the given key and type.
|
||||||
// It supports both bot and web contexts, with optional template parameters.
|
// It supports web contexts, with optional template parameters.
|
||||||
// Returns the localized message or an empty string if localization fails.
|
// Returns the localized message or an empty string if localization fails.
|
||||||
func I18n(i18nType I18nType, key string, params ...string) string {
|
func I18n(i18nType I18nType, key string, params ...string) string {
|
||||||
var localizer *i18n.Localizer
|
var localizer *i18n.Localizer
|
||||||
|
|
||||||
switch i18nType {
|
switch i18nType {
|
||||||
case "bot":
|
|
||||||
localizer = LocalizerBot
|
|
||||||
case "web":
|
case "web":
|
||||||
localizer = LocalizerWeb
|
localizer = LocalizerWeb
|
||||||
default:
|
default:
|
||||||
@@ -105,17 +91,6 @@ func I18n(i18nType I18nType, key string, params ...string) string {
|
|||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// initTGBotLocalizer initializes the bot localizer with the configured language.
|
|
||||||
func initTGBotLocalizer(settingService SettingService) error {
|
|
||||||
botLang, err := settingService.GetTgLang()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
LocalizerBot = i18n.NewLocalizer(i18nBundle, botLang)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LocalizerMiddleware returns a Gin middleware that sets up localization for web requests.
|
// LocalizerMiddleware returns a Gin middleware that sets up localization for web requests.
|
||||||
// It determines the user's language from cookies or Accept-Language header,
|
// It determines the user's language from cookies or Accept-Language header,
|
||||||
// creates a localizer instance, and stores it in the Gin context for use in handlers.
|
// creates a localizer instance, and stores it in the Gin context for use in handlers.
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGetClientTrafficTgBot_SettingsSerializationStyles guards against the
|
|
||||||
// prefilter regressing into a formatting-sensitive string match (#5805): the
|
|
||||||
// lookup must find clients whether inbounds.settings stores compact JSON
|
|
||||||
// ("tgId":N, as written by node sync/import) or indented JSON ("tgId": N, as
|
|
||||||
// written by the panel's MarshalIndent).
|
|
||||||
func TestGetClientTrafficTgBot_SettingsSerializationStyles(t *testing.T) {
|
|
||||||
dbDir := t.TempDir()
|
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
||||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
||||||
t.Fatalf("InitDB: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
const tgId int64 = 123456789
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
settings string
|
|
||||||
email string
|
|
||||||
port int
|
|
||||||
}{
|
|
||||||
{"compact", `{"clients":[{"id":"u1","email":"compact-user","tgId":123456789}]}`, "compact-user", 41001},
|
|
||||||
{"spaced", `{"clients": [{"id": "u2", "email": "spaced-user", "tgId": 123456789}]}`, "spaced-user", 41002},
|
|
||||||
}
|
|
||||||
for _, c := range cases {
|
|
||||||
inbound := &model.Inbound{UserId: 1, Tag: "tg-" + c.name, Enable: true, Port: c.port, Protocol: model.VLESS, Settings: c.settings}
|
|
||||||
if err := db.Create(inbound).Error; err != nil {
|
|
||||||
t.Fatalf("create %s inbound: %v", c.name, err)
|
|
||||||
}
|
|
||||||
if err := db.Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: c.email, Enable: true, Up: 10, Down: 20}).Error; err != nil {
|
|
||||||
t.Fatalf("create %s client_traffics: %v", c.name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := InboundService{}
|
|
||||||
traffics, err := svc.GetClientTrafficTgBot(tgId)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetClientTrafficTgBot: %v", err)
|
|
||||||
}
|
|
||||||
got := make(map[string]bool, len(traffics))
|
|
||||||
for _, tr := range traffics {
|
|
||||||
got[tr.Email] = true
|
|
||||||
}
|
|
||||||
if len(traffics) != 2 || !got["compact-user"] || !got["spaced-user"] {
|
|
||||||
t.Fatalf("expected traffic for compact-user and spaced-user, got %v", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
other, err := svc.GetClientTrafficTgBot(42)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetClientTrafficTgBot(42): %v", err)
|
|
||||||
}
|
|
||||||
if len(other) != 0 {
|
|
||||||
t.Fatalf("expected no traffic for unknown tgId, got %d rows", len(other))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -998,75 +997,6 @@ func (s *InboundService) DelDepletedClients(id int) (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
|
|
||||||
db := database.GetDB()
|
|
||||||
|
|
||||||
idQuery := fmt.Sprintf(
|
|
||||||
"SELECT DISTINCT inbounds.id %s WHERE %s = ?",
|
|
||||||
database.JSONClientsFromInbound(),
|
|
||||||
database.JSONFieldText("client.value", "tgId"),
|
|
||||||
)
|
|
||||||
var inboundIds []int
|
|
||||||
if err := db.Raw(idQuery, strconv.FormatInt(tgId, 10)).Scan(&inboundIds).Error; err != nil {
|
|
||||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var inbounds []*model.Inbound
|
|
||||||
if len(inboundIds) > 0 {
|
|
||||||
err := db.Model(model.Inbound{}).Where("id IN ?", inboundIds).Find(&inbounds).Error
|
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
logger.Errorf("Error retrieving inbounds with tgId %d: %v", tgId, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var emails []string
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
clients, err := s.GetClients(inbound)
|
|
||||||
if err != nil {
|
|
||||||
logger.Errorf("Error retrieving clients for inbound %d: %v", inbound.Id, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.TgID == tgId {
|
|
||||||
emails = append(emails, client.Email)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Chunked to stay under SQLite's bind-variable limit when a single Telegram
|
|
||||||
// account owns thousands of clients across inbounds.
|
|
||||||
uniqEmails := uniqueNonEmptyStrings(emails)
|
|
||||||
traffics := make([]*xray.ClientTraffic, 0, len(uniqEmails))
|
|
||||||
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
|
|
||||||
var page []*xray.ClientTraffic
|
|
||||||
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logger.Errorf("Error retrieving ClientTraffic for emails %v: %v", batch, err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
traffics = append(traffics, page...)
|
|
||||||
}
|
|
||||||
if len(traffics) == 0 {
|
|
||||||
logger.Warning("No ClientTraffic records found for emails:", emails)
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate UUID and other client data for each traffic record
|
|
||||||
for i := range traffics {
|
|
||||||
if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil {
|
|
||||||
traffics[i].Enable = client.Enable
|
|
||||||
traffics[i].UUID = client.ID
|
|
||||||
traffics[i].SubId = client.SubID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return traffics, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BumpClientsLastOnline sets client_traffics.last_online to now for the given
|
// BumpClientsLastOnline sets client_traffics.last_online to now for the given
|
||||||
// emails. Used in online-API mode for clients that hold a live connection but
|
// emails. Used in online-API mode for clients that hold a live connection but
|
||||||
// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
|
// moved no bytes this poll — the traffic path (addClientTraffic) only bumps
|
||||||
|
|||||||
@@ -70,16 +70,6 @@ var defaultValueMap = map[string]string{
|
|||||||
"remarkTemplate": DefaultRemarkTemplate,
|
"remarkTemplate": DefaultRemarkTemplate,
|
||||||
"subShowIdentityOnAllLinks": "false",
|
"subShowIdentityOnAllLinks": "false",
|
||||||
"timeLocation": "Local",
|
"timeLocation": "Local",
|
||||||
"tgBotEnable": "false",
|
|
||||||
"tgBotToken": "",
|
|
||||||
"tgBotProxy": "",
|
|
||||||
"tgBotAPIServer": "",
|
|
||||||
"tgBotChatId": "",
|
|
||||||
"tgRunTime": "@daily",
|
|
||||||
"tgBotBackup": "false",
|
|
||||||
"tgCpu": "80",
|
|
||||||
"tgMemory": "80",
|
|
||||||
"tgLang": "en-US",
|
|
||||||
"subEnable": "true",
|
"subEnable": "true",
|
||||||
"subJsonEnable": "false",
|
"subJsonEnable": "false",
|
||||||
"subJsonAutoDetect": "false",
|
"subJsonAutoDetect": "false",
|
||||||
@@ -128,7 +118,6 @@ var defaultValueMap = map[string]string{
|
|||||||
"devChannelEnable": "false",
|
"devChannelEnable": "false",
|
||||||
|
|
||||||
// Event bus — per-subscriber event filtering (empty = all disabled)
|
// Event bus — per-subscriber event filtering (empty = all disabled)
|
||||||
"tgEnabledEvents": "login.attempt,cpu.high",
|
|
||||||
|
|
||||||
// Consecutive failed observatory probes before an outbound.down event fires
|
// Consecutive failed observatory probes before an outbound.down event fires
|
||||||
"outboundDownThreshold": "3",
|
"outboundDownThreshold": "3",
|
||||||
@@ -228,14 +217,12 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
view := &entity.AllSettingView{AllSetting: *allSetting}
|
view := &entity.AllSettingView{AllSetting: *allSetting}
|
||||||
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
|
|
||||||
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
|
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
|
||||||
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
|
view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
|
||||||
var apiTokenCount int64
|
var apiTokenCount int64
|
||||||
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
|
if err := database.GetDB().Model(model.ApiToken{}).Where("enabled = ?", true).Count(&apiTokenCount).Error; err == nil {
|
||||||
view.HasApiToken = apiTokenCount > 0
|
view.HasApiToken = apiTokenCount > 0
|
||||||
}
|
}
|
||||||
view.TgBotToken = ""
|
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,22 +383,6 @@ func (s *SettingService) GetWebDomain() (string, error) {
|
|||||||
return s.getString("webDomain")
|
return s.getString("webDomain")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetTgBotToken() (string, error) {
|
|
||||||
return s.getString("tgBotToken")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgBotToken(token string) error {
|
|
||||||
return s.setString("tgBotToken", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgBotProxy() (string, error) {
|
|
||||||
return s.getString("tgBotProxy")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgBotProxy(token string) error {
|
|
||||||
return s.setString("tgBotProxy", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
|
// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
|
||||||
// requests (version checks, Telegram, subscription fetches) are routed through.
|
// requests (version checks, Telegram, subscription fetches) are routed through.
|
||||||
func (s *SettingService) GetPanelOutbound() (string, error) {
|
func (s *SettingService) GetPanelOutbound() (string, error) {
|
||||||
@@ -483,58 +454,6 @@ func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Clien
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetTgBotAPIServer() (string, error) {
|
|
||||||
return s.getString("tgBotAPIServer")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgBotAPIServer(token string) error {
|
|
||||||
return s.setString("tgBotAPIServer", token)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgBotChatId() (string, error) {
|
|
||||||
return s.getString("tgBotChatId")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgBotChatId(chatIds string) error {
|
|
||||||
return s.setString("tgBotChatId", chatIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgbotEnabled() (bool, error) {
|
|
||||||
return s.getBool("tgBotEnable")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgbotEnabled(value bool) error {
|
|
||||||
return s.setBool("tgBotEnable", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgbotRuntime() (string, error) {
|
|
||||||
return s.getString("tgRunTime")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgbotRuntime(time string) error {
|
|
||||||
return s.setString("tgRunTime", time)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgBotBackup() (bool, error) {
|
|
||||||
return s.getBool("tgBotBackup")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgCpu() (int, error) {
|
|
||||||
return s.getInt("tgCpu")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgMemory() (int, error) {
|
|
||||||
return s.getInt("tgMemory")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgMemory(value int) error {
|
|
||||||
return s.setInt("tgMemory", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetTgLang() (string, error) {
|
|
||||||
return s.getString("tgLang")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetPort() (int, error) {
|
func (s *SettingService) GetPort() (int, error) {
|
||||||
return s.getInt("webPort")
|
return s.getInt("webPort")
|
||||||
}
|
}
|
||||||
@@ -887,14 +806,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) {
|
|||||||
|
|
||||||
// Event bus — per-subscriber event filtering
|
// Event bus — per-subscriber event filtering
|
||||||
|
|
||||||
func (s *SettingService) GetTgEnabledEvents() (string, error) {
|
|
||||||
return s.getString("tgEnabledEvents")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) SetTgEnabledEvents(events string) error {
|
|
||||||
return s.setString("tgEnabledEvents", events)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetOutboundDownThreshold returns how many consecutive failed observatory
|
// GetOutboundDownThreshold returns how many consecutive failed observatory
|
||||||
// probes an outbound must accumulate before an outbound.down notification is
|
// probes an outbound must accumulate before an outbound.down notification is
|
||||||
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
|
// emitted. 1 preserves the legacy "notify on the first failed probe" behaviour.
|
||||||
@@ -906,17 +817,7 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error {
|
|||||||
return s.setInt("outboundDownThreshold", value)
|
return s.setInt("outboundDownThreshold", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretClears marks redacted secrets the user explicitly emptied. Without a
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||||
// flag, a blank submitted secret means "unchanged" (the field is always served
|
|
||||||
// blank to the browser) and the stored value is preserved.
|
|
||||||
type SecretClears struct {
|
|
||||||
TgBotToken bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
|
||||||
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateSettingsURLs(allSetting); err != nil {
|
if err := validateSettingsURLs(allSetting); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1005,25 +906,7 @@ func ValidateRegex(pattern string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
|
|
||||||
if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
|
|
||||||
value, err := s.GetTgBotToken()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
allSetting.TgBotToken = value
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSettingsURLs(allSetting *entity.AllSetting) error {
|
func validateSettingsURLs(allSetting *entity.AllSetting) error {
|
||||||
if allSetting.TgBotAPIServer != "" {
|
|
||||||
u, err := SanitizeHTTPURL(allSetting.TgBotAPIServer)
|
|
||||||
if err != nil {
|
|
||||||
return common.NewError("telegram API server URL is invalid:", err)
|
|
||||||
}
|
|
||||||
allSetting.TgBotAPIServer = u
|
|
||||||
}
|
|
||||||
// Support/profile links land in subscription headers and page data, where
|
// Support/profile links land in subscription headers and page data, where
|
||||||
// client apps resolve a scheme-less value against the panel's own domain.
|
// client apps resolve a scheme-less value against the panel's own domain.
|
||||||
// Non-http schemes (tg://, mailto:) are legitimate here, so only default
|
// Non-http schemes (tg://, mailto:) are legitimate here, so only default
|
||||||
@@ -1053,15 +936,6 @@ func validateRemoteRoutingURLSetting(name string, value *string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) UpdateSecret(key string, value string) error {
|
|
||||||
switch key {
|
|
||||||
case "tgBotToken":
|
|
||||||
return s.saveSetting(key, strings.TrimSpace(value))
|
|
||||||
default:
|
|
||||||
return common.NewError("secret key is not replaceable:", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
func (s *SettingService) GetDefaultXrayConfig() (any, error) {
|
||||||
var jsonData any
|
var jsonData any
|
||||||
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
err := json.Unmarshal([]byte(xrayTemplateConfig), &jsonData)
|
||||||
@@ -1123,7 +997,6 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
"pageSize": func() (any, error) { return s.GetPageSize() },
|
"pageSize": func() (any, error) { return s.GetPageSize() },
|
||||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
|
||||||
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
||||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||||
@@ -1187,16 +1060,12 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var factoryDefaultSecretKeys = map[string]bool{
|
|
||||||
"tgBotToken": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
GetFactoryDefaults returns the shipped default value per setting, keyed by
|
GetFactoryDefaults returns the shipped default value per setting, keyed by
|
||||||
the AllSetting json field name. Unlike GetDefaultSettings (which reports
|
the AllSetting json field name. Unlike GetDefaultSettings (which reports
|
||||||
current effective values), this is defaultValueMap projected through the
|
current effective values), this is defaultValueMap projected through the
|
||||||
AllSetting field set: only keys that exist as an AllSetting json tag are
|
AllSetting field set: only keys that exist as an AllSetting json tag are
|
||||||
returned, minus the credential fields in factoryDefaultSecretKeys. Keys
|
returned. Keys
|
||||||
with no AllSetting field (secret, panelGuid, the node mTLS material,
|
with no AllSetting field (secret, panelGuid, the node mTLS material,
|
||||||
xrayTemplateConfig) are excluded structurally rather than by deny-list.
|
xrayTemplateConfig) are excluded structurally rather than by deny-list.
|
||||||
*/
|
*/
|
||||||
@@ -1204,7 +1073,7 @@ func (s *SettingService) GetFactoryDefaults() map[string]string {
|
|||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) {
|
||||||
key := field.Tag.Get("json")
|
key := field.Tag.Get("json")
|
||||||
if key == "" || factoryDefaultSecretKeys[key] {
|
if key == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if value, ok := defaultValueMap[key]; ok {
|
if value, ok := defaultValueMap[key]; ok {
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func TestUpdateAllSettingPreservesEmptyUserAgentRegexes(t *testing.T) {
|
|||||||
settings.SubJsonUserAgentRegex = " "
|
settings.SubJsonUserAgentRegex = " "
|
||||||
settings.SubClashUserAgentRegex = ""
|
settings.SubClashUserAgentRegex = ""
|
||||||
|
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
if err := s.UpdateAllSetting(settings); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ func TestUpdateAllSettingPersistsClashSubscriptionSettings(t *testing.T) {
|
|||||||
settings.SubClashEnableRouting = true
|
settings.SubClashEnableRouting = true
|
||||||
settings.SubClashRules = "GEOIP,private,DIRECT"
|
settings.SubClashRules = "GEOIP,private,DIRECT"
|
||||||
|
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
if err := s.UpdateAllSetting(settings); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ func TestUpdateAllSettingRejectsInvalidClashUserAgentRegex(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
settings.SubClashUserAgentRegex = "["
|
settings.SubClashUserAgentRegex = "["
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
|
if err := s.UpdateAllSetting(settings); err == nil {
|
||||||
t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex")
|
t.Fatal("UpdateAllSetting accepted an invalid Clash/Mihomo User-Agent regex")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,7 @@ func TestUpdateAllSettingRejectsInvalidJsonUserAgentRegex(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
settings.SubJsonUserAgentRegex = "["
|
settings.SubJsonUserAgentRegex = "["
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err == nil {
|
if err := s.UpdateAllSetting(settings); err == nil {
|
||||||
t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex")
|
t.Fatal("UpdateAllSetting accepted an invalid Xray JSON User-Agent regex")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
|
|||||||
"nodeMtlsClientKeyPem",
|
"nodeMtlsClientKeyPem",
|
||||||
"nodeMtlsClientCertSha256",
|
"nodeMtlsClientCertSha256",
|
||||||
"xrayTemplateConfig",
|
"xrayTemplateConfig",
|
||||||
"tgBotToken",
|
|
||||||
"twoFactorToken",
|
|
||||||
"smtpPassword",
|
|
||||||
} {
|
} {
|
||||||
t.Run(key, func(t *testing.T) {
|
t.Run(key, func(t *testing.T) {
|
||||||
if _, ok := defaults[key]; ok {
|
if _, ok := defaults[key]; ok {
|
||||||
@@ -75,9 +72,6 @@ func TestGetFactoryDefaultsInvariant(t *testing.T) {
|
|||||||
if !tags[key] {
|
if !tags[key] {
|
||||||
t.Errorf("key %q is not an entity.AllSetting json tag", key)
|
t.Errorf("key %q is not an entity.AllSetting json tag", key)
|
||||||
}
|
}
|
||||||
if factoryDefaultSecretKeys[key] {
|
|
||||||
t.Errorf("key %q is in the credential deny-list and must not be returned", key)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/xlzd/gotp"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupSettingTestDB(t *testing.T) {
|
func setupSettingTestDB(t *testing.T) {
|
||||||
@@ -22,126 +19,6 @@ func setupSettingTestDB(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
|
||||||
setupSettingTestDB(t)
|
|
||||||
s := &SettingService{}
|
|
||||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
view, err := s.GetAllSettingView()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" || view.SmtpPassword != "" {
|
|
||||||
t.Fatalf("settings view leaked secrets: %#v", view)
|
|
||||||
}
|
|
||||||
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken || !view.HasSmtpPassword {
|
|
||||||
t.Fatalf("settings view did not report configured secret flags: %#v", view)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
|
||||||
setupSettingTestDB(t)
|
|
||||||
s := &SettingService{}
|
|
||||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
view, err := s.GetAllSettingView()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
settings := &view.AllSetting
|
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
|
||||||
t.Fatalf("tg token = %q, want preserved secret", got)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
|
||||||
t.Fatalf("ldap password = %q, want preserved secret", got)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
|
|
||||||
t.Fatalf("2fa token = %q, want preserved secret", got)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetSmtpPassword(); got != "smtp-secret" {
|
|
||||||
t.Fatalf("smtp password = %q, want preserved secret", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
|
|
||||||
setupSettingTestDB(t)
|
|
||||||
s := &SettingService{}
|
|
||||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
view, err := s.GetAllSettingView()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetSmtpPassword(); got != "" {
|
|
||||||
t.Fatalf("smtp password = %q, want cleared", got)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
|
||||||
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
|
||||||
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
view, err = s.GetAllSettingView()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if view.HasSmtpPassword {
|
|
||||||
t.Fatal("hasSmtpPassword must report false after clearing")
|
|
||||||
}
|
|
||||||
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetTgBotToken(); got != "" {
|
|
||||||
t.Fatalf("tg token = %q, want cleared", got)
|
|
||||||
}
|
|
||||||
if got, _ := s.GetLdapPassword(); got != "" {
|
|
||||||
t.Fatalf("ldap password = %q, want cleared", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||||
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
||||||
t.Fatal("expected localhost URL to be blocked")
|
t.Fatal("expected localhost URL to be blocked")
|
||||||
@@ -151,25 +28,6 @@ func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVerifyTwoFactorCode(t *testing.T) {
|
|
||||||
setupSettingTestDB(t)
|
|
||||||
s := &SettingService{}
|
|
||||||
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
const token = "JBSWY3DPEHPK3PXP"
|
|
||||||
if err := s.saveSetting("twoFactorToken", token); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.VerifyTwoFactorCode(gotp.NewDefaultTOTP(token).Now()); err != nil {
|
|
||||||
t.Fatalf("valid code rejected: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.VerifyTwoFactorCode("000000"); err == nil {
|
|
||||||
t.Fatal("invalid code accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetSecret_FallbacksOnEmptyDatabaseSetting(t *testing.T) {
|
func TestGetSecret_FallbacksOnEmptyDatabaseSetting(t *testing.T) {
|
||||||
setupSettingTestDB(t)
|
setupSettingTestDB(t)
|
||||||
s := &SettingService{}
|
s := &SettingService{}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
settings.SubShowIdentityOnAllLinks = true
|
settings.SubShowIdentityOnAllLinks = true
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
if err := s.UpdateAllSetting(settings); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
|
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || !got {
|
||||||
@@ -30,7 +30,7 @@ func TestSubShowIdentityOnAllLinksDefaultsAndPersists(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
settings.SubShowIdentityOnAllLinks = false
|
settings.SubShowIdentityOnAllLinks = false
|
||||||
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
if err := s.UpdateAllSetting(settings); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
if got, err := s.GetSubShowIdentityOnAllLinks(); err != nil || got {
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
// updateNumericInput applies one number-pad key: -2 clears, -1 backspaces, and 0..9 append.
|
|
||||||
// Callers retain their own validation and keyboard labels.
|
|
||||||
func updateNumericInput(value, key int) int {
|
|
||||||
switch key {
|
|
||||||
case -2:
|
|
||||||
return 0
|
|
||||||
case -1:
|
|
||||||
if value > 0 {
|
|
||||||
return value / 10
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
default:
|
|
||||||
return value*10 + key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"go/ast"
|
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUpdateNumericInput(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
value, key int
|
|
||||||
want int
|
|
||||||
}{
|
|
||||||
{name: "append digit", value: 12, key: 3, want: 123},
|
|
||||||
{name: "append zero", value: 12, key: 0, want: 120},
|
|
||||||
{name: "backspace", value: 123, key: -1, want: 12},
|
|
||||||
{name: "backspace zero", value: 0, key: -1, want: 0},
|
|
||||||
{name: "clear", value: 123, key: -2, want: 0},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
if got := updateNumericInput(tt.value, tt.key); got != tt.want {
|
|
||||||
t.Fatalf("updateNumericInput(%d, %d) = %d, want %d", tt.value, tt.key, got, tt.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNumericInputTransitionIsUsedByEveryKeypad(t *testing.T) {
|
|
||||||
entries, err := os.ReadDir(".")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read tgbot package: %v", err)
|
|
||||||
}
|
|
||||||
fset := token.NewFileSet()
|
|
||||||
for _, entry := range entries {
|
|
||||||
name := entry.Name()
|
|
||||||
if entry.IsDir() || filepath.Ext(name) != ".go" || name == "numeric_input.go" || filepath.Ext(strings.TrimSuffix(name, "_test.go")) != ".go" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
parsed, err := parser.ParseFile(fset, name, nil, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse %s: %v", name, err)
|
|
||||||
}
|
|
||||||
ast.Inspect(parsed, func(node ast.Node) bool {
|
|
||||||
switchStmt, ok := node.(*ast.SwitchStmt)
|
|
||||||
if !ok {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
hasClear, hasBackspace, hasDefault := false, false, false
|
|
||||||
for _, stmt := range switchStmt.Body.List {
|
|
||||||
clause := stmt.(*ast.CaseClause)
|
|
||||||
if clause.List == nil {
|
|
||||||
hasDefault = true
|
|
||||||
}
|
|
||||||
for _, expr := range clause.List {
|
|
||||||
hasClear = hasClear || numericKeyLiteral(expr, "2")
|
|
||||||
hasBackspace = hasBackspace || numericKeyLiteral(expr, "1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasClear && hasBackspace && hasDefault {
|
|
||||||
position := fset.Position(switchStmt.Pos())
|
|
||||||
t.Errorf("open-coded numeric keypad transition at %s; use updateNumericInput", position)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func numericKeyLiteral(expr ast.Expr, magnitude string) bool {
|
|
||||||
unary, ok := expr.(*ast.UnaryExpr)
|
|
||||||
if !ok || unary.Op != token.SUB {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
literal, ok := unary.X.(*ast.BasicLit)
|
|
||||||
return ok && literal.Kind == token.INT && literal.Value == magnitude
|
|
||||||
}
|
|
||||||
@@ -1,537 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"embed"
|
|
||||||
"math/big"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"regexp"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
th "github.com/mymmrac/telego/telegohandler"
|
|
||||||
"github.com/valyala/fasthttp"
|
|
||||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
bot *telego.Bot
|
|
||||||
|
|
||||||
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
|
|
||||||
botCancel context.CancelFunc
|
|
||||||
// tgBotMutex protects concurrent access to botCancel variable
|
|
||||||
tgBotMutex sync.Mutex
|
|
||||||
// botWG waits for the OnReceive Long Polling goroutine to finish.
|
|
||||||
botWG sync.WaitGroup
|
|
||||||
|
|
||||||
botHandler *th.BotHandler
|
|
||||||
adminIds []int64
|
|
||||||
isRunning bool
|
|
||||||
hostname string
|
|
||||||
hashStorage *global.HashStorage
|
|
||||||
|
|
||||||
// EventBus is set from web layer to publish login/security events.
|
|
||||||
EventBus *eventbus.Bus
|
|
||||||
|
|
||||||
// Performance improvements
|
|
||||||
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
|
|
||||||
statusCache struct {
|
|
||||||
data *service.Status
|
|
||||||
timestamp time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
serverStatsCache struct {
|
|
||||||
data string
|
|
||||||
timestamp time.Time
|
|
||||||
mutex sync.RWMutex
|
|
||||||
}
|
|
||||||
|
|
||||||
// clients data to adding new client. receiver_inbound_IDs is the set of
|
|
||||||
// inbounds the new client will be attached to; receiver_inbound_ID mirrors
|
|
||||||
// the primary pick for the legacy attach-picker entry point. Per-protocol
|
|
||||||
// secrets (UUID, password, flow, method) are filled per-inbound on submit
|
|
||||||
// by ClientService.fillProtocolDefaults, so the bot only tracks universal
|
|
||||||
// client fields here.
|
|
||||||
receiver_inbound_ID int
|
|
||||||
receiver_inbound_IDs []int
|
|
||||||
client_Email string
|
|
||||||
client_LimitIP int
|
|
||||||
client_TotalGB int64
|
|
||||||
client_ExpiryTime int64
|
|
||||||
client_Enable bool
|
|
||||||
client_TgID string
|
|
||||||
client_SubID string
|
|
||||||
client_Comment string
|
|
||||||
client_Reset int
|
|
||||||
)
|
|
||||||
|
|
||||||
// userStateStore guards the per-chat conversation states. The Telegram command
|
|
||||||
// and callback handlers run on a worker-pool goroutine while the message handler
|
|
||||||
// runs on the dispatch goroutine, so a bare map would be a concurrent-map-write
|
|
||||||
// crash. It also expires abandoned conversations so a user who starts a flow and
|
|
||||||
// goes silent doesn't leave an entry forever.
|
|
||||||
type userStateStore struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
states map[int64]userStateEntry
|
|
||||||
lastPrune time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type userStateEntry struct {
|
|
||||||
state string
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var userStateMgr = &userStateStore{states: make(map[int64]userStateEntry)}
|
|
||||||
|
|
||||||
func (s *userStateStore) set(chatID int64, state string) {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.states[chatID] = userStateEntry{state: state, at: time.Now()}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userStateStore) get(chatID int64) (string, bool) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
e, ok := s.states[chatID]
|
|
||||||
return e.state, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userStateStore) clear(chatID int64) {
|
|
||||||
s.mu.Lock()
|
|
||||||
delete(s.states, chatID)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *userStateStore) reset() {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.states = make(map[int64]userStateEntry)
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// maybePrune drops conversations older than maxAge, at most once per maxAge so a
|
|
||||||
// busy bot doesn't sweep the whole map on every message.
|
|
||||||
func (s *userStateStore) maybePrune(maxAge time.Duration) {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
now := time.Now()
|
|
||||||
if now.Sub(s.lastPrune) < maxAge {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.lastPrune = now
|
|
||||||
for id, e := range s.states {
|
|
||||||
if now.Sub(e.at) > maxAge {
|
|
||||||
delete(s.states, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginStatus represents the result of a login attempt.
|
|
||||||
type LoginStatus byte
|
|
||||||
|
|
||||||
// Login status constants
|
|
||||||
const (
|
|
||||||
LoginSuccess LoginStatus = 1 // Login was successful
|
|
||||||
LoginFail LoginStatus = 0 // Login failed
|
|
||||||
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
|
|
||||||
)
|
|
||||||
|
|
||||||
// LoginAttempt contains safe metadata for panel login notifications.
|
|
||||||
// It intentionally does not include attempted passwords.
|
|
||||||
type LoginAttempt struct {
|
|
||||||
Username string
|
|
||||||
IP string
|
|
||||||
Time string
|
|
||||||
Status LoginStatus
|
|
||||||
Reason string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tgbot provides business logic for Telegram bot integration.
|
|
||||||
// It handles bot commands, user interactions, and status reporting via Telegram.
|
|
||||||
type Tgbot struct {
|
|
||||||
inboundService service.InboundService
|
|
||||||
clientService service.ClientService
|
|
||||||
settingService service.SettingService
|
|
||||||
serverService service.ServerService
|
|
||||||
xrayService service.XrayService
|
|
||||||
lastStatus *service.Status
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTgbot creates a new Tgbot instance.
|
|
||||||
func (t *Tgbot) NewTgbot() *Tgbot {
|
|
||||||
return new(Tgbot)
|
|
||||||
}
|
|
||||||
|
|
||||||
// I18nBot retrieves a localized message for the bot interface.
|
|
||||||
func (t *Tgbot) I18nBot(name string, params ...string) string {
|
|
||||||
return locale.I18n(locale.Bot, name, params...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetHashStorage returns the hash storage instance for callback queries.
|
|
||||||
func (t *Tgbot) GetHashStorage() *global.HashStorage {
|
|
||||||
return hashStorage
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old)
|
|
||||||
func (t *Tgbot) getCachedStatus() (*service.Status, bool) {
|
|
||||||
statusCache.mutex.RLock()
|
|
||||||
defer statusCache.mutex.RUnlock()
|
|
||||||
|
|
||||||
if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second {
|
|
||||||
return statusCache.data, true
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCachedStatus updates the status cache
|
|
||||||
func (t *Tgbot) setCachedStatus(status *service.Status) {
|
|
||||||
statusCache.mutex.Lock()
|
|
||||||
defer statusCache.mutex.Unlock()
|
|
||||||
|
|
||||||
statusCache.data = status
|
|
||||||
statusCache.timestamp = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old)
|
|
||||||
func (t *Tgbot) getCachedServerStats() (string, bool) {
|
|
||||||
serverStatsCache.mutex.RLock()
|
|
||||||
defer serverStatsCache.mutex.RUnlock()
|
|
||||||
|
|
||||||
if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second {
|
|
||||||
return serverStatsCache.data, true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// setCachedServerStats updates the server stats cache
|
|
||||||
func (t *Tgbot) setCachedServerStats(stats string) {
|
|
||||||
serverStatsCache.mutex.Lock()
|
|
||||||
defer serverStatsCache.mutex.Unlock()
|
|
||||||
|
|
||||||
serverStatsCache.data = stats
|
|
||||||
serverStatsCache.timestamp = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start initializes and starts the Telegram bot with the provided translation files.
|
|
||||||
func (t *Tgbot) Start(i18nFS embed.FS) error {
|
|
||||||
// Initialize localizer
|
|
||||||
err := locale.InitLocalizer(i18nFS, &t.settingService)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// If Start is called again (e.g. during reload), ensure any previous long-polling
|
|
||||||
// loop is stopped before creating a new bot / receiver.
|
|
||||||
StopBot()
|
|
||||||
|
|
||||||
// Initialize hash storage to store callback queries
|
|
||||||
hashStorage = global.NewHashStorage(20 * time.Minute)
|
|
||||||
|
|
||||||
// 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
|
|
||||||
tgBotToken, err := t.settingService.GetTgBotToken()
|
|
||||||
if err != nil || tgBotToken == "" {
|
|
||||||
logger.Warning("Failed to get Telegram bot token:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Telegram bot chat ID(s)
|
|
||||||
tgBotID, err := t.settingService.GetTgBotChatId()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to get Telegram bot chat ID:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedAdminIds := make([]int64, 0)
|
|
||||||
// Parse admin IDs from comma-separated string
|
|
||||||
if tgBotID != "" {
|
|
||||||
for adminID := range strings.SplitSeq(tgBotID, ",") {
|
|
||||||
id, err := strconv.ParseInt(adminID, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
parsedAdminIds = append(parsedAdminIds, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tgBotMutex.Lock()
|
|
||||||
adminIds = parsedAdminIds
|
|
||||||
tgBotMutex.Unlock()
|
|
||||||
|
|
||||||
// Get Telegram bot proxy URL
|
|
||||||
tgBotProxy, err := t.settingService.GetTgBotProxy()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to get Telegram bot proxy URL:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to the panel-wide egress bridge when no dedicated bot proxy is
|
|
||||||
// set. Resolved once at bot start: if Xray comes up later, the bot keeps
|
|
||||||
// its direct connection until it is restarted.
|
|
||||||
if tgBotProxy == "" {
|
|
||||||
if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) {
|
|
||||||
tgBotProxy = egress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Telegram bot API server URL
|
|
||||||
tgBotAPIServer, err := t.settingService.GetTgBotAPIServer()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to get Telegram bot API server URL:", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new Telegram bot instance
|
|
||||||
bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Failed to initialize Telegram bot API:", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
t.trySetBotCommands(bot)
|
|
||||||
|
|
||||||
// Start receiving Telegram bot messages
|
|
||||||
tgBotMutex.Lock()
|
|
||||||
alreadyRunning := isRunning || botCancel != nil
|
|
||||||
tgBotMutex.Unlock()
|
|
||||||
if !alreadyRunning {
|
|
||||||
logger.Info("Telegram bot receiver started")
|
|
||||||
go t.OnReceive()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
|
|
||||||
Commands: []telego.BotCommand{
|
|
||||||
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
|
|
||||||
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
|
|
||||||
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
|
|
||||||
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
|
|
||||||
{Command: "usage", Description: t.I18nBot("tgbot.commands.usageDesc")},
|
|
||||||
{Command: "inbound", Description: t.I18nBot("tgbot.commands.inboundDesc")},
|
|
||||||
{Command: "restart", Description: t.I18nBot("tgbot.commands.restartDesc")},
|
|
||||||
{Command: "clearall", Description: t.I18nBot("tgbot.commands.clearallDesc")},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to set bot commands:", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isSupportedBotProxyScheme(proxyUrl string) bool {
|
|
||||||
return strings.HasPrefix(proxyUrl, "socks5://") ||
|
|
||||||
strings.HasPrefix(proxyUrl, "http://") ||
|
|
||||||
strings.HasPrefix(proxyUrl, "https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
|
|
||||||
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
|
|
||||||
client := &fasthttp.Client{
|
|
||||||
// Connection timeouts
|
|
||||||
ReadTimeout: 30 * time.Second,
|
|
||||||
WriteTimeout: 30 * time.Second,
|
|
||||||
MaxIdleConnDuration: 60 * time.Second,
|
|
||||||
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
|
|
||||||
MaxIdemponentCallAttempts: 3,
|
|
||||||
ReadBufferSize: 4096,
|
|
||||||
WriteBufferSize: 4096,
|
|
||||||
MaxConnsPerHost: 100,
|
|
||||||
MaxConnWaitTimeout: 10 * time.Second,
|
|
||||||
DisableHeaderNamesNormalizing: false,
|
|
||||||
DisablePathNormalizing: false,
|
|
||||||
// resetTimeout stays false to keep the pre-RetryIfErr retry timing.
|
|
||||||
RetryIfErr: func(request *fasthttp.Request, _ int, _ error) (bool, bool) {
|
|
||||||
method := string(request.Header.Method())
|
|
||||||
return false, method == "GET" || method == "POST"
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if proxyUrl != "" {
|
|
||||||
if strings.HasPrefix(proxyUrl, "socks5://") {
|
|
||||||
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
|
|
||||||
} else {
|
|
||||||
client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
|
|
||||||
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
|
||||||
// Validate proxy URL if provided
|
|
||||||
if proxyUrl != "" {
|
|
||||||
if !isSupportedBotProxyScheme(proxyUrl) {
|
|
||||||
logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy")
|
|
||||||
proxyUrl = "" // Clear invalid proxy
|
|
||||||
} else if _, err := url.Parse(proxyUrl); err != nil {
|
|
||||||
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
|
|
||||||
proxyUrl = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate API server URL if provided
|
|
||||||
if apiServerUrl != "" {
|
|
||||||
safeURL, err := service.SanitizePublicHTTPURL(apiServerUrl, false)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("Invalid or blocked API server URL, using default: %v", err)
|
|
||||||
apiServerUrl = ""
|
|
||||||
} else {
|
|
||||||
apiServerUrl = safeURL
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create robust fasthttp client
|
|
||||||
client := t.createRobustFastHTTPClient(proxyUrl)
|
|
||||||
|
|
||||||
// Build bot options
|
|
||||||
var options []telego.BotOption
|
|
||||||
options = append(options, telego.WithFastHTTPClient(client))
|
|
||||||
|
|
||||||
if apiServerUrl != "" {
|
|
||||||
options = append(options, telego.WithAPIServer(apiServerUrl))
|
|
||||||
}
|
|
||||||
|
|
||||||
return telego.NewBot(token, options...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsRunning checks if the Telegram bot is currently running.
|
|
||||||
func (t *Tgbot) IsRunning() bool {
|
|
||||||
tgBotMutex.Lock()
|
|
||||||
defer tgBotMutex.Unlock()
|
|
||||||
return isRunning
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetHostname sets the hostname for the bot.
|
|
||||||
func (t *Tgbot) SetHostname() {
|
|
||||||
host, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("get hostname error:", err)
|
|
||||||
hostname = ""
|
|
||||||
return
|
|
||||||
}
|
|
||||||
hostname = host
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop safely stops the Telegram bot's Long Polling operation.
|
|
||||||
// This method now calls the global StopBot function and cleans up other resources.
|
|
||||||
func (t *Tgbot) Stop() {
|
|
||||||
StopBot()
|
|
||||||
logger.Info("Stop Telegram receiver ...")
|
|
||||||
tgBotMutex.Lock()
|
|
||||||
adminIds = nil
|
|
||||||
tgBotMutex.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// StopBot safely stops the Telegram bot's Long Polling operation by cancelling its context.
|
|
||||||
// This is the global function called from main.go's signal handler and t.Stop().
|
|
||||||
func StopBot() {
|
|
||||||
// Don't hold the mutex while cancelling/waiting.
|
|
||||||
tgBotMutex.Lock()
|
|
||||||
cancel := botCancel
|
|
||||||
botCancel = nil
|
|
||||||
handler := botHandler
|
|
||||||
botHandler = nil
|
|
||||||
isRunning = false
|
|
||||||
tgBotMutex.Unlock()
|
|
||||||
|
|
||||||
userStateMgr.reset()
|
|
||||||
|
|
||||||
if handler != nil {
|
|
||||||
_ = handler.Stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
if cancel != nil {
|
|
||||||
logger.Info("Sending cancellation signal to Telegram bot...")
|
|
||||||
// Cancels the context passed to UpdatesViaLongPolling; this closes updates channel
|
|
||||||
// and lets botHandler.Start() exit cleanly.
|
|
||||||
cancel()
|
|
||||||
botWG.Wait()
|
|
||||||
logger.Info("Telegram bot successfully stopped.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// encodeQuery encodes the query string if it's longer than 64 characters.
|
|
||||||
func (t *Tgbot) encodeQuery(query string) string {
|
|
||||||
// NOTE: we only need to hash for more than 64 chars
|
|
||||||
if len(query) <= 64 {
|
|
||||||
return query
|
|
||||||
}
|
|
||||||
|
|
||||||
return hashStorage.SaveHash(query)
|
|
||||||
}
|
|
||||||
|
|
||||||
// decodeQuery decodes a hashed query string back to its original form.
|
|
||||||
func (t *Tgbot) decodeQuery(query string) (string, error) {
|
|
||||||
if !hashStorage.IsMD5(query) {
|
|
||||||
return query, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded, exists := hashStorage.GetValue(query)
|
|
||||||
if !exists {
|
|
||||||
return "", common.NewError("hash not found in storage!")
|
|
||||||
}
|
|
||||||
|
|
||||||
return decoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// randomLowerAndNum generates a random string of lowercase letters and numbers.
|
|
||||||
func (t *Tgbot) randomLowerAndNum(length int) string {
|
|
||||||
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
bytes := make([]byte, length)
|
|
||||||
for i := range bytes {
|
|
||||||
randomIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
|
||||||
bytes[i] = charset[randomIndex.Int64()]
|
|
||||||
}
|
|
||||||
return string(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// int64Contains checks if an int64 slice contains a specific item.
|
|
||||||
func int64Contains(slice []int64, item int64) bool {
|
|
||||||
return slices.Contains(slice, item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// isSingleWord checks if the text contains only a single word.
|
|
||||||
func (t *Tgbot) isSingleWord(text string) bool {
|
|
||||||
text = strings.TrimSpace(text)
|
|
||||||
re := regexp.MustCompile(`\s+`)
|
|
||||||
return re.MatchString(text)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestAnswerCallbackDeniesPrivilegedActionToNonAdmin(t *testing.T) {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Fatalf("a non-admin callback reached a privileged handler: %v", r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
tg := &Tgbot{}
|
|
||||||
for _, data := range []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "inbounds"} {
|
|
||||||
q := &telego.CallbackQuery{
|
|
||||||
Data: data,
|
|
||||||
From: telego.User{ID: 999999},
|
|
||||||
Message: &telego.Message{Chat: telego.Chat{ID: 1}},
|
|
||||||
}
|
|
||||||
tg.answerCallback(q, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsClientSelfCallback(t *testing.T) {
|
|
||||||
allowed := []string{"client_traffic", "client_sub_links", "client_qr_links", "client_sub_links alice@x"}
|
|
||||||
for _, d := range allowed {
|
|
||||||
if !isClientSelfCallback(d) {
|
|
||||||
t.Errorf("%q should be a per-user client callback", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
denied := []string{"get_backup", "reset_all_traffics_c", "add_client", "onlines", "get_banlogs", "get_usage"}
|
|
||||||
for _, d := range denied {
|
|
||||||
if isClientSelfCallback(d) {
|
|
||||||
t.Errorf("%q is an admin-only callback and must not be treated as per-user", d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
|
||||||
"github.com/skip2/go-qrcode"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress
|
|
||||||
// client (email, attached inbounds, traffic limit, expiry, ip limit, comment)
|
|
||||||
// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password,
|
|
||||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
|
||||||
// never has to track them per inbound itself.
|
|
||||||
func (t *Tgbot) BuildClientDraftMessage() string {
|
|
||||||
now := time.Now().UnixMilli()
|
|
||||||
|
|
||||||
expiry := ""
|
|
||||||
switch {
|
|
||||||
case client_ExpiryTime == 0:
|
|
||||||
expiry = t.I18nBot("tgbot.unlimited")
|
|
||||||
case client_ExpiryTime < 0:
|
|
||||||
expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
|
||||||
default:
|
|
||||||
diff := client_ExpiryTime - now
|
|
||||||
if diff > 172800000 {
|
|
||||||
expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05")
|
|
||||||
} else {
|
|
||||||
expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traffic := "♾️ Unlimited(Reset)"
|
|
||||||
if client_TotalGB > 0 {
|
|
||||||
traffic = common.FormatTraffic(client_TotalGB)
|
|
||||||
}
|
|
||||||
|
|
||||||
ipLimit := "♾️ Unlimited(Reset)"
|
|
||||||
if client_LimitIP > 0 {
|
|
||||||
ipLimit = fmt.Sprint(client_LimitIP)
|
|
||||||
}
|
|
||||||
|
|
||||||
attached := t.describeAttachedInbounds(receiver_inbound_IDs)
|
|
||||||
if attached == "" {
|
|
||||||
attached = "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
comment := client_Comment
|
|
||||||
if comment == "" {
|
|
||||||
comment = "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
tgID := client_TgID
|
|
||||||
if tgID == "" {
|
|
||||||
tgID = "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
b.WriteString("📝 *New client draft*\r\n")
|
|
||||||
fmt.Fprintf(&b, "📧 Email: `%s`\r\n", client_Email)
|
|
||||||
fmt.Fprintf(&b, "🔗 Attached: %s\r\n", attached)
|
|
||||||
fmt.Fprintf(&b, "📊 Traffic: %s\r\n", traffic)
|
|
||||||
fmt.Fprintf(&b, "📅 Expire: %s\r\n", expiry)
|
|
||||||
fmt.Fprintf(&b, "🔢 IP limit: %s\r\n", ipLimit)
|
|
||||||
fmt.Fprintf(&b, "👤 TG user: %s\r\n", tgID)
|
|
||||||
fmt.Fprintf(&b, "💬 Comment: %s\r\n", comment)
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// describeAttachedInbounds returns a short "remark1, remark2" list for the given
|
|
||||||
// inbound ids, falling back to "#id" when an inbound can't be loaded.
|
|
||||||
func (t *Tgbot) describeAttachedInbounds(ids []int) string {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
parts := make([]string, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
ib, err := t.inboundService.GetInbound(id)
|
|
||||||
if err != nil || ib == nil {
|
|
||||||
parts = append(parts, fmt.Sprintf("#%d", id))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
label := ib.Remark
|
|
||||||
if label == "" {
|
|
||||||
label = fmt.Sprintf("#%d", id)
|
|
||||||
}
|
|
||||||
parts = append(parts, label)
|
|
||||||
}
|
|
||||||
return strings.Join(parts, ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// SubmitAddClient sends the in-progress client to ClientService.Create with
|
|
||||||
// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on
|
|
||||||
// the panel generates UUID/password/auth per protocol, so the bot only
|
|
||||||
// supplies the universal fields it actually collected.
|
|
||||||
func (t *Tgbot) SubmitAddClient() (bool, error) {
|
|
||||||
inboundIDs := receiver_inbound_IDs
|
|
||||||
if len(inboundIDs) == 0 && receiver_inbound_ID > 0 {
|
|
||||||
inboundIDs = []int{receiver_inbound_ID}
|
|
||||||
}
|
|
||||||
if len(inboundIDs) == 0 {
|
|
||||||
return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64)
|
|
||||||
client := model.Client{
|
|
||||||
Email: client_Email,
|
|
||||||
Enable: client_Enable,
|
|
||||||
LimitIP: client_LimitIP,
|
|
||||||
TotalGB: client_TotalGB,
|
|
||||||
ExpiryTime: client_ExpiryTime,
|
|
||||||
SubID: client_SubID,
|
|
||||||
Comment: client_Comment,
|
|
||||||
Reset: client_Reset,
|
|
||||||
TgID: tgIDInt,
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{
|
|
||||||
Client: client,
|
|
||||||
InboundIds: inboundIDs,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
|
|
||||||
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|
||||||
// Resolve subId from client email
|
|
||||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
|
||||||
_ = traffic
|
|
||||||
if err != nil || client == nil {
|
|
||||||
return "", "", errors.New("client not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather settings to construct absolute URLs
|
|
||||||
subURI, _ := t.settingService.GetSubURI()
|
|
||||||
subJsonURI, _ := t.settingService.GetSubJsonURI()
|
|
||||||
subDomain, _ := t.settingService.GetSubDomain()
|
|
||||||
subPort, _ := t.settingService.GetSubPort()
|
|
||||||
subPath, _ := t.settingService.GetSubPath()
|
|
||||||
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
|
||||||
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
|
|
||||||
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
|
||||||
subCertFile, _ := t.settingService.GetSubCertFile()
|
|
||||||
|
|
||||||
tls := (subKeyFile != "" && subCertFile != "")
|
|
||||||
scheme := "http"
|
|
||||||
if tls {
|
|
||||||
scheme = "https"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallbacks
|
|
||||||
if subDomain == "" {
|
|
||||||
// try panel domain, otherwise OS hostname
|
|
||||||
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
|
|
||||||
subDomain = d
|
|
||||||
} else if hostname != "" {
|
|
||||||
subDomain = hostname
|
|
||||||
} else {
|
|
||||||
subDomain = "localhost"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
host := subDomain
|
|
||||||
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
|
|
||||||
// standard ports: no port in host
|
|
||||||
} else {
|
|
||||||
host = fmt.Sprintf("%s:%d", subDomain, subPort)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure paths
|
|
||||||
if !strings.HasPrefix(subPath, "/") {
|
|
||||||
subPath = "/" + subPath
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(subPath, "/") {
|
|
||||||
subPath = subPath + "/"
|
|
||||||
}
|
|
||||||
if !strings.HasPrefix(subJsonPath, "/") {
|
|
||||||
subJsonPath = "/" + subJsonPath
|
|
||||||
}
|
|
||||||
if !strings.HasSuffix(subJsonPath, "/") {
|
|
||||||
subJsonPath = subJsonPath + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
var subURL string
|
|
||||||
var subJsonURL string
|
|
||||||
|
|
||||||
// If pre-configured URIs are available, use them directly
|
|
||||||
if subURI != "" {
|
|
||||||
if !strings.HasSuffix(subURI, "/") {
|
|
||||||
subURI = subURI + "/"
|
|
||||||
}
|
|
||||||
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
|
|
||||||
} else {
|
|
||||||
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if subJsonURI != "" {
|
|
||||||
if !strings.HasSuffix(subJsonURI, "/") {
|
|
||||||
subJsonURI = subJsonURI + "/"
|
|
||||||
}
|
|
||||||
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
|
|
||||||
} else {
|
|
||||||
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !subJsonEnable {
|
|
||||||
subJsonURL = ""
|
|
||||||
}
|
|
||||||
return subURL, subJsonURL, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendClientSubLinks sends the subscription links for the client to the chat.
|
|
||||||
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
|
||||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
|
||||||
if err != nil {
|
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
|
|
||||||
if subJsonURL != "" {
|
|
||||||
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
|
||||||
}
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it 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)
|
|
||||||
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 {
|
|
||||||
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]
|
|
||||||
var msg strings.Builder
|
|
||||||
msg.WriteString(t.I18nBot("subscription.individualLinks"))
|
|
||||||
msg.WriteString(":\r\n")
|
|
||||||
for _, link := range chunk {
|
|
||||||
// wrap each link in <code>
|
|
||||||
msg.WriteString("<code>")
|
|
||||||
msg.WriteString(link)
|
|
||||||
msg.WriteString("</code>\r\n")
|
|
||||||
}
|
|
||||||
t.SendMsgToTgbot(chatId, msg.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
|
|
||||||
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
|
||||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
|
||||||
if err != nil {
|
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to create QR PNG bytes from content
|
|
||||||
createQR := func(content string, size int) ([]byte, error) {
|
|
||||||
if size <= 0 {
|
|
||||||
size = 256
|
|
||||||
}
|
|
||||||
return qrcode.Encode(content, qrcode.Medium, size)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inform user
|
|
||||||
t.SendMsgToTgbot(chatId, "QRCode for client "+email+":")
|
|
||||||
|
|
||||||
// Send sub URL QR (filename: sub.png)
|
|
||||||
if png, err := createQR(subURL, 320); err == nil {
|
|
||||||
document := tu.Document(
|
|
||||||
tu.ID(chatId),
|
|
||||||
tu.FileFromBytes(png, "sub.png"),
|
|
||||||
)
|
|
||||||
_, _ = bot.SendDocument(context.Background(), document)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send JSON URL QR (filename: subjson.png) when available
|
|
||||||
if subJsonURL != "" {
|
|
||||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
|
||||||
document := tu.Document(
|
|
||||||
tu.ID(chatId),
|
|
||||||
tu.FileFromBytes(png, "subjson.png"),
|
|
||||||
)
|
|
||||||
_, _ = bot.SendDocument(context.Background(), document)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientInfoMsg formats client information message based on traffic and flags.
|
|
||||||
func (t *Tgbot) clientInfoMsg(
|
|
||||||
traffic *xray.ClientTraffic,
|
|
||||||
printEnabled bool,
|
|
||||||
printOnline bool,
|
|
||||||
printActive bool,
|
|
||||||
printDate bool,
|
|
||||||
printTraffic bool,
|
|
||||||
printRefreshed bool,
|
|
||||||
) string {
|
|
||||||
now := time.Now().Unix()
|
|
||||||
expiryTime := ""
|
|
||||||
flag := false
|
|
||||||
diff := traffic.ExpiryTime/1000 - now
|
|
||||||
if traffic.ExpiryTime == 0 {
|
|
||||||
expiryTime = t.I18nBot("tgbot.unlimited")
|
|
||||||
} else if diff > 172800 || !traffic.Enable {
|
|
||||||
expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
|
|
||||||
if diff > 0 {
|
|
||||||
days := diff / 86400
|
|
||||||
hours := (diff % 86400) / 3600
|
|
||||||
minutes := (diff % 3600) / 60
|
|
||||||
remainingTime := ""
|
|
||||||
if days > 0 {
|
|
||||||
remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days"))
|
|
||||||
}
|
|
||||||
if hours > 0 {
|
|
||||||
remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours"))
|
|
||||||
}
|
|
||||||
if minutes > 0 {
|
|
||||||
remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes"))
|
|
||||||
}
|
|
||||||
expiryTime += fmt.Sprintf(" (%s)", remainingTime)
|
|
||||||
}
|
|
||||||
} else if traffic.ExpiryTime < 0 {
|
|
||||||
expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
|
||||||
flag = true
|
|
||||||
} else {
|
|
||||||
expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours"))
|
|
||||||
flag = true
|
|
||||||
}
|
|
||||||
|
|
||||||
total := ""
|
|
||||||
if traffic.Total == 0 {
|
|
||||||
total = t.I18nBot("tgbot.unlimited")
|
|
||||||
} else {
|
|
||||||
total = common.FormatTraffic(traffic.Total)
|
|
||||||
}
|
|
||||||
|
|
||||||
enabled := ""
|
|
||||||
isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
enabled = t.I18nBot("tgbot.wentWrong")
|
|
||||||
} else if isEnabled {
|
|
||||||
enabled = t.I18nBot("tgbot.messages.yes")
|
|
||||||
} else {
|
|
||||||
enabled = t.I18nBot("tgbot.messages.no")
|
|
||||||
}
|
|
||||||
|
|
||||||
active := ""
|
|
||||||
if traffic.Enable {
|
|
||||||
active = t.I18nBot("tgbot.messages.yes")
|
|
||||||
} else {
|
|
||||||
active = t.I18nBot("tgbot.messages.no")
|
|
||||||
}
|
|
||||||
|
|
||||||
status := t.I18nBot("tgbot.offline")
|
|
||||||
isOnline := false
|
|
||||||
if process := service.XrayProcess(); process != nil && process.IsRunning() {
|
|
||||||
if slices.Contains(process.GetOnlineClients(), traffic.Email) {
|
|
||||||
status = t.I18nBot("tgbot.online")
|
|
||||||
isOnline = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output := ""
|
|
||||||
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
|
|
||||||
if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 {
|
|
||||||
output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds))
|
|
||||||
}
|
|
||||||
if printEnabled {
|
|
||||||
output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled)
|
|
||||||
}
|
|
||||||
if printOnline {
|
|
||||||
output += t.I18nBot("tgbot.messages.online", "Status=="+status)
|
|
||||||
if !isOnline && traffic.LastOnline > 0 {
|
|
||||||
output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if printActive {
|
|
||||||
output += t.I18nBot("tgbot.messages.active", "Enable=="+active)
|
|
||||||
}
|
|
||||||
if printDate {
|
|
||||||
if flag {
|
|
||||||
output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime)
|
|
||||||
} else {
|
|
||||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if printTraffic {
|
|
||||||
output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up))
|
|
||||||
output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down))
|
|
||||||
output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total)
|
|
||||||
}
|
|
||||||
if printRefreshed {
|
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
// getClientUsage retrieves and sends client usage information to the chat.
|
|
||||||
func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
|
||||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
msg := t.I18nBot("tgbot.wentWrong")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(traffics) == 0 {
|
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
output := ""
|
|
||||||
|
|
||||||
if len(traffics) > 0 {
|
|
||||||
if len(email) > 0 {
|
|
||||||
for _, traffic := range traffics {
|
|
||||||
if traffic.Email == email[0] {
|
|
||||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
|
||||||
t.SendMsgToTgbot(chatId, output)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg := t.I18nBot("tgbot.noResult")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
for _, traffic := range traffics {
|
|
||||||
output += t.clientInfoMsg(traffic, true, true, true, true, true, false)
|
|
||||||
output += "\r\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
t.SendMsgToTgbot(chatId, output)
|
|
||||||
output = t.I18nBot("tgbot.commands.pleaseChoose")
|
|
||||||
t.SendAnswer(chatId, output, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchClientIps searches and sends client IP addresses for the given email.
|
|
||||||
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
|
||||||
ips, err := t.inboundService.GetInboundClientIps(email)
|
|
||||||
if err != nil || len(ips) == 0 {
|
|
||||||
ips = t.I18nBot("tgbot.noIpRecord")
|
|
||||||
}
|
|
||||||
|
|
||||||
formattedIps := ips
|
|
||||||
if err == nil && len(ips) > 0 {
|
|
||||||
type ipWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ipsWithTime []ipWithTimestamp
|
|
||||||
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
|
|
||||||
lines := make([]string, 0, len(ipsWithTime))
|
|
||||||
for _, item := range ipsWithTime {
|
|
||||||
if item.IP == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if item.Timestamp > 0 {
|
|
||||||
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
|
|
||||||
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines = append(lines, item.IP)
|
|
||||||
}
|
|
||||||
if len(lines) > 0 {
|
|
||||||
formattedIps = strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var oldIps []string
|
|
||||||
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
|
|
||||||
formattedIps = strings.Join(oldIps, "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
output := ""
|
|
||||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
|
||||||
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
|
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// clientTelegramUserInfo retrieves and sends Telegram user info for the client.
|
|
||||||
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
|
|
||||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
msg := t.I18nBot("tgbot.wentWrong")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if client == nil {
|
|
||||||
msg := t.I18nBot("tgbot.noResult")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tgId := "None"
|
|
||||||
if client.TgID != 0 {
|
|
||||||
tgId = strconv.FormatInt(client.TgID, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := ""
|
|
||||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
|
||||||
output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId)
|
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
|
||||||
requestUser := telego.KeyboardButtonRequestUsers{
|
|
||||||
RequestID: int32(traffic.Id),
|
|
||||||
UserIsBot: new(bool),
|
|
||||||
}
|
|
||||||
keyboard := tu.Keyboard(
|
|
||||||
tu.KeyboardRow(
|
|
||||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser),
|
|
||||||
),
|
|
||||||
tu.KeyboardRow(
|
|
||||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")),
|
|
||||||
),
|
|
||||||
).WithIsPersistent().WithResizeKeyboard()
|
|
||||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchClient searches for a client by email and sends the information.
|
|
||||||
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
|
||||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
msg := t.I18nBot("tgbot.wentWrong")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if traffic == nil {
|
|
||||||
msg := t.I18nBot("tgbot.noResult")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
|
||||||
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCommonClientButtons returns the shared inline keyboard rows for the
|
|
||||||
// client-first multi-inbound add flow. Per-protocol secrets (UUID, password,
|
|
||||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
|
||||||
// only exposes the universal client fields here.
|
|
||||||
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
|
|
||||||
attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs))
|
|
||||||
return [][]telego.InlineKeyboardButton{
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// addClient renders the draft message + shared client-first keyboard.
|
|
||||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
|
||||||
inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...)
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestDeleteMessageAfterDelayKeepsUserState(t *testing.T) {
|
|
||||||
userStateMgr.reset()
|
|
||||||
t.Cleanup(userStateMgr.reset)
|
|
||||||
|
|
||||||
const chatID = int64(4242)
|
|
||||||
userStateMgr.set(chatID, "awaiting_comment")
|
|
||||||
|
|
||||||
tg := &Tgbot{}
|
|
||||||
tg.deleteMessageAfterDelay(chatID, 1, 0)
|
|
||||||
|
|
||||||
if st, ok := userStateMgr.get(chatID); !ok || st != "awaiting_comment" {
|
|
||||||
t.Fatalf("delayed message deletion cleared the conversation state: got (%q, %v), want (%q, true)", st, ok, "awaiting_comment")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
||||||
)
|
|
||||||
|
|
||||||
var cachedHostname string
|
|
||||||
|
|
||||||
func getHostname() string {
|
|
||||||
if cachedHostname != "" {
|
|
||||||
return cachedHostname
|
|
||||||
}
|
|
||||||
h, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
cachedHostname = "unknown"
|
|
||||||
} else {
|
|
||||||
cachedHostname = h
|
|
||||||
}
|
|
||||||
return cachedHostname
|
|
||||||
}
|
|
||||||
|
|
||||||
var tgEventLimiter = eventbus.NewRateLimiter(1 * time.Minute)
|
|
||||||
|
|
||||||
// HandleEvent is the eventbus subscriber callback. It formats incoming events
|
|
||||||
// as Telegram messages and sends them to all admin chats.
|
|
||||||
func (t *Tgbot) HandleEvent(e eventbus.Event) {
|
|
||||||
if !t.isEventEnabled(e.Type) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if e.Type != eventbus.EventLoginAttempt {
|
|
||||||
if !tgEventLimiter.Allow(e.Type, e.Source) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
msg := t.formatEventMessage(e)
|
|
||||||
if msg != "" {
|
|
||||||
t.SendMsgToTgbotAdmins(msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tgbot) isEventEnabled(eventType eventbus.EventType) bool {
|
|
||||||
events, err := t.settingService.GetTgEnabledEvents()
|
|
||||||
if err != nil || events == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for e := range strings.SplitSeq(events, ",") {
|
|
||||||
if strings.TrimSpace(e) == string(eventType) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tgbot) formatEventMessage(e eventbus.Event) string {
|
|
||||||
host := getHostname()
|
|
||||||
header := fmt.Sprintf("<b>📡 %s</b>\n", host)
|
|
||||||
|
|
||||||
switch e.Type {
|
|
||||||
case eventbus.EventOutboundDown:
|
|
||||||
msg := header + t.I18nBot("tgbot.messages.eventOutboundDown",
|
|
||||||
"Tag=="+e.Source)
|
|
||||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok {
|
|
||||||
if data.Error != "" {
|
|
||||||
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail",
|
|
||||||
"Error=="+data.Error)
|
|
||||||
}
|
|
||||||
if data.Delay > 0 {
|
|
||||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
|
|
||||||
"Delay=="+fmt.Sprintf("%d", data.Delay))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
|
|
||||||
case eventbus.EventOutboundUp:
|
|
||||||
msg := header + t.I18nBot("tgbot.messages.eventOutboundUp",
|
|
||||||
"Tag=="+e.Source)
|
|
||||||
if data, ok := e.Data.(*eventbus.OutboundHealthData); ok && data.Delay > 0 {
|
|
||||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail",
|
|
||||||
"Delay=="+fmt.Sprintf("%d", data.Delay))
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
|
|
||||||
case eventbus.EventXrayCrash:
|
|
||||||
errStr := ""
|
|
||||||
if e.Data != nil {
|
|
||||||
errStr = fmt.Sprint(e.Data)
|
|
||||||
}
|
|
||||||
msg := header + "🔥 " + t.I18nBot("tgbot.messages.eventXrayCrash")
|
|
||||||
if errStr != "" {
|
|
||||||
msg += "\n" + t.I18nBot("tgbot.messages.eventXrayCrashError", "Error=="+errStr)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
|
|
||||||
case eventbus.EventNodeDown:
|
|
||||||
msg := header + "🔴 " + t.I18nBot("tgbot.messages.eventNodeDown", "Name=="+e.Source)
|
|
||||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.XrayError != "" {
|
|
||||||
msg += "\n" + t.I18nBot("tgbot.messages.eventErrorDetail", "Error=="+data.XrayError)
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
|
|
||||||
case eventbus.EventNodeUp:
|
|
||||||
msg := header + "🟢 " + t.I18nBot("tgbot.messages.eventNodeUp", "Name=="+e.Source)
|
|
||||||
if data, ok := e.Data.(*eventbus.NodeHealthData); ok && data.LatencyMs > 0 {
|
|
||||||
msg += "\n" + t.I18nBot("tgbot.messages.eventDelayDetail", "Delay=="+fmt.Sprintf("%d", data.LatencyMs))
|
|
||||||
}
|
|
||||||
return msg
|
|
||||||
|
|
||||||
case eventbus.EventCPUHigh:
|
|
||||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
|
||||||
tgCpu, err := t.settingService.GetTgCpu()
|
|
||||||
if err != nil || tgCpu <= 0 || data.Percent <= float64(tgCpu) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return header + "🔴 " + t.I18nBot("tgbot.messages.cpuThreshold",
|
|
||||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
|
||||||
"Threshold=="+strconv.Itoa(tgCpu))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
|
|
||||||
case eventbus.EventMemoryHigh:
|
|
||||||
if data, ok := e.Data.(*eventbus.SystemMetricData); ok {
|
|
||||||
tgMemory, err := t.settingService.GetTgMemory()
|
|
||||||
if err != nil || tgMemory <= 0 || data.Percent <= float64(tgMemory) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return header + "🔴 " + t.I18nBot("tgbot.messages.memoryThreshold",
|
|
||||||
"Percent=="+strconv.FormatFloat(data.Percent, 'f', 2, 64),
|
|
||||||
"Threshold=="+strconv.Itoa(tgMemory))
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
|
|
||||||
case eventbus.EventLoginAttempt:
|
|
||||||
if data, ok := e.Data.(*eventbus.LoginEventData); ok {
|
|
||||||
if data.Status == "success" {
|
|
||||||
msg := t.I18nBot("tgbot.messages.loginSuccess")
|
|
||||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
|
|
||||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
|
|
||||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
|
|
||||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
msg := t.I18nBot("tgbot.messages.loginFailed")
|
|
||||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+host)
|
|
||||||
if data.Reason != "" {
|
|
||||||
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+data.Reason)
|
|
||||||
}
|
|
||||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+data.Username)
|
|
||||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+data.IP)
|
|
||||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+data.Time)
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
return header + t.I18nBot("tgbot.messages.eventLoginFallback", "Source=="+e.Source)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
// getInboundUsages retrieves and formats inbound usage information.
|
|
||||||
func (t *Tgbot) getInboundUsages() string {
|
|
||||||
var info strings.Builder
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
|
||||||
info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
return info.String()
|
|
||||||
}
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
|
|
||||||
info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
|
|
||||||
info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
|
|
||||||
|
|
||||||
clients, listErr := t.clientService.ListForInbound(nil, inbound.Id)
|
|
||||||
if listErr == nil {
|
|
||||||
fmt.Fprintf(&info, "👥 Clients: %d\r\n", len(clients))
|
|
||||||
}
|
|
||||||
|
|
||||||
if inbound.ExpiryTime == 0 {
|
|
||||||
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
|
|
||||||
} else {
|
|
||||||
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
|
|
||||||
}
|
|
||||||
info.WriteString("\r\n")
|
|
||||||
}
|
|
||||||
return info.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInbounds creates an inline keyboard with all inbounds.
|
|
||||||
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
|
||||||
logger.Warning("No inbounds found")
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
status := "❌"
|
|
||||||
if inbound.Enable {
|
|
||||||
status = "✅"
|
|
||||||
}
|
|
||||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id))
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
|
||||||
}
|
|
||||||
|
|
||||||
cols := 1
|
|
||||||
if len(buttons) >= 6 {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
||||||
return keyboard, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
|
|
||||||
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
|
||||||
logger.Warning("No inbounds found")
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
status := "❌"
|
|
||||||
if inbound.Enable {
|
|
||||||
status = "✅"
|
|
||||||
}
|
|
||||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
|
||||||
}
|
|
||||||
|
|
||||||
cols := 1
|
|
||||||
if len(buttons) >= 6 {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
||||||
return keyboard, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
|
|
||||||
func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) {
|
|
||||||
inbound, err := t.inboundService.GetInbound(inboundID)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("getInboundClientsFor run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
clients, err := t.inboundService.GetClients(inbound)
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetInboundClients run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
} else {
|
|
||||||
if len(clients) > 0 {
|
|
||||||
for _, client := range clients {
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cols := 0
|
|
||||||
if len(buttons) < 6 {
|
|
||||||
cols = 3
|
|
||||||
} else {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
||||||
|
|
||||||
return keyboard, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
|
|
||||||
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
|
||||||
logger.Warning("No inbounds found")
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
excludedProtocols := map[model.Protocol]bool{
|
|
||||||
model.Tunnel: true,
|
|
||||||
model.Mixed: true,
|
|
||||||
model.WireGuard: true,
|
|
||||||
model.AmneziaWG: true,
|
|
||||||
model.HTTP: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
if excludedProtocols[inbound.Protocol] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "❌"
|
|
||||||
if inbound.Enable {
|
|
||||||
status = "✅"
|
|
||||||
}
|
|
||||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id))
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
|
||||||
}
|
|
||||||
|
|
||||||
cols := 1
|
|
||||||
if len(buttons) >= 6 {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
||||||
return keyboard, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInboundsAttachPicker builds a toggle picker over multi-client inbounds
|
|
||||||
// for the "attach more inbounds to the new client" step. Each row shows the
|
|
||||||
// current selection state for the inbound; tapping fires
|
|
||||||
// add_client_toggle_attach <id> which flips it and re-renders. A final
|
|
||||||
// "Done" button (add_client_attach_done) returns to the field-edit screen.
|
|
||||||
func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) {
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetAllInbounds run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
if len(inbounds) == 0 {
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
excludedProtocols := map[model.Protocol]bool{
|
|
||||||
model.Tunnel: true,
|
|
||||||
model.Mixed: true,
|
|
||||||
model.WireGuard: true,
|
|
||||||
model.AmneziaWG: true,
|
|
||||||
model.HTTP: true,
|
|
||||||
}
|
|
||||||
selected := make(map[int]bool, len(receiver_inbound_IDs))
|
|
||||||
for _, id := range receiver_inbound_IDs {
|
|
||||||
selected[id] = true
|
|
||||||
}
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
for _, ib := range inbounds {
|
|
||||||
if excludedProtocols[ib.Protocol] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mark := "☐"
|
|
||||||
if selected[ib.Id] {
|
|
||||||
mark = "✅"
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol)
|
|
||||||
callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id))
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback))
|
|
||||||
}
|
|
||||||
cols := 1
|
|
||||||
if len(buttons) >= 6 {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
rows := tu.InlineKeyboardCols(cols, buttons...)
|
|
||||||
rows = append(rows, tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")),
|
|
||||||
))
|
|
||||||
return tu.InlineKeyboardGrid(rows), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getInboundClients creates an inline keyboard with clients of a specific inbound.
|
|
||||||
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
|
||||||
inbound, err := t.inboundService.GetInbound(id)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("getIboundClients run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
}
|
|
||||||
clients, err := t.inboundService.GetClients(inbound)
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("GetInboundClients run failed:", err)
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
|
||||||
} else {
|
|
||||||
if len(clients) > 0 {
|
|
||||||
for _, client := range clients {
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cols := 0
|
|
||||||
if len(buttons) < 6 {
|
|
||||||
cols = 3
|
|
||||||
} else {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
||||||
|
|
||||||
return keyboard, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// searchInbound searches for inbounds by remark and sends the results.
|
|
||||||
func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
|
||||||
inbounds, err := t.inboundService.SearchInbounds(remark)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
msg := t.I18nBot("tgbot.wentWrong")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(inbounds) == 0 {
|
|
||||||
msg := t.I18nBot("tgbot.noInbounds")
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
info := ""
|
|
||||||
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
|
||||||
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
|
||||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
|
||||||
|
|
||||||
if inbound.ExpiryTime == 0 {
|
|
||||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
|
||||||
} else {
|
|
||||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
|
||||||
t.SendMsgToTgbot(chatId, info)
|
|
||||||
|
|
||||||
if len(inbound.ClientStats) > 0 {
|
|
||||||
var output strings.Builder
|
|
||||||
for _, traffic := range inbound.ClientStats {
|
|
||||||
output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
|
|
||||||
}
|
|
||||||
t.SendMsgToTgbot(chatId, output.String())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,509 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"os"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SendReport sends a periodic report to admin chats.
|
|
||||||
func (t *Tgbot) SendReport() {
|
|
||||||
runTime, err := t.settingService.GetTgbotRuntime()
|
|
||||||
if err == nil && len(runTime) > 0 {
|
|
||||||
msg := ""
|
|
||||||
msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime)
|
|
||||||
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
t.SendMsgToTgbotAdmins(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
info := t.sendServerUsage()
|
|
||||||
t.SendMsgToTgbotAdmins(info)
|
|
||||||
|
|
||||||
t.sendExhaustedToAdmins()
|
|
||||||
t.notifyExhausted()
|
|
||||||
|
|
||||||
backupEnable, err := t.settingService.GetTgBotBackup()
|
|
||||||
if err == nil && backupEnable {
|
|
||||||
t.SendBackupToAdmins()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendBackupToAdmins sends a database backup to admin chats.
|
|
||||||
func (t *Tgbot) SendBackupToAdmins() {
|
|
||||||
if !t.IsRunning() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dbData, err := t.serverService.GetDb()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error in getting db backup: ", err)
|
|
||||||
}
|
|
||||||
dbFilename := t.serverService.BackupFilename("")
|
|
||||||
for i, adminId := range adminIds {
|
|
||||||
t.sendBackupData(adminId, dbData, dbFilename)
|
|
||||||
// Add delay between sends to avoid Telegram rate limits
|
|
||||||
if i < len(adminIds)-1 {
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
|
|
||||||
func (t *Tgbot) sendExhaustedToAdmins() {
|
|
||||||
if !t.IsRunning() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, adminId := range adminIds {
|
|
||||||
t.getExhausted(adminId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// getServerUsage retrieves and formats server usage information.
|
|
||||||
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
|
|
||||||
info := t.prepareServerUsageInfo()
|
|
||||||
|
|
||||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh"))))
|
|
||||||
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], info, keyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, info, keyboard)
|
|
||||||
}
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send server usage without an inline keyboard
|
|
||||||
func (t *Tgbot) sendServerUsage() string {
|
|
||||||
info := t.prepareServerUsageInfo()
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepareServerUsageInfo prepares the server usage information string.
|
|
||||||
func (t *Tgbot) prepareServerUsageInfo() string {
|
|
||||||
// Check if we have cached data first
|
|
||||||
if cachedStats, found := t.getCachedServerStats(); found {
|
|
||||||
return cachedStats
|
|
||||||
}
|
|
||||||
|
|
||||||
info, ipv4, ipv6 := "", "", ""
|
|
||||||
|
|
||||||
// get latest status of server with caching
|
|
||||||
if cachedStatus, found := t.getCachedStatus(); found {
|
|
||||||
t.lastStatus = cachedStatus
|
|
||||||
} else {
|
|
||||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
|
||||||
t.setCachedStatus(t.lastStatus)
|
|
||||||
}
|
|
||||||
var onlines []string
|
|
||||||
if process := service.XrayProcess(); process != nil {
|
|
||||||
onlines = process.GetOnlineClients()
|
|
||||||
}
|
|
||||||
|
|
||||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
|
||||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetPanelVersion())
|
|
||||||
info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version))
|
|
||||||
|
|
||||||
// get ip address
|
|
||||||
netInterfaces, err := net.Interfaces()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("net.Interfaces failed, err: ", err.Error())
|
|
||||||
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
|
||||||
info += "\r\n"
|
|
||||||
} else {
|
|
||||||
for i := range netInterfaces {
|
|
||||||
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
|
||||||
addrs, _ := netInterfaces[i].Addrs()
|
|
||||||
|
|
||||||
for _, address := range addrs {
|
|
||||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
|
||||||
if ipnet.IP.To4() != nil {
|
|
||||||
ipv4 += ipnet.IP.String() + " "
|
|
||||||
} else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() {
|
|
||||||
ipv6 += ipnet.IP.String() + " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info += t.I18nBot("tgbot.messages.ipv4", "IPv4=="+ipv4)
|
|
||||||
info += t.I18nBot("tgbot.messages.ipv6", "IPv6=="+ipv6)
|
|
||||||
}
|
|
||||||
|
|
||||||
info += t.I18nBot("tgbot.messages.serverUpTime", "UpTime=="+strconv.FormatUint(t.lastStatus.Uptime/86400, 10), "Unit=="+t.I18nBot("tgbot.days"))
|
|
||||||
info += t.I18nBot("tgbot.messages.serverLoad", "Load1=="+strconv.FormatFloat(t.lastStatus.Loads[0], 'f', 2, 64), "Load2=="+strconv.FormatFloat(t.lastStatus.Loads[1], 'f', 2, 64), "Load3=="+strconv.FormatFloat(t.lastStatus.Loads[2], 'f', 2, 64))
|
|
||||||
info += t.I18nBot("tgbot.messages.serverMemory", "Current=="+common.FormatTraffic(int64(t.lastStatus.Mem.Current)), "Total=="+common.FormatTraffic(int64(t.lastStatus.Mem.Total)))
|
|
||||||
info += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(len(onlines)))
|
|
||||||
info += t.I18nBot("tgbot.messages.tcpCount", "Count=="+strconv.Itoa(t.lastStatus.TcpCount))
|
|
||||||
info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount))
|
|
||||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
|
|
||||||
info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State))
|
|
||||||
|
|
||||||
// Cache the complete server stats
|
|
||||||
t.setCachedServerStats(info)
|
|
||||||
|
|
||||||
return info
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserLoginNotify publishes a login event to the event bus.
|
|
||||||
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
|
|
||||||
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
|
|
||||||
logger.Warning("UserLoginNotify failed, invalid info!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if EventBus == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "fail"
|
|
||||||
if attempt.Status == LoginSuccess {
|
|
||||||
status = "success"
|
|
||||||
}
|
|
||||||
|
|
||||||
EventBus.Publish(eventbus.Event{
|
|
||||||
Type: eventbus.EventLoginAttempt,
|
|
||||||
Source: attempt.IP,
|
|
||||||
Data: &eventbus.LoginEventData{
|
|
||||||
Username: attempt.Username,
|
|
||||||
IP: attempt.IP,
|
|
||||||
Time: attempt.Time,
|
|
||||||
Status: status,
|
|
||||||
Reason: attempt.Reason,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// getExhausted retrieves and sends information about exhausted clients.
|
|
||||||
func (t *Tgbot) getExhausted(chatId int64) {
|
|
||||||
trDiff := int64(0)
|
|
||||||
exDiff := int64(0)
|
|
||||||
now := time.Now().Unix() * 1000
|
|
||||||
var exhaustedInbounds []model.Inbound
|
|
||||||
var exhaustedClients []xray.ClientTraffic
|
|
||||||
var disabledInbounds []model.Inbound
|
|
||||||
var disabledClients []xray.ClientTraffic
|
|
||||||
|
|
||||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
|
||||||
if err == nil && TrafficThreshold > 0 {
|
|
||||||
trDiff = int64(TrafficThreshold) * 1073741824
|
|
||||||
}
|
|
||||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
|
||||||
if err == nil && ExpireThreshold > 0 {
|
|
||||||
exDiff = int64(ExpireThreshold) * 86400000
|
|
||||||
}
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Unable to load Inbounds", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
seenClients := make(map[string]bool)
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
if inbound.Enable {
|
|
||||||
if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
|
|
||||||
(inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) {
|
|
||||||
exhaustedInbounds = append(exhaustedInbounds, *inbound)
|
|
||||||
}
|
|
||||||
if len(inbound.ClientStats) > 0 {
|
|
||||||
for _, client := range inbound.ClientStats {
|
|
||||||
if seenClients[client.Email] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seenClients[client.Email] = true
|
|
||||||
if client.Enable {
|
|
||||||
if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
|
|
||||||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
|
|
||||||
exhaustedClients = append(exhaustedClients, client)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
disabledClients = append(disabledClients, client)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
disabledInbounds = append(disabledInbounds, *inbound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inbounds
|
|
||||||
output := ""
|
|
||||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
|
|
||||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds)))
|
|
||||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds)))
|
|
||||||
|
|
||||||
if len(exhaustedInbounds) > 0 {
|
|
||||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds"))
|
|
||||||
|
|
||||||
for _, inbound := range exhaustedInbounds {
|
|
||||||
output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
|
||||||
output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
|
||||||
output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
|
||||||
if inbound.ExpiryTime == 0 {
|
|
||||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
|
||||||
} else {
|
|
||||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
|
||||||
}
|
|
||||||
output += "\r\n"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clients
|
|
||||||
exhaustedCC := len(exhaustedClients)
|
|
||||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
|
|
||||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))
|
|
||||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC))
|
|
||||||
|
|
||||||
if exhaustedCC > 0 {
|
|
||||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients"))
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
for _, traffic := range exhaustedClients {
|
|
||||||
output += t.clientInfoMsg(&traffic, true, false, false, true, true, false)
|
|
||||||
output += "\r\n"
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email)))
|
|
||||||
}
|
|
||||||
cols := 0
|
|
||||||
if exhaustedCC < 11 {
|
|
||||||
cols = 1
|
|
||||||
} else {
|
|
||||||
cols = 2
|
|
||||||
}
|
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
|
||||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
|
||||||
} else {
|
|
||||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
t.SendMsgToTgbot(chatId, output)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// notifyExhausted sends notifications for exhausted clients.
|
|
||||||
func (t *Tgbot) notifyExhausted() {
|
|
||||||
trDiff := int64(0)
|
|
||||||
exDiff := int64(0)
|
|
||||||
now := time.Now().Unix() * 1000
|
|
||||||
|
|
||||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
|
||||||
if err == nil && TrafficThreshold > 0 {
|
|
||||||
trDiff = int64(TrafficThreshold) * 1073741824
|
|
||||||
}
|
|
||||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
|
||||||
if err == nil && ExpireThreshold > 0 {
|
|
||||||
exDiff = int64(ExpireThreshold) * 86400000
|
|
||||||
}
|
|
||||||
inbounds, err := t.inboundService.GetAllInbounds()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Unable to load Inbounds", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var chatIDsDone []int64
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
if inbound.Enable {
|
|
||||||
if len(inbound.ClientStats) > 0 {
|
|
||||||
clients, err := t.inboundService.GetClients(inbound)
|
|
||||||
if err == nil {
|
|
||||||
for _, client := range clients {
|
|
||||||
if client.TgID != 0 {
|
|
||||||
chatID := client.TgID
|
|
||||||
if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) {
|
|
||||||
var disabledClients []xray.ClientTraffic
|
|
||||||
var exhaustedClients []xray.ClientTraffic
|
|
||||||
traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID)
|
|
||||||
if err == nil && len(traffics) > 0 {
|
|
||||||
var output strings.Builder
|
|
||||||
output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")))
|
|
||||||
for _, traffic := range traffics {
|
|
||||||
if traffic.Enable {
|
|
||||||
if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) ||
|
|
||||||
(traffic.Total > 0 && (traffic.Total-(traffic.Up+traffic.Down) < trDiff)) {
|
|
||||||
exhaustedClients = append(exhaustedClients, *traffic)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
disabledClients = append(disabledClients, *traffic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(exhaustedClients) > 0 {
|
|
||||||
output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))))
|
|
||||||
if len(disabledClients) > 0 {
|
|
||||||
output.WriteString(t.I18nBot("tgbot.clients"))
|
|
||||||
output.WriteString(":\r\n")
|
|
||||||
for _, traffic := range disabledClients {
|
|
||||||
output.WriteString(" ")
|
|
||||||
output.WriteString(traffic.Email)
|
|
||||||
}
|
|
||||||
output.WriteString("\r\n")
|
|
||||||
}
|
|
||||||
output.WriteString("\r\n")
|
|
||||||
output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients))))
|
|
||||||
for _, traffic := range exhaustedClients {
|
|
||||||
output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false))
|
|
||||||
output.WriteString("\r\n")
|
|
||||||
}
|
|
||||||
t.SendMsgToTgbot(chatID, output.String())
|
|
||||||
}
|
|
||||||
chatIDsDone = append(chatIDsDone, chatID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlineClients retrieves and sends information about online clients.
|
|
||||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
|
||||||
process := service.XrayProcess()
|
|
||||||
if process == nil || !process.IsRunning() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
onlines := process.GetOnlineClients()
|
|
||||||
onlinesCount := len(onlines)
|
|
||||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
|
||||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh"))))
|
|
||||||
|
|
||||||
if onlinesCount > 0 {
|
|
||||||
var buttons []telego.InlineKeyboardButton
|
|
||||||
for _, online := range onlines {
|
|
||||||
label := online
|
|
||||||
if _, inbound, err := t.inboundService.GetClientInboundByEmail(online); err == nil && inbound != nil && inbound.Remark != "" {
|
|
||||||
label = online + " - " + inbound.Remark
|
|
||||||
}
|
|
||||||
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
|
|
||||||
}
|
|
||||||
cols := 0
|
|
||||||
if onlinesCount < 21 {
|
|
||||||
cols = 2
|
|
||||||
} else if onlinesCount < 61 {
|
|
||||||
cols = 3
|
|
||||||
} else {
|
|
||||||
cols = 4
|
|
||||||
}
|
|
||||||
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(messageID) > 0 {
|
|
||||||
t.editMessageTgBot(chatId, messageID[0], output, keyboard)
|
|
||||||
} else {
|
|
||||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendBackup sends a backup of the database and configuration files.
|
|
||||||
func (t *Tgbot) sendBackup(chatId int64) {
|
|
||||||
dbData, err := t.serverService.GetDb()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error in getting db backup: ", err)
|
|
||||||
}
|
|
||||||
t.sendBackupData(chatId, dbData, t.serverService.BackupFilename(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t *Tgbot) sendBackupData(chatId int64, dbData []byte, dbFilename string) {
|
|
||||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
|
||||||
output += t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
t.SendMsgToTgbot(chatId, output)
|
|
||||||
|
|
||||||
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
|
|
||||||
if dbData != nil {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
document := tu.Document(
|
|
||||||
tu.ID(chatId),
|
|
||||||
tu.FileFromBytes(dbData, dbFilename),
|
|
||||||
)
|
|
||||||
_, err := bot.SendDocument(ctx, document)
|
|
||||||
cancel()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error in uploading backup: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay between file sends
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
|
|
||||||
// Send config.json backup
|
|
||||||
file, err := os.Open(xray.GetConfigPath())
|
|
||||||
if err == nil {
|
|
||||||
defer file.Close()
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
document := tu.Document(
|
|
||||||
tu.ID(chatId),
|
|
||||||
tu.File(file),
|
|
||||||
)
|
|
||||||
_, err = bot.SendDocument(ctx, document)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error in uploading config.json: ", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Error("Error in opening config.json file for backup: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendBanLogs sends the ban logs to the specified chat.
|
|
||||||
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
|
||||||
if dt {
|
|
||||||
output := t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
|
||||||
output += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
|
||||||
t.SendMsgToTgbot(chatId, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
|
|
||||||
if err == nil {
|
|
||||||
// Check if the file is non-empty before attempting to upload
|
|
||||||
fileInfo, _ := file.Stat()
|
|
||||||
if fileInfo.Size() > 0 {
|
|
||||||
document := tu.Document(
|
|
||||||
tu.ID(chatId),
|
|
||||||
tu.File(file),
|
|
||||||
)
|
|
||||||
_, err = bot.SendDocument(context.Background(), document)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.")
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
} else {
|
|
||||||
logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err = os.Open(xray.GetIPLimitBannedLogPath())
|
|
||||||
if err == nil {
|
|
||||||
// Check if the file is non-empty before attempting to upload
|
|
||||||
fileInfo, _ := file.Stat()
|
|
||||||
if fileInfo.Size() > 0 {
|
|
||||||
document := tu.Document(
|
|
||||||
tu.ID(chatId),
|
|
||||||
tu.File(file),
|
|
||||||
)
|
|
||||||
_, err = bot.SendDocument(context.Background(), document)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Error in uploading IPLimitBannedLog: ", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.Warning("IPLimitBannedLog file is empty, not uploading.")
|
|
||||||
}
|
|
||||||
file.Close()
|
|
||||||
} else {
|
|
||||||
logger.Error("Error in opening IPLimitBannedLog file for backup: ", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,296 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
tu "github.com/mymmrac/telego/telegoutil"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sendResponse sends the response message based on the onlyMessage flag.
|
|
||||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
|
||||||
if onlyMessage {
|
|
||||||
t.SendMsgToTgbot(chatId, msg)
|
|
||||||
} else {
|
|
||||||
t.SendAnswer(chatId, msg, isAdmin)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendAnswer sends a response message with an inline keyboard to the specified chat.
|
|
||||||
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
|
||||||
numericKeyboard := tu.InlineKeyboard(
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
|
|
||||||
),
|
|
||||||
// TODOOOOOOOOOOOOOO: Add restart button here.
|
|
||||||
)
|
|
||||||
numericKeyboardClient := tu.InlineKeyboard(
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
|
|
||||||
),
|
|
||||||
tu.InlineKeyboardRow(
|
|
||||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
var ReplyMarkup telego.ReplyMarkup
|
|
||||||
if isAdmin {
|
|
||||||
ReplyMarkup = numericKeyboard
|
|
||||||
} else {
|
|
||||||
ReplyMarkup = numericKeyboardClient
|
|
||||||
}
|
|
||||||
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
|
|
||||||
}
|
|
||||||
|
|
||||||
const telegramPageLimit = 2000
|
|
||||||
|
|
||||||
func pageMessage(message string, limit int) []string {
|
|
||||||
if len(message) <= limit {
|
|
||||||
return []string{message}
|
|
||||||
}
|
|
||||||
|
|
||||||
pages := make([]string, 0)
|
|
||||||
for _, block := range strings.Split(message, "\r\n\r\n") {
|
|
||||||
for _, page := range splitMessageLines(block, limit) {
|
|
||||||
last := len(pages) - 1
|
|
||||||
if last >= 0 && len(pages[last])+len("\r\n\r\n")+len(page) <= limit {
|
|
||||||
pages[last] += "\r\n\r\n" + page
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pages = append(pages, page)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(pages) > 0 && strings.TrimSpace(pages[len(pages)-1]) == "" {
|
|
||||||
pages = pages[:len(pages)-1]
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitMessageLines(block string, limit int) []string {
|
|
||||||
if len(block) <= limit {
|
|
||||||
return []string{block}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(block, "\r\n")
|
|
||||||
pages := []string{lines[0]}
|
|
||||||
for _, line := range lines[1:] {
|
|
||||||
last := len(pages) - 1
|
|
||||||
if len(pages[last])+len("\r\n")+len(line) > limit {
|
|
||||||
pages = append(pages, line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pages[last] += "\r\n" + line
|
|
||||||
}
|
|
||||||
return pages
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
|
|
||||||
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
|
||||||
if !isRunning {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg == "" {
|
|
||||||
logger.Info("[tgbot] message is empty!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
allMessages := pageMessage(msg, telegramPageLimit)
|
|
||||||
for n, message := range allMessages {
|
|
||||||
params := telego.SendMessageParams{
|
|
||||||
ChatID: tu.ID(chatId),
|
|
||||||
Text: message,
|
|
||||||
ParseMode: "HTML",
|
|
||||||
}
|
|
||||||
// only add replyMarkup to last message
|
|
||||||
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
|
|
||||||
params.ReplyMarkup = replyMarkup[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retry logic with exponential backoff for connection errors
|
|
||||||
maxRetries := 3
|
|
||||||
for attempt := range maxRetries {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
_, err := bot.SendMessage(ctx, ¶ms)
|
|
||||||
cancel()
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
break // Success
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if error is a connection error
|
|
||||||
errStr := err.Error()
|
|
||||||
isConnectionError := strings.Contains(errStr, "connection") ||
|
|
||||||
strings.Contains(errStr, "timeout") ||
|
|
||||||
strings.Contains(errStr, "closed")
|
|
||||||
|
|
||||||
if isConnectionError && attempt < maxRetries-1 {
|
|
||||||
// Exponential backoff: 1s, 2s, 4s
|
|
||||||
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
|
||||||
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
|
|
||||||
attempt+1, maxRetries, backoff, err)
|
|
||||||
time.Sleep(backoff)
|
|
||||||
} else {
|
|
||||||
logger.Warning("Error sending telegram message:", err)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reduced delay to improve performance (only needed for rate limiting)
|
|
||||||
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
|
|
||||||
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
|
||||||
if len(replyMarkup) > 0 {
|
|
||||||
for _, adminId := range adminIds {
|
|
||||||
t.SendMsgToTgbot(adminId, msg, replyMarkup[0])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, adminId := range adminIds {
|
|
||||||
t.SendMsgToTgbot(adminId, msg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sendCallbackAnswerTgBot answers a callback query with a message.
|
|
||||||
func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
|
||||||
params := telego.AnswerCallbackQueryParams{
|
|
||||||
CallbackQueryID: id,
|
|
||||||
Text: message,
|
|
||||||
}
|
|
||||||
if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// editMessageCallbackTgBot edits the reply markup of a message.
|
|
||||||
func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) {
|
|
||||||
params := telego.EditMessageReplyMarkupParams{
|
|
||||||
ChatID: tu.ID(chatId),
|
|
||||||
MessageID: messageID,
|
|
||||||
ReplyMarkup: inlineKeyboard,
|
|
||||||
}
|
|
||||||
if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// editMessageTgBot edits the text and reply markup of a message.
|
|
||||||
func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) {
|
|
||||||
params := telego.EditMessageTextParams{
|
|
||||||
ChatID: tu.ID(chatId),
|
|
||||||
MessageID: messageID,
|
|
||||||
Text: text,
|
|
||||||
ParseMode: "HTML",
|
|
||||||
}
|
|
||||||
if len(inlineKeyboard) > 0 {
|
|
||||||
params.ReplyMarkup = inlineKeyboard[0]
|
|
||||||
}
|
|
||||||
if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil {
|
|
||||||
logger.Warning(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
|
|
||||||
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
|
|
||||||
// Determine if replyMarkup was passed; otherwise, set it to nil
|
|
||||||
var replyMarkupParam telego.ReplyMarkup
|
|
||||||
if len(replyMarkup) > 0 {
|
|
||||||
replyMarkupParam = replyMarkup[0] // Use the first element
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the message
|
|
||||||
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
|
|
||||||
ChatID: tu.ID(chatId),
|
|
||||||
Text: msg,
|
|
||||||
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("Failed to send message:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the sent message after the specified number of seconds.
|
|
||||||
go t.deleteMessageAfterDelay(chatId, sentMsg.MessageID, delayInSeconds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteMessageAfterDelay waits delayInSeconds and then removes the message. It
|
|
||||||
// deliberately does not touch the conversation state: every caller that ends a
|
|
||||||
// wizard step already clears the state synchronously, and clearing it here — up
|
|
||||||
// to several seconds later — would wipe a state the user set for the next step
|
|
||||||
// in the meantime, silently dropping their following input.
|
|
||||||
func (t *Tgbot) deleteMessageAfterDelay(chatId int64, messageID, delayInSeconds int) {
|
|
||||||
time.Sleep(time.Duration(delayInSeconds) * time.Second)
|
|
||||||
t.deleteMessageTgBot(chatId, messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteMessageTgBot deletes a message from the chat.
|
|
||||||
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
|
||||||
if bot == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
params := telego.DeleteMessageParams{
|
|
||||||
ChatID: tu.ID(chatId),
|
|
||||||
MessageID: messageID,
|
|
||||||
}
|
|
||||||
if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil {
|
|
||||||
logger.Warning("Failed to delete message:", err)
|
|
||||||
} else {
|
|
||||||
logger.Info("Message deleted successfully")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnection verifies the bot token is valid and the API is reachable.
|
|
||||||
func (t *Tgbot) TestConnection() error {
|
|
||||||
tgBotMutex.Lock()
|
|
||||||
b := bot
|
|
||||||
tgBotMutex.Unlock()
|
|
||||||
if b == nil {
|
|
||||||
return fmt.Errorf("bot not initialized")
|
|
||||||
}
|
|
||||||
me, err := b.GetMe(context.Background())
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("API unreachable: %w", err)
|
|
||||||
}
|
|
||||||
_ = me
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestPageMessageSplitsLinkListWithoutBlankLines(t *testing.T) {
|
|
||||||
var message strings.Builder
|
|
||||||
message.WriteString("Individual links:\r\n")
|
|
||||||
for range 50 {
|
|
||||||
message.WriteString("<code>vless://" + strings.Repeat("a", 300) + "</code>\r\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
pages := pageMessage(message.String(), telegramPageLimit)
|
|
||||||
if len(pages) < 2 {
|
|
||||||
t.Fatalf("pageMessage() returned %d page, want multiple", len(pages))
|
|
||||||
}
|
|
||||||
|
|
||||||
links := 0
|
|
||||||
for index, page := range pages {
|
|
||||||
if len(page) > telegramPageLimit {
|
|
||||||
t.Errorf("page %d has %d bytes, want at most %d", index, len(page), telegramPageLimit)
|
|
||||||
}
|
|
||||||
openingTags := strings.Count(page, "<code>")
|
|
||||||
closingTags := strings.Count(page, "</code>")
|
|
||||||
if openingTags != closingTags {
|
|
||||||
t.Errorf("page %d has %d opening tags and %d closing tags", index, openingTags, closingTags)
|
|
||||||
}
|
|
||||||
links += openingTags
|
|
||||||
}
|
|
||||||
if links != 50 {
|
|
||||||
t.Errorf("pages contain %d links, want 50", links)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
package tgbot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"reflect"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
|
||||||
typ := reflect.TypeFor[LoginAttempt]()
|
|
||||||
if _, ok := typ.FieldByName("Password"); ok {
|
|
||||||
t.Fatal("LoginAttempt must not carry attempted passwords")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsSupportedBotProxyScheme(t *testing.T) {
|
|
||||||
supported := []string{
|
|
||||||
"socks5://127.0.0.1:1080",
|
|
||||||
"http://127.0.0.1:8080",
|
|
||||||
"https://127.0.0.1:8080",
|
|
||||||
}
|
|
||||||
for _, p := range supported {
|
|
||||||
if !isSupportedBotProxyScheme(p) {
|
|
||||||
t.Errorf("expected %q to be supported", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"}
|
|
||||||
for _, p := range unsupported {
|
|
||||||
if isSupportedBotProxyScheme(p) {
|
|
||||||
t.Errorf("expected %q to be unsupported", p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) {
|
|
||||||
t.Helper()
|
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
got = make(chan []byte, 1)
|
|
||||||
t.Cleanup(func() { _ = ln.Close() })
|
|
||||||
go func() {
|
|
||||||
conn, err := ln.Accept()
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
|
||||||
buf := make([]byte, n)
|
|
||||||
m, _ := io.ReadFull(conn, buf)
|
|
||||||
got <- buf[:m]
|
|
||||||
}()
|
|
||||||
return ln.Addr().String(), got
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) {
|
|
||||||
addr, got := recordingDialTarget(t, len("CONNECT "))
|
|
||||||
tg := &Tgbot{}
|
|
||||||
client := tg.createRobustFastHTTPClient("http://" + addr)
|
|
||||||
if client.Dial == nil {
|
|
||||||
t.Fatal("Dial must be set for an http:// proxy")
|
|
||||||
}
|
|
||||||
go func() { _, _ = client.Dial("example.com:443") }()
|
|
||||||
select {
|
|
||||||
case b := <-got:
|
|
||||||
if string(b) != "CONNECT " {
|
|
||||||
t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b)
|
|
||||||
}
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("proxy never received a connection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) {
|
|
||||||
addr, got := recordingDialTarget(t, 1)
|
|
||||||
tg := &Tgbot{}
|
|
||||||
client := tg.createRobustFastHTTPClient("socks5://" + addr)
|
|
||||||
if client.Dial == nil {
|
|
||||||
t.Fatal("Dial must be set for a socks5:// proxy")
|
|
||||||
}
|
|
||||||
go func() { _, _ = client.Dial("example.com:443") }()
|
|
||||||
select {
|
|
||||||
case b := <-got:
|
|
||||||
if len(b) != 1 || b[0] != 0x05 {
|
|
||||||
t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b)
|
|
||||||
}
|
|
||||||
case <-time.After(3 * time.Second):
|
|
||||||
t.Fatal("proxy never received a connection")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
|
|
||||||
tg := &Tgbot{}
|
|
||||||
client := tg.createRobustFastHTTPClient("")
|
|
||||||
if client.Dial != nil {
|
|
||||||
t.Fatal("Dial must be nil when no proxy is configured")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
|
|
||||||
if !isCommandForBot("/status", "panel_bot") {
|
|
||||||
t.Fatal("untargeted commands must remain accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
|
|
||||||
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
|
|
||||||
t.Fatal("commands targeted to this bot must be accepted")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
|
|
||||||
if isCommandForBot("/status@other_bot", "panel_bot") {
|
|
||||||
t.Fatal("commands targeted to another bot must be ignored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
|
|
||||||
if !isCommandForBot("/status@panel_bot", "") {
|
|
||||||
t.Fatal("commands must remain accepted when the current bot username is unavailable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -77,7 +77,7 @@ func (s *XrayService) IsXrayRunning() bool {
|
|||||||
|
|
||||||
// XrayProcess returns the current Xray process instance (may be nil when Xray
|
// XrayProcess returns the current Xray process instance (may be nil when Xray
|
||||||
// is not running). It exposes the lifecycle snapshot to callers outside this
|
// is not running). It exposes the lifecycle snapshot to callers outside this
|
||||||
// package (e.g. the tgbot subpackage).
|
// package.
|
||||||
func XrayProcess() *xray.Process {
|
func XrayProcess() *xray.Process {
|
||||||
return currentXrayProcess()
|
return currentXrayProcess()
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-135
@@ -31,7 +31,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
@@ -119,7 +118,6 @@ type Server struct {
|
|||||||
|
|
||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
tgbotService tgbot.Tgbot
|
|
||||||
|
|
||||||
wsHub *websocket.Hub
|
wsHub *websocket.Hub
|
||||||
|
|
||||||
@@ -220,7 +218,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||||||
|
|
||||||
// init i18n — still used by backend strings (errors, log messages,
|
// init i18n — still used by backend strings (errors, log messages,
|
||||||
// SubPage menu entries) even though the Go template engine is gone.
|
// SubPage menu entries) even though the Go template engine is gone.
|
||||||
err = locale.InitLocalizer(i18nFS, &s.settingService)
|
err = locale.InitLocalizer(i18nFS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -371,36 +369,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
|||||||
// Check monthly reset days at midnight
|
// Check monthly reset days at midnight
|
||||||
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc))
|
||||||
|
|
||||||
// Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
|
|
||||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
|
||||||
if (err == nil) && isTgbotenabled {
|
|
||||||
runtime, err := s.settingService.GetTgbotRuntime()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warningf("Add NewStatsNotifyJob: failed to load runtime: %v; using default @daily", err)
|
|
||||||
runtime = "@daily"
|
|
||||||
} else if strings.TrimSpace(runtime) == "" {
|
|
||||||
logger.Warning("Add NewStatsNotifyJob runtime is empty, using default @daily")
|
|
||||||
runtime = "@daily"
|
|
||||||
}
|
|
||||||
logger.Infof("Tg notify enabled,run at %s", runtime)
|
|
||||||
if _, err = s.cron.AddJob(runtime, job.NewStatsNotifyJob()); err != nil {
|
|
||||||
logger.Warningf("Add NewStatsNotifyJob: failed to schedule runtime %q: %v", runtime, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for Telegram bot callback query hash storage reset
|
|
||||||
_, _ = s.cron.AddJob(cadenceCheckHash, job.NewCheckHashStorageJob())
|
|
||||||
}
|
|
||||||
|
|
||||||
// CPU monitor publishes cpu.high events; register it whenever any notifier
|
|
||||||
// (Telegram or Email) wants them, independent of the Telegram bot being on.
|
|
||||||
if s.cpuAlarmWanted() {
|
|
||||||
_, _ = s.cron.AddJob(cadenceCPUAlarm, job.NewCheckCpuJob())
|
|
||||||
}
|
|
||||||
// Memory monitor publishes memory.high events; register it whenever any notifier wants them.
|
|
||||||
if s.memoryAlarmWanted() {
|
|
||||||
_, _ = s.cron.AddJob(cadenceMemoryAlarm, job.NewCheckMemJob())
|
|
||||||
}
|
|
||||||
|
|
||||||
if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
|
if mins := sys.MemoryReleaseIntervalMinutes(); mins > 0 {
|
||||||
_, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
|
_, _ = s.cron.AddJob(fmt.Sprintf("@every %dm", mins), job.NewMemoryReleaseJob())
|
||||||
go func() {
|
go func() {
|
||||||
@@ -410,63 +378,16 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// cpuAlarmWanted reports whether any notifier is configured to receive cpu.high
|
|
||||||
// alerts, so the minute-long blocking CPU sampler only runs when it's needed.
|
|
||||||
func (s *Server) cpuAlarmWanted() bool {
|
|
||||||
wants := func(events string, threshold int) bool {
|
|
||||||
if threshold <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for e := range strings.SplitSeq(events, ",") {
|
|
||||||
if strings.TrimSpace(e) == string(eventbus.EventCPUHigh) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
|
||||||
events, _ := s.settingService.GetTgEnabledEvents()
|
|
||||||
cpu, _ := s.settingService.GetTgCpu()
|
|
||||||
if wants(events, cpu) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// memoryAlarmWanted reports whether any notifier is configured to receive memory.high alerts.
|
|
||||||
func (s *Server) memoryAlarmWanted() bool {
|
|
||||||
wants := func(events string, threshold int) bool {
|
|
||||||
if threshold <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for e := range strings.SplitSeq(events, ",") {
|
|
||||||
if strings.TrimSpace(e) == string(eventbus.EventMemoryHigh) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if on, _ := s.settingService.GetTgbotEnabled(); on {
|
|
||||||
events, _ := s.settingService.GetTgEnabledEvents()
|
|
||||||
mem, _ := s.settingService.GetTgMemory()
|
|
||||||
if wants(events, mem) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
// Start initializes and starts the web server with configured settings, routes, and background jobs.
|
||||||
func (s *Server) Start() (err error) {
|
func (s *Server) Start() (err error) {
|
||||||
return s.start(true, true)
|
return s.start(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) StartPanelOnly() (err error) {
|
func (s *Server) StartPanelOnly() (err error) {
|
||||||
return s.start(false, true)
|
return s.start(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
func (s *Server) start(restartXray bool) (err error) {
|
||||||
// This is an anonymous function, no function name
|
// This is an anonymous function, no function name
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -589,7 +510,6 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
|||||||
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
||||||
service.SetEventBus(s.bus)
|
service.SetEventBus(s.bus)
|
||||||
job.EventBus = s.bus
|
job.EventBus = s.bus
|
||||||
tgbot.EventBus = s.bus
|
|
||||||
|
|
||||||
// Wire xray crash callback BEFORE startTask so it's ready
|
// Wire xray crash callback BEFORE startTask so it's ready
|
||||||
xray.OnCrash = func(err error) {
|
xray.OnCrash = func(err error) {
|
||||||
@@ -601,65 +521,21 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire Telegram test function to controller
|
|
||||||
controller.SetTestTgFunc(func() error {
|
|
||||||
if !s.tgbotService.IsRunning() {
|
|
||||||
return fmt.Errorf("telegram bot is not running (check token and chat ID)")
|
|
||||||
}
|
|
||||||
if err := s.tgbotService.TestConnection(); err != nil {
|
|
||||||
return fmt.Errorf("telegram API test failed: %w", err)
|
|
||||||
}
|
|
||||||
s.tgbotService.SendMsgToTgbotAdmins("✅ Test message from 3x-ui")
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
controller.SetReloadTgbotFunc(func() {
|
|
||||||
enabled, err := s.settingService.GetTgbotEnabled()
|
|
||||||
if err != nil || !enabled {
|
|
||||||
if s.tgbotService.IsRunning() {
|
|
||||||
s.tgbotService.Stop()
|
|
||||||
}
|
|
||||||
if s.bus != nil {
|
|
||||||
s.bus.Unsubscribe("tg-notifier")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Start() stops any previous receiver first, so it is safe whether or not the bot is already running.
|
|
||||||
tgBot := s.tgbotService.NewTgbot()
|
|
||||||
if startErr := tgBot.Start(i18nFS); startErr != nil {
|
|
||||||
logger.Warning("reload Telegram bot failed:", startErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.bus != nil {
|
|
||||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
s.startTask(restartXray, loc)
|
s.startTask(restartXray, loc)
|
||||||
|
|
||||||
if startTgBot {
|
|
||||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
|
||||||
if (err == nil) && isTgbotenabled {
|
|
||||||
tgBot := s.tgbotService.NewTgbot()
|
|
||||||
_ = tgBot.Start(i18nFS)
|
|
||||||
// Subscribe Telegram notifications for event bus
|
|
||||||
s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop gracefully shuts down the web server, stops Xray, cron jobs, and Telegram bot.
|
// Stop gracefully shuts down the web server, stops Xray, and cron jobs.
|
||||||
func (s *Server) Stop() error {
|
func (s *Server) Stop() error {
|
||||||
return s.stop(true, true)
|
return s.stop(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) StopPanelOnly() error {
|
func (s *Server) StopPanelOnly() error {
|
||||||
return s.stop(false, true)
|
return s.stop(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
func (s *Server) stop(stopXray bool) error {
|
||||||
s.cancel()
|
s.cancel()
|
||||||
if stopXray {
|
if stopXray {
|
||||||
_ = s.xrayService.StopXray()
|
_ = s.xrayService.StopXray()
|
||||||
@@ -678,9 +554,6 @@ func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
|||||||
if stopXray {
|
if stopXray {
|
||||||
service.StopTrafficWriter()
|
service.StopTrafficWriter()
|
||||||
}
|
}
|
||||||
if stopTgBot && s.tgbotService.IsRunning() {
|
|
||||||
s.tgbotService.Stop()
|
|
||||||
}
|
|
||||||
// Gracefully stop WebSocket hub
|
// Gracefully stop WebSocket hub
|
||||||
if s.wsHub != nil {
|
if s.wsHub != nil {
|
||||||
s.wsHub.Stop()
|
s.wsHub.Stop()
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
@@ -237,10 +236,6 @@ func runWebServer() {
|
|||||||
stopTunnelHealthMonitor()
|
stopTunnelHealthMonitor()
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- FIX FOR TELEGRAM BOT CONFLICT (409) on full shutdown ---
|
|
||||||
tgbot.StopBot()
|
|
||||||
// ------------------------------------------------------------
|
|
||||||
|
|
||||||
_ = server.Stop()
|
_ = server.Stop()
|
||||||
_ = subServer.Stop()
|
_ = subServer.Stop()
|
||||||
log.Println("Shutting down servers.")
|
log.Println("Shutting down servers.")
|
||||||
@@ -318,66 +313,6 @@ func showSetting(show bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateTgbotEnableSts enables or disables the Telegram bot notifications based on the status parameter.
|
|
||||||
func updateTgbotEnableSts(status bool) {
|
|
||||||
settingService := service.SettingService{}
|
|
||||||
currentTgSts, err := settingService.GetTgbotEnabled()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("current enabletgbot status[%v],need update to status[%v]", currentTgSts, status)
|
|
||||||
if currentTgSts != status {
|
|
||||||
err := settingService.SetTgbotEnabled(status)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
logger.Infof("SetTgbotEnabled[%v] success", status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateTgbotSetting updates Telegram bot settings including token, chat ID, and runtime schedule.
|
|
||||||
func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime string) {
|
|
||||||
err := database.InitDB(config.GetDBPath())
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println("Error initializing database:", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
settingService := service.SettingService{}
|
|
||||||
|
|
||||||
if tgBotToken != "" {
|
|
||||||
err := settingService.SetTgBotToken(tgBotToken)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error setting Telegram bot token: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Info("Successfully updated Telegram bot token.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if tgBotRuntime != "" {
|
|
||||||
err := settingService.SetTgbotRuntime(tgBotRuntime)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error setting Telegram bot runtime: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Infof("Successfully updated Telegram bot runtime to [%s].", tgBotRuntime)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tgBotChatid != "" {
|
|
||||||
err := settingService.SetTgBotChatId(tgBotChatid)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error setting Telegram bot chat ID: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Info("Successfully updated Telegram bot chat ID.")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// encryptNodeTokens re-encrypts stored tokens after enablement or rotation.
|
|
||||||
// It requires migration|required mode and a configured key.
|
|
||||||
func encryptNodeTokens() {
|
func encryptNodeTokens() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
if err := initNodeTokenCrypto(); err != nil {
|
if err := initNodeTokenCrypto(); err != nil {
|
||||||
@@ -625,10 +560,6 @@ func main() {
|
|||||||
var getListen bool
|
var getListen bool
|
||||||
var webCertFile string
|
var webCertFile string
|
||||||
var webKeyFile string
|
var webKeyFile string
|
||||||
var tgbottoken string
|
|
||||||
var tgbotchatid string
|
|
||||||
var enabletgbot bool
|
|
||||||
var tgbotRuntime string
|
|
||||||
var reset bool
|
var reset bool
|
||||||
var show bool
|
var show bool
|
||||||
var getCert bool
|
var getCert bool
|
||||||
@@ -645,10 +576,6 @@ func main() {
|
|||||||
settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
|
settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
|
||||||
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
|
settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
|
||||||
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
|
||||||
settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
|
|
||||||
settingCmd.StringVar(&tgbotRuntime, "tgbotRuntime", "", "Set cron time for Telegram bot notifications")
|
|
||||||
settingCmd.StringVar(&tgbotchatid, "tgbotchatid", "", "Set chat ID for Telegram bot notifications")
|
|
||||||
settingCmd.BoolVar(&enabletgbot, "enabletgbot", false, "Enable notifications via Telegram bot")
|
|
||||||
|
|
||||||
oldUsage := flag.Usage
|
oldUsage := flag.Usage
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
@@ -740,12 +667,6 @@ func main() {
|
|||||||
if getApiToken {
|
if getApiToken {
|
||||||
GetApiToken(getApiToken)
|
GetApiToken(getApiToken)
|
||||||
}
|
}
|
||||||
if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
|
|
||||||
updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
|
|
||||||
}
|
|
||||||
if enabletgbot {
|
|
||||||
updateTgbotEnableSts(enabletgbot)
|
|
||||||
}
|
|
||||||
case "cert":
|
case "cert":
|
||||||
err := settingCmd.Parse(os.Args[2:])
|
err := settingCmd.Parse(os.Args[2:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user