refactor: focused service files, leaf subpackages, and an internal/ layout (#5167)

* refactor(service): split client.go into focused files

client.go had grown to 4455 lines mixing ~10 responsibilities. Split it
verbatim into cohesive same-package files (no behavior change):

  client.go            foundation: ClientService, ClientWithAttachments,
                       ClientCreatePayload, ErrClientNotInInbound, sqlInChunk
  client_locks.go      inbound mutation locks, delete tombstones, compactOrphans
  client_lookup.go     read-only lookups (GetByID, List, EffectiveFlow, ...)
  client_link.go       inbound association sync (SyncInbound, DetachInbound, ...)
  client_crud.go       single-client CRUD + validation + protocol defaults
  client_inbound_apply.go  low-level inbound-settings mutators + by-email setters
  client_bulk.go       bulk attach/detach/adjust/delete/create + DelDepleted
  client_traffic.go    traffic-reset paths
  client_groups.go     client group management
  client_paging.go     paged listing, filtering, sorting, summary

Every declaration moved unchanged (verified: identical func/type/const/var
signature set before vs after). Imports redistributed per file via goimports.
go build ./..., go vet, and go test ./web/service/... all pass.

* refactor(service): split inbound.go into focused files

inbound.go was 4100 lines. Split it verbatim into cohesive same-package
files (no behavior change):

  inbound.go             core inbound CRUD + InboundService (keeps pkg doc)
  inbound_protocol.go    protocol / stream capability helpers
  inbound_node.go        node/runtime/remote coordination + online tracking
  inbound_traffic.go     traffic accounting, reset, client stats
  inbound_client_ips.go  per-client IP tracking
  inbound_clients.go     client lookups within inbounds + copy-clients
  inbound_disable.go     auto-disable invalid inbounds/clients
  inbound_migration.go   DB migrations
  inbound_sublink.go     subscription link providers
  inbound_util.go        generic slice/string helpers

Identical func/type/const/var signature set before vs after; package doc
comment preserved on inbound.go. Imports redistributed via goimports.
Build, vet, and go test ./web/service/... all pass.

* refactor(service): split tgbot.go into focused files

tgbot.go was 3738 lines dominated by a 1246-line answerCallback. Split it
verbatim into cohesive same-package files (no behavior change):

  tgbot.go           lifecycle, bot setup, caches, small utils
  tgbot_router.go    incoming update / command / callback dispatch
  tgbot_send.go      outbound messaging primitives
  tgbot_client.go    client views, actions, subscription links
  tgbot_inbound.go   inbound listing / pickers
  tgbot_report.go    server usage, exhausted, online, backups, notifications

Identical func/type/const/var signature set before vs after. Imports
redistributed via goimports. Build, vet, and go test ./web/service/... pass.

* refactor(client): dedupe single-field by-email setters

ResetClientIpLimitByEmail, ResetClientExpiryTimeByEmail, and
ResetClientTrafficLimitByEmail shared an identical ~50-line body that
resolves the inbound by email, confirms the client exists, rewrites a
single-client settings payload, and delegates to UpdateInboundClient.

Extract that into applyClientFieldByEmail(inboundSvc, email, mutate) and
reduce each setter to a 3-line wrapper. Behavior is unchanged: same checks
and error strings, same single-client payload contract, same totalGB guard.

SetClientTelegramUserID (resolves by traffic id, different error text) and
ToggleClientEnableByEmail/SetClientEnableByEmail (different return shape and
a pre-read of the old state) intentionally keep their own bodies.

* refactor(service): extract panel/ subpackage

Move the panel-administration leaf services out of the flat service
package into web/service/panel/ (package panel):

  user.go         UserService (auth / 2FA / LDAP)
  panel.go        PanelService (restart / self-update) + version helpers
  panel_other.go  non-unix RestartPanel
  panel_unix.go   unix RestartPanel
  api_token.go    ApiTokenService
  websocket.go    WebSocketService
  panel_test.go   version/shellQuote unit tests

These are leaves: they depend on core (SettingService, Release) but no
core file references them, so the extraction creates no import cycle.
Core references are now qualified (service.SettingService, service.Release);
callers in main.go, web/web.go, and web/controller/* updated to panel.*.
Build, vet, and go test ./web/... pass.

* refactor(service): extract integration/ subpackage

Move the external-provider integration leaves into web/service/integration/
(package integration):

  warp.go        WarpService (Cloudflare WARP)
  nord.go        NordService (NordVPN)
  custom_geo.go  CustomGeoService (custom geo asset management)
  *_test.go      custom_geo / panel-proxy tests

These depend on core (SettingService, ServerService, XraySettingService) but
no core file references them. xray_setting.go stays in core because it calls
the unexported SettingService.saveSetting. The shared isBlockedIP SSRF helper
(used by core url_safety.go and by custom_geo) now has a small copy in each
package rather than being exported. Core references qualified; callers in
web/web.go, web/job/*, and web/controller/* updated to integration.*.
Build, vet, and go test ./web/... pass.

* refactor(service): extract tgbot/ subpackage

Move the Telegram bot (6 files + test) into web/service/tgbot/ (package
tgbot). It is a leaf: it embeds five core services (Inbound/Client/Setting/
Server/Xray) and the core never references it, so no import cycle.

To support the package boundary without changing behavior:
  - core exposes XrayProcess() *xray.Process so tgbot keeps calling the
    exact same running-process methods it used via the package-level `p`;
  - three core methods tgbot calls are exported: ClientService.checkIs-
    EnabledByEmail -> CheckIsEnabledByEmail, InboundService.getAllEmails ->
    GetAllEmails (callers updated in-package);
  - tgbot's embedded-field types and the few core type refs (Status,
    ClientCreatePayload, SanitizePublicHTTPURL) are now service-qualified.

Callers in main.go, web/web.go, web/job/*, and web/controller/* updated to
tgbot.*. Build, vet, and go test ./web/... pass.

* refactor(service): extract outbound/ subpackage

OutboundService (outbound.go) imports only neutral packages (config,
database, model, xray) and its production code is referenced by no core or
sibling service file — only by web/controller/xray_setting.go and
web/job/xray_traffic_job.go. Move it to web/service/outbound/ (package
outbound); no core qualification needed inside. Callers updated to outbound.*.

The one coupling was a tiny pure test helper, outboundsContainTag, used by
both outbound.go and the core outbound_subscription_test.go; it now has a
small copy in that test file rather than being shared across the boundary.
Build, vet, and go test ./web/... pass.

* refactor(util): move wireguard into its own subpackage

util/wireguard.go was the lone file of the root `util` package (24 lines,
one exported func GenerateWireguardKeypair), while every other util concern
lives in a focused subpackage (util/common, util/crypto, util/netsafe, ...).
Move it to util/wireguard/ (package wireguard) for consistency; its only
importer, web/service/integration/warp.go, is updated. The root `util`
package no longer exists.

* refactor(sub): drop redundant sub prefix from filenames

Inside package sub the subXxx.go prefix just repeats the package name
(like client_*.go did inside service). Rename for consistency; content and
type names are unchanged:

  subController.go    -> controller.go
  subService.go       -> service.go
  subClashService.go  -> clash_service.go
  subJsonService.go   -> json_service.go
  (+ matching _test.go files)

* refactor(controller): rename xui.go -> spa.go

XUIController serves the panel's single-page-app shell; spa.go names that
role plainly (the other controller files are domain-named). File rename only
— the type stays XUIController. api_docs_test.go keys route base paths by
filename, so its "xui.go" case is updated to "spa.go".

* refactor: move backend packages under internal/

Adopt the idiomatic Go application layout: the backend packages now live
under internal/ (a boundary the toolchain enforces), signalling private
implementation instead of a library-style flat root. No runtime behavior
changes — only import paths and a few build/config paths move.

Moved: config, database, logger, mtproto, sub, util, web, xray -> internal/.
main.go stays at the repo root and tools/openapigen stays under tools/ (both
still import internal/* because the internal rule keys off the module root).
The module path github.com/mhsanaei/3x-ui/v3 is unchanged; 149 .go files had
their import prefix rewritten to .../internal/<pkg>.

Couplings the Go compiler can't see, updated to the new layout:
  - frontend i18n imports of web/translation (react.ts, setup.components.ts)
  - vite outDir + eslint/tsconfig ignore globs -> internal/web/dist
  - Dockerfile COPY paths for web/dist and web/translation
  - locale.go os.DirFS("web") disk fallback -> "internal/web"
  - .gitignore and ci.yml go:embed stub for internal/web/dist
  - api_docs_test.go repo-root relative walk (one level deeper)
  - tools/openapigen filesystem package paths; ApiTokenView repointed to the
    web/service/panel subpackage and codegen regenerated (clears a stale
    type the ci.yml codegen check was failing on)

Verified: go build/vet/test (all packages), and frontend typecheck, lint,
vitest (478 tests), and production build into internal/web/dist.

* fix(config): keep test runs from writing logs into the source tree

GetLogFolder() returns a CWD-relative "./log" on Windows. Under `go test`
the working directory is each package's own folder, so InitLogger (called by
tests in web/job, web/service, xray, web/websocket) created stray log/
directories scattered through the source tree (e.g. internal/web/job/log/).

Redirect to a shared temp folder when testing.Testing() reports a test run.
Production behavior is unchanged: Windows still uses ./log next to the binary
and Linux /var/log/x-ui. The log files were always gitignored (*.log) and
never committed; this just stops the noise at the source.

* docs: move subscription-template guide out of root into docs/

sub_templates/ was a top-level folder holding only a README and no actual
templates (3x-ui ships none by design), referenced nowhere and unlinked from
any doc — it read like an empty placeholder cluttering the repo root.

Move the guide to docs/custom-subscription-templates.md (a proper docs home),
reword its intro to read as documentation rather than a folder note, link it
from the Features list in README.md, and drop the empty sub_templates/ folder.

* fix: update stale web/ path references after the internal/ move

The internal/ migration rewrote Go import paths but left some references to
the old top-level layout in docs, comments, and a few runtime disk paths.

Functional (dev-mode only): the disk-serving fallbacks that read the Vite
build from disk when running from source still pointed at web/dist/, which
moved to internal/web/dist/ — so `os.DirFS`/`os.Stat`/`os.ReadFile` in
internal/web/web.go and internal/sub/{sub,controller}.go are corrected.
Production was unaffected (it serves the embedded FS; verified by the Docker
build), but `go run` with a live frontend build silently fell back to embed.

Docs/comments: frontend/README.md, CONTRIBUTING.md, the claude-issue-bot and
release workflows, the openapigen -root help text, and assorted Go comments
now reference internal/web, internal/database, internal/sub, internal/xray,
etc. Package-name mentions (the "web" package), root paths (main.go,
frontend/, install scripts, /etc/x-ui), routes (/panel/api/xray), and the
historical "web/assets no longer exists" note were intentionally left as-is.

* refactor(web): remove the legacy /xui -> /panel redirect middleware

RedirectMiddleware existed only for backward compatibility with the old
`/xui` URL scheme (301-redirecting /xui and /xui/API to /panel and
/panel/api). That cutover was long ago, so drop the middleware, its
registration in initRouter, and the now-inaccurate "URL redirection"
mention in the middleware package doc. Old /xui URLs now 404 like any other
unknown path. HTTPS auto-redirect and auth redirects are unrelated and stay.

* build: fix .dockerignore for internal/ layout and exclude runtime dir

- web/dist -> internal/web/dist: the embedded frontend moved under internal/,
  so the stale exclude no longer matched and the locally-built dist could be
  sent to the build context (the frontend stage rebuilds it fresh anyway).
- exclude x-ui/: the local runtime directory (SQLite db, geo .dat files, xray
  binaries, certs — ~150MB) was being shipped into the build context for no
  reason. Verified the pattern excludes only the directory and still keeps
  x-ui.sh, which the Dockerfile copies to /usr/bin/x-ui.
This commit is contained in:
Sanaei
2026-06-10 15:19:22 +02:00
committed by GitHub
parent 26c549a95a
commit 41645255f1
254 changed files with 13074 additions and 12836 deletions
+100
View File
@@ -0,0 +1,100 @@
package controller
import (
"net/http"
"strings"
"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/integration"
"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/gin-gonic/gin"
)
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
type APIController struct {
BaseController
inboundController *InboundController
serverController *ServerController
nodeController *NodeController
settingController *SettingController
xraySettingController *XraySettingController
settingService service.SettingService
userService panel.UserService
apiTokenService panel.ApiTokenService
Tgbot tgbot.Tgbot
}
// NewAPIController creates a new APIController instance and initializes its routes.
func NewAPIController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *APIController {
a := &APIController{}
a.initRouter(g, customGeo)
return a
}
func (a *APIController) checkAPIAuth(c *gin.Context) {
auth := c.GetHeader("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
tok := after
if a.apiTokenService.Match(tok) {
if u, err := a.userService.GetFirstUser(); err == nil {
session.SetAPIAuthUser(c, u)
}
c.Set("api_authed", true)
c.Next()
return
}
}
if !session.IsLogin(c) {
if c.GetHeader("X-Requested-With") == "XMLHttpRequest" {
c.AbortWithStatus(http.StatusUnauthorized)
} else {
c.AbortWithStatus(http.StatusNotFound)
}
return
}
c.Next()
}
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *integration.CustomGeoService) {
// Main API group
api := g.Group("/panel/api")
api.Use(a.checkAPIAuth)
api.Use(middleware.CSRFMiddleware())
// Inbounds API
inbounds := api.Group("/inbounds")
a.inboundController = NewInboundController(inbounds)
clients := api.Group("/clients")
NewClientController(clients)
NewGroupController(clients)
// Server API
server := api.Group("/server")
a.serverController = NewServerController(server)
// Nodes API — multi-panel management
nodes := api.Group("/nodes")
a.nodeController = NewNodeController(nodes)
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Settings + Xray config management live under the API surface too, so the
// same API token drives them. Paths are /panel/api/setting/* and
// /panel/api/xray/*.
a.settingController = NewSettingController(api)
a.xraySettingController = NewXraySettingController(api)
// 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()
}
+165
View File
@@ -0,0 +1,165 @@
package controller
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
type routeDef struct {
Method string
Path string
}
// routePattern matches route registrations like g.GET("/path", handler) or api.GET("/path", handler)
var routePattern = regexp.MustCompile(`\b(g|api)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\("([^"]+)"`)
// docRoutePattern matches { method: 'X', path: 'Y' ... } entries in endpoints.ts.
var docRoutePattern = regexp.MustCompile(`method:\s*'([A-Z]+)'\s*,\s*path:\s*'([^']+)'`)
// buildDocSet parses frontend/src/pages/api-docs/endpoints.ts and returns the
// set of documented "METHOD PATH" keys. WS pseudo-routes and subscription
// placeholders (paths starting with /{...}) are skipped because they aren't
// registered on the main Gin engine.
func buildDocSet(t *testing.T) map[string]bool {
t.Helper()
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
endpointsPath := filepath.Join(controllerDir, "..", "..", "..", "frontend", "src", "pages", "api-docs", "endpoints.ts")
data, err := os.ReadFile(endpointsPath)
if err != nil {
t.Fatalf("failed to read endpoints.ts at %s: %v", endpointsPath, err)
}
docSet := make(map[string]bool)
for _, m := range docRoutePattern.FindAllStringSubmatch(string(data), -1) {
method, path := m[1], m[2]
if method == "WS" {
continue
}
if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "/{") {
continue
}
docSet[method+" "+path] = true
}
if len(docSet) == 0 {
t.Fatalf("no documented routes parsed from %s — regex or file format may have changed", endpointsPath)
}
return docSet
}
func TestAPIRoutesDocumented(t *testing.T) {
docSet := buildDocSet(t)
controllerDir, err := filepath.Abs(".")
if err != nil {
t.Fatalf("failed to get current dir: %v", err)
}
var allRoutes []routeDef
entries, err := os.ReadDir(controllerDir)
if err != nil {
t.Fatalf("failed to read controller dir: %v", err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") {
continue
}
data, err := os.ReadFile(filepath.Join(controllerDir, entry.Name()))
if err != nil {
t.Fatalf("failed to read %s: %v", entry.Name(), err)
}
src := string(data)
// Determine the base path for this file based on its initRouter patterns
basePath := ""
switch entry.Name() {
case "index.go":
basePath = ""
case "spa.go":
basePath = "/panel"
case "api.go":
basePath = "/panel/api"
case "inbound.go":
basePath = "/panel/api/inbounds"
case "client.go":
basePath = "/panel/api/clients"
case "group.go":
basePath = "/panel/api/clients"
case "server.go":
basePath = "/panel/api/server"
case "node.go":
basePath = "/panel/api/nodes"
case "setting.go":
basePath = "/panel/api/setting"
case "xray_setting.go":
basePath = "/panel/api/xray"
case "custom_geo.go":
basePath = "/panel/api/custom-geo"
case "websocket.go":
basePath = ""
}
// Find all route registrations
matches := routePattern.FindAllStringSubmatch(src, -1)
for _, m := range matches {
method := m[2]
path := strings.TrimSpace(m[3])
if basePath == "" {
allRoutes = append(allRoutes, routeDef{Method: method, Path: path})
} else {
fullPath := basePath + path
allRoutes = append(allRoutes, routeDef{Method: method, Path: fullPath})
}
}
}
// The WebSocket route /ws is registered in web/web.go (not a controller file)
allRoutes = append(allRoutes, routeDef{Method: "GET", Path: "/ws"})
missingFromDocs := 0
foundInDoc := 0
sourceSet := make(map[string]bool)
for _, r := range allRoutes {
key := r.Method + " " + r.Path
// Skip SPA page routes (these are UI pages, not API endpoints)
spaPages := map[string]bool{
"/": true, "/panel/": true, "/panel/inbounds": true,
"/panel/clients": true, "/panel/groups": true,
"/panel/nodes": true, "/panel/settings": true,
"/panel/xray": true, "/panel/api-docs": true,
}
if spaPages[r.Path] {
continue
}
// Skip /panel/csrf-token (documented under auth as /csrf-token)
if r.Path == "/panel/csrf-token" {
continue
}
// Skip Chrome DevTools route
if strings.Contains(r.Path, ".well-known") {
continue
}
sourceSet[key] = true
if docSet[key] {
foundInDoc++
} else {
missingFromDocs++
t.Errorf("Route not documented in endpoints.ts: %s %s", r.Method, r.Path)
}
}
t.Logf("Routes found in source: %d, documented: %d, matching: %d, missing: %d",
len(sourceSet), len(docSet), foundInDoc, missingFromDocs)
if missingFromDocs > 0 {
t.Errorf("Found %d undocumented route(s). Update endpoints.ts to match.", missingFromDocs)
}
}
+43
View File
@@ -0,0 +1,43 @@
// Package controller provides HTTP request handlers and controllers for the 3x-ui web management panel.
// It handles routing, authentication, and API endpoints for managing Xray inbounds, settings, and more.
package controller
import (
"net/http"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
)
// BaseController provides common functionality for all controllers, including authentication checks.
type BaseController struct{}
// checkLogin is a middleware that verifies user authentication and handles unauthorized access.
func (a *BaseController) checkLogin(c *gin.Context) {
if !session.IsLogin(c) {
if isAjax(c) {
pureJsonMsg(c, http.StatusUnauthorized, false, I18nWeb(c, "pages.login.loginAgain"))
} else {
c.Header("Cache-Control", "no-store")
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
}
c.Abort()
} else {
c.Next()
}
}
// I18nWeb retrieves an internationalized message for the web interface based on the current locale.
func I18nWeb(c *gin.Context, name string, params ...string) string {
anyfunc, funcExists := c.Get("I18n")
if !funcExists {
logger.Warning("I18n function not exists in gin context!")
return ""
}
i18nFunc, _ := anyfunc.(func(i18nType locale.I18nType, key string, keyParams ...string) string)
msg := i18nFunc(locale.Web, name, params...)
return msg
}
+498
View File
@@ -0,0 +1,498 @@
package controller
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/gin-gonic/gin"
)
func notifyClientsChanged() {
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
func parseInboundIdsQuery(raw string) []int {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
ids := make([]int, 0, len(parts))
for _, p := range parts {
if id, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
ids = append(ids, id)
}
}
return ids
}
type ClientController struct {
clientService service.ClientService
inboundService service.InboundService
xrayService service.XrayService
settingService service.SettingService
}
func NewClientController(g *gin.RouterGroup) *ClientController {
a := &ClientController{}
a.initRouter(g)
return a
}
func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get/:email", a.get)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks)
g.GET("/links/:email", a.getClientLinks)
g.POST("/add", a.create)
g.POST("/update/:email", a.update)
g.POST("/del/:email", a.delete)
g.POST("/:email/attach", a.attach)
g.POST("/:email/detach", a.detach)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/delDepleted", a.delDepleted)
g.POST("/bulkAdjust", a.bulkAdjust)
g.POST("/bulkDel", a.bulkDelete)
g.POST("/bulkCreate", a.bulkCreate)
g.POST("/bulkAttach", a.bulkAttach)
g.POST("/bulkDetach", a.bulkDetach)
g.POST("/bulkResetTraffic", a.bulkResetTraffic)
g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/onlinesByGuid", a.onlinesByGuid)
g.POST("/activeInbounds", a.activeInbounds)
g.POST("/lastOnline", a.lastOnline)
}
func (a *ClientController) list(c *gin.Context) {
rows, err := a.clientService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *ClientController) listPaged(c *gin.Context) {
var params service.ClientPageParams
if err := c.ShouldBindQuery(&params); err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
resp, err := a.clientService.ListPaged(&a.inboundService, &a.settingService, params)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, resp, nil)
}
func (a *ClientController) get(c *gin.Context) {
email := c.Param("email")
rec, err := a.clientService.GetRecordByEmail(nil, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
rec.Flow = flow
jsonObj(c, gin.H{"client": rec, "inboundIds": inboundIds}, nil)
}
func (a *ClientController) create(c *gin.Context) {
var payload service.ClientCreatePayload
if err := c.ShouldBindJSON(&payload); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.Create(&a.inboundService, &payload)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) update(c *gin.Context) {
email := c.Param("email")
var updated model.Client
if err := c.ShouldBindJSON(&updated); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delete(c *gin.Context) {
email := c.Param("email")
keepTraffic := c.Query("keepTraffic") == "1"
needRestart, err := a.clientService.DeleteByEmail(&a.inboundService, email, keepTraffic)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type attachDetachBody struct {
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) attach(c *gin.Context) {
email := c.Param("email")
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.AttachByEmail(&a.inboundService, email, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) resetAllTraffics(c *gin.Context) {
needRestart, err := a.clientService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkAdjustRequest struct {
Emails []string `json:"emails"`
AddDays int `json:"addDays"`
AddBytes int64 `json:"addBytes"`
}
func (a *ClientController) bulkAdjust(c *gin.Context) {
var req bulkAdjustRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkDeleteRequest struct {
Emails []string `json:"emails"`
KeepTraffic bool `json:"keepTraffic"`
}
type bulkAttachRequest struct {
Emails []string `json:"emails"`
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) bulkAttach(c *gin.Context) {
var req bulkAttachRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkAttach(&a.inboundService, req.Emails, req.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkDetachRequest struct {
Emails []string `json:"emails"`
InboundIds []int `json:"inboundIds"`
}
func (a *ClientController) bulkDetach(c *gin.Context) {
var req bulkDetachRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkDetach(&a.inboundService, req.Emails, req.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) bulkDelete(c *gin.Context) {
var req bulkDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, req.Emails, req.KeepTraffic)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) bulkCreate(c *gin.Context) {
var payloads []service.ClientCreatePayload
if err := c.ShouldBindJSON(&payloads); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.clientService.BulkCreate(&a.inboundService, payloads)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) delDepleted(c *gin.Context) {
deleted, needRestart, err := a.clientService.DelDepleted(&a.inboundService)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"deleted": deleted}, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
email := c.Param("email")
needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundClientTrafficSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type trafficUpdateRequest struct {
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
func (a *ClientController) updateTrafficByEmail(c *gin.Context) {
email := c.Param("email")
var req trafficUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.inboundService.UpdateClientTrafficByEmail(email, req.Upload, req.Download); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
notifyClientsChanged()
}
func (a *ClientController) getIps(c *gin.Context) {
email := c.Param("email")
ips, err := a.inboundService.GetInboundClientIps(email)
if err != nil || ips == "" {
jsonObj(c, "No IP Record", nil)
return
}
type ipWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []ipWithTimestamp
if err := json.Unmarshal([]byte(ips), &ipsWithTime); err == nil && len(ipsWithTime) > 0 {
formatted := make([]string, 0, len(ipsWithTime))
for _, item := range ipsWithTime {
if item.IP == "" {
continue
}
if item.Timestamp > 0 {
ts := time.Unix(item.Timestamp, 0).Local().Format("2006-01-02 15:04:05")
formatted = append(formatted, fmt.Sprintf("%s (%s)", item.IP, ts))
continue
}
formatted = append(formatted, item.IP)
}
jsonObj(c, formatted, nil)
return
}
var oldIps []string
if err := json.Unmarshal([]byte(ips), &oldIps); err == nil && len(oldIps) > 0 {
jsonObj(c, oldIps, nil)
return
}
jsonObj(c, ips, nil)
}
func (a *ClientController) clearIps(c *gin.Context) {
email := c.Param("email")
if err := a.inboundService.ClearClientIps(email); err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
}
func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
func (a *ClientController) onlinesByGuid(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
}
func (a *ClientController) activeInbounds(c *gin.Context) {
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {
data, err := a.inboundService.GetClientsLastOnline()
jsonObj(c, data, err)
}
func (a *ClientController) getTrafficByEmail(c *gin.Context) {
email := c.Param("email")
traffic, err := a.inboundService.GetClientTrafficByEmail(email)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.trafficGetError"), err)
return
}
jsonObj(c, traffic, nil)
}
func (a *ClientController) getSubLinks(c *gin.Context) {
links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
func (a *ClientController) getClientLinks(c *gin.Context) {
links, err := a.inboundService.GetAllClientLinks(resolveHost(c), c.Param("email"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, links, nil)
}
func (a *ClientController) detach(c *gin.Context) {
email := c.Param("email")
var body attachDetachBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.clientService.DetachByEmailMany(&a.inboundService, email, body.InboundIds)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
notifyClientsChanged()
}
type bulkResetRequest struct {
Emails []string `json:"emails"`
}
func (a *ClientController) bulkResetTraffic(c *gin.Context) {
var req bulkResetRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.BulkResetTraffic(&a.inboundService, req.Emails)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
+180
View File
@@ -0,0 +1,180 @@
package controller
import (
"errors"
"net/http"
"strconv"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
"github.com/gin-gonic/gin"
)
type CustomGeoController struct {
BaseController
customGeoService *integration.CustomGeoService
}
func NewCustomGeoController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *CustomGeoController {
a := &CustomGeoController{customGeoService: customGeo}
a.initRouter(g)
return a
}
func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/aliases", a.aliases)
g.POST("/add", a.add)
g.POST("/update/:id", a.update)
g.POST("/delete/:id", a.delete)
g.POST("/download/:id", a.download)
g.POST("/update-all", a.updateAll)
}
func mapCustomGeoErr(c *gin.Context, err error) error {
if err == nil {
return nil
}
switch {
case errors.Is(err, integration.ErrCustomGeoInvalidType):
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
case errors.Is(err, integration.ErrCustomGeoAliasRequired):
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
case errors.Is(err, integration.ErrCustomGeoAliasPattern):
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
case errors.Is(err, integration.ErrCustomGeoAliasReserved):
return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
case errors.Is(err, integration.ErrCustomGeoURLRequired):
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
case errors.Is(err, integration.ErrCustomGeoInvalidURL):
return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
case errors.Is(err, integration.ErrCustomGeoURLScheme):
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
case errors.Is(err, integration.ErrCustomGeoURLHost):
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
case errors.Is(err, integration.ErrCustomGeoDuplicateAlias):
return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
case errors.Is(err, integration.ErrCustomGeoNotFound):
return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
case errors.Is(err, integration.ErrCustomGeoDownload):
logger.Warning("custom geo download:", err)
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
case errors.Is(err, integration.ErrCustomGeoSSRFBlocked):
logger.Warning("custom geo SSRF blocked:", err)
return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
case errors.Is(err, integration.ErrCustomGeoPathTraversal):
logger.Warning("custom geo path traversal blocked:", err)
return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
default:
return err
}
}
func (a *CustomGeoController) list(c *gin.Context) {
list, err := a.customGeoService.GetAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
return
}
jsonObj(c, list, nil)
}
func (a *CustomGeoController) aliases(c *gin.Context) {
out, err := a.customGeoService.GetAliasesForUI()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
return
}
jsonObj(c, out, nil)
}
type customGeoForm struct {
Type string `json:"type" form:"type"`
Alias string `json:"alias" form:"alias"`
Url string `json:"url" form:"url"`
}
func (a *CustomGeoController) add(c *gin.Context) {
var form customGeoForm
if err := c.ShouldBind(&form); err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
return
}
r := &model.CustomGeoResource{
Type: form.Type,
Alias: form.Alias,
Url: form.Url,
}
err := a.customGeoService.Create(r)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
}
func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
id, err := strconv.Atoi(idStr)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
return 0, false
}
if id <= 0 {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
return 0, false
}
return id, true
}
func (a *CustomGeoController) update(c *gin.Context) {
id, ok := parseCustomGeoID(c, c.Param("id"))
if !ok {
return
}
var form customGeoForm
if bindErr := c.ShouldBind(&form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
return
}
r := &model.CustomGeoResource{
Type: form.Type,
Alias: form.Alias,
Url: form.Url,
}
err := a.customGeoService.Update(id, r)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
}
func (a *CustomGeoController) delete(c *gin.Context) {
id, ok := parseCustomGeoID(c, c.Param("id"))
if !ok {
return
}
name, err := a.customGeoService.Delete(id)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
}
func (a *CustomGeoController) download(c *gin.Context) {
id, ok := parseCustomGeoID(c, c.Param("id"))
if !ok {
return
}
name, err := a.customGeoService.TriggerUpdate(id)
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
}
func (a *CustomGeoController) updateAll(c *gin.Context) {
res, err := a.customGeoService.TriggerUpdateAll()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
return
}
if len(res.Failed) > 0 {
c.JSON(http.StatusOK, entity.Msg{
Success: false,
Msg: I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
Obj: res,
})
return
}
jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
}
+131
View File
@@ -0,0 +1,131 @@
package controller
import (
"bytes"
"embed"
"encoding/json"
htmlpkg "html"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
)
var distFS embed.FS
func SetDistFS(fs embed.FS) {
distFS = fs
}
var distPageBuildTime = time.Now()
// ServeOpenAPISpec returns the generated OpenAPI 3.0 description of the
// panel API. Postman / Insomnia / openapi-generator consume this URL
// directly; the in-panel Swagger UI page also fetches it. The spec is
// produced at frontend build time by scripts/build-openapi.mjs and
// embedded into the binary via the dist FS.
func ServeOpenAPISpec(c *gin.Context) {
body, err := distFS.ReadFile("dist/openapi.json")
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return
}
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
// under a non-root web base path, Swagger UI "Try it out" and external
// generators must target that prefix, so rewrite the single server entry to
// the runtime base path before serving.
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
logger.Warning("openapi.json: could not inject base path:", err)
} else {
body = rebuilt
}
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// withServerBasePath rewrites the spec's `servers` entry so requests target the
// panel's configured web base path. Only the top-level `servers` field is
// replaced; every other field is preserved verbatim via json.RawMessage.
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
var doc map[string]json.RawMessage
if err := json.Unmarshal(spec, &doc); err != nil {
return nil, err
}
servers, err := json.Marshal([]map[string]string{{
"url": strings.TrimSuffix(basePath, "/"),
"description": "Current panel",
}})
if err != nil {
return nil, err
}
doc["servers"] = servers
return json.Marshal(doc)
}
func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name)
if err != nil {
c.String(http.StatusInternalServerError, "missing embedded page: %s", name)
return
}
basePath := c.GetString("base_path")
if basePath == "" {
basePath = "/"
}
if basePath != "/" {
body = bytes.ReplaceAll(body, []byte(`src="/assets/`), []byte(`src="`+basePath+`assets/`))
body = bytes.ReplaceAll(body, []byte(`href="/assets/`), []byte(`href="`+basePath+`assets/`))
}
jsEscape := strings.NewReplacer(
`\`, `\\`,
`"`, `\"`,
"\n", `\n`,
"\r", `\r`,
"<", `<`,
">", `>`,
"&", `&`,
)
escapedBase := jsEscape.Replace(basePath)
csrfToken, err := session.EnsureCSRFToken(c)
if err != nil {
logger.Warning("Unable to mint CSRF token for", name+":", err)
csrfToken = ""
}
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
nonceAttr = ` nonce="` + htmlpkg.EscapeString(nonce) + `"`
}
script := `<script` + nonceAttr + `>window.X_UI_BASE_PATH="` + escapedBase + `"`
if name != "login.html" {
escapedVer := jsEscape.Replace(config.GetVersion())
script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
script += `;window.X_UI_DB_TYPE="` + config.GetDBKind() + `"`
}
script += `;</script>`
inject := []byte(script)
inject = append(inject, csrfMeta...)
inject = append(inject, basePathMeta...)
inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1)
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
c.Header("Last-Modified", distPageBuildTime.UTC().Format(http.TimeFormat))
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
}
+42
View File
@@ -0,0 +1,42 @@
package controller
import (
"encoding/json"
"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")
}
}
+154
View File
@@ -0,0 +1,154 @@
package controller
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/gin-gonic/gin"
)
type GroupController struct {
clientService service.ClientService
xrayService service.XrayService
}
func NewGroupController(g *gin.RouterGroup) *GroupController {
a := &GroupController{}
a.initRouter(g)
return a
}
func (a *GroupController) initRouter(g *gin.RouterGroup) {
g.GET("/groups", a.list)
g.GET("/groups/:name/emails", a.emails)
g.POST("/groups/create", a.create)
g.POST("/groups/rename", a.rename)
g.POST("/groups/delete", a.delete)
g.POST("/groups/bulkAdd", a.bulkAdd)
g.POST("/groups/bulkRemove", a.bulkRemove)
}
func (a *GroupController) list(c *gin.Context) {
rows, err := a.clientService.ListGroups()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *GroupController) emails(c *gin.Context) {
name := c.Param("name")
emails, err := a.clientService.EmailsByGroup(name)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, emails, nil)
}
type groupCreateBody struct {
Name string `json:"name"`
}
func (a *GroupController) create(c *gin.Context) {
var body groupCreateBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.clientService.CreateGroup(body.Name); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"name": body.Name}, nil)
notifyClientsChanged()
}
type groupRenameBody struct {
OldName string `json:"oldName"`
NewName string `json:"newName"`
}
func (a *GroupController) rename(c *gin.Context) {
var body groupRenameBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type groupDeleteBody struct {
Name string `json:"name"`
}
func (a *GroupController) delete(c *gin.Context) {
var body groupDeleteBody
if err := c.ShouldBindJSON(&body); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.DeleteGroup(body.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonObj(c, gin.H{"affected": affected}, nil)
notifyClientsChanged()
}
type bulkAddToGroupRequest struct {
Emails []string `json:"emails"`
Group string `json:"group"`
}
func (a *GroupController) bulkAdd(c *gin.Context) {
var req bulkAddToGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if strings.TrimSpace(req.Group) == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
return
}
affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
type bulkRemoveFromGroupRequest struct {
Emails []string `json:"emails"`
}
func (a *GroupController) bulkRemove(c *gin.Context) {
var req bulkRemoveFromGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
affected, err := a.clientService.RemoveFromGroup(req.Emails)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
+446
View File
@@ -0,0 +1,446 @@
package controller
import (
"encoding/json"
"net"
"strconv"
"strings"
"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/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/gin-gonic/gin"
)
// InboundController handles HTTP requests related to Xray inbounds management.
type InboundController struct {
inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService
fallbackService service.FallbackService
}
// NewInboundController creates a new InboundController and sets up its routes.
func NewInboundController(g *gin.RouterGroup) *InboundController {
a := &InboundController{}
a.initRouter(g)
return a
}
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
// Mirrors the same heuristic used by the periodic traffic job.
const broadcastInboundsUpdateClientLimit = 5000
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
// so we send an invalidate signal instead — frontend re-fetches via REST.
// Skipped entirely when no WebSocket clients are connected.
func (a *InboundController) broadcastInboundsUpdate(userId int) {
if !websocket.HasClients() {
return
}
inbounds, err := a.inboundService.GetInbounds(userId)
if err != nil {
return
}
totalClients := 0
for _, ib := range inbounds {
totalClients += len(ib.ClientStats)
}
if totalClients > broadcastInboundsUpdateClientLimit {
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
return
}
websocket.BroadcastInbounds(inbounds)
}
// initRouter initializes the routes for inbound-related operations.
func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds)
g.GET("/list/slim", a.getInboundsSlim)
g.GET("/options", a.getInboundOptions)
g.GET("/get/:id", a.getInbound)
g.GET("/:id/fallbacks", a.getFallbacks)
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
g.POST("/bulkDel", a.bulkDelInbounds)
g.POST("/update/:id", a.updateInbound)
g.POST("/setEnable/:id", a.setInboundEnable)
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/:id/delAllClients", a.delAllInboundClients)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/import", a.importInbound)
g.POST("/:id/fallbacks", a.setFallbacks)
}
// getInbounds retrieves the list of inbounds for the logged-in user.
func (a *InboundController) getInbounds(c *gin.Context) {
user := session.GetLoginUser(c)
inbounds, err := a.inboundService.GetInbounds(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbounds, nil)
}
// getInboundsSlim is the list-page variant that strips full client
// payloads from settings.clients[]. Detail-view flows still use /get/:id.
func (a *InboundController) getInboundsSlim(c *gin.Context) {
user := session.GetLoginUser(c)
inbounds, err := a.inboundService.GetInboundsSlim(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbounds, nil)
}
// getInboundOptions returns a lightweight projection of the user's inbounds
// (id, remark, protocol, port, tlsFlowCapable) for pickers in the clients UI.
// Avoids shipping per-client settings and traffic stats just to fill a dropdown.
func (a *InboundController) getInboundOptions(c *gin.Context) {
user := session.GetLoginUser(c)
options, err := a.inboundService.GetInboundOptions(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, options, nil)
}
// getInbound retrieves a specific inbound by its ID.
func (a *InboundController) getInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
inbound, err := a.inboundService.GetInboundDetail(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
jsonObj(c, inbound, nil)
}
// addInbound creates a new inbound configuration.
func (a *InboundController) addInbound(c *gin.Context) {
inbound, ok := middleware.BindAndValidate[model.Inbound](c)
if !ok {
return
}
user := session.GetLoginUser(c)
inbound.UserId = user.Id
// Treat NodeID=0 as "no node" — gin's *int form binding can land on
// 0 when the field is absent or empty, and 0 is never a valid Node
// row id. Without this normalization the runtime layer would try to
// load Node id=0 and surface "record not found".
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
}
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
}
// delInbound deletes an inbound configuration by its ID.
func (a *InboundController) delInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), err)
return
}
needRestart, err := a.inboundService.DelInbound(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundDeleteSuccess"), id, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
}
type bulkDelInboundsRequest struct {
Ids []int `json:"ids"`
}
// bulkDelInbounds deletes several inbounds in one call. Failures are
// reported per id and the rest still proceed; xray restarts at most once.
func (a *InboundController) bulkDelInbounds(c *gin.Context) {
var req bulkDelInboundsRequest
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, needRestart, err := a.inboundService.DelInbounds(req.Ids)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
}
// updateInbound updates an existing inbound configuration.
func (a *InboundController) updateInbound(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
inbound := &model.Inbound{
Id: id,
}
if !middleware.BindAndValidateInto(c, inbound) {
return
}
// Same NodeID=0 → nil normalisation as addInbound. UpdateInbound
// loads the existing row's NodeID from DB anyway (Phase 1 doesn't
// support migrating an inbound between nodes), but normalising here
// keeps the wire shape consistent.
if inbound.NodeID != nil && *inbound.NodeID == 0 {
inbound.NodeID = nil
}
inbound, needRestart, err := a.inboundService.UpdateInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
}
// setInboundEnable flips only the enable flag of an inbound. This is a
// dedicated endpoint because the regular update path serialises the entire
// settings JSON (every client) — far too heavy for an interactive switch
// on inbounds with thousands of clients. Frontend optimistically updates
// the UI; we just persist + sync xray + nudge other open admin sessions.
func (a *InboundController) setInboundEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
type form struct {
Enable bool `json:"enable" form:"enable"`
}
var f form
if err := c.ShouldBind(&f); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
// instead of fetching + serialising the whole inbound list. Other open
// sessions re-fetch via REST. The toggling admin's own UI already
// updated optimistically.
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
}
// resetInboundTraffic resets traffic counters for a specific inbound.
func (a *InboundController) resetInboundTraffic(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
err = a.inboundService.ResetInboundTraffic(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetInboundTrafficSuccess"), nil)
}
// delAllInboundClients removes every client attached to a specific inbound
// while keeping the inbound itself. Internally collects the current email
// list from settings.clients[] and feeds it into ClientService.BulkDelete,
// which handles per-inbound JSON rewriting, runtime user removal, traffic
// row cleanup, and the SyncInbound mapping pass in one optimized cycle.
func (a *InboundController) delAllInboundClients(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
emails, err := a.inboundService.EmailsByInbound(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if len(emails) == 0 {
jsonObj(c, service.BulkDeleteResult{}, nil)
return
}
result, needRestart, err := a.clientService.BulkDelete(&a.inboundService, emails, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
user := session.GetLoginUser(c)
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
}
// resetAllTraffics resets all traffic counters across all inbounds.
func (a *InboundController) resetAllTraffics(c *gin.Context) {
err := a.inboundService.ResetAllTraffics()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
} else {
a.xrayService.SetToNeedRestart()
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil)
}
// importInbound imports an inbound configuration from provided data.
func (a *InboundController) importInbound(c *gin.Context) {
inbound := &model.Inbound{}
err := json.Unmarshal([]byte(c.PostForm("data")), inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
user := session.GetLoginUser(c)
inbound.Id = 0
inbound.UserId = user.Id
// Node IDs are panel-local and not portable across panels. Drop a node
// reference that is zero or that points to a node which doesn't exist on
// this panel, so a cross-panel export imports as a local inbound instead of
// failing with "record not found" when nodePushPlan looks the node up.
if inbound.NodeID != nil {
if *inbound.NodeID == 0 {
inbound.NodeID = nil
} else if exists, err := (&service.NodeService{}).NodeExists(*inbound.NodeID); err == nil && !exists {
inbound.NodeID = nil
}
}
for index := range inbound.ClientStats {
inbound.ClientStats[index].Id = 0
inbound.ClientStats[index].Enable = true
}
inbound, needRestart, err := a.inboundService.AddInbound(inbound)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundCreateSuccess"), inbound, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
a.broadcastInboundsUpdate(user.Id)
notifyClientsChanged()
}
// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
// controller layer means the service interface stays HTTP-agnostic — service
// methods receive a plain host string instead of a *gin.Context.
func resolveHost(c *gin.Context) string {
if isTrustedForwardedRequest(c) {
if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
if i := strings.Index(h, ","); i >= 0 {
h = strings.TrimSpace(h[:i])
}
if hp, _, err := net.SplitHostPort(h); err == nil {
return hp
}
return h
}
if h := c.GetHeader("X-Real-IP"); h != "" {
return h
}
}
if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
return h
}
return c.Request.Host
}
// getFallbacks returns the fallback rules attached to the master inbound.
func (a *InboundController) getFallbacks(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
rows, err := a.fallbackService.GetByMaster(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
jsonObj(c, rows, nil)
}
// setFallbacks atomically replaces the master inbound's fallback list
// and triggers an Xray restart so the new settings.fallbacks take effect.
func (a *InboundController) setFallbacks(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
type body struct {
Fallbacks []service.FallbackInput `json:"fallbacks"`
}
var b body
if err := c.ShouldBindJSON(&b); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
a.xrayService.SetToNeedRestart()
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
}
+169
View File
@@ -0,0 +1,169 @@
package controller
import (
"net/http"
"text/template"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"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/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
)
// LoginForm represents the login request structure.
type LoginForm struct {
Username string `json:"username" form:"username"`
Password string `json:"password" form:"password"`
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
}
// IndexController handles the main index and login-related routes.
type IndexController struct {
BaseController
settingService service.SettingService
userService panel.UserService
tgbot tgbot.Tgbot
}
// NewIndexController creates a new IndexController and initializes its routes.
func NewIndexController(g *gin.RouterGroup) *IndexController {
a := &IndexController{}
a.initRouter(g)
return a
}
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
g.GET("/csrf-token", a.csrfToken)
g.POST("/login", middleware.CSRFMiddleware(), a.login)
g.POST("/logout", middleware.CSRFMiddleware(), a.logout)
g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
}
// index handles the root route, redirecting logged-in users to the panel or showing the login page.
func (a *IndexController) index(c *gin.Context) {
if session.IsLogin(c) {
c.Header("Cache-Control", "no-store")
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path")+"panel/")
return
}
serveDistPage(c, "login.html")
}
// login handles user authentication and session creation.
func (a *IndexController) login(c *gin.Context) {
var form LoginForm
if err := c.ShouldBind(&form); err != nil {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.invalidFormData"))
return
}
if form.Username == "" {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyUsername"))
return
}
if form.Password == "" {
pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.emptyPassword"))
return
}
remoteIP := getRemoteIp(c)
safeUser := template.HTMLEscapeString(form.Username)
timeStr := time.Now().Format("2006-01-02 15:04:05")
if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
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))
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"))
return
}
user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
if user == nil {
reason := loginFailureReason(checkErr)
if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
} else {
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"))
return
}
defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
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 {
logger.Warning("Unable to save session:", err)
return
}
logger.Infof("%s logged in successfully", safeUser)
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
}
func loginFailureReason(err error) string {
if err != nil && err.Error() == "invalid 2fa code" {
return "invalid 2FA code"
}
return "invalid credentials"
}
func (a *IndexController) logout(c *gin.Context) {
user := session.GetLoginUser(c)
if user != nil {
logger.Infof("%s logged out successfully", user.Username)
}
if err := session.ClearSession(c); err != nil {
logger.Warning("Unable to clear session on logout:", err)
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, gin.H{"success": true})
}
// csrfToken returns the session CSRF token. Public — the login page
// needs a token before authenticating.
func (a *IndexController) csrfToken(c *gin.Context) {
token, err := session.EnsureCSRFToken(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "msg": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "obj": token})
}
// getTwoFactorEnable retrieves the current status of two-factor authentication.
func (a *IndexController) getTwoFactorEnable(c *gin.Context) {
status, err := a.settingService.GetTwoFactorEnable()
if err == nil {
jsonObj(c, status, nil)
}
}
+99
View File
@@ -0,0 +1,99 @@
package controller
import (
"strings"
"sync"
"time"
)
const (
loginLimitMaxFailures = 5
loginLimitWindow = 5 * time.Minute
loginLimitCooldown = 15 * time.Minute
)
var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
type loginLimiter struct {
mu sync.Mutex
now func() time.Time
maxFailures int
window time.Duration
cooldown time.Duration
attempts map[string]*loginLimitRecord
}
type loginLimitRecord struct {
failures []time.Time
blockedUntil time.Time
}
func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter {
return &loginLimiter{
now: time.Now,
maxFailures: maxFailures,
window: window,
cooldown: cooldown,
attempts: make(map[string]*loginLimitRecord),
}
}
func (l *loginLimiter) allow(ip, username string) (time.Time, bool) {
l.mu.Lock()
defer l.mu.Unlock()
key := loginLimitKey(ip, username)
record := l.attempts[key]
if record == nil {
return time.Time{}, true
}
now := l.now()
if now.Before(record.blockedUntil) {
return record.blockedUntil, false
}
record.blockedUntil = time.Time{}
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
if len(record.failures) == 0 {
delete(l.attempts, key)
}
return time.Time{}, true
}
func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
l.mu.Lock()
defer l.mu.Unlock()
key := loginLimitKey(ip, username)
record := l.attempts[key]
if record == nil {
record = &loginLimitRecord{}
l.attempts[key] = record
}
now := l.now()
record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
record.failures = append(record.failures, now)
if len(record.failures) >= l.maxFailures {
record.failures = nil
record.blockedUntil = now.Add(l.cooldown)
return record.blockedUntil, true
}
return time.Time{}, false
}
func (l *loginLimiter) registerSuccess(ip, username string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.attempts, loginLimitKey(ip, username))
}
func loginLimitKey(ip, username string) string {
return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
}
func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time {
keepFrom := 0
for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) {
keepFrom++
}
return failures[keepFrom:]
}
@@ -0,0 +1,74 @@
package controller
import (
"testing"
"time"
)
func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for i := range 4 {
if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
t.Fatalf("failure %d should not block yet", i+1)
}
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
t.Fatalf("failure %d should still allow login attempts", i+1)
}
}
blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
if !blocked {
t.Fatal("fifth failure should start cooldown")
}
if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
t.Fatalf("blocked until %s, want %s", blockedUntil, want)
}
if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
t.Fatal("login should be blocked during cooldown")
}
now = blockedUntil
if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
t.Fatal("login should be allowed after cooldown")
}
}
func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for range 4 {
limiter.registerFailure("192.0.2.10", "admin")
}
now = now.Add(6 * time.Minute)
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
t.Fatal("old failures should be pruned outside the rolling window")
}
limiter.registerSuccess("192.0.2.10", "admin")
for i := range 4 {
if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
}
}
}
func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
limiter.now = func() time.Time { return now }
for range 5 {
limiter.registerFailure("192.0.2.10", "admin")
}
if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
t.Fatal("different IP should not be blocked")
}
if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
t.Fatal("different username should not be blocked")
}
}
+261
View File
@@ -0,0 +1,261 @@
package controller
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"time"
"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/service"
"github.com/gin-gonic/gin"
)
type NodeController struct {
nodeService service.NodeService
}
func NewNodeController(g *gin.RouterGroup) *NodeController {
a := &NodeController{}
a.initRouter(g)
return a
}
func (a *NodeController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/get/:id", a.get)
g.GET("/webCert/:id", a.webCert)
g.POST("/add", a.add)
g.POST("/update/:id", a.update)
g.POST("/del/:id", a.del)
g.POST("/setEnable/:id", a.setEnable)
g.POST("/test", a.test)
g.POST("/certFingerprint", a.certFingerprint)
g.POST("/probe/:id", a.probe)
g.POST("/updatePanel", a.updatePanel)
g.GET("/history/:id/:metric/:bucket", a.history)
}
func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetNodeTree()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return
}
jsonObj(c, nodes, nil)
}
func (a *NodeController) get(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
jsonObj(c, n, nil)
}
// webCert returns the node's own web TLS certificate/key file paths so the
// inbound form's "Set Cert from Panel" can fill paths that exist on the node.
func (a *NodeController) webCert(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
files, err := a.nodeService.GetWebCertFiles(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
jsonObj(c, files, nil)
}
func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
if _, err := a.nodeService.Probe(ctx, n); err != nil {
return errors.New(service.FriendlyProbeError(err.Error()))
}
return nil
}
func (a *NodeController) add(c *gin.Context) {
n, ok := middleware.BindAndValidate[model.Node](c)
if !ok {
return
}
if err := a.ensureReachable(c, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
if err := a.nodeService.Create(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err)
return
}
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), n, nil)
}
func (a *NodeController) update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, ok := middleware.BindAndValidate[model.Node](c)
if !ok {
return
}
if err := a.ensureReachable(c, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
if err := a.nodeService.Update(id, n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
func (a *NodeController) del(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
if err := a.nodeService.Delete(id); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.delete"), nil)
}
func (a *NodeController) setEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
body := struct {
Enable bool `json:"enable" form:"enable"`
}{}
if err := c.ShouldBind(&body); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
if err := a.nodeService.SetEnable(id, body.Enable); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil)
}
func (a *NodeController) test(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, err := a.nodeService.Probe(ctx, n)
jsonObj(c, patch.ToUI(err == nil), nil)
}
func (a *NodeController) certFingerprint(c *gin.Context) {
n := &model.Node{}
if err := c.ShouldBind(n); err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
if n.Scheme == "" {
n.Scheme = "https"
}
if n.BasePath == "" {
n.BasePath = "/"
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
fp, err := a.nodeService.FetchCertFingerprint(ctx, n)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err)
return
}
jsonObj(c, fp, nil)
}
func (a *NodeController) probe(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
n, err := a.nodeService.GetById(id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
patch, probeErr := a.nodeService.Probe(ctx, n)
if probeErr != nil {
patch.Status = "offline"
} else {
patch.Status = "online"
}
_ = a.nodeService.UpdateHeartbeat(id, patch)
jsonObj(c, patch.ToUI(probeErr == nil), nil)
}
func (a *NodeController) updatePanel(c *gin.Context) {
var req struct {
Ids []int `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if len(req.Ids) == 0 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("no nodes selected"))
return
}
results, err := a.nodeService.UpdatePanels(req.Ids)
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.updateStarted"), results, err)
}
func (a *NodeController) history(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "get"), err)
return
}
metric := c.Param("metric")
if !slices.Contains(service.NodeMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return
}
jsonObj(c, a.nodeService.AggregateNodeMetric(id, metric, bucket, 60), nil)
}
+441
View File
@@ -0,0 +1,441 @@
package controller
import (
"fmt"
"net/http"
"regexp"
"slices"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"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/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/gin-gonic/gin"
)
var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
// ServerController handles server management and status-related operations.
type ServerController struct {
BaseController
serverService service.ServerService
settingService service.SettingService
panelService panel.PanelService
xrayMetricsService service.XrayMetricsService
}
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
func NewServerController(g *gin.RouterGroup) *ServerController {
a := &ServerController{}
service.RestoreSystemMetrics()
a.initRouter(g)
a.startTask()
return a
}
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
g.GET("/xrayMetricsState", a.getXrayMetricsState)
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
g.GET("/xrayObservatory", a.getXrayObservatory)
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb)
g.GET("/getMigration", a.getMigration)
g.GET("/getNewUUID", a.getNewUUID)
g.GET("/getWebCertFiles", a.getWebCertFiles)
g.GET("/descendants", a.descendants)
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
g.GET("/getNewmldsa65", a.getNewmldsa65)
g.GET("/getNewmlkem768", a.getNewmlkem768)
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
g.GET("/clientIps", a.getClientIps)
g.POST("/stopXrayService", a.stopXrayService)
g.POST("/restartXrayService", a.restartXrayService)
g.POST("/installXray/:version", a.installXray)
g.POST("/updatePanel", a.updatePanel)
g.POST("/updateGeofile", a.updateGeofile)
g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/logs/:count", a.getLogs)
g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/importDB", a.importDB)
g.POST("/getNewEchCert", a.getNewEchCert)
g.POST("/clientIps", a.setClientIps)
}
// startTask registers the @2s ticker that refreshes server status, samples
// xray metrics, and pushes the new snapshot to all websocket subscribers.
// State + sampling live in ServerService; the controller only orchestrates
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
func (a *ServerController) startTask() {
c := global.GetWebServer().GetCron()
c.AddFunc("@every 2s", func() {
status := a.serverService.RefreshStatus()
if status == nil {
return
}
a.xrayMetricsService.Sample(time.Now())
websocket.BroadcastStatus(status)
})
c.AddFunc("@every 1m", func() {
if err := service.PersistSystemMetrics(); err != nil {
logger.Warning("persist system metrics failed:", err)
}
})
}
// status returns the current server status information.
func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
func parseHistoryBucket(c *gin.Context) (int, bool) {
bucket, err := strconv.Atoi(c.Param("bucket"))
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
return 0, false
}
return bucket, true
}
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
// Kept for back-compat; new callers should use /history/cpu/:bucket which
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
}
// getMetricHistoryBucket returns up to 60 buckets of history for a single
// system metric (cpu, mem, netUp, netDown, online, load1/5/15). The
// SystemHistoryModal calls one endpoint per active tab.
func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
metric := c.Param("metric")
if !slices.Contains(service.SystemMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
}
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.State(), nil)
}
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
metric := c.Param("metric")
if !slices.Contains(service.XrayMetricKeys, metric) {
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
return
}
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
}
func (a *ServerController) getXrayObservatory(c *gin.Context) {
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
}
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
tag := c.Param("tag")
if !a.xrayMetricsService.HasObservatoryTag(tag) {
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
return
}
bucket, ok := parseHistoryBucket(c)
if !ok {
return
}
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
}
func (a *ServerController) getXrayVersion(c *gin.Context) {
versions, err := a.serverService.GetXrayVersionsCached()
if err != nil {
jsonMsg(c, I18nWeb(c, "getVersion"), err)
return
}
jsonObj(c, versions, nil)
}
// getPanelUpdateInfo retrieves the current and latest panel version.
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
info, err := a.panelService.GetUpdateInfo()
if err != nil {
logger.Debug("panel update check failed:", err)
c.JSON(http.StatusOK, entity.Msg{Success: false})
return
}
jsonObj(c, info, nil)
}
// installXray installs or updates Xray to the specified version.
func (a *ServerController) installXray(c *gin.Context) {
version := c.Param("version")
err := a.serverService.UpdateXray(version)
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
}
// updatePanel starts a panel self-update to the latest release.
func (a *ServerController) updatePanel(c *gin.Context) {
err := a.panelService.StartUpdate()
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
}
// updateGeofile updates the specified geo file for Xray.
func (a *ServerController) updateGeofile(c *gin.Context) {
fileName := c.Param("fileName")
if fileName != "" && !a.serverService.IsValidGeofileName(fileName) {
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"),
fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns"))
return
}
err := a.serverService.UpdateGeofile(fileName)
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
}
// stopXrayService stops the Xray service.
func (a *ServerController) stopXrayService(c *gin.Context) {
err := a.serverService.StopXrayService()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
websocket.BroadcastXrayState("error", err.Error())
return
}
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
websocket.BroadcastXrayState("stop", "")
websocket.BroadcastNotification(
I18nWeb(c, "pages.xray.stopSuccess"),
"Xray service has been stopped",
"warning",
)
}
// restartXrayService restarts the Xray service.
func (a *ServerController) restartXrayService(c *gin.Context) {
err := a.serverService.RestartXrayService()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
websocket.BroadcastXrayState("error", err.Error())
return
}
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
websocket.BroadcastXrayState("running", "")
websocket.BroadcastNotification(
I18nWeb(c, "pages.xray.restartSuccess"),
"Xray service has been restarted successfully",
"success",
)
}
// getLogs retrieves the application logs based on count, level, and syslog filters.
func (a *ServerController) getLogs(c *gin.Context) {
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
jsonObj(c, logs, nil)
}
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
func (a *ServerController) getXrayLogs(c *gin.Context) {
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
logs := a.serverService.GetXrayLogs(
c.Param("count"),
c.PostForm("filter"),
c.PostForm("showDirect"),
c.PostForm("showBlocked"),
c.PostForm("showProxy"),
freedoms,
blackholes,
)
jsonObj(c, logs, nil)
}
// getConfigJson retrieves the Xray configuration as JSON.
func (a *ServerController) getConfigJson(c *gin.Context) {
configJson, err := a.serverService.GetConfigJson()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.getConfigError"), err)
return
}
jsonObj(c, configJson, nil)
}
// getDb downloads the database file.
func (a *ServerController) getDb(c *gin.Context) {
db, err := a.serverService.GetDb()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
return
}
filename := "x-ui.db"
if database.IsPostgres() {
filename = "x-ui.dump"
}
if !filenameRegex.MatchString(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Writer.Write(db)
}
// getMigration downloads a cross-engine migration file: a .dump on SQLite or a
// .db SQLite database on PostgreSQL, so the data can seed the other backend.
func (a *ServerController) getMigration(c *gin.Context) {
data, filename, err := a.serverService.GetMigration()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.getDatabaseError"), err)
return
}
if !filenameRegex.MatchString(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return
}
c.Header("Content-Type", "application/octet-stream")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Writer.Write(data)
}
// importDB imports a database file and restarts the Xray service.
func (a *ServerController) importDB(c *gin.Context) {
file, _, err := c.Request.FormFile("db")
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.readDatabaseError"), err)
return
}
defer file.Close()
if err := a.serverService.ImportDB(file); err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
return
}
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
}
// descendants publishes read-only summaries of the nodes this panel manages so
// a parent panel can surface them as transitive sub-nodes in a chained
// topology. Called by the parent via the node's API token (#4983).
func (a *ServerController) descendants(c *gin.Context) {
data, err := (&service.NodeService{}).LocalDescendants()
jsonObj(c, data, err)
}
// getWebCertFiles returns this panel's own web TLS certificate and key file
// paths. The central panel calls it on a node (via the node's API token) so
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
// on the node's filesystem instead of the central panel's — see issue #4854.
func (a *ServerController) getWebCertFiles(c *gin.Context) {
certFile, err := a.settingService.GetCertFile()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
keyFile, err := a.settingService.GetKeyFile()
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil)
}
// getNewX25519Cert generates a new X25519 certificate.
func (a *ServerController) getNewX25519Cert(c *gin.Context) {
cert, err := a.serverService.GetNewX25519Cert()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewX25519CertError"), err)
return
}
jsonObj(c, cert, nil)
}
// getNewmldsa65 generates a new ML-DSA-65 key.
func (a *ServerController) getNewmldsa65(c *gin.Context) {
cert, err := a.serverService.GetNewmldsa65()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewmldsa65Error"), err)
return
}
jsonObj(c, cert, nil)
}
// getNewEchCert generates a new ECH certificate for the given SNI.
func (a *ServerController) getNewEchCert(c *gin.Context) {
cert, err := a.serverService.GetNewEchCert(c.PostForm("sni"))
if err != nil {
jsonMsg(c, "get ech certificate", err)
return
}
jsonObj(c, cert, nil)
}
// getNewVlessEnc generates a new VLESS encryption key.
func (a *ServerController) getNewVlessEnc(c *gin.Context) {
out, err := a.serverService.GetNewVlessEnc()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.getNewVlessEncError"), err)
return
}
jsonObj(c, out, nil)
}
// getNewUUID generates a new UUID.
func (a *ServerController) getNewUUID(c *gin.Context) {
uuidResp, err := a.serverService.GetNewUUID()
if err != nil {
jsonMsg(c, "Failed to generate UUID", err)
return
}
jsonObj(c, uuidResp, nil)
}
// getNewmlkem768 generates a new ML-KEM-768 key.
func (a *ServerController) getNewmlkem768(c *gin.Context) {
out, err := a.serverService.GetNewmlkem768()
if err != nil {
jsonMsg(c, "Failed to generate mlkem768 keys", err)
return
}
jsonObj(c, out, nil)
}
func (a *ServerController) getClientIps(c *gin.Context) {
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
jsonObj(c, ips, err)
}
func (a *ServerController) setClientIps(c *gin.Context) {
var ips []model.InboundClientIps
if err := c.ShouldBindJSON(&ips); err != nil {
jsonMsg(c, "invalid data", err)
return
}
err := (&service.InboundService{}).MergeInboundClientIps(ips)
jsonMsg(c, "Client IPs merged", err)
}
+189
View File
@@ -0,0 +1,189 @@
package controller
import (
"errors"
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"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/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
)
// updateUserForm represents the form for updating user credentials.
type updateUserForm struct {
OldUsername string `json:"oldUsername" form:"oldUsername"`
OldPassword string `json:"oldPassword" form:"oldPassword"`
NewUsername string `json:"newUsername" form:"newUsername"`
NewPassword string `json:"newPassword" form:"newPassword"`
}
// SettingController handles settings and user management operations.
type SettingController struct {
settingService service.SettingService
userService panel.UserService
panelService panel.PanelService
apiTokenService panel.ApiTokenService
}
// NewSettingController creates a new SettingController and initializes its routes.
func NewSettingController(g *gin.RouterGroup) *SettingController {
a := &SettingController{}
a.initRouter(g)
return a
}
// initRouter sets up the routes for settings management.
func (a *SettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/setting")
g.POST("/all", a.getAllSetting)
g.POST("/defaultSettings", a.getDefaultSettings)
g.POST("/update", a.updateSetting)
g.POST("/updateUser", a.updateUser)
g.POST("/restartPanel", a.restartPanel)
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/apiTokens", a.listApiTokens)
g.POST("/apiTokens/create", a.createApiToken)
g.POST("/apiTokens/delete/:id", a.deleteApiToken)
g.POST("/apiTokens/setEnabled/:id", a.setApiTokenEnabled)
}
// getAllSetting retrieves all current settings.
func (a *SettingController) getAllSetting(c *gin.Context) {
allSetting, err := a.settingService.GetAllSetting()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, allSetting, nil)
}
// getDefaultSettings retrieves the default settings based on the host.
func (a *SettingController) getDefaultSettings(c *gin.Context) {
result, err := a.settingService.GetDefaultSettings(c.Request.Host)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, result, nil)
}
// updateSetting updates all settings with the provided data.
func (a *SettingController) updateSetting(c *gin.Context) {
allSetting, ok := middleware.BindAndValidate[entity.AllSetting](c)
if !ok {
return
}
oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
err := a.settingService.UpdateAllSetting(allSetting)
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
err = bumpErr
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
}
// updateUser updates the current user's username and password.
func (a *SettingController) updateUser(c *gin.Context) {
form := &updateUserForm{}
err := c.ShouldBind(form)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
user := session.GetLoginUser(c)
if user.Username != form.OldUsername || !crypto.CheckPasswordHash(user.Password, form.OldPassword) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.originalUserPassIncorrect")))
return
}
if form.NewUsername == "" || form.NewPassword == "" {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUserError"), errors.New(I18nWeb(c, "pages.settings.toasts.userPassMustBeNotEmpty")))
return
}
err = a.userService.UpdateUser(user.Id, form.NewUsername, form.NewPassword)
if err == nil {
user.Username = form.NewUsername
user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword)
if saveErr := session.SetLoginUser(c, user); saveErr != nil {
err = saveErr
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
}
// restartPanel restarts the panel service after a delay.
func (a *SettingController) restartPanel(c *gin.Context) {
err := a.panelService.RestartPanel(time.Second * 3)
jsonMsg(c, I18nWeb(c, "pages.settings.restartPanelSuccess"), err)
}
// getDefaultXrayConfig retrieves the default Xray configuration.
func (a *SettingController) getDefaultXrayConfig(c *gin.Context) {
defaultJsonConfig, err := a.settingService.GetDefaultXrayConfig()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, defaultJsonConfig, nil)
}
type apiTokenCreateForm struct {
Name string `json:"name" form:"name"`
}
type apiTokenEnabledForm struct {
Enabled bool `json:"enabled" form:"enabled"`
}
func (a *SettingController) listApiTokens(c *gin.Context) {
rows, err := a.apiTokenService.List()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, rows, nil)
}
func (a *SettingController) createApiToken(c *gin.Context) {
form := &apiTokenCreateForm{}
if err := c.ShouldBind(form); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
row, err := a.apiTokenService.Create(form.Name)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonObj(c, row, nil)
}
func (a *SettingController) deleteApiToken(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.Delete(id))
}
func (a *SettingController) setApiTokenEnabled(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
form := &apiTokenEnabledForm{}
if bindErr := c.ShouldBind(form); bindErr != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), bindErr)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), a.apiTokenService.SetEnabled(id, form.Enabled))
}
+68
View File
@@ -0,0 +1,68 @@
package controller
import (
"net/http"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
)
// XUIController is the main controller for the X-UI panel, serving the SPA shell.
type XUIController struct {
BaseController
}
// NewXUIController creates a new XUIController and initializes its routes.
func NewXUIController(g *gin.RouterGroup) *XUIController {
a := &XUIController{}
a.initRouter(g)
return a
}
// initRouter sets up the main panel routes and initializes sub-controllers.
//
// The HTML routes all hand the same single-page-app shell (index.html) to the
// browser; React Router takes over and renders the correct page from the URL.
// The /panel/api, /panel/setting, /panel/xray sub-routers register POST/JSON
// endpoints on different paths and stay untouched by the shell handler.
func (a *XUIController) initRouter(g *gin.RouterGroup) {
g = g.Group("/panel")
g.Use(a.checkLogin)
g.Use(middleware.CSRFMiddleware())
g.GET("/", a.panelSPA)
g.GET("/inbounds", a.panelSPA)
g.GET("/clients", a.panelSPA)
g.GET("/groups", a.panelSPA)
g.GET("/nodes", a.panelSPA)
g.GET("/settings", a.panelSPA)
g.GET("/xray", a.panelSPA)
g.GET("/api-docs", a.panelSPA)
// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,
// so they fetch the session token via this endpoint at startup and replay it
// on subsequent unsafe requests through axios.
g.GET("/csrf-token", a.csrfToken)
}
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
// API endpoint returns the same index.html — React Router reads the URL and
// mounts the matching page on the client.
func (a *XUIController) panelSPA(c *gin.Context) {
serveDistPage(c, "index.html")
}
// csrfToken returns the session CSRF token to authenticated SPA clients.
// The endpoint is GET (a safe method) so it bypasses CSRFMiddleware itself,
// but checkLogin still gates the response — anonymous callers get 401/redirect.
func (a *XUIController) csrfToken(c *gin.Context) {
token, err := session.EnsureCSRFToken(c)
if err != nil {
c.JSON(http.StatusInternalServerError, entity.Msg{Success: false, Msg: err.Error()})
return
}
c.JSON(http.StatusOK, entity.Msg{Success: true, Obj: token})
}
+206
View File
@@ -0,0 +1,206 @@
package controller
import (
"fmt"
"net"
"net/http"
"net/netip"
"path/filepath"
"runtime"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/gin-gonic/gin"
)
// getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string {
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
if !ok {
return "unknown"
}
if isTrustedProxy(remoteIP) {
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
return ip
}
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
for part := range strings.SplitSeq(xff, ",") {
if ip, ok := extractTrustedIP(part); ok {
return ip
}
}
}
}
return remoteIP
}
func isTrustedForwardedRequest(c *gin.Context) bool {
remoteIP, ok := extractTrustedIP(c.Request.RemoteAddr)
return ok && isTrustedProxy(remoteIP)
}
func isTrustedProxy(ip string) bool {
addr, err := netip.ParseAddr(ip)
if err != nil {
return false
}
trusted := trustedProxyCIDRs()
for value := range strings.SplitSeq(trusted, ",") {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if prefix, err := netip.ParsePrefix(value); err == nil {
if prefix.Contains(addr) {
return true
}
continue
}
if proxyIP, err := netip.ParseAddr(value); err == nil && proxyIP.Unmap() == addr.Unmap() {
return true
}
}
return false
}
func trustedProxyCIDRs() (trusted string) {
trusted = "127.0.0.1/32,::1/128"
defer func() {
_ = recover()
}()
settingService := service.SettingService{}
if value, err := settingService.GetTrustedProxyCIDRs(); err == nil && strings.TrimSpace(value) != "" {
trusted = value
}
return trusted
}
func extractTrustedIP(value string) (string, bool) {
candidate := strings.TrimSpace(value)
if candidate == "" {
return "", false
}
if ip, ok := parseIPCandidate(candidate); ok {
return ip.String(), true
}
if host, _, err := net.SplitHostPort(candidate); err == nil {
if ip, ok := parseIPCandidate(host); ok {
return ip.String(), true
}
}
if strings.Count(candidate, ":") == 1 {
if host, _, err := net.SplitHostPort(fmt.Sprintf("[%s]", candidate)); err == nil {
if ip, ok := parseIPCandidate(host); ok {
return ip.String(), true
}
}
}
return "", false
}
func parseIPCandidate(value string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil {
return netip.Addr{}, false
}
return ip.Unmap(), true
}
// jsonMsg sends a JSON response with a message and error status.
func jsonMsg(c *gin.Context, msg string, err error) {
jsonMsgObj(c, msg, nil, err)
}
// jsonObj sends a JSON response with an object and error status.
func jsonObj(c *gin.Context, obj any, err error) {
jsonMsgObj(c, "", obj, err)
}
func requestErrorContext(c *gin.Context) string {
handler, loc := callerOutsideUtil()
return fmt.Sprintf("[%s %s handler=%s %s]", c.Request.Method, c.Request.URL.Path, handler, loc)
}
func callerOutsideUtil() (string, string) {
var pcs [12]uintptr
n := runtime.Callers(2, pcs[:])
frames := runtime.CallersFrames(pcs[:n])
for {
frame, more := frames.Next()
base := filepath.Base(frame.File)
if base != "util.go" {
name := frame.Function
if idx := strings.LastIndex(name, "/"); idx >= 0 {
name = name[idx+1:]
}
return name, fmt.Sprintf("%s:%d", base, frame.Line)
}
if !more {
break
}
}
return "unknown", "unknown"
}
// jsonMsgObj sends a JSON response with a message, object, and error status.
func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
m := entity.Msg{
Obj: obj,
}
if err == nil {
m.Success = true
if msg != "" {
m.Msg = msg
}
} else {
m.Success = false
ctx := requestErrorContext(c)
fail := I18nWeb(c, "fail")
errStr := err.Error()
if errStr != "" {
m.Msg = msg + " (" + errStr + ")"
logger.Warningf("%s %s %s: %v", ctx, msg, fail, err)
} else if msg != "" {
m.Msg = msg
logger.Warningf("%s %s %s", ctx, msg, fail)
} else {
m.Msg = I18nWeb(c, "somethingWentWrong")
logger.Warningf("%s %s %s", ctx, m.Msg, fail)
}
}
c.JSON(http.StatusOK, m)
}
// pendingNodeObj returns a response object flagging that the save committed
// locally but a backing node was offline/disabled, so the change will be
// mirrored to the node once it reconnects. Returns nil when nothing is pending.
func pendingNodeObj(pending bool) any {
if pending {
return gin.H{"nodePending": true}
}
return nil
}
// pureJsonMsg sends a pure JSON message response with custom status code.
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
c.JSON(statusCode, entity.Msg{
Success: success,
Msg: msg,
})
}
// isAjax checks if the request is an AJAX request.
func isAjax(c *gin.Context) bool {
return c.GetHeader("X-Requested-With") == "XMLHttpRequest"
}
+34
View File
@@ -0,0 +1,34 @@
package controller
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestGetRemoteIpIgnoresForwardedHeadersFromUntrustedRemote(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.RemoteAddr = "203.0.113.10:12345"
c.Request.Header.Set("X-Real-IP", "198.51.100.9")
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8")
if got := getRemoteIp(c); got != "203.0.113.10" {
t.Fatalf("remote IP = %q, want request remote address", got)
}
}
func TestGetRemoteIpHonorsForwardedHeadersFromTrustedLoopbackProxy(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
c.Request.RemoteAddr = "127.0.0.1:12345"
c.Request.Header.Set("X-Forwarded-For", "198.51.100.8, 127.0.0.1")
if got := getRemoteIp(c); got != "198.51.100.8" {
t.Fatalf("remote IP = %q, want forwarded client IP", got)
}
}
+79
View File
@@ -0,0 +1,79 @@
package controller
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
)
var upgrader = ws.Upgrader{
ReadBufferSize: 32768,
WriteBufferSize: 32768,
EnableCompression: true,
CheckOrigin: checkSameOrigin,
}
// checkSameOrigin allows requests with no Origin header (same-origin or non-browser
// clients) and otherwise requires the Origin hostname to match the request hostname.
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
// (the panel often sits behind a reverse proxy on a different port).
func checkSameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Hostname() == "" {
return false
}
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort
// fails in that case while url.Hostname() returns the address without
// brackets. Strip them so same-origin checks pass for bare IPv6 hosts.
host = r.Host
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
host = host[1 : len(host)-1]
}
}
return strings.EqualFold(u.Hostname(), host)
}
// WebSocketController handles the HTTP→WebSocket upgrade for real-time updates.
// All per-connection lifecycle (pumps, hub registration) lives in
// panel.WebSocketService — this controller is HTTP-layer only.
type WebSocketController struct {
BaseController
service *panel.WebSocketService
}
// NewWebSocketController creates a controller wired to the given service.
func NewWebSocketController(svc *panel.WebSocketService) *WebSocketController {
return &WebSocketController{service: svc}
}
// HandleWebSocket authenticates the request, upgrades the HTTP connection, and
// hands ownership of the connection off to the service.
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
if !session.IsLogin(c) {
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Error("Failed to upgrade WebSocket connection:", err)
return
}
w.service.HandleConnection(conn, getRemoteIp(c))
}
+419
View File
@@ -0,0 +1,419 @@
package controller
import (
"encoding/json"
"fmt"
"strconv"
"time"
"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/web/service/integration"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
"github.com/gin-gonic/gin"
)
// XraySettingController handles Xray configuration and settings operations.
type XraySettingController struct {
XraySettingService service.XraySettingService
SettingService service.SettingService
InboundService service.InboundService
OutboundService outbound.OutboundService
XrayService service.XrayService
WarpService integration.WarpService
NordService integration.NordService
OutboundSubscriptionService service.OutboundSubscriptionService
}
// NewXraySettingController creates a new XraySettingController and initializes its routes.
func NewXraySettingController(g *gin.RouterGroup) *XraySettingController {
a := &XraySettingController{}
a.initRouter(g)
return a
}
// initRouter sets up the routes for Xray settings management.
func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
g = g.Group("/xray")
g.GET("/getDefaultJsonConfig", a.getDefaultXrayConfig)
g.GET("/getOutboundsTraffic", a.getOutboundsTraffic)
g.GET("/getXrayResult", a.getXrayResult)
g.POST("/", a.getXraySetting)
g.POST("/warp/:action", a.warp)
g.POST("/nord/:action", a.nord)
g.POST("/update", a.updateSetting)
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
g.POST("/testOutbound", a.testOutbound)
// Outbound subscription (remote outbound lists)
g.GET("/outbound-subs", a.listOutboundSubs)
g.POST("/outbound-subs", a.createOutboundSub)
g.POST("/outbound-subs/:id/refresh", a.refreshOutboundSub)
g.POST("/outbound-subs/:id/move", a.moveOutboundSub)
g.POST("/outbound-subs/:id", a.updateOutboundSub)
g.DELETE("/outbound-subs/:id", a.deleteOutboundSub)
g.POST("/outbound-subs/:id/del", a.deleteOutboundSub) // axios-friendly alias
g.POST("/outbound-subs/parse", a.parseOutboundSubURL) // preview without saving
}
// getXraySetting retrieves the Xray configuration template, inbound tags, and outbound test URL.
func (a *XraySettingController) getXraySetting(c *gin.Context) {
xraySetting, err := a.SettingService.GetXrayConfigTemplate()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
// Older versions of this handler embedded the raw DB value as
// `xraySetting` in the response without checking if the value
// already had that wrapper shape. When the frontend saved it
// back through the textarea verbatim, the wrapper got persisted
// and every subsequent save nested another layer, which is what
// eventually produced the blank Xray Settings page in #4059.
// Strip any such wrapper here, and heal the DB if we found one so
// the next read is O(1) instead of climbing the same pile again.
if unwrapped := service.UnwrapXrayTemplateConfig(xraySetting); unwrapped != xraySetting {
if saveErr := a.XraySettingService.SaveXraySetting(unwrapped); saveErr == nil {
xraySetting = unwrapped
} else {
// Don't fail the read — just serve the unwrapped value
// and leave the DB healing for a later save.
xraySetting = unwrapped
}
}
inboundTags, err := a.InboundService.GetInboundTags()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
clientReverseTags, err := a.InboundService.GetClientReverseTags()
if err != nil {
clientReverseTags = "[]"
}
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
xrayResponse := map[string]any{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTestUrl": outboundTestUrl,
}
// Surface subscription outbounds (and their tags) so the frontend can:
// - show them as read-only items in the Outbounds tab
// - let users pick them in balancers and routing rules
// These are not part of the editable template; they are injected at runtime.
if subObs, err := a.OutboundSubscriptionService.AllActiveOutbounds(); err == nil && len(subObs) > 0 {
xrayResponse["subscriptionOutbounds"] = subObs
}
if subTags, err := a.OutboundSubscriptionService.AllActiveOutboundTags(); err == nil && len(subTags) > 0 {
xrayResponse["subscriptionOutboundTags"] = subTags
}
result, err := json.Marshal(xrayResponse)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, string(result), nil)
}
// updateSetting updates the Xray configuration settings.
func (a *XraySettingController) updateSetting(c *gin.Context) {
xraySetting := c.PostForm("xraySetting")
if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
outboundTestUrl := c.PostForm("outboundTestUrl")
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
if err := a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
}
// getDefaultXrayConfig retrieves the default Xray configuration.
func (a *XraySettingController) getDefaultXrayConfig(c *gin.Context) {
defaultJsonConfig, err := a.SettingService.GetDefaultXrayConfig()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
jsonObj(c, defaultJsonConfig, nil)
}
// getXrayResult retrieves the current Xray service result.
func (a *XraySettingController) getXrayResult(c *gin.Context) {
jsonObj(c, a.XrayService.GetXrayResult(), nil)
}
// warp handles Warp-related operations based on the action parameter.
func (a *XraySettingController) warp(c *gin.Context) {
action := c.Param("action")
var resp string
var err error
switch action {
case "data":
resp, err = a.WarpService.GetWarpData()
case "del":
err = a.WarpService.DelWarpData()
case "config":
resp, err = a.WarpService.GetWarpConfig()
case "reg":
skey := c.PostForm("privateKey")
pkey := c.PostForm("publicKey")
resp, err = a.WarpService.RegWarp(skey, pkey)
case "changeIp":
resp, err = a.WarpService.ChangeWarpIP()
if err == nil {
a.XrayService.SetToNeedRestart()
// Restart the auto-update clock so a scheduled rotation
// doesn't fire right after this manual one.
_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
}
case "license":
license := c.PostForm("license")
resp, err = a.WarpService.SetWarpLicense(license)
case "interval":
interval, convErr := strconv.Atoi(c.PostForm("interval"))
if convErr != nil || interval < 0 {
err = common.NewError("invalid warp update interval")
} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
// Count the interval from now rather than from epoch 0,
// otherwise the job would rotate on its next tick.
_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
}
}
jsonObj(c, resp, err)
}
// nord handles NordVPN-related operations based on the action parameter.
func (a *XraySettingController) nord(c *gin.Context) {
action := c.Param("action")
var resp string
var err error
switch action {
case "countries":
resp, err = a.NordService.GetCountries()
case "servers":
countryId := c.PostForm("countryId")
resp, err = a.NordService.GetServers(countryId)
case "reg":
token := c.PostForm("token")
resp, err = a.NordService.GetCredentials(token)
case "setKey":
key := c.PostForm("key")
resp, err = a.NordService.SetKey(key)
case "data":
resp, err = a.NordService.GetNordData()
case "del":
err = a.NordService.DelNordData()
}
jsonObj(c, resp, err)
}
// getOutboundsTraffic retrieves the traffic statistics for outbounds.
func (a *XraySettingController) getOutboundsTraffic(c *gin.Context) {
outboundsTraffic, err := a.OutboundService.GetOutboundsTraffic()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getOutboundTrafficError"), err)
return
}
jsonObj(c, outboundsTraffic, nil)
}
// resetOutboundsTraffic resets the traffic statistics for the specified outbound tag.
func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
tag := c.PostForm("tag")
err := a.OutboundService.ResetOutboundTraffic(tag)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.resetOutboundTrafficError"), err)
return
}
jsonObj(c, "", nil)
}
// testOutbound tests an outbound configuration and returns the delay/response time.
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
// Optional form "mode": "tcp" for a fast dial-only probe (parallel-safe),
// anything else (default) for a full HTTP probe through a temp xray instance.
func (a *XraySettingController) testOutbound(c *gin.Context) {
outboundJSON := c.PostForm("outbound")
allOutboundsJSON := c.PostForm("allOutbounds")
mode := c.PostForm("mode")
if outboundJSON == "" {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
return
}
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
}
// --- Outbound Subscription handlers ---
func (a *XraySettingController) listOutboundSubs(c *gin.Context) {
list, err := a.OutboundSubscriptionService.List()
if err != nil {
jsonMsg(c, "Failed to list outbound subscriptions", err)
return
}
jsonObj(c, list, nil)
}
func (a *XraySettingController) createOutboundSub(c *gin.Context) {
remark := c.PostForm("remark")
rawURL := c.PostForm("url")
prefix := c.PostForm("tagPrefix")
enabled := c.PostForm("enabled") != "false"
allowPrivate := c.PostForm("allowPrivate") == "true"
prepend := c.PostForm("prepend") == "true"
intervalStr := c.PostForm("updateInterval")
interval := 600
if intervalStr != "" {
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
interval = v
}
}
sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
if err != nil {
jsonMsg(c, "Failed to create outbound subscription", err)
return
}
jsonObj(c, sub, nil)
}
func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
remark := c.PostForm("remark")
rawURL := c.PostForm("url")
prefix := c.PostForm("tagPrefix")
enabled := c.PostForm("enabled") != "false"
allowPrivate := c.PostForm("allowPrivate") == "true"
prepend := c.PostForm("prepend") == "true"
intervalStr := c.PostForm("updateInterval")
interval := 600
if intervalStr != "" {
if v, err := parseIntSafe(intervalStr); err == nil && v > 0 {
interval = v
}
}
if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
jsonMsg(c, "Failed to update outbound subscription", err)
return
}
jsonObj(c, "", nil)
}
func (a *XraySettingController) deleteOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
if err := a.OutboundSubscriptionService.Delete(subID); err != nil {
jsonMsg(c, "Failed to delete outbound subscription", err)
return
}
// Signal that xray should drop this subscription's outbounds on next reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
func (a *XraySettingController) refreshOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
obs, err := a.OutboundSubscriptionService.Refresh(subID)
if err != nil {
jsonMsg(c, "Refresh failed", err)
return
}
// Signal that xray should pick up the new outbounds on next restart/reload
a.XrayService.SetToNeedRestart()
jsonObj(c, obs, nil)
}
func (a *XraySettingController) moveOutboundSub(c *gin.Context) {
id := c.Param("id")
var subID int
if _, err := fmt.Sscanf(id, "%d", &subID); err != nil {
jsonMsg(c, "Invalid id", err)
return
}
up := c.PostForm("dir") == "up"
if err := a.OutboundSubscriptionService.Move(subID, up); err != nil {
jsonMsg(c, "Failed to reorder outbound subscription", err)
return
}
// Order affects the merged outbounds, so xray needs a reload.
a.XrayService.SetToNeedRestart()
jsonObj(c, "", nil)
}
// parseOutboundSubURL is a preview endpoint: it fetches + parses the provided
// URL but does not persist anything. Useful for the "add subscription" flow
// so the user can see the resulting outbounds (and assigned tags) before saving.
func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
rawURL := c.PostForm("url")
if rawURL == "" {
jsonMsg(c, "url is required", common.NewError("missing url"))
return
}
allowPrivate := c.PostForm("allowPrivate") == "true"
// Use a throw-away service instance; it only needs the settingService for proxy.
svc := service.OutboundSubscriptionService{}
// We don't have a direct "fetch once" that returns without storing, so we
// temporarily create a disabled row, refresh it, then delete. Cleaner would
// be to expose a pure ParseURL on the service, but this keeps the surface small.
tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
if err != nil {
jsonMsg(c, "Failed to preview subscription", err)
return
}
obs, err := svc.Refresh(tmp.Id)
// best-effort cleanup
_ = svc.Delete(tmp.Id)
if err != nil {
jsonMsg(c, "Failed to fetch/parse subscription", err)
return
}
jsonObj(c, obs, nil)
}
func parseIntSafe(s string) (int, error) {
var v int
_, err := fmt.Sscanf(s, "%d", &v)
return v, err
}
+246
View File
@@ -0,0 +1,246 @@
// Package entity defines data structures and entities used by the web layer of the 3x-ui panel.
package entity
import (
"crypto/tls"
"math"
"net"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
// Msg represents a standard API response message with success status, message text, and optional data object.
type Msg struct {
Success bool `json:"success"` // Indicates if the operation was successful
Msg string `json:"msg"` // Response message text
Obj any `json:"obj"` // Optional data object
}
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
type AllSetting struct {
// Web server settings
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"` // Web server port number
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"` // Session maximum age in minutes (cap at one year)
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
PanelProxy string `json:"panelProxy" form:"panelProxy"` // Proxy URL for the panel's own outbound requests (GitHub/Telegram)
// UI settings
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"` // Number of items per page in lists (0 disables pagination)
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"` // Expiration warning threshold in days
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
RemarkModel string `json:"remarkModel" form:"remarkModel"` // Remark model pattern for inbounds
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
// Telegram bot settings
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
TgBotLoginNotify bool `json:"tgBotLoginNotify" form:"tgBotLoginNotify"` // Send login notifications
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language
// Security settings
TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` // Enable two-factor authentication
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` // Two-factor authentication token
// Subscription server settings
SubEnable bool `json:"subEnable" form:"subEnable"` // Enable subscription server
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` // Enable JSON subscription endpoint
SubTitle string `json:"subTitle" form:"subTitle"` // Subscription title
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` // Subscription support URL
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` // Subscription profile URL
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ)
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` // Subscription server port
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation
SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` // Subscription update interval in minutes
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
SubEmailInRemark bool `json:"subEmailInRemark" form:"subEmailInRemark"` // Include email in subscription remark/name
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` // Absolute path to a folder containing a custom subscription page template
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
LdapHost string `json:"ldapHost" form:"ldapHost"`
LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"`
LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"`
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
// Generic flag configuration
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
// JSON subscription routing rules
// WARP
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
}
// AllSettingView is the browser-safe settings read model. Secret values
// are redacted from the embedded write model and represented by presence
// flags so the UI can show configured/not configured state.
type AllSettingView struct {
AllSetting
HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"`
}
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
func pathHasForbiddenChar(s string) bool {
for _, r := range s {
if r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
return true
}
}
return false
}
func (s *AllSetting) CheckValid() error {
if s.WebListen != "" {
ip := net.ParseIP(s.WebListen)
if ip == nil {
return common.NewError("web listen is not valid ip:", s.WebListen)
}
}
if s.SubListen != "" {
ip := net.ParseIP(s.SubListen)
if ip == nil {
return common.NewError("Sub listen is not valid ip:", s.SubListen)
}
}
if s.WebPort <= 0 || s.WebPort > math.MaxUint16 {
return common.NewError("web port is not a valid port:", s.WebPort)
}
if s.SubPort <= 0 || s.SubPort > math.MaxUint16 {
return common.NewError("Sub port is not a valid port:", s.SubPort)
}
if (s.SubPort == s.WebPort) && (s.WebListen == s.SubListen) {
return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
}
if s.WebCertFile != "" || s.WebKeyFile != "" {
_, err := tls.LoadX509KeyPair(s.WebCertFile, s.WebKeyFile)
if err != nil {
return common.NewErrorf("cert file <%v> or key file <%v> invalid: %v", s.WebCertFile, s.WebKeyFile, err)
}
}
if s.SubCertFile != "" || s.SubKeyFile != "" {
_, err := tls.LoadX509KeyPair(s.SubCertFile, s.SubKeyFile)
if err != nil {
return common.NewErrorf("cert file <%v> or key file <%v> invalid: %v", s.SubCertFile, s.SubKeyFile, err)
}
}
for _, p := range []struct {
name string
value string
}{
{"web base path", s.WebBasePath},
{"subscription path", s.SubPath},
{"subscription JSON path", s.SubJsonPath},
{"subscription Clash path", s.SubClashPath},
} {
if pathHasForbiddenChar(p.value) {
return common.NewError("URI path contains an invalid character:", p.name)
}
}
if !strings.HasPrefix(s.WebBasePath, "/") {
s.WebBasePath = "/" + s.WebBasePath
}
if !strings.HasSuffix(s.WebBasePath, "/") {
s.WebBasePath += "/"
}
if !strings.HasPrefix(s.SubPath, "/") {
s.SubPath = "/" + s.SubPath
}
if !strings.HasSuffix(s.SubPath, "/") {
s.SubPath += "/"
}
if !strings.HasPrefix(s.SubJsonPath, "/") {
s.SubJsonPath = "/" + s.SubJsonPath
}
if !strings.HasSuffix(s.SubJsonPath, "/") {
s.SubJsonPath += "/"
}
if !strings.HasPrefix(s.SubClashPath, "/") {
s.SubClashPath = "/" + s.SubClashPath
}
if !strings.HasSuffix(s.SubClashPath, "/") {
s.SubClashPath += "/"
}
for cidr := range strings.SplitSeq(s.TrustedProxyCIDRs, ",") {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if ip := net.ParseIP(cidr); ip != nil {
continue
}
if _, _, err := net.ParseCIDR(cidr); err != nil {
return common.NewError("trusted proxy CIDR is not valid:", cidr)
}
}
_, err := time.LoadLocation(s.TimeLocation)
if err != nil {
return common.NewError("time location not exist:", s.TimeLocation)
}
return nil
}
@@ -0,0 +1,32 @@
package entity
import "testing"
func TestPathHasForbiddenChar(t *testing.T) {
valid := []string{
"",
"/",
"/sub/",
"/json/",
"/a/b/c/",
"/My-Path_123/",
}
for _, p := range valid {
if pathHasForbiddenChar(p) {
t.Errorf("pathHasForbiddenChar(%q) = true, want false", p)
}
}
invalid := []string{
"/sub path/",
"/back\\slash/",
"/tab\there/",
"/new\nline/",
"/\x7f/",
}
for _, p := range invalid {
if !pathHasForbiddenChar(p) {
t.Errorf("pathHasForbiddenChar(%q) = false, want true", p)
}
}
}
+71
View File
@@ -0,0 +1,71 @@
// Package global provides global variables and interfaces for accessing web and subscription servers.
package global
import (
"context"
"sync"
_ "unsafe"
"github.com/robfig/cron/v3"
)
var (
webServer WebServer
subServer SubServer
restartHookMu sync.RWMutex
restartHook func()
)
// WebServer interface defines methods for accessing the web server instance.
type WebServer interface {
GetCron() *cron.Cron // Get the cron scheduler
GetCtx() context.Context // Get the server context
GetWSHub() any // Get the WebSocket hub (using any to avoid circular dependency)
}
// SubServer interface defines methods for accessing the subscription server instance.
type SubServer interface {
GetCtx() context.Context // Get the server context
}
// SetWebServer sets the global web server instance.
func SetWebServer(s WebServer) {
webServer = s
}
// GetWebServer returns the global web server instance.
func GetWebServer() WebServer {
return webServer
}
// SetSubServer sets the global subscription server instance.
func SetSubServer(s SubServer) {
subServer = s
}
// GetSubServer returns the global subscription server instance.
func GetSubServer() SubServer {
return subServer
}
// SetRestartHook registers a callback that triggers an in-process panel
// restart. main.go sets this up to push SIGHUP into its own signal channel
// so the restart path works on Windows (where p.Signal(SIGHUP) is unsupported).
func SetRestartHook(fn func()) {
restartHookMu.Lock()
defer restartHookMu.Unlock()
restartHook = fn
}
// TriggerRestart fires the registered restart hook. Returns false if none is set.
func TriggerRestart() bool {
restartHookMu.RLock()
fn := restartHook
restartHookMu.RUnlock()
if fn == nil {
return false
}
fn()
return true
}
+88
View File
@@ -0,0 +1,88 @@
package global
import (
"crypto/md5"
"encoding/hex"
"regexp"
"sync"
"time"
)
// HashEntry represents a stored hash entry with its value and timestamp.
type HashEntry struct {
Hash string // MD5 hash string
Value string // Original value
Timestamp time.Time // Time when the hash was created
}
// HashStorage provides thread-safe storage for hash-value pairs with expiration.
type HashStorage struct {
sync.RWMutex
Data map[string]HashEntry // Map of hash to entry
Expiration time.Duration // Expiration duration for entries
}
// NewHashStorage creates a new HashStorage instance with the specified expiration duration.
func NewHashStorage(expiration time.Duration) *HashStorage {
return &HashStorage{
Data: make(map[string]HashEntry),
Expiration: expiration,
}
}
// SaveHash generates an MD5 hash for the given query string and stores it with a timestamp.
func (h *HashStorage) SaveHash(query string) string {
h.Lock()
defer h.Unlock()
md5Hash := md5.Sum([]byte(query))
md5HashString := hex.EncodeToString(md5Hash[:])
entry := HashEntry{
Hash: md5HashString,
Value: query,
Timestamp: time.Now(),
}
h.Data[md5HashString] = entry
return md5HashString
}
// GetValue retrieves the original value for the given hash, returning true if found.
func (h *HashStorage) GetValue(hash string) (string, bool) {
h.RLock()
defer h.RUnlock()
entry, exists := h.Data[hash]
return entry.Value, exists
}
// IsMD5 checks if the given string is a valid 32-character MD5 hash.
func (h *HashStorage) IsMD5(hash string) bool {
match, _ := regexp.MatchString("^[a-f0-9]{32}$", hash)
return match
}
// RemoveExpiredHashes removes all hash entries that have exceeded the expiration duration.
func (h *HashStorage) RemoveExpiredHashes() {
h.Lock()
defer h.Unlock()
now := time.Now()
for hash, entry := range h.Data {
if now.Sub(entry.Timestamp) > h.Expiration {
delete(h.Data, hash)
}
}
}
// Reset clears all stored hash entries.
func (h *HashStorage) Reset() {
h.Lock()
defer h.Unlock()
h.Data = make(map[string]HashEntry)
}
+586
View File
@@ -0,0 +1,586 @@
package job
import (
"bufio"
"encoding/json"
"errors"
"io"
"log"
"os"
"os/exec"
"regexp"
"runtime"
"sort"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
// IPWithTimestamp tracks an IP address with its last seen timestamp
type IPWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
// CheckClientIpJob monitors client IP addresses from access logs and manages IP blocking based on configured limits.
type CheckClientIpJob struct {
lastClear int64
disAllowedIps []string
}
var job *CheckClientIpJob
const defaultXrayAPIPort = 62789
const ipStaleAfterSeconds = int64(30 * 60)
// NewCheckClientIpJob creates a new client IP monitoring job instance.
func NewCheckClientIpJob() *CheckClientIpJob {
job = new(CheckClientIpJob)
return job
}
func (j *CheckClientIpJob) Run() {
if j.lastClear == 0 {
j.lastClear = time.Now().Unix()
}
shouldClearAccessLog := false
fail2BanEnabled := isFail2BanEnabled()
hasLimit := fail2BanEnabled && j.hasLimitIp()
f2bInstalled := false
if hasLimit {
f2bInstalled = j.checkFail2BanInstalled()
}
isAccessLogAvailable := j.checkAccessLogAvailable(hasLimit)
if fail2BanEnabled && isAccessLogAvailable {
enforce := hasLimit
if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
enforce = false
}
shouldClearAccessLog = j.processLogFile(enforce)
}
if shouldClearAccessLog || (isAccessLogAvailable && time.Now().Unix()-j.lastClear > 3600) {
j.clearAccessLog()
}
}
func (j *CheckClientIpJob) clearAccessLog() {
logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
j.checkError(err)
defer logAccessP.Close()
accessLogPath, err := xray.GetAccessLogPath()
j.checkError(err)
file, err := os.Open(accessLogPath)
j.checkError(err)
defer file.Close()
_, err = io.Copy(logAccessP, file)
j.checkError(err)
err = os.Truncate(accessLogPath, 0)
j.checkError(err)
j.lastClear = time.Now().Unix()
}
func (j *CheckClientIpJob) hasLimitIp() bool {
db := database.GetDB()
var inbounds []*model.Inbound
err := db.Model(model.Inbound{}).Find(&inbounds).Error
if err != nil {
return false
}
for _, inbound := range inbounds {
if inbound.Settings == "" {
continue
}
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
for _, client := range clients {
limitIp := client.LimitIP
if limitIp > 0 {
return true
}
}
}
return false
}
func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
ipRegex := regexp.MustCompile(`from (?:tcp:|udp:)?\[?([0-9a-fA-F\.:]+)\]?:\d+ accepted`)
emailRegex := regexp.MustCompile(`email: (.+)$`)
timestampRegex := regexp.MustCompile(`^(\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2})`)
accessLogPath, _ := xray.GetAccessLogPath()
file, _ := os.Open(accessLogPath)
defer file.Close()
// Track IPs with their last seen timestamp
inboundClientIps := make(map[string]map[string]int64, 100)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
ipMatches := ipRegex.FindStringSubmatch(line)
if len(ipMatches) < 2 {
continue
}
ip := ipMatches[1]
if ip == "127.0.0.1" || ip == "::1" {
continue
}
emailMatches := emailRegex.FindStringSubmatch(line)
if len(emailMatches) < 2 {
continue
}
email := emailMatches[1]
// Extract timestamp from log line
var timestamp int64
timestampMatches := timestampRegex.FindStringSubmatch(line)
if len(timestampMatches) >= 2 {
t, err := time.ParseInLocation("2006/01/02 15:04:05", timestampMatches[1], time.Local)
if err == nil {
timestamp = t.Unix()
} else {
timestamp = time.Now().Unix()
}
} else {
timestamp = time.Now().Unix()
}
if _, exists := inboundClientIps[email]; !exists {
inboundClientIps[email] = make(map[string]int64)
}
// Update timestamp - keep the latest
if existingTime, ok := inboundClientIps[email][ip]; !ok || timestamp > existingTime {
inboundClientIps[email][ip] = timestamp
}
}
if err := scanner.Err(); err != nil {
j.checkError(err)
}
shouldCleanLog := false
for email, ipTimestamps := range inboundClientIps {
// The access log can still reference a client that was just renamed
// or deleted; its email no longer matches any inbound. Skip it (and
// drop any orphaned tracking row) instead of recreating a row and
// logging an ERROR every run until the log rotates out the old email
// (#4963).
inbound, err := j.getInboundByEmail(email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
j.delInboundClientIps(email)
} else {
j.checkError(err)
}
continue
}
// Convert to IPWithTimestamp slice
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
for ip, timestamp := range ipTimestamps {
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
}
clientIpsRecord, err := j.getInboundClientIps(email)
if err != nil {
j.addInboundClientIps(email, ipsWithTime)
continue
}
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
}
return shouldCleanLog
}
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]int64 {
ipMap := make(map[string]int64, len(old)+len(new))
for _, ipTime := range old {
if ipTime.Timestamp < staleCutoff {
continue
}
ipMap[ipTime.IP] = ipTime.Timestamp
}
for _, ipTime := range new {
if ipTime.Timestamp < staleCutoff {
continue
}
if existingTime, ok := ipMap[ipTime.IP]; !ok || ipTime.Timestamp > existingTime {
ipMap[ipTime.IP] = ipTime.Timestamp
}
}
return ipMap
}
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
live = make([]IPWithTimestamp, 0, len(observedThisScan))
historical = make([]IPWithTimestamp, 0, len(ipMap))
now := time.Now().Unix()
for ip, ts := range ipMap {
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
// Consider an IP "live" if it was seen locally in this scan, OR if its
// timestamp from the synced database is very recent (e.g. within 2 minutes).
// This ensures cluster-wide limits work even if the IP was seen on another node.
if observedThisScan[ip] || now-ts < 120 {
live = append(live, entry)
} else {
historical = append(historical, entry)
}
}
sort.Slice(live, func(i, j int) bool { return live[i].Timestamp < live[j].Timestamp })
sort.Slice(historical, func(i, j int) bool { return historical[i].Timestamp < historical[j].Timestamp })
return live, historical
}
func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
if !isFail2BanEnabled() {
return false
}
cmd := "fail2ban-client"
args := []string{"-h"}
err := exec.Command(cmd, args...).Run()
return err == nil
}
func isFail2BanEnabled() bool {
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
return !ok || value == "true"
}
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
accessLogPath, err := xray.GetAccessLogPath()
if err != nil {
return false
}
if accessLogPath == "none" || accessLogPath == "" {
if iplimitActive {
logger.Warning("[LimitIP] Access log path is not set, Please configure the access log path in Xray configs.")
}
return false
}
return true
}
func (j *CheckClientIpJob) checkError(e error) {
if e != nil {
logger.Warning("client ip job err:", e)
}
}
func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.InboundClientIps, error) {
db := database.GetDB()
InboundClientIps := &model.InboundClientIps{}
err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
if err != nil {
return nil, err
}
return InboundClientIps, nil
}
func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime []IPWithTimestamp) error {
inboundClientIps := &model.InboundClientIps{}
jsonIps, err := json.Marshal(ipsWithTime)
j.checkError(err)
inboundClientIps.ClientEmail = clientEmail
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
tx := db.Begin()
defer func() {
if err == nil {
tx.Commit()
} else {
tx.Rollback()
}
}()
err = tx.Save(inboundClientIps).Error
if err != nil {
return err
}
return nil
}
// delInboundClientIps drops the inbound_client_ips tracking row for an email
// that no longer maps to any inbound (a renamed or deleted client), so stale
// access-log entries don't keep a ghost row alive (#4963).
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
db := database.GetDB()
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
j.checkError(err)
}
}
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
if inbound.Settings == "" {
logger.Debug("wrong data:", inbound)
return false
}
settings := map[string][]model.Client{}
json.Unmarshal([]byte(inbound.Settings), &settings)
clients := settings["clients"]
// Find the client's IP limit
var limitIp int
var clientFound bool
for _, client := range clients {
if client.Email == clientEmail {
limitIp = client.LimitIP
clientFound = true
break
}
}
if !enforce || !clientFound || limitIp <= 0 || !inbound.Enable {
// Nothing to enforce (collection-only run, no limit, client missing, or
// inbound disabled): record the observed IPs for the panel and return.
jsonIps, _ := json.Marshal(newIpsWithTime)
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
db.Save(inboundClientIps)
return false
}
// Parse old IPs from database
var oldIpsWithTime []IPWithTimestamp
if inboundClientIps.Ips != "" {
json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
}
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, time.Now().Unix()-ipStaleAfterSeconds)
// only ips seen in this scan count toward the limit. see
// partitionLiveIps.
observedThisScan := make(map[string]bool, len(newIpsWithTime))
for _, ipTime := range newIpsWithTime {
observedThisScan[ipTime.IP] = true
}
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
shouldCleanLog := false
j.disAllowedIps = []string{}
// historical db-only ips are excluded from this count on purpose.
var keptLive []IPWithTimestamp
if len(liveIps) > limitIp {
shouldCleanLog = true
// keep the newest live ips, ban older ones.
cutoff := len(liveIps) - limitIp
keptLive = liveIps[cutoff:]
bannedLive := liveIps[:cutoff]
logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
logger.Errorf("failed to open IP limit log file: %s", err)
return false
}
defer logIpFile.Close()
ipLogger := log.New(logIpFile, "", log.LstdFlags)
// log format is load-bearing: x-ui.sh create_iplimit_jails builds
// filter.d/3x-ipl.conf with
// failregex = \[LIMIT_IP\]\s*Email\s*=\s*<F-USER>.+</F-USER>\s*\|\|\s*Disconnecting OLD IP\s*=\s*<ADDR>\s*\|\|\s*Timestamp\s*=\s*\d+
// don't change the wording.
for _, ipTime := range bannedLive {
j.disAllowedIps = append(j.disAllowedIps, ipTime.IP)
ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp)
}
// force xray to drop existing connections from banned ips
j.disconnectClientTemporarily(inbound, clientEmail, clients)
} else {
keptLive = liveIps
}
// keep kept-live + historical in the blob so the panel keeps showing
// recently seen ips. banned live ips are already in the fail2ban log
// and will reappear in the next scan if they reconnect.
dbIps := make([]IPWithTimestamp, 0, len(keptLive)+len(historicalIps))
dbIps = append(dbIps, keptLive...)
dbIps = append(dbIps, historicalIps...)
jsonIps, _ := json.Marshal(dbIps)
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
err := db.Save(inboundClientIps).Error
if err != nil {
logger.Error("failed to save inboundClientIps:", err)
return false
}
if len(j.disAllowedIps) > 0 {
logger.Infof("[LIMIT_IP] Client %s: Kept %d live IPs, queued %d old IPs for fail2ban", clientEmail, len(keptLive), len(j.disAllowedIps))
}
return shouldCleanLog
}
// disconnectClientTemporarily removes and re-adds a client to force disconnect banned connections
func (j *CheckClientIpJob) disconnectClientTemporarily(inbound *model.Inbound, clientEmail string, clients []model.Client) {
var xrayAPI xray.XrayAPI
apiPort := j.resolveXrayAPIPort()
err := xrayAPI.Init(apiPort)
if err != nil {
logger.Warningf("[LIMIT_IP] Failed to init Xray API for disconnection: %v", err)
return
}
defer xrayAPI.Close()
// Find the client config
var clientConfig map[string]any
for _, client := range clients {
if client.Email == clientEmail {
// Convert client to map for API
clientBytes, _ := json.Marshal(client)
json.Unmarshal(clientBytes, &clientConfig)
break
}
}
if clientConfig == nil {
return
}
// Only perform remove/re-add for protocols supported by XrayAPI.AddUser
protocol := string(inbound.Protocol)
switch protocol {
case "vmess", "vless", "trojan", "shadowsocks":
// supported protocols, continue
default:
logger.Warningf("[LIMIT_IP] Temporary disconnect is not supported for protocol %s on inbound %s", protocol, inbound.Tag)
return
}
// For Shadowsocks, ensure the required "cipher" field is present by
// reading it from the inbound settings (e.g., settings["method"]).
if string(inbound.Protocol) == "shadowsocks" {
var inboundSettings map[string]any
if err := json.Unmarshal([]byte(inbound.Settings), &inboundSettings); err != nil {
logger.Warningf("[LIMIT_IP] Failed to parse inbound settings for shadowsocks cipher: %v", err)
} else {
if method, ok := inboundSettings["method"].(string); ok && method != "" {
clientConfig["cipher"] = method
}
}
}
// Remove user to disconnect all connections
err = xrayAPI.RemoveUser(inbound.Tag, clientEmail)
if err != nil {
logger.Warningf("[LIMIT_IP] Failed to remove user %s: %v", clientEmail, err)
return
}
// Wait a moment for disconnection to take effect
time.Sleep(100 * time.Millisecond)
// Re-add user to allow new connections
err = xrayAPI.AddUser(protocol, inbound.Tag, clientConfig)
if err != nil {
logger.Warningf("[LIMIT_IP] Failed to re-add user %s: %v", clientEmail, err)
}
}
// resolveXrayAPIPort returns the API inbound port from running config, then template config, then default.
func (j *CheckClientIpJob) resolveXrayAPIPort() int {
var configErr error
var templateErr error
if port, err := getAPIPortFromConfigPath(xray.GetConfigPath()); err == nil {
return port
} else {
configErr = err
}
db := database.GetDB()
var template model.Setting
if err := db.Where("key = ?", "xrayTemplateConfig").First(&template).Error; err == nil {
if port, parseErr := getAPIPortFromConfigData([]byte(template.Value)); parseErr == nil {
return port
} else {
templateErr = parseErr
}
} else {
templateErr = err
}
logger.Warningf(
"[LIMIT_IP] Could not determine Xray API port from config or template; falling back to default port %d (config error: %v, template error: %v)",
defaultXrayAPIPort,
configErr,
templateErr,
)
return defaultXrayAPIPort
}
func getAPIPortFromConfigPath(configPath string) (int, error) {
configData, err := os.ReadFile(configPath)
if err != nil {
return 0, err
}
return getAPIPortFromConfigData(configData)
}
func getAPIPortFromConfigData(configData []byte) (int, error) {
xrayConfig := &xray.Config{}
if err := json.Unmarshal(configData, xrayConfig); err != nil {
return 0, err
}
for _, inboundConfig := range xrayConfig.InboundConfigs {
if inboundConfig.Tag == "api" && inboundConfig.Port > 0 {
return inboundConfig.Port, nil
}
}
return 0, errors.New("api inbound port not found")
}
func (j *CheckClientIpJob) getInboundByEmail(clientEmail string) (*model.Inbound, error) {
db := database.GetDB()
inbound := &model.Inbound{}
err := db.Model(&model.Inbound{}).Where("settings LIKE ?", "%"+clientEmail+"%").First(inbound).Error
if err != nil {
return nil, err
}
return inbound, nil
}
@@ -0,0 +1,377 @@
package job
import (
"encoding/json"
"log"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/op/go-logging"
)
// 3x-ui logger must be initialised once before any code path that can
// log a warning. otherwise log.Warningf panics on a nil logger.
var loggerInitOnce sync.Once
// setupIntegrationDB wires a temp sqlite db and log folder so
// updateInboundClientIps can run end to end. closes the db before
// TempDir cleanup so windows doesn't complain about the file being in
// use.
func setupIntegrationDB(t *testing.T) {
t.Helper()
loggerInitOnce.Do(func() {
xuilogger.InitLogger(logging.ERROR)
})
dbDir := t.TempDir()
logDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
t.Setenv("XUI_LOG_FOLDER", logDir)
// updateInboundClientIps calls log.SetOutput on the package global,
// which would leak to other tests in the same binary.
origLogWriter := log.Writer()
origLogFlags := log.Flags()
t.Cleanup(func() {
log.SetOutput(origLogWriter)
log.SetFlags(origLogFlags)
})
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("database.InitDB failed: %v", err)
}
// LIFO cleanup order: this runs before t.TempDir's own cleanup.
t.Cleanup(func() {
if err := database.CloseDB(); err != nil {
t.Logf("database.CloseDB warning: %v", err)
}
})
}
// seed an inbound whose settings json has a single client with the
// given email and ip limit.
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
t.Helper()
settings := map[string]any{
"clients": []map[string]any{
{
"email": email,
"limitIp": limitIp,
"enable": true,
},
},
}
settingsJSON, err := json.Marshal(settings)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := &model.Inbound{
Tag: tag,
Enable: true,
Protocol: model.VLESS,
Port: 4321,
Settings: string(settingsJSON),
}
if err := database.GetDB().Create(inbound).Error; err != nil {
t.Fatalf("seed inbound: %v", err)
}
}
// seed an InboundClientIps row with the given blob.
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
t.Helper()
blob, err := json.Marshal(ips)
if err != nil {
t.Fatalf("marshal ips: %v", err)
}
row := &model.InboundClientIps{
ClientEmail: email,
Ips: string(blob),
}
if err := database.GetDB().Create(row).Error; err != nil {
t.Fatalf("seed InboundClientIps: %v", err)
}
return row
}
// read the persisted blob and parse it back.
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
t.Helper()
row := &model.InboundClientIps{}
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
t.Fatalf("read InboundClientIps for %s: %v", email, err)
}
if row.Ips == "" {
return nil
}
var out []IPWithTimestamp
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
}
return out
}
// make a lookup map so asserts don't depend on slice order.
func ipSet(entries []IPWithTimestamp) map[string]int64 {
out := make(map[string]int64, len(entries))
for _, e := range entries {
out[e.IP] = e.Timestamp
}
return out
}
func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
const email = "disabled-fail2ban"
seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
binDir := t.TempDir()
accessLog := filepath.Join(t.TempDir(), "access.log")
t.Setenv("XUI_BIN_FOLDER", binDir)
configData, err := json.Marshal(map[string]any{
"log": map[string]any{"access": accessLog},
})
if err != nil {
t.Fatalf("marshal xray config: %v", err)
}
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
t.Fatalf("write xray config: %v", err)
}
if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
t.Fatalf("write access log: %v", err)
}
j := NewCheckClientIpJob()
j.Run()
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
body, _ := os.ReadFile(readIpLimitLogPath())
t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
}
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
t.Fatalf("count InboundClientIps: %v", err)
}
if count != 0 {
t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
}
}
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
// few minutes ago, only one live ip is actually connecting. pre-fix:
// live ip got banned every tick and never appeared in the panel.
// post-fix: no ban, live ip persisted, historical ips still visible.
func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
setupIntegrationDB(t)
const email = "pr4091-repro"
seedInboundWithClient(t, "inbound-pr4091", email, 3)
now := time.Now().Unix()
// idle but still within the 30min staleness window.
row := seedClientIps(t, email, []IPWithTimestamp{
{IP: "10.0.0.1", Timestamp: now - 20*60},
{IP: "10.0.0.2", Timestamp: now - 15*60},
{IP: "10.0.0.3", Timestamp: now - 10*60},
})
j := NewCheckClientIpJob()
// the one that's actually connecting (user's 128.71.x.x).
live := []IPWithTimestamp{
{IP: "128.71.1.1", Timestamp: now},
}
inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
if shouldCleanLog {
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
}
if len(j.disAllowedIps) != 0 {
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
}
persisted := ipSet(readClientIps(t, email))
for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
if _, ok := persisted[want]; !ok {
t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
}
}
if got := persisted["128.71.1.1"]; got != now {
t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
}
// 3xipl.log must not contain a ban line.
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
body, _ := os.ReadFile(readIpLimitLogPath())
t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
}
}
// opposite invariant: when several ips are actually live and exceed
// the limit, the oldest connection is dropped and the most recent one
// keeps the slot (last-IP-wins policy from #3735, restored in #4699).
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
setupIntegrationDB(t)
const email = "pr4091-abuse"
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
now := time.Now().Unix()
row := seedClientIps(t, email, []IPWithTimestamp{
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
})
j := NewCheckClientIpJob()
// both live, limit=1. use distinct timestamps so sort-by-timestamp
// is deterministic: 10.1.0.1 is the original (older) and must get
// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
live := []IPWithTimestamp{
{IP: "10.1.0.1", Timestamp: now - 5},
{IP: "192.0.2.9", Timestamp: now},
}
inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
if !shouldCleanLog {
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
}
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
}
persisted := ipSet(readClientIps(t, email))
if _, ok := persisted["192.0.2.9"]; !ok {
t.Errorf("newest IP 192.0.2.9 must still be persisted; got %v", persisted)
}
if _, ok := persisted["10.1.0.1"]; ok {
t.Errorf("banned IP 10.1.0.1 must NOT be persisted; got %v", persisted)
}
// 3xipl.log must contain the ban line in the exact fail2ban format.
body, err := os.ReadFile(readIpLimitLogPath())
if err != nil {
t.Fatalf("read 3xipl.log: %v", err)
}
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 10.1.0.1"
if !contains(string(body), wantSubstr) {
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
}
}
// writeXrayAccessLog points bin/config.json at a fresh access.log holding a
// single default-format Xray line (`from tcp:<ip>:<port> accepted … email: <e>`)
// for the given client, so Run() has something to scrape.
func writeXrayAccessLog(t *testing.T, email, ip string) {
t.Helper()
binDir := t.TempDir()
accessLog := filepath.Join(t.TempDir(), "access.log")
t.Setenv("XUI_BIN_FOLDER", binDir)
configData, err := json.Marshal(map[string]any{
"log": map[string]any{"access": accessLog},
})
if err != nil {
t.Fatalf("marshal xray config: %v", err)
}
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
t.Fatalf("write xray config: %v", err)
}
line := "2026/06/02 13:35:53 from tcp:" + ip + ":2387 accepted tcp:example.com:443 email: " + email + "\n"
if err := os.WriteFile(accessLog, []byte(line), 0644); err != nil {
t.Fatalf("write access log: %v", err)
}
}
// #4800: the per-client IP log must populate even when no client has an IP
// limit. Before the fix, Run() only scraped the access log when an IP limit
// was active, so a limit-free install always showed an empty IP log despite
// valid access-log lines. No ban may be written since there's no limit.
func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
fakeFail2BanClient(t)
const email = "no-limit-user"
seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
writeXrayAccessLog(t, email, "203.0.113.10")
NewCheckClientIpJob().Run()
ips := readClientIps(t, email)
if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
t.Fatalf("expected the access-log IP to be collected without a limit, got %v", ips)
}
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
body, _ := os.ReadFile(readIpLimitLogPath())
t.Fatalf("3xipl.log should be empty with no limit set, got:\n%s", body)
}
}
// #4963: a stale access-log entry for a renamed/deleted client (its email no
// longer maps to any inbound) must not create or resurrect an
// inbound_client_ips row, and must drop any orphan left behind — instead of
// spamming "failed to fetch inbound settings" every run.
func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
fakeFail2BanClient(t)
const staleEmail = "renamed-away"
// No inbound references staleEmail. Pre-seed an orphan tracking row to
// confirm the job removes it rather than leaving it to error forever.
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
writeXrayAccessLog(t, staleEmail, "203.0.113.5")
NewCheckClientIpJob().Run()
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
t.Fatalf("count InboundClientIps: %v", err)
}
if count != 0 {
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
}
}
// readIpLimitLogPath reads the 3xipl.log path the same way the job
// does via xray.GetIPLimitLogPath but without importing xray here
// just for the path helper (which would pull a lot more deps into the
// test binary). The env-derived log folder is deterministic.
func readIpLimitLogPath() string {
folder := os.Getenv("XUI_LOG_FOLDER")
if folder == "" {
folder = filepath.Join(".", "log")
}
return filepath.Join(folder, "3xipl.log")
}
func contains(haystack, needle string) bool {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return true
}
}
return false
}
@@ -0,0 +1,243 @@
package job
import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
"time"
)
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
// #4077: after a ban expires, a single IP that reconnects used to get
// banned again immediately because a long-disconnected IP stayed in the
// DB with an ancient timestamp and kept "protecting" itself against
// eviction. Guard against that regression here.
old := []IPWithTimestamp{
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
}
new := []IPWithTimestamp{
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
}
got := mergeClientIps(old, new, 1000)
want := map[string]int64{"2.2.2.2": 2000}
if !reflect.DeepEqual(got, want) {
t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
}
}
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
// Backwards-compat: entries that aren't stale are still carried forward,
// so enforcement survives access-log rotation.
old := []IPWithTimestamp{
{IP: "1.1.1.1", Timestamp: 1500},
}
got := mergeClientIps(old, nil, 1000)
want := map[string]int64{"1.1.1.1": 1500}
if !reflect.DeepEqual(got, want) {
t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
}
}
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
got := mergeClientIps(old, new, 1000)
if got["1.1.1.1"] != 1700 {
t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
}
}
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
// A log line with a clock-skewed old timestamp must not resurrect a
// stale IP past the cutoff.
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
got := mergeClientIps(nil, new, 1000)
if len(got) != 0 {
t.Fatalf("stale new IP should have been dropped, got %v", got)
}
}
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
// Defensive: a zero cutoff (e.g. during very first run on a fresh
// install) must not over-evict.
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
got := mergeClientIps(old, new, 0)
want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
if !reflect.DeepEqual(got, want) {
t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
}
}
func collectIps(entries []IPWithTimestamp) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.IP)
}
return out
}
func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing.T) {
// #4091: db holds A, B, C from minutes ago (still in the 30min
// window) but they're not connecting anymore. only D is. old code
// merged all four, sorted ascending, kept [A,B,C] and banned D
// every tick. pin the new rule: only live ips count toward the limit.
ipMap := map[string]int64{
"A": 1000,
"B": 1100,
"C": 1200,
"D": 2000,
}
observed := map[string]bool{"D": true}
live, historical := partitionLiveIps(ipMap, observed)
if got := collectIps(live); !reflect.DeepEqual(got, []string{"D"}) {
t.Fatalf("live set should only contain the ip observed this scan\ngot: %v\nwant: [D]", got)
}
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B", "C"}) {
t.Fatalf("historical set should contain db-only ips in ascending order\ngot: %v\nwant: [A B C]", got)
}
}
func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
// when several ips are really live, partition returns them all in the
// live set sorted ascending by timestamp. updateInboundClientIps then
// keeps the newest and bans the oldest (last-IP-wins, #4699).
ipMap := map[string]int64{
"A": 5000,
"B": 5500,
}
observed := map[string]bool{"A": true, "B": true}
live, historical := partitionLiveIps(ipMap, observed)
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A", "B"}) {
t.Fatalf("both live ips should be in the live set, ascending\ngot: %v\nwant: [A B]", got)
}
if len(historical) != 0 {
t.Fatalf("no historical ips expected, got %v", historical)
}
}
func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
// quiet tick: nothing observed => nothing live. everything merged
// is historical. keeps the panel from wiping recent-but-idle ips.
ipMap := map[string]int64{
"A": 1000,
"B": 1100,
}
observed := map[string]bool{}
live, historical := partitionLiveIps(ipMap, observed)
if len(live) != 0 {
t.Fatalf("no live ips expected, got %v", live)
}
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B"}) {
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
}
}
func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
// Synced IPs from other nodes within 2 minutes should be counted as live
// even if they weren't observed in the local scan.
now := time.Now().Unix()
ipMap := map[string]int64{
"A": now - 30, // synced 30s ago -> live
"B": now - 150, // synced 2m30s ago -> historical
}
observed := map[string]bool{}
live, historical := partitionLiveIps(ipMap, observed)
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
t.Fatalf("recent IP should be live\ngot: %v\nwant: [A]", got)
}
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
t.Fatalf("older IP should be historical\ngot: %v\nwant: [B]", got)
}
}
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
}
func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "")
marker := fakeFail2BanClient(t)
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
}
func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
t.Cleanup(func() {
if ok {
os.Setenv("XUI_ENABLE_FAIL2BAN", value)
} else {
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
}
})
if !isFail2BanEnabled() {
t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
}
}
func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
marker := fakeFail2BanClient(t)
if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be available when the client probe succeeds")
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("fail2ban-client should have been executed: %v", err)
}
}
func fakeFail2BanClient(t *testing.T) string {
t.Helper()
dir := t.TempDir()
marker := filepath.Join(dir, "probe-called")
fakeClient := filepath.Join(dir, "fail2ban-client")
script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
if runtime.GOOS == "windows" {
fakeClient += ".bat"
script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
}
if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
t.Fatalf("write fake fail2ban-client: %v", err)
}
t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return marker
}
+41
View File
@@ -0,0 +1,41 @@
package job
import (
"strconv"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
"github.com/shirou/gopsutil/v4/cpu"
)
// CheckCpuJob monitors CPU usage and sends Telegram notifications when usage exceeds the configured threshold.
type CheckCpuJob struct {
tgbotService tgbot.Tgbot
settingService service.SettingService
}
// NewCheckCpuJob creates a new CPU monitoring job instance.
func NewCheckCpuJob() *CheckCpuJob {
return new(CheckCpuJob)
}
// Run checks CPU usage over the last minute and sends a Telegram alert if it exceeds the threshold.
func (j *CheckCpuJob) Run() {
threshold, err := j.settingService.GetTgCpu()
if err != nil || threshold <= 0 {
// If threshold cannot be retrieved or is not set, skip sending notifications
return
}
// get latest status of server
percent, err := cpu.Percent(1*time.Minute, false)
if err == nil && percent[0] > float64(threshold) {
msg := j.tgbotService.I18nBot("tgbot.messages.cpuThreshold",
"Percent=="+strconv.FormatFloat(percent[0], 'f', 2, 64),
"Threshold=="+strconv.Itoa(threshold))
j.tgbotService.SendMsgToTgbotAdmins(msg)
}
}
+22
View File
@@ -0,0 +1,22 @@
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()
}
@@ -0,0 +1,12 @@
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()
}
@@ -0,0 +1,36 @@
// Package job provides background job implementations for the 3x-ui web panel,
// including traffic monitoring, system checks, and periodic maintenance tasks.
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// CheckXrayRunningJob monitors Xray process health and restarts it if it crashes.
type CheckXrayRunningJob struct {
xrayService service.XrayService
checkTime int
}
// NewCheckXrayRunningJob creates a new Xray health check job instance.
func NewCheckXrayRunningJob() *CheckXrayRunningJob {
return new(CheckXrayRunningJob)
}
// Run checks if Xray has crashed and restarts it after confirming it's down for 2 consecutive checks.
func (j *CheckXrayRunningJob) Run() {
if !j.xrayService.DidXrayCrash() {
j.checkTime = 0
} else {
j.checkTime++
// only restart if it's down 2 times in a row
if j.checkTime > 1 {
err := j.xrayService.RestartXray(false)
j.checkTime = 0
if err != nil {
logger.Error("Restart xray failed:", err)
}
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package job
import (
"io"
"os"
"path/filepath"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// ClearLogsJob clears old log files to prevent disk space issues.
type ClearLogsJob struct{}
// NewClearLogsJob creates a new log cleanup job instance.
func NewClearLogsJob() *ClearLogsJob {
return new(ClearLogsJob)
}
// ensureFileExists creates the necessary directories and file if they don't exist
func ensureFileExists(path string) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return err
}
file.Close()
return nil
}
// Here Run is an interface method of the Job interface
func (j *ClearLogsJob) Run() {
logFiles := []string{xray.GetIPLimitLogPath(), xray.GetIPLimitBannedLogPath(), xray.GetAccessPersistentLogPath()}
logFilesPrev := []string{xray.GetIPLimitBannedPrevLogPath(), xray.GetAccessPersistentPrevLogPath()}
// Ensure all log files and their paths exist
for _, path := range append(logFiles, logFilesPrev...) {
if err := ensureFileExists(path); err != nil {
logger.Warning("Failed to ensure log file exists:", path, "-", err)
}
}
// Clear log files and copy to previous logs
for i := range len(logFiles) {
if i > 0 {
// Copy to previous logs
logFilePrev, err := os.OpenFile(logFilesPrev[i-1], os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
logger.Warning("Failed to open previous log file for writing:", logFilesPrev[i-1], "-", err)
continue
}
logFile, err := os.OpenFile(logFiles[i], os.O_RDONLY, 0644)
if err != nil {
logger.Warning("Failed to open current log file for reading:", logFiles[i], "-", err)
logFilePrev.Close()
continue
}
_, err = io.Copy(logFilePrev, logFile)
if err != nil {
logger.Warning("Failed to copy log file:", logFiles[i], "to", logFilesPrev[i-1], "-", err)
}
logFile.Close()
logFilePrev.Close()
}
err := os.Truncate(logFiles[i], 0)
if err != nil {
logger.Warning("Failed to truncate log file:", logFiles[i], "-", err)
}
}
}
+302
View File
@@ -0,0 +1,302 @@
package job
import (
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
var DefaultTruthyValues = []string{"true", "1", "yes", "on"}
type LdapSyncJob struct {
settingService service.SettingService
inboundService service.InboundService
clientService service.ClientService
xrayService service.XrayService
}
// --- Helper functions for mustGet ---
func mustGetString(fn func() (string, error)) string {
v, err := fn()
if err != nil {
panic(err)
}
return v
}
func mustGetInt(fn func() (int, error)) int {
v, err := fn()
if err != nil {
panic(err)
}
return v
}
func mustGetBool(fn func() (bool, error)) bool {
v, err := fn()
if err != nil {
panic(err)
}
return v
}
func mustGetStringOr(fn func() (string, error), fallback string) string {
v, err := fn()
if err != nil || v == "" {
return fallback
}
return v
}
func NewLdapSyncJob() *LdapSyncJob {
return new(LdapSyncJob)
}
func (j *LdapSyncJob) Run() {
logger.Info("LDAP sync job started")
enabled, err := j.settingService.GetLdapEnable()
if err != nil || !enabled {
logger.Warning("LDAP disabled or failed to fetch flag")
return
}
// --- LDAP fetch ---
cfg := ldaputil.Config{
Host: mustGetString(j.settingService.GetLdapHost),
Port: mustGetInt(j.settingService.GetLdapPort),
UseTLS: mustGetBool(j.settingService.GetLdapUseTLS),
BindDN: mustGetString(j.settingService.GetLdapBindDN),
Password: mustGetString(j.settingService.GetLdapPassword),
BaseDN: mustGetString(j.settingService.GetLdapBaseDN),
UserFilter: mustGetString(j.settingService.GetLdapUserFilter),
UserAttr: mustGetString(j.settingService.GetLdapUserAttr),
FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)),
TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)),
Invert: mustGetBool(j.settingService.GetLdapInvertFlag),
}
flags, err := ldaputil.FetchVlessFlags(cfg)
if err != nil {
logger.Warning("LDAP fetch failed:", err)
return
}
logger.Infof("Fetched %d LDAP flags", len(flags))
// --- Load all inbounds and all clients once ---
inboundTags := splitCsv(mustGetString(j.settingService.GetLdapInboundTags))
inbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Failed to get inbounds:", err)
return
}
allClients := map[string]*model.Client{} // email -> client
inboundMap := map[string]*model.Inbound{} // tag -> inbound
for _, ib := range inbounds {
inboundMap[ib.Tag] = ib
clients, _ := j.inboundService.GetClients(ib)
for i := range clients {
allClients[clients[i].Email] = &clients[i]
}
}
// --- Prepare batch operations ---
autoCreate := mustGetBool(j.settingService.GetLdapAutoCreate)
defGB := mustGetInt(j.settingService.GetLdapDefaultTotalGB)
defExpiryDays := mustGetInt(j.settingService.GetLdapDefaultExpiryDays)
defLimitIP := mustGetInt(j.settingService.GetLdapDefaultLimitIP)
clientsToCreate := map[string][]model.Client{} // tag -> []new clients
clientsToEnable := map[string][]string{} // tag -> []email
clientsToDisable := map[string][]string{} // tag -> []email
for email, allowed := range flags {
exists := allClients[email] != nil
for _, tag := range inboundTags {
if !exists && allowed && autoCreate {
newClient := j.buildClient(inboundMap[tag], email, defGB, defExpiryDays, defLimitIP)
clientsToCreate[tag] = append(clientsToCreate[tag], newClient)
} else if exists {
if allowed && !allClients[email].Enable {
clientsToEnable[tag] = append(clientsToEnable[tag], email)
} else if !allowed && allClients[email].Enable {
clientsToDisable[tag] = append(clientsToDisable[tag], email)
}
}
}
}
for tag, newClients := range clientsToCreate {
if len(newClients) == 0 {
continue
}
ib := inboundMap[tag]
created := 0
restartNeeded := false
for _, c := range newClients {
nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c)
if err != nil {
logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err)
continue
}
created++
if nr {
restartNeeded = true
}
}
if created > 0 {
logger.Infof("LDAP auto-create: %d clients for %s", created, tag)
if restartNeeded {
j.xrayService.SetToNeedRestart()
}
}
}
// --- Execute enable/disable batch ---
for tag, emails := range clientsToEnable {
j.batchSetEnable(inboundMap[tag], emails, true)
}
for tag, emails := range clientsToDisable {
j.batchSetEnable(inboundMap[tag], emails, false)
}
// --- Auto delete clients not in LDAP ---
autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete)
if autoDelete {
ldapEmailSet := map[string]struct{}{}
for e := range flags {
ldapEmailSet[e] = struct{}{}
}
for _, tag := range inboundTags {
j.deleteClientsNotInLDAP(tag, ldapEmailSet)
}
}
}
func splitCsv(s string) []string {
if s == "" {
return DefaultTruthyValues
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
v := strings.TrimSpace(p)
if v != "" {
out = append(out, v)
}
}
return out
}
// buildClient creates a new client for auto-create
func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExpiryDays, defLimitIP int) model.Client {
c := model.Client{
Email: email,
Enable: true,
LimitIP: defLimitIP,
TotalGB: int64(defGB),
}
if defExpiryDays > 0 {
c.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli()
}
switch ib.Protocol {
case model.Trojan, model.Shadowsocks:
c.Password = uuid.NewString()
default:
c.ID = uuid.NewString()
}
return c
}
func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) {
if len(emails) == 0 {
return
}
restartNeeded := false
changed := 0
for _, email := range emails {
ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable)
if err != nil {
logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err)
continue
}
if ok {
changed++
}
if needRestart {
restartNeeded = true
}
}
if changed > 0 {
logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag)
}
if restartNeeded {
j.xrayService.SetToNeedRestart()
}
}
// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart
func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) {
inbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("Failed to get inbounds for deletion:", err)
return
}
batchSize := 50 // clients in 1 batch
restartNeeded := false
for _, ib := range inbounds {
if ib.Tag != inboundTag {
continue
}
clients, err := j.inboundService.GetClients(ib)
if err != nil {
logger.Warningf("Failed to get clients for inbound %s: %v", ib.Tag, err)
continue
}
// Collect clients for deletion
toDelete := []model.Client{}
for _, c := range clients {
if _, ok := ldapEmails[c.Email]; !ok {
toDelete = append(toDelete, c)
}
}
if len(toDelete) == 0 {
continue
}
for i := 0; i < len(toDelete); i += batchSize {
end := min(i+batchSize, len(toDelete))
batch := toDelete[i:end]
for _, c := range batch {
nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email)
if err != nil {
logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v",
c.Email, ib.Id, ib.Tag, err)
continue
}
logger.Infof("Deleted client %s from inbound id=%d(tag=%s)",
c.Email, ib.Id, ib.Tag)
if nr {
restartNeeded = true
}
}
}
}
if restartNeeded {
j.xrayService.SetToNeedRestart()
logger.Info("Xray restart scheduled after batch deletion")
}
}
+62
View File
@@ -0,0 +1,62 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// MtprotoJob reconciles the running mtg sidecar processes against the enabled
// mtproto inbounds in the database, restarts any that crashed, and folds the
// per-inbound traffic scraped from each mtg metrics endpoint into the usual
// inbound traffic accounting.
type MtprotoJob struct {
inboundService service.InboundService
}
// NewMtprotoJob creates a new mtproto reconcile/traffic job instance.
func NewMtprotoJob() *MtprotoJob {
return new(MtprotoJob)
}
// Run reconciles desired mtproto inbounds with running mtg processes and
// records traffic deltas.
func (j *MtprotoJob) Run() {
inbounds, err := j.inboundService.GetAllInbounds()
if err != nil {
logger.Warning("mtproto job: get inbounds failed:", err)
return
}
var desired []mtproto.Instance
for _, ib := range inbounds {
if ib.Protocol != model.MTProto || !ib.Enable || ib.NodeID != nil {
continue
}
if inst, ok := mtproto.InstanceFromInbound(ib); ok {
desired = append(desired, inst)
}
}
mgr := mtproto.GetManager()
mgr.Reconcile(desired)
deltas := mgr.CollectTraffic()
if len(deltas) == 0 {
return
}
traffics := make([]*xray.Traffic, 0, len(deltas))
for _, d := range deltas {
traffics = append(traffics, &xray.Traffic{
IsInbound: true,
Tag: d.Tag,
Up: d.Up,
Down: d.Down,
})
}
if _, _, err := j.inboundService.AddTraffic(traffics, nil); err != nil {
logger.Warning("mtproto job: add traffic failed:", err)
}
}
+92
View File
@@ -0,0 +1,92 @@
package job
import (
"context"
"sync"
"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/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
)
const (
nodeHeartbeatConcurrency = 32
nodeHeartbeatRequestTimeout = 4 * time.Second
)
type NodeHeartbeatJob struct {
nodeService service.NodeService
running sync.Mutex
}
func NewNodeHeartbeatJob() *NodeHeartbeatJob {
return &NodeHeartbeatJob{}
}
func (j *NodeHeartbeatJob) Run() {
if !j.running.TryLock() {
return
}
defer j.running.Unlock()
nodes, err := j.nodeService.GetAll()
if err != nil {
logger.Warning("node heartbeat: load nodes failed:", err)
return
}
if len(nodes) == 0 {
return
}
sem := make(chan struct{}, nodeHeartbeatConcurrency)
var wg sync.WaitGroup
for _, n := range nodes {
if !n.Enable {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(n *model.Node) {
defer wg.Done()
defer func() { <-sem }()
j.probeOne(n)
}(n)
}
wg.Wait()
if !websocket.HasClients() {
return
}
updated, err := j.nodeService.GetNodeTree()
if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return
}
websocket.BroadcastNodes(updated)
}
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
defer cancel()
patch, err := j.nodeService.Probe(ctx, n)
if err != nil {
patch.Status = "offline"
} else {
patch.Status = "online"
}
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
}
// Learn the nodes this node manages so the panel can surface them as
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
// be spent. Drop them when the node is unreachable.
if patch.Status == "online" {
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
j.nodeService.RefreshDescendants(dctx, n)
dcancel()
} else {
j.nodeService.ClearDescendants(n.Id)
}
}
+230
View File
@@ -0,0 +1,230 @@
package job
import (
"context"
"sync"
"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/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
)
const (
nodeTrafficSyncConcurrency = 8
nodeTrafficSyncRequestTimeout = 4 * time.Second
nodeReconcileTimeout = 30 * time.Second
nodeClientIpSyncInterval = 10 * time.Second
)
type NodeTrafficSyncJob struct {
nodeService service.NodeService
inboundService service.InboundService
settingService service.SettingService
xrayService service.XrayService
running sync.Mutex
structural atomicBool
ipSyncMu sync.Mutex
lastIpSync int64
}
type atomicBool struct {
mu sync.Mutex
v bool
}
func (a *atomicBool) set() {
a.mu.Lock()
a.v = true
a.mu.Unlock()
}
func (a *atomicBool) takeAndReset() bool {
a.mu.Lock()
v := a.v
a.v = false
a.mu.Unlock()
return v
}
func NewNodeTrafficSyncJob() *NodeTrafficSyncJob {
return &NodeTrafficSyncJob{}
}
func (j *NodeTrafficSyncJob) Run() {
if !j.running.TryLock() {
return
}
defer j.running.Unlock()
mgr := runtime.GetManager()
if mgr == nil {
return
}
nodes, err := j.nodeService.GetAll()
if err != nil {
logger.Warning("node traffic sync: load nodes failed:", err)
return
}
if len(nodes) == 0 {
return
}
// Decide once per tick whether this run also syncs client IPs, and stamp the
// clock before the loop so two back-to-back 5s ticks can't both qualify.
doIpSync := false
j.ipSyncMu.Lock()
if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
doIpSync = true
j.lastIpSync = now
}
j.ipSyncMu.Unlock()
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
var wg sync.WaitGroup
for _, n := range nodes {
if !n.Enable || n.Status != "online" {
continue
}
wg.Add(1)
sem <- struct{}{}
go func(n *model.Node) {
defer wg.Done()
defer func() { <-sem }()
j.syncOne(mgr, n, doIpSync)
}(n)
}
wg.Wait()
_, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil)
if err != nil {
logger.Warning("node traffic sync: depletion check failed:", err)
}
if clientsDisabled {
if restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable(); settingErr == nil && restartOnDisable {
if err := j.xrayService.RestartXray(true); err != nil {
logger.Warning("node traffic sync: restart xray after disabling clients failed:", err)
j.xrayService.SetToNeedRestart()
}
} else if settingErr != nil {
logger.Warning("node traffic sync: get RestartXrayOnClientDisable failed:", settingErr)
}
j.structural.set()
}
lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err)
}
if lastOnline == nil {
lastOnline = map[string]int64{}
}
// Prune stale local-online entries (no local active emails or inbound tags
// to add here — only the local xray poll feeds those) so a stopped local
// xray's clients and inbounds still age out between traffic polls.
j.inboundService.RefreshLocalOnlineClients(nil, nil)
if !websocket.HasClients() {
return
}
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnline,
})
clientStats := map[string]any{}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("node traffic sync: get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStats["clients"] = stats
}
if summary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
logger.Warning("node traffic sync: get inbounds summary for websocket failed:", err)
} else if len(summary) > 0 {
clientStats["inbounds"] = summary
}
if len(clientStats) > 0 {
websocket.BroadcastClientStats(clientStats)
}
if j.structural.takeAndReset() {
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
websocket.BroadcastInvalidate(websocket.MessageTypeClients)
}
}
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
rt, err := mgr.RemoteFor(n)
if err != nil {
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
return
}
if n.ConfigDirty {
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
reconcileCancel()
if reconcileErr != nil {
logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
return
}
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
}
j.structural.set()
}
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
defer cancel()
snap, err := rt.FetchTrafficSnapshot(ctx)
if err != nil {
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
j.inboundService.ClearNodeOnlineClients(n.Id)
return
}
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
if err != nil {
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
return
}
if changed {
j.structural.set()
}
if !doIpSync {
return
}
nodeIps, err := rt.FetchAllClientIps(ctx)
if err == nil && len(nodeIps) > 0 {
if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
logger.Warning("node traffic sync: merge client ips from", n.Name, "failed:", err)
}
} else if err != nil {
logger.Warning("node traffic sync: fetch client ips from", n.Name, "failed:", err)
}
masterIps, err := j.inboundService.GetAllInboundClientIps()
if err != nil {
logger.Warning("node traffic sync: load client ips for push to", n.Name, "failed:", err)
return
}
if len(masterIps) > 0 {
if err := rt.PushAllClientIps(ctx, masterIps); err != nil {
logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
}
}
}
@@ -0,0 +1,69 @@
package job
import (
"sync"
"testing"
)
func TestAtomicBool_DefaultIsFalse(t *testing.T) {
var a atomicBool
if a.takeAndReset() {
t.Fatal("default atomicBool should report false")
}
}
func TestAtomicBool_SetThenTakeReturnsTrueOnce(t *testing.T) {
var a atomicBool
a.set()
if !a.takeAndReset() {
t.Fatal("takeAndReset after set should return true")
}
if a.takeAndReset() {
t.Fatal("second takeAndReset should return false (state was reset)")
}
}
func TestAtomicBool_SetIsIdempotent(t *testing.T) {
var a atomicBool
a.set()
a.set()
a.set()
if !a.takeAndReset() {
t.Fatal("repeated set should still leave the flag true")
}
if a.takeAndReset() {
t.Fatal("flag should be cleared after the first take")
}
}
func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) {
var a atomicBool
const setters = 100
const readers = 20
var wg sync.WaitGroup
for range setters {
wg.Go(func() {
a.set()
})
}
wg.Wait()
trueCount := 0
var rwg sync.WaitGroup
var mu sync.Mutex
for range readers {
rwg.Go(func() {
if a.takeAndReset() {
mu.Lock()
trueCount++
mu.Unlock()
}
})
}
rwg.Wait()
if trueCount != 1 {
t.Fatalf("expected exactly one reader to observe true, got %d", trueCount)
}
}
@@ -0,0 +1,48 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
)
// OutboundSubscriptionJob periodically re-fetches enabled outbound subscriptions,
// updates the stored outbounds (with stable tags), and signals that xray
// should be reloaded so the new outbounds take effect.
type OutboundSubscriptionJob struct {
subService *service.OutboundSubscriptionService
xraySvc *service.XrayService
}
// NewOutboundSubscriptionJob creates the job (zero-value services are populated
// on first Run via method calls, same pattern as other jobs).
func NewOutboundSubscriptionJob() *OutboundSubscriptionJob {
return &OutboundSubscriptionJob{
subService: &service.OutboundSubscriptionService{},
xraySvc: &service.XrayService{},
}
}
// Run is invoked by the cron scheduler.
func (j *OutboundSubscriptionJob) Run() {
if j.subService == nil {
j.subService = &service.OutboundSubscriptionService{}
}
if j.xraySvc == nil {
j.xraySvc = &service.XrayService{}
}
count, err := j.subService.RefreshAllEnabled()
if err != nil {
logger.Warning("outbound subscription auto-update error:", err)
return
}
if count > 0 {
logger.Infof("Refreshed %d outbound subscription(s)", count)
// Ask the xray manager to restart/reload on the next 30s check.
j.xraySvc.SetToNeedRestart()
// Also broadcast an invalidate so the UI can refresh the xray setting
// view (new outbounds will be visible after the reload cycle).
websocket.BroadcastInvalidate(websocket.MessageTypeOutbounds)
}
}
@@ -0,0 +1,59 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// Period represents the time period for traffic resets.
type Period string
// PeriodicTrafficResetJob resets traffic statistics for inbounds based on their configured reset period.
type PeriodicTrafficResetJob struct {
inboundService service.InboundService
clientService service.ClientService
period Period
}
// NewPeriodicTrafficResetJob creates a new periodic traffic reset job for the specified period.
func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
return &PeriodicTrafficResetJob{
period: period,
}
}
// Run resets traffic statistics for all inbounds that match the configured reset period.
func (j *PeriodicTrafficResetJob) Run() {
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
if err != nil {
logger.Warning("Failed to get inbounds for traffic reset:", err)
return
}
if len(inbounds) == 0 {
return
}
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
resetCount := 0
for _, inbound := range inbounds {
resetInboundErr := j.inboundService.ResetInboundTraffic(inbound.Id)
if resetInboundErr != nil {
logger.Warning("Failed to reset traffic for inbound", inbound.Id, ":", resetInboundErr)
}
resetClientErr := j.clientService.ResetAllClientTraffics(&j.inboundService, inbound.Id)
if resetClientErr != nil {
logger.Warning("Failed to reset traffic for all users of inbound", inbound.Id, ":", resetClientErr)
}
if resetInboundErr == nil && resetClientErr == nil {
resetCount++
}
}
if resetCount > 0 {
logger.Infof("Periodic traffic reset completed: %d inbounds reset", resetCount)
}
}
+33
View File
@@ -0,0 +1,33 @@
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()
}
+53
View File
@@ -0,0 +1,53 @@
package job
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
)
type WarpIpJob struct {
settingService service.SettingService
warpService integration.WarpService
xrayService service.XrayService
}
func NewWarpIpJob() *WarpIpJob {
return &WarpIpJob{}
}
func (j *WarpIpJob) Run() {
allSetting, err := j.settingService.GetAllSetting()
if err != nil {
return
}
interval := allSetting.WarpUpdateInterval
if interval <= 0 {
return
}
lastUpdate, _ := j.settingService.GetWarpLastUpdate()
now := time.Now().Unix()
// First run after the feature is enabled (e.g. interval set via direct
// DB edit): establish a baseline instead of rotating immediately.
if lastUpdate == 0 {
_ = j.settingService.SetWarpLastUpdate(now)
return
}
if now-lastUpdate >= int64(interval*24*3600) {
logger.Info("Starting scheduled WARP IP update...")
_, err := j.warpService.ChangeWarpIP()
if err != nil {
logger.Warning("Failed to update WARP IP: ", err)
return
}
_ = j.settingService.SetWarpLastUpdate(now)
j.xrayService.SetToNeedRestart()
logger.Info("Successfully updated WARP IP and scheduled Xray restart")
}
}
+165
View File
@@ -0,0 +1,165 @@
package job
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/valyala/fasthttp"
)
// XrayTrafficJob collects and processes traffic statistics from Xray, updating the database and optionally informing external APIs.
type XrayTrafficJob struct {
settingService service.SettingService
xrayService service.XrayService
inboundService service.InboundService
outboundService outbound.OutboundService
}
// NewXrayTrafficJob creates a new traffic collection job instance.
func NewXrayTrafficJob() *XrayTrafficJob {
return new(XrayTrafficJob)
}
// Run collects traffic statistics from Xray, updates the database, and pushes
// real-time updates over WebSocket using compact delta payloads — no REST
// fallback, scales to 10k20k+ clients per inbound.
func (j *XrayTrafficJob) Run() {
if !j.xrayService.IsXrayRunning() {
return
}
traffics, clientTraffics, err := j.xrayService.GetXrayTraffic()
if err != nil {
return
}
needRestart0, clientsDisabled, err := j.inboundService.AddTraffic(traffics, clientTraffics)
if err != nil {
logger.Warning("add inbound traffic failed:", err)
}
err, needRestart1 := j.outboundService.AddTraffic(traffics, clientTraffics)
if err != nil {
logger.Warning("add outbound traffic failed:", err)
}
if clientsDisabled {
restartOnDisable, settingErr := j.settingService.GetRestartXrayOnClientDisable()
if settingErr != nil {
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
}
if restartOnDisable {
if err := j.xrayService.RestartXray(true); err != nil {
logger.Warning("restart xray after disabling clients failed:", err)
j.xrayService.SetToNeedRestart()
}
}
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
}
if ExternalTrafficInformEnable, err := j.settingService.GetExternalTrafficInformEnable(); ExternalTrafficInformEnable {
j.informTrafficToExternalAPI(traffics, clientTraffics)
} else if err != nil {
logger.Warning("get ExternalTrafficInformEnable failed:", err)
}
if needRestart0 || needRestart1 {
j.xrayService.SetToNeedRestart()
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("get clients last online failed:", err)
}
if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64)
}
// Derive the local online set from this poll's per-email deltas rather
// than the shared last_online column, which remote-node syncs also bump
// and would otherwise make a client active only on a remote node appear
// online on local inbounds.
activeEmails := make([]string, 0, len(clientTraffics))
for _, ct := range clientTraffics {
if ct != nil && ct.Up+ct.Down > 0 {
activeEmails = append(activeEmails, ct.Email)
}
}
// Pair the email signal with the inbound tags that moved bytes this poll.
// Xray's user>>>email counter aggregates across every inbound a client is
// attached to, so an online email alone can't say which inbound it used —
// gating the per-inbound view on these tags keeps a multi-inbound client
// off inbounds that saw no traffic. See issue #4859.
activeInboundTags := make([]string, 0, len(traffics))
for _, tr := range traffics {
if tr != nil && tr.IsInbound && tr.Up+tr.Down > 0 {
activeInboundTags = append(activeInboundTags, tr.Tag)
}
}
j.inboundService.RefreshLocalOnlineClients(activeEmails, activeInboundTags)
if !websocket.HasClients() {
return
}
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
websocket.BroadcastTraffic(map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnlineMap,
})
clientStatsPayload := map[string]any{}
if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
logger.Warning("get all client traffics for websocket failed:", err)
} else if len(stats) > 0 {
clientStatsPayload["clients"] = stats
}
if inboundSummary, err := j.inboundService.GetInboundsTrafficSummary(); err != nil {
logger.Warning("get inbounds traffic summary for websocket failed:", err)
} else if len(inboundSummary) > 0 {
clientStatsPayload["inbounds"] = inboundSummary
}
if len(clientStatsPayload) > 0 {
websocket.BroadcastClientStats(clientStatsPayload)
}
if updatedOutbounds, err := j.outboundService.GetOutboundsTraffic(); err == nil && updatedOutbounds != nil {
websocket.BroadcastOutbounds(updatedOutbounds)
} else if err != nil {
logger.Warning("get all outbounds for websocket failed:", err)
}
}
func (j *XrayTrafficJob) informTrafficToExternalAPI(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) {
informURL, err := j.settingService.GetExternalTrafficInformURI()
if err != nil {
logger.Warning("get ExternalTrafficInformURI failed:", err)
return
}
informURL, err = service.SanitizePublicHTTPURL(informURL, false)
if err != nil {
logger.Warning("ExternalTrafficInformURI blocked:", err)
return
}
requestBody, err := json.Marshal(map[string]any{"clientTraffics": clientTraffics, "inboundTraffics": inboundTraffics})
if err != nil {
logger.Warning("parse client/inbound traffic failed:", err)
return
}
request := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(request)
request.Header.SetMethod("POST")
request.Header.SetContentType("application/json; charset=UTF-8")
request.SetBody([]byte(requestBody))
request.SetRequestURI(informURL)
response := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(response)
if err := fasthttp.Do(request, response); err != nil {
logger.Warning("POST ExternalTrafficInformURI failed:", err)
}
}
+194
View File
@@ -0,0 +1,194 @@
// Package locale provides internationalization (i18n) support for the 3x-ui web panel,
// including translation loading, localization, and middleware for web and bot interfaces.
package locale
import (
"embed"
"encoding/json"
"io/fs"
"os"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
var (
i18nBundle *i18n.Bundle
LocalizerWeb *i18n.Localizer
LocalizerBot *i18n.Localizer
)
// I18nType represents the type of interface for internationalization.
type I18nType string
const (
Bot I18nType = "bot" // Bot 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.
func InitLocalizer(i18nFS embed.FS, settingService SettingService) error {
// set default bundle to English
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// parse files
if err := parseTranslationFiles(i18nFS, i18nBundle); err != nil {
return err
}
// setup bot locale
if err := initTGBotLocalizer(settingService); err != nil {
return err
}
return nil
}
// createTemplateData creates a template data map from parameters with optional separator.
func createTemplateData(params []string, separator ...string) map[string]any {
var sep string = "=="
if len(separator) > 0 {
sep = separator[0]
}
templateData := make(map[string]any)
for _, param := range params {
parts := strings.SplitN(param, sep, 2)
templateData[parts[0]] = parts[1]
}
return templateData
}
// I18n retrieves a localized message for the given key and type.
// It supports both bot and web contexts, with optional template parameters.
// Returns the localized message or an empty string if localization fails.
func I18n(i18nType I18nType, key string, params ...string) string {
var localizer *i18n.Localizer
switch i18nType {
case "bot":
localizer = LocalizerBot
case "web":
localizer = LocalizerWeb
default:
logger.Errorf("Invalid type for I18n: %s", i18nType)
return ""
}
templateData := createTemplateData(params)
if localizer == nil {
// Fallback to key if localizer not ready; prevents nil panic on pages like sub
return key
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: templateData,
})
if err != nil {
logger.Errorf("Failed to localize message: %v", err)
return ""
}
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.
// 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.
// Also provides the I18n function in the context for template rendering.
func LocalizerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Ensure bundle is initialized so creating a Localizer won't panic
if i18nBundle == nil {
i18nBundle = i18n.NewBundle(language.MustParse("en-US"))
i18nBundle.RegisterUnmarshalFunc("json", json.Unmarshal)
// Try lazy-load from disk when running sub server without InitLocalizer
if err := loadTranslationsFromDisk(i18nBundle); err != nil {
logger.Warning("i18n lazy load failed:", err)
}
}
var lang string
if cookie, err := c.Request.Cookie("lang"); err == nil {
lang = cookie.Value
} else {
lang = c.GetHeader("Accept-Language")
}
LocalizerWeb = i18n.NewLocalizer(i18nBundle, lang)
c.Set("localizer", LocalizerWeb)
c.Set("I18n", I18n)
c.Next()
}
}
// loadTranslationsFromDisk attempts to load translation files from "internal/web/translation" using the local filesystem.
func loadTranslationsFromDisk(bundle *i18n.Bundle) error {
root := os.DirFS("internal/web")
return fs.WalkDir(root, "translation", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := fs.ReadFile(root, path)
if err != nil {
return err
}
_, err = bundle.ParseMessageFileBytes(data, path)
return err
})
}
// parseTranslationFiles parses embedded translation files and adds them to the i18n bundle.
func parseTranslationFiles(i18nFS embed.FS, i18nBundle *i18n.Bundle) error {
err := fs.WalkDir(i18nFS, "translation",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
data, err := i18nFS.ReadFile(path)
if err != nil {
return err
}
_, err = i18nBundle.ParseMessageFileBytes(data, path)
return err
})
if err != nil {
return err
}
return nil
}
@@ -0,0 +1,31 @@
// Package middleware provides HTTP middleware functions for the 3x-ui web panel,
// including domain validation utilities.
package middleware
import (
"net"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// DomainValidatorMiddleware returns a Gin middleware that validates the request domain.
// It extracts the host from the request, strips any port number, and compares it
// against the configured domain. Requests from unauthorized domains are rejected
// with HTTP 403 Forbidden status.
func DomainValidatorMiddleware(domain string) gin.HandlerFunc {
return func(c *gin.Context) {
host := c.Request.Host
if colonIndex := strings.LastIndex(host, ":"); colonIndex != -1 {
host, _, _ = net.SplitHostPort(c.Request.Host)
}
if host != domain {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}
+66
View File
@@ -0,0 +1,66 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"net/http"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
)
// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
return func(c *gin.Context) {
nonce := newCSPNonce()
c.Set("csp_nonce", nonce)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("Referrer-Policy", "no-referrer")
c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'nonce-"+nonce+"'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
if directHTTPS {
c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
}
c.Next()
}
}
func newCSPNonce() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return ""
}
return base64.RawStdEncoding.EncodeToString(b[:])
}
// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
// Bearer-token-authenticated callers (api_authed flag set by APIController.checkAPIAuth)
// short-circuit the CSRF check — they are not browser sessions, so the
// cross-site request forgery threat model doesn't apply to them.
func CSRFMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if c.GetBool("api_authed") {
c.Next()
return
}
if isSafeMethod(c.Request.Method) {
c.Next()
return
}
if !session.ValidateCSRFToken(c) {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
}
}
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return true
default:
return false
}
}
+121
View File
@@ -0,0 +1,121 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
)
func TestCSRFMiddlewareAllowsSafeMethods(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(CSRFMiddleware())
router.GET("/safe", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/safe", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestCSRFMiddlewareRejectsMissingTokenAndAcceptsValidToken(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
store := cookie.NewStore([]byte("01234567890123456789012345678901"))
router.Use(sessions.Sessions("3x-ui", store))
router.GET("/token", func(c *gin.Context) {
token, err := session.EnsureCSRFToken(c)
if err != nil {
t.Fatal(err)
}
c.String(http.StatusOK, token)
})
router.POST("/submit", CSRFMiddleware(), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
tokenRec := httptest.NewRecorder()
tokenReq := httptest.NewRequest(http.MethodGet, "/token", nil)
router.ServeHTTP(tokenRec, tokenReq)
if tokenRec.Code != http.StatusOK {
t.Fatalf("token status = %d, want %d", tokenRec.Code, http.StatusOK)
}
cookies := tokenRec.Result().Cookies()
token := tokenRec.Body.String()
missingRec := httptest.NewRecorder()
missingReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
for _, cookie := range cookies {
missingReq.AddCookie(cookie)
}
router.ServeHTTP(missingRec, missingReq)
if missingRec.Code != http.StatusForbidden {
t.Fatalf("missing token status = %d, want %d", missingRec.Code, http.StatusForbidden)
}
validRec := httptest.NewRecorder()
validReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
for _, cookie := range cookies {
validReq.AddCookie(cookie)
}
validReq.Header.Set(session.CSRFHeaderName, token)
router.ServeHTTP(validRec, validReq)
if validRec.Code != http.StatusOK {
t.Fatalf("valid token status = %d, want %d", validRec.Code, http.StatusOK)
}
}
func TestSecurityHeadersMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeadersMiddleware(true))
router.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(rec, req)
headers := rec.Result().Header
if got := headers.Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q", got)
}
if got := headers.Get("X-Frame-Options"); got != "DENY" {
t.Fatalf("X-Frame-Options = %q", got)
}
if got := headers.Get("Referrer-Policy"); got != "no-referrer" {
t.Fatalf("Referrer-Policy = %q", got)
}
if got := headers.Get("Strict-Transport-Security"); got == "" {
t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
}
}
func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(SecurityHeadersMiddleware(false))
router.GET("/", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/", nil)
router.ServeHTTP(rec, req)
if got := rec.Result().Header.Get("Strict-Transport-Security"); got != "" {
t.Fatalf("Strict-Transport-Security = %q, want empty", got)
}
}
+111
View File
@@ -0,0 +1,111 @@
package middleware
import (
"errors"
"net/http"
"reflect"
"strings"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
)
var validate = validator.New(validator.WithRequiredStructEnabled())
func BindAndValidate[T any](c *gin.Context) (*T, bool) {
var dst T
if err := c.ShouldBind(&dst); err != nil {
writeBindFailure(c, err)
return nil, false
}
if err := validate.Struct(&dst); err != nil {
writeBindFailure(c, err)
return nil, false
}
return &dst, true
}
func BindAndValidateInto(c *gin.Context, dst any) bool {
if err := c.ShouldBind(dst); err != nil {
writeBindFailure(c, err)
return false
}
if err := validate.Struct(dst); err != nil {
writeBindFailure(c, err)
return false
}
return true
}
func BindJSONAndValidate[T any](c *gin.Context) (*T, bool) {
var dst T
if err := c.ShouldBindWith(&dst, binding.JSON); err != nil {
writeBindFailure(c, err)
return nil, false
}
if err := validate.Struct(&dst); err != nil {
writeBindFailure(c, err)
return nil, false
}
return &dst, true
}
func BindJSONAndValidateInto(c *gin.Context, dst any) bool {
if err := c.ShouldBindWith(dst, binding.JSON); err != nil {
writeBindFailure(c, err)
return false
}
if err := validate.Struct(dst); err != nil {
writeBindFailure(c, err)
return false
}
return true
}
type FieldIssue struct {
Field string `json:"field"`
Rule string `json:"rule"`
Param string `json:"param,omitempty"`
Message string `json:"message"`
}
type ValidationPayload struct {
Issues []FieldIssue `json:"issues"`
Message string `json:"message"`
}
func writeBindFailure(c *gin.Context, err error) {
payload := ValidationPayload{Issues: []FieldIssue{}, Message: err.Error()}
var ve validator.ValidationErrors
if errors.As(err, &ve) {
payload.Issues = make([]FieldIssue, 0, len(ve))
for _, fe := range ve {
payload.Issues = append(payload.Issues, FieldIssue{
Field: fe.Field(),
Rule: fe.Tag(),
Param: fe.Param(),
Message: fe.Error(),
})
}
}
c.AbortWithStatusJSON(http.StatusOK, entity.Msg{
Success: false,
Msg: "request body failed validation",
Obj: payload,
})
}
func init() {
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" || name == "" {
return fld.Name
}
return name
})
}
+207
View File
@@ -0,0 +1,207 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
)
type sampleBody struct {
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
Protocol string `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan"`
Tag string `json:"tag" form:"tag"`
}
func newRouter(handler gin.HandlerFunc) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/submit", handler)
return r
}
func decodeMsg(t *testing.T, body string) entity.Msg {
t.Helper()
var msg entity.Msg
if err := json.Unmarshal([]byte(body), &msg); err != nil {
t.Fatalf("decode msg: %v (body=%q)", err, body)
}
return msg
}
func TestBindAndValidate_ValidPayloadPassesThrough(t *testing.T) {
r := newRouter(func(c *gin.Context) {
got, ok := BindAndValidate[sampleBody](c)
if !ok {
t.Fatalf("expected ok=true, got false (body should be valid)")
}
if got.Port != 443 || got.Protocol != "vless" || got.Tag != "inbound-443" {
t.Fatalf("decoded payload mismatch: %+v", got)
}
c.JSON(http.StatusOK, entity.Msg{Success: true, Msg: "ok"})
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"vless","tag":"inbound-443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String())
}
if msg := decodeMsg(t, rec.Body.String()); !msg.Success {
t.Fatalf("expected Success=true; got %+v", msg)
}
}
func TestBindAndValidate_PortOutOfRangeIsRejected(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on invalid port; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":70000,"protocol":"vless"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
if msg.Success {
t.Fatalf("expected Success=false; got %+v", msg)
}
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
found := false
for _, issue := range payload.Issues {
if issue.Field == "port" && issue.Rule == "lte" {
found = true
break
}
}
if !found {
t.Fatalf("expected an Issue for field=port rule=lte; got %+v", payload.Issues)
}
}
func TestBindAndValidate_ProtocolEnumIsRejected(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on invalid protocol; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"unknown"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
found := false
for _, issue := range payload.Issues {
if issue.Field == "protocol" && issue.Rule == "oneof" {
found = true
}
}
if !found {
t.Fatalf("expected an Issue for field=protocol rule=oneof; got %+v", payload.Issues)
}
}
func TestBindAndValidate_MalformedJSONReturnsMessageButNoIssues(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false on malformed JSON; got true")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
msg := decodeMsg(t, rec.Body.String())
if msg.Success {
t.Fatal("expected Success=false on malformed JSON")
}
payload, err := payloadFromObj(msg.Obj)
if err != nil {
t.Fatalf("payload extraction: %v", err)
}
if len(payload.Issues) != 0 {
t.Fatalf("expected empty Issues for parse error; got %+v", payload.Issues)
}
if payload.Message == "" {
t.Fatal("expected non-empty Message describing the parse error")
}
}
func TestBindAndValidateInto_PreservesPrePopulatedFields(t *testing.T) {
r := newRouter(func(c *gin.Context) {
dst := &sampleBody{Tag: "preset"}
if !BindAndValidateInto(c, dst) {
t.Fatal("expected ok=true; got false")
}
if dst.Tag != "inbound-443" {
t.Fatalf("expected payload Tag to overwrite preset; got %q", dst.Tag)
}
if dst.Port != 443 {
t.Fatalf("expected Port=443; got %d", dst.Port)
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader(`{"port":443,"protocol":"trojan","tag":"inbound-443"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
}
func TestBindJSONAndValidate_RejectsFormEncodedBody(t *testing.T) {
r := newRouter(func(c *gin.Context) {
if _, ok := BindJSONAndValidate[sampleBody](c); ok {
t.Fatal("expected ok=false for form-encoded request to a JSON-only endpoint")
}
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/submit",
strings.NewReader("port=443&protocol=vless"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.ServeHTTP(rec, req)
if msg := decodeMsg(t, rec.Body.String()); msg.Success {
t.Fatalf("expected Success=false; got %+v", msg)
}
}
func payloadFromObj(obj any) (ValidationPayload, error) {
raw, err := json.Marshal(obj)
if err != nil {
return ValidationPayload{}, err
}
var payload ValidationPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return ValidationPayload{}, err
}
return payload, nil
}
+77
View File
@@ -0,0 +1,77 @@
// Package network provides network utilities for the 3x-ui web panel,
// including automatic HTTP to HTTPS redirection functionality.
package network
import (
"bufio"
"bytes"
"fmt"
"net"
"net/http"
"sync"
)
// AutoHttpsConn wraps a net.Conn to provide automatic HTTP to HTTPS redirection.
// It intercepts the first read to detect HTTP requests and responds with a 307 redirect
// to the HTTPS equivalent URL. Subsequent reads work normally for HTTPS connections.
type AutoHttpsConn struct {
net.Conn
firstBuf []byte
bufStart int
readRequestOnce sync.Once
}
// NewAutoHttpsConn creates a new AutoHttpsConn that wraps the given connection.
// It enables automatic redirection of HTTP requests to HTTPS.
func NewAutoHttpsConn(conn net.Conn) net.Conn {
return &AutoHttpsConn{
Conn: conn,
}
}
func (c *AutoHttpsConn) readRequest() bool {
c.firstBuf = make([]byte, 2048)
n, err := c.Conn.Read(c.firstBuf)
c.firstBuf = c.firstBuf[:n]
if err != nil {
return false
}
reader := bytes.NewReader(c.firstBuf)
bufReader := bufio.NewReader(reader)
request, err := http.ReadRequest(bufReader)
if err != nil {
return false
}
resp := http.Response{
Header: http.Header{},
}
resp.StatusCode = http.StatusTemporaryRedirect
location := fmt.Sprintf("https://%v%v", request.Host, request.RequestURI)
resp.Header.Set("Location", location)
resp.Write(c.Conn)
c.Close()
c.firstBuf = nil
return true
}
// Read implements the net.Conn Read method with automatic HTTPS redirection.
// On the first read, it checks if the request is HTTP and redirects to HTTPS if so.
// Subsequent reads work normally.
func (c *AutoHttpsConn) Read(buf []byte) (int, error) {
c.readRequestOnce.Do(func() {
c.readRequest()
})
if c.firstBuf != nil {
n := copy(buf, c.firstBuf[c.bufStart:])
c.bufStart += n
if c.bufStart >= len(c.firstBuf) {
c.firstBuf = nil
}
return n, nil
}
return c.Conn.Read(buf)
}
@@ -0,0 +1,27 @@
package network
import "net"
// AutoHttpsListener wraps a net.Listener to provide automatic HTTPS redirection.
// It returns AutoHttpsConn connections that handle HTTP to HTTPS redirection.
type AutoHttpsListener struct {
net.Listener
}
// NewAutoHttpsListener creates a new AutoHttpsListener that wraps the given listener.
// It enables automatic redirection of HTTP requests to HTTPS for all accepted connections.
func NewAutoHttpsListener(listener net.Listener) net.Listener {
return &AutoHttpsListener{
Listener: listener,
}
}
// Accept implements the net.Listener Accept method.
// It accepts connections and wraps them with AutoHttpsConn for HTTPS redirection.
func (l *AutoHttpsListener) Accept() (net.Conn, error) {
conn, err := l.Listener.Accept()
if err != nil {
return nil, err
}
return NewAutoHttpsConn(conn), nil
}
+165
View File
@@ -0,0 +1,165 @@
package runtime
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/mtproto"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type LocalDeps struct {
APIPort func() int
SetNeedRestart func()
}
type Local struct {
deps LocalDeps
mu sync.Mutex
}
func NewLocal(deps LocalDeps) *Local {
return &Local{deps: deps}
}
func (l *Local) Name() string { return "local" }
func (l *Local) withAPI(fn func(api *xray.XrayAPI) error) error {
l.mu.Lock()
defer l.mu.Unlock()
port := l.deps.APIPort()
if port <= 0 {
return errors.New("local xray is not running")
}
var api xray.XrayAPI
if err := api.Init(port); err != nil {
return err
}
defer api.Close()
return fn(&api)
}
func (l *Local) AddInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
inst, ok := mtproto.InstanceFromInbound(ib)
if !ok {
return nil
}
return mtproto.GetManager().Ensure(inst)
}
body, err := json.MarshalIndent(ib.GenXrayInboundConfig(), "", " ")
if err != nil {
return err
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddInbound(body)
})
}
func (l *Local) DelInbound(_ context.Context, ib *model.Inbound) error {
if ib.Protocol == model.MTProto {
mtproto.GetManager().Remove(ib.Id)
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.DelInbound(ib.Tag)
})
}
func (l *Local) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
_ = l.DelInbound(ctx, oldIb)
if !newIb.Enable {
return nil
}
return l.AddInbound(ctx, newIb)
}
func (l *Local) AddUser(_ context.Context, ib *model.Inbound, userMap map[string]any) error {
if ib.Protocol == model.MTProto {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.AddUser(string(ib.Protocol), ib.Tag, userMap)
})
}
func (l *Local) RemoveUser(_ context.Context, ib *model.Inbound, email string) error {
if ib.Protocol == model.MTProto {
return nil
}
return l.withAPI(func(api *xray.XrayAPI) error {
return api.RemoveUser(ib.Tag, email)
})
}
func (l *Local) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
if !client.Enable {
return nil
}
user := map[string]any{
"email": client.Email,
"id": client.ID,
"security": client.Security,
"flow": client.Flow,
"auth": client.Auth,
"password": client.Password,
}
return l.AddUser(ctx, ib, user)
}
func (l *Local) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
if err := l.RemoveUser(ctx, ib, email); err != nil {
if strings.Contains(err.Error(), "not found") {
return nil
}
return err
}
return nil
}
func (l *Local) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail != "" {
if err := l.RemoveUser(ctx, ib, oldEmail); err != nil && !strings.Contains(err.Error(), "not found") {
return err
}
}
if !payload.Enable {
return nil
}
user := map[string]any{
"email": payload.Email,
"id": payload.ID,
"security": payload.Security,
"flow": payload.Flow,
"auth": payload.Auth,
"password": payload.Password,
}
return l.AddUser(ctx, ib, user)
}
func (l *Local) RestartXray(_ context.Context) error {
if l.deps.SetNeedRestart != nil {
l.deps.SetNeedRestart()
}
return nil
}
func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string) error {
return nil
}
func (l *Local) ResetAllTraffics(_ context.Context) error {
return nil
}
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package runtime
import (
"errors"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type Manager struct {
local Runtime
mu sync.RWMutex
remotes map[int]*Remote
}
func NewManager(localDeps LocalDeps) *Manager {
return &Manager{
local: NewLocal(localDeps),
remotes: make(map[int]*Remote),
}
}
func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) {
if nodeID == nil {
return m.local, nil
}
m.mu.RLock()
if rt, ok := m.remotes[*nodeID]; ok {
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if rt, ok := m.remotes[*nodeID]; ok {
return rt, nil
}
n, err := loadNode(*nodeID)
if err != nil {
return nil, err
}
if !n.Enable {
return nil, errors.New("node " + n.Name + " is disabled")
}
rt := NewRemote(n)
m.remotes[*nodeID] = rt
return rt, nil
}
func (m *Manager) Local() Runtime { return m.local }
func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) {
if node == nil {
return nil, errors.New("node is nil")
}
m.mu.RLock()
if rt, ok := m.remotes[node.Id]; ok {
m.mu.RUnlock()
return rt, nil
}
m.mu.RUnlock()
m.mu.Lock()
defer m.mu.Unlock()
if rt, ok := m.remotes[node.Id]; ok {
return rt, nil
}
rt := NewRemote(node)
m.remotes[node.Id] = rt
return rt, nil
}
func (m *Manager) InvalidateNode(nodeID int) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.remotes, nodeID)
}
func loadNode(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
return nil, err
}
return n, nil
}
var (
managerMu sync.RWMutex
manager *Manager
)
func SetManager(m *Manager) {
managerMu.Lock()
defer managerMu.Unlock()
manager = m
}
func GetManager() *Manager {
managerMu.RLock()
defer managerMu.RUnlock()
return manager
}
+559
View File
@@ -0,0 +1,559 @@
package runtime
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"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/netsafe"
)
const remoteHTTPTimeout = 10 * time.Second
var remoteHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
type envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
type Remote struct {
node *model.Node
mu sync.RWMutex
remoteIDByTag map[string]int
}
func NewRemote(n *model.Node) *Remote {
return &Remote{
node: n,
remoteIDByTag: make(map[string]int),
}
}
func (r *Remote) Name() string { return "node:" + r.node.Name }
func (r *Remote) baseURL() (string, error) {
addr, err := netsafe.NormalizeHost(r.node.Address)
if err != nil {
return "", err
}
scheme := r.node.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if r.node.Port <= 0 || r.node.Port > 65535 {
return "", fmt.Errorf("invalid node port %d", r.node.Port)
}
bp := r.node.BasePath
if bp == "" {
bp = "/"
}
if !strings.HasSuffix(bp, "/") {
bp += "/"
}
u := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)),
Path: bp,
}
return u.String(), nil
}
func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
if r.node.ApiToken == "" {
return nil, errors.New("node has no API token configured")
}
base, err := r.baseURL()
if err != nil {
return nil, err
}
target := base + strings.TrimPrefix(path, "/")
var (
reqBody io.Reader
contentType string
)
switch b := body.(type) {
case nil:
case url.Values:
reqBody = strings.NewReader(b.Encode())
contentType = "application/x-www-form-urlencoded"
default:
buf, jerr := json.Marshal(b)
if jerr != nil {
return nil, fmt.Errorf("marshal body: %w", jerr)
}
reqBody = bytes.NewReader(buf)
contentType = "application/json"
}
cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
defer cancel()
req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+r.node.ApiToken)
req.Header.Set("Accept", "application/json")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := remoteHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode)
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
return nil, fmt.Errorf("decode envelope: %w", err)
}
if !env.Success {
return &env, fmt.Errorf("remote: %s", env.Msg)
}
return &env, nil
}
func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) {
if id, ok := r.cacheGetTag(tag); ok {
return id, nil
}
if err := r.refreshRemoteIDs(ctx); err != nil {
return 0, err
}
if id, ok := r.cacheGetTag(tag); ok {
return id, nil
}
return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name)
}
// cacheGetTag looks up a remote inbound id by tag, tolerating an n<id>- prefix
// that lives on only one of the two panels: the node may carry the bare tag
// while the central panel stores the prefixed form, or vice versa.
func (r *Remote) cacheGetTag(tag string) (int, bool) {
if id, ok := r.cacheGet(tag); ok {
return id, true
}
prefix := fmt.Sprintf("n%d-", r.node.Id)
if stripped, found := strings.CutPrefix(tag, prefix); found {
return r.cacheGet(stripped)
}
return r.cacheGet(prefix + tag)
}
func (r *Remote) cacheGet(tag string) (int, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id, ok := r.remoteIDByTag[tag]
return id, ok
}
func (r *Remote) cacheSet(tag string, id int) {
r.mu.Lock()
defer r.mu.Unlock()
r.remoteIDByTag[tag] = id
}
func (r *Remote) cacheDel(tag string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.remoteIDByTag, tag)
}
func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) {
if err := r.refreshRemoteIDs(ctx); err != nil {
return nil, err
}
r.mu.RLock()
defer r.mu.RUnlock()
tags := make([]string, 0, len(r.remoteIDByTag))
for tag := range r.remoteIDByTag {
tags = append(tags, tag)
}
return tags, nil
}
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
if err != nil {
return err
}
var list []struct {
Id int `json:"id"`
Tag string `json:"tag"`
}
if err := json.Unmarshal(env.Obj, &list); err != nil {
return fmt.Errorf("decode inbound list: %w", err)
}
next := make(map[string]int, len(list))
for _, ib := range list {
if ib.Tag == "" {
continue
}
next[ib.Tag] = ib.Id
}
r.mu.Lock()
r.remoteIDByTag = next
r.mu.Unlock()
return nil
}
func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error {
payload := wireInbound(ib)
env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload)
if err != nil {
return err
}
var created struct {
Id int `json:"id"`
Tag string `json:"tag"`
}
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &created); err == nil && created.Id > 0 && created.Tag != "" {
r.cacheSet(created.Tag, created.Id)
}
}
return nil
}
func (r *Remote) DelInbound(ctx context.Context, ib *model.Inbound) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
logger.Warning("remote DelInbound: tag", ib.Tag, "not found on", r.node.Name)
return nil
}
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/del/"+strconv.Itoa(id), nil); err != nil {
return err
}
r.cacheDel(ib.Tag)
return nil
}
func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error {
id, err := r.resolveRemoteID(ctx, oldIb.Tag)
if err != nil {
return r.AddInbound(ctx, newIb)
}
payload := wireInbound(newIb)
if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil {
return err
}
if oldIb.Tag != newIb.Tag {
r.cacheDel(oldIb.Tag)
}
r.cacheSet(newIb.Tag, id)
return nil
}
func (r *Remote) AddUser(ctx context.Context, ib *model.Inbound, _ map[string]any) error {
return r.UpdateInbound(ctx, ib, ib)
}
func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) error {
return r.UpdateInbound(ctx, ib, ib)
}
func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error {
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err)
}
payload := map[string]any{
"client": client,
"inboundIds": []int{id},
}
if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil {
return err
}
return nil
}
func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error {
if email == "" {
return nil
}
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return nil
}
body := map[string]any{"inboundIds": []int{id}}
_, err = r.do(ctx, http.MethodPost,
"panel/api/clients/"+url.PathEscape(email)+"/detach", body)
if err == nil {
return nil
}
if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil
}
return err
}
func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error {
if oldEmail == "" {
oldEmail = payload.Email
}
id, err := r.resolveRemoteID(ctx, ib.Tag)
if err != nil {
return err
}
path := "panel/api/clients/update/" + url.PathEscape(oldEmail) +
"?inboundIds=" + strconv.Itoa(id)
if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil {
return err
}
return nil
}
func (r *Remote) RestartXray(ctx context.Context) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil)
return err
}
// UpdatePanel asks the node to run its own official self-updater (update.sh)
// and restart onto the latest release. The node returns as soon as the job is
// launched; the new version surfaces on the next heartbeat.
func (r *Remote) UpdatePanel(ctx context.Context) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", nil)
return err
}
// WebCertFiles holds a node's own web TLS certificate and key file paths.
type WebCertFiles struct {
WebCertFile string `json:"webCertFile"`
WebKeyFile string `json:"webKeyFile"`
}
// GetWebCertFiles fetches the node's own web TLS certificate/key file paths so
// the central panel can offer them as the "Set Cert from Panel" default for a
// node-assigned inbound — those paths exist on the node, the central panel's
// don't. See issue #4854.
func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/getWebCertFiles", nil)
if err != nil {
return nil, err
}
var files WebCertFiles
if err := json.Unmarshal(env.Obj, &files); err != nil {
return nil, fmt.Errorf("decode web cert files: %w", err)
}
return &files, nil
}
// GetDescendants fetches the node's read-only summaries of the nodes IT
// manages, so this panel can surface them as transitive sub-nodes in a chained
// topology (#4983). Best-effort: an old-build node without the endpoint returns
// an error the caller ignores.
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
if err != nil {
return nil, err
}
var out []model.NodeSummary
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &out); err != nil {
return nil, fmt.Errorf("decode descendants: %w", err)
}
}
return out, nil
}
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
_, err := r.do(ctx, http.MethodPost,
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
return err
}
func (r *Remote) ResetAllTraffics(ctx context.Context) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/resetAllTraffics", nil)
return err
}
func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
return err
}
type TrafficSnapshot struct {
Inbounds []*model.Inbound
OnlineEmails []string
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
// its panelGuid plus every descendant under theirs). Preferred over the flat
// OnlineEmails so the master can attribute deeply nested clients to the real
// node across a chain (#4983). Empty when the node is an old build without
// the per-GUID endpoint — OnlineEmails is the fallback then.
OnlineTree map[string][]string
LastOnlineMap map[string]int64
}
func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) {
snap := &TrafficSnapshot{LastOnlineMap: map[string]int64{}}
envList, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
if err != nil {
return nil, err
}
if err := json.Unmarshal(envList.Obj, &snap.Inbounds); err != nil {
return nil, fmt.Errorf("decode inbound list: %w", err)
}
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
// node is an old build without the per-GUID endpoint (#4983).
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
if err == nil && len(envTree.Obj) > 0 {
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
}
if len(snap.OnlineTree) == 0 {
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
}
}
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err)
} else if len(envLastOnline.Obj) > 0 {
_ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap)
}
return snap, nil
}
func wireInbound(ib *model.Inbound) url.Values {
v := url.Values{}
v.Set("total", strconv.FormatInt(ib.Total, 10))
v.Set("remark", ib.Remark)
v.Set("enable", strconv.FormatBool(ib.Enable))
v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10))
v.Set("listen", ib.Listen)
v.Set("port", strconv.Itoa(ib.Port))
v.Set("protocol", string(ib.Protocol))
v.Set("settings", ib.Settings)
v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings))
v.Set("tag", ib.Tag)
v.Set("sniffing", ib.Sniffing)
if ib.TrafficReset != "" {
v.Set("trafficReset", ib.TrafficReset)
}
return v
}
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node, but ONLY when
// inline certificate content (certificate / key) is also present in the same
// entry. In that case the file paths are redundant and stripping them avoids
// confusion when the central panel's local paths don't exist on the remote.
//
// When a certificate entry contains ONLY file paths (no inline content) the
// paths are left untouched: the user explicitly entered paths that exist on
// the remote node's filesystem, and removing them would leave Xray with TLS
// configured but no certificate, causing Xray to crash on the remote node.
func sanitizeStreamSettingsForRemote(streamSettings string) string {
if streamSettings == "" {
return streamSettings
}
var stream map[string]any
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return streamSettings
}
tlsSettings, ok := stream["tlsSettings"].(map[string]any)
if !ok {
return streamSettings
}
certificates, ok := tlsSettings["certificates"].([]any)
if !ok {
return streamSettings
}
changed := false
for _, cert := range certificates {
c, ok := cert.(map[string]any)
if !ok {
continue
}
// Only strip file paths when inline content is present so that the
// remote Xray still has a valid certificate to use.
hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != ""
hasKeyFile := c["keyFile"] != nil && c["keyFile"] != ""
hasCertInline := isNonEmptySlice(c["certificate"])
hasKeyInline := isNonEmptySlice(c["key"])
if hasCertFile && hasCertInline {
delete(c, "certificateFile")
changed = true
}
if hasKeyFile && hasKeyInline {
delete(c, "keyFile")
changed = true
}
}
if !changed {
return streamSettings
}
out, err := json.Marshal(stream)
if err != nil {
return streamSettings
}
return string(out)
}
// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value.
func isNonEmptySlice(v any) bool {
s, ok := v.([]any)
return ok && len(s) > 0
}
func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil)
if err != nil {
return nil, err
}
var ips []model.InboundClientIps
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &ips); err != nil {
return nil, fmt.Errorf("decode client ips: %w", err)
}
}
return ips, nil
}
func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error {
_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
return err
}
+127
View File
@@ -0,0 +1,127 @@
package runtime
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// cacheGetTag must resolve a remote inbound id even when the n<id>- prefix
// sits on only one side: the node may store the bare tag while the central
// panel pushes the prefixed form, or vice versa. Without this a mismatch makes
// the push create a duplicate inbound on the node.
func TestCacheGetTag_PrefixAgnostic(t *testing.T) {
cases := []struct {
name string
cacheTag string
lookup string
wantID int
wantFound bool
}{
{"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true},
{"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true},
{"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true},
{"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := NewRemote(&model.Node{Id: 1, Name: "n1"})
r.cacheSet(c.cacheTag, 7)
id, ok := r.cacheGetTag(c.lookup)
if ok != c.wantFound || id != c.wantID {
t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound)
}
})
}
}
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
tests := []struct {
name string
input string
// wantCertFile / wantKeyFile: expected presence after sanitize
wantCertFile bool
wantKeyFile bool
}{
{
name: "file paths only — kept intact (remote node paths)",
input: `{
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/ssl/cert.crt",
"keyFile": "/etc/ssl/key.key"
}]
}
}`,
wantCertFile: true,
wantKeyFile: true,
},
{
name: "inline content only — unchanged",
input: `{
"tlsSettings": {
"certificates": [{
"certificate": ["-----BEGIN CERTIFICATE-----"],
"key": ["-----BEGIN PRIVATE KEY-----"]
}]
}
}`,
wantCertFile: false,
wantKeyFile: false,
},
{
name: "both file paths and inline content — file paths stripped (redundant)",
input: `{
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/ssl/cert.crt",
"keyFile": "/etc/ssl/key.key",
"certificate": ["-----BEGIN CERTIFICATE-----"],
"key": ["-----BEGIN PRIVATE KEY-----"]
}]
}
}`,
wantCertFile: false,
wantKeyFile: false,
},
{
name: "empty stream settings",
input: "",
// empty input returns empty, nothing to check
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.input == "" {
if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
t.Errorf("expected empty string, got %q", got)
}
return
}
got := sanitizeStreamSettingsForRemote(tc.input)
var out map[string]any
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
}
tls, _ := out["tlsSettings"].(map[string]any)
certs, _ := tls["certificates"].([]any)
if len(certs) == 0 {
t.Fatal("certificates array missing in output")
}
cert, _ := certs[0].(map[string]any)
_, hasCertFile := cert["certificateFile"]
_, hasKeyFile := cert["keyFile"]
if hasCertFile != tc.wantCertFile {
t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
}
if hasKeyFile != tc.wantKeyFile {
t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
}
})
}
}
+31
View File
@@ -0,0 +1,31 @@
package runtime
import (
"context"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type Runtime interface {
Name() string
AddInbound(ctx context.Context, ib *model.Inbound) error
DelInbound(ctx context.Context, ib *model.Inbound) error
UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error
AddUser(ctx context.Context, ib *model.Inbound, userMap map[string]any) error
RemoveUser(ctx context.Context, ib *model.Inbound, email string) error
// Per-client operations that route through the node's clients API on
// Remote (instead of pushing the whole inbound) so the node applies
// per-user xray API calls without a DelInbound+AddInbound cycle.
UpdateUser(ctx context.Context, ib *model.Inbound, email string, payload model.Client) error
DeleteUser(ctx context.Context, ib *model.Inbound, email string) error
AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error
RestartXray(ctx context.Context) error
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error
ResetAllTraffics(ctx context.Context) error
}
@@ -0,0 +1,219 @@
package service
import (
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/op/go-logging"
)
func seedClientTraffics(t *testing.T, inboundId int, clients []model.Client) {
t.Helper()
db := database.GetDB()
rows := make([]xray.ClientTraffic, len(clients))
for i := range clients {
rows[i] = xray.ClientTraffic{
InboundId: inboundId,
Email: clients[i].Email,
Enable: true,
Total: clients[i].TotalGB,
ExpiryTime: clients[i].ExpiryTime,
}
}
if err := db.CreateInBatches(rows, 1000).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
}
// TestAllAPIsPostgresScale exercises every client/inbound/group service method
// reachable from the REST API at 100k/200k clients, asserting none crash on the
// PostgreSQL bind-parameter ceiling and logging the wall-clock cost of each.
func TestAllAPIsPostgresScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
settingSvc := &SettingService{}
const userId = 1
const m = 2000
sizes := []int{50000, 100000, 200000}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics, client_groups RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
for i := range clients {
clients[i].ExpiryTime = exp
clients[i].TotalGB = 100 << 30
}
ib := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
ib2 := &model.Inbound{UserId: userId, Tag: fmt.Sprintf("all2-%d", n), Enable: true, Port: 40001, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(ib2).Error; err != nil {
t.Fatalf("create inbound2: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
run := func(name string, fn func() error) {
start := time.Now()
if err := fn(); err != nil {
t.Fatalf("%s: %v", name, err)
}
t.Logf("N=%-7d %-26s %v", n, name, time.Since(start).Round(time.Millisecond))
}
run("GetInboundDetail(noTraffic)", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
emails := make([]string, n)
for i := 0; i < n; i++ {
emails[i] = clients[i].Email
}
emailsM := emails[:m]
run("GetInbounds", func() error { _, err := inboundSvc.GetInbounds(userId); return err })
run("GetInboundsSlim", func() error { _, err := inboundSvc.GetInboundsSlim(userId); return err })
run("GetInboundDetail", func() error { _, err := inboundSvc.GetInboundDetail(ib.Id); return err })
run("GetInboundOptions", func() error { _, err := inboundSvc.GetInboundOptions(userId); return err })
run("ListPaged", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25})
return err
})
run("ListPaged+search", func() error {
_, err := svc.ListPaged(inboundSvc, settingSvc, ClientPageParams{Page: 1, PageSize: 25, Search: "user-0012345"})
return err
})
run("GetClientsLastOnline", func() error { _, err := inboundSvc.GetClientsLastOnline(); return err })
run("GetClientTrafficByEmail", func() error { _, err := inboundSvc.GetClientTrafficByEmail(emails[n/2]); return err })
run("GetRecordByEmail", func() error { _, err := svc.GetRecordByEmail(nil, emails[n/2]); return err })
run("ListGroups", func() error { _, err := svc.ListGroups(); return err })
run("AddToGroup(M)", func() error { _, err := svc.AddToGroup(emailsM, "g1"); return err })
run("EmailsByGroup", func() error { _, err := svc.EmailsByGroup("g1"); return err })
run("RenameGroup", func() error { _, err := svc.RenameGroup("g1", "g2"); return err })
run("DeleteGroup", func() error { _, err := svc.DeleteGroup("g2"); return err })
run("ResetInboundTraffic", func() error { return inboundSvc.ResetInboundTraffic(ib.Id) })
run("Inbound.ResetAllTraffics", func() error { return inboundSvc.ResetAllTraffics() })
run("Client.ResetAllTraffics", func() error { _, err := svc.ResetAllTraffics(); return err })
run("BulkResetTraffic(M)", func() error { _, err := svc.BulkResetTraffic(inboundSvc, emailsM); return err })
run("UpdateByEmail", func() error {
upd := clients[n/3]
upd.Comment = "touched"
_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
return err
})
run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
run("DetachByEmailMany", func() error { _, err := svc.DetachByEmailMany(inboundSvc, emails[n/3], []int{ib2.Id}); return err })
depEmails := emails[:1000]
for _, batch := range chunkStrings(depEmails, sqlInChunk) {
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Update("down", int64(200)<<30).Error; err != nil {
t.Fatalf("mark depleted: %v", err)
}
}
run("DelDepleted(1k)", func() error { _, _, err := svc.DelDepleted(inboundSvc); return err })
run("DelInbound(full)", func() error { _, err := inboundSvc.DelInbound(ib.Id); return err })
})
}
}
// TestGetClientTrafficByEmailABScale measures the GetClientTrafficByEmail change:
// old path (GetClientByEmail, which parses the inbound's entire settings JSON to
// find one client) vs new path (UUID/subId read from the indexed clients table).
func TestGetClientTrafficByEmailABScale(t *testing.T) {
if strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" || os.Getenv("XUI_DB_TYPE") != "postgres" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run the postgres scale benchmark")
}
xuilogger.InitLogger(logging.ERROR)
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := &ClientService{}
inboundSvc := &InboundService{}
const reps = 10
sizes := []int{50000, 100000, 200000}
oldImpl := func(email string) error {
tr, client, err := inboundSvc.GetClientByEmail(email)
if err != nil {
return err
}
if tr != nil && client != nil {
tr.UUID = client.ID
tr.SubId = client.SubID
}
return nil
}
for _, n := range sizes {
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
db := database.GetDB()
if err := db.Exec("TRUNCATE TABLE inbounds, clients, client_inbounds, client_traffics RESTART IDENTITY CASCADE").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
clients := makeScaleClients(n)
ib := &model.Inbound{UserId: 1, Tag: fmt.Sprintf("ctbe-%d", n), Enable: true, Port: 40000, Protocol: model.VLESS, Settings: clientsSettings(t, clients)}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("seed SyncInbound: %v", err)
}
seedClientTraffics(t, ib.Id, clients)
db.Exec("ANALYZE")
targets := []string{clients[0].Email, clients[n/2].Email, clients[n-1].Email}
start := time.Now()
for i := 0; i < reps; i++ {
if _, err := inboundSvc.GetClientTrafficByEmail(targets[i%len(targets)]); err != nil {
t.Fatalf("new GetClientTrafficByEmail: %v", err)
}
}
newDur := time.Since(start) / reps
start = time.Now()
for i := 0; i < reps; i++ {
if err := oldImpl(targets[i%len(targets)]); err != nil {
t.Fatalf("old GetClientTrafficByEmail: %v", err)
}
}
oldDur := time.Since(start) / reps
t.Logf("N=%-7d new=%-9v old=%-9v speedup=%.0fx", n,
newDur.Round(time.Microsecond), oldDur.Round(time.Millisecond),
float64(oldDur)/float64(maxDur(newDur, time.Microsecond)))
})
}
}
+244
View File
@@ -0,0 +1,244 @@
package service
import (
"encoding/json"
"path/filepath"
"sort"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func setupBulkDB(t *testing.T) {
t.Helper()
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() })
}
func clientsSettings(t *testing.T, clients []model.Client) string {
t.Helper()
b, err := json.Marshal(map[string][]model.Client{"clients": clients})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(b)
}
func emailsOf(clients []model.Client) []string {
out := make([]string, 0, len(clients))
for _, c := range clients {
out = append(out, c.Email)
}
return out
}
func sortedEmails(list []model.Client) []string {
out := emailsOf(list)
sort.Strings(out)
return out
}
func mkInbound(t *testing.T, port int, proto model.Protocol, settings string) *model.Inbound {
t.Helper()
ib := &model.Inbound{
Tag: string(proto) + "-" + filepath.Base(t.TempDir()),
Enable: true,
Port: port,
Protocol: proto,
Settings: settings,
}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound %d: %v", port, err)
}
return ib
}
// TestBulkAttachDetach_VLESS exercises the batched attach/detach round-trip on
// VLESS inbounds: linkage, settings JSON, idempotency, skip, and record survival.
func TestBulkAttachDetach_VLESS(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib1 := mkInbound(t, 20001, model.VLESS, clientsSettings(t, source))
ib2 := mkInbound(t, 20002, model.VLESS, `{"clients":[]}`)
ib3 := mkInbound(t, 20003, model.VLESS, `{"clients":[]}`)
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
t.Fatalf("seed source linkage: %v", err)
}
emails := emailsOf(source)
res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
if err != nil {
t.Fatalf("BulkAttach: %v", err)
}
if len(res.Errors) != 0 {
t.Fatalf("BulkAttach errors: %v", res.Errors)
}
if len(res.Skipped) != 0 {
t.Fatalf("BulkAttach skipped unexpectedly: %v", res.Skipped)
}
if len(res.Attached) != 6 {
t.Fatalf("expected 6 attach entries (3 clients x 2 inbounds), got %d: %v", len(res.Attached), res.Attached)
}
for _, ib := range []*model.Inbound{ib2, ib3} {
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound(%d): %v", ib.Id, err)
}
if got := sortedEmails(list); len(got) != 3 {
t.Fatalf("inbound %d: expected 3 linked clients, got %v", ib.Id, got)
}
reloaded, err := inboundSvc.GetInbound(ib.Id)
if err != nil {
t.Fatalf("GetInbound(%d): %v", ib.Id, err)
}
jsonClients, err := inboundSvc.GetClients(reloaded)
if err != nil {
t.Fatalf("GetClients(%d): %v", ib.Id, err)
}
if len(jsonClients) != 3 {
t.Fatalf("inbound %d settings JSON: expected 3 clients, got %d", ib.Id, len(jsonClients))
}
}
res2, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
if err != nil {
t.Fatalf("BulkAttach (idempotent): %v", err)
}
if len(res2.Attached) != 0 {
t.Fatalf("re-attach should add nothing, got Attached=%v", res2.Attached)
}
if len(res2.Skipped) != 6 {
t.Fatalf("re-attach should skip all 6, got Skipped=%v", res2.Skipped)
}
dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id, ib3.Id})
if err != nil {
t.Fatalf("BulkDetach: %v", err)
}
if len(dres.Errors) != 0 {
t.Fatalf("BulkDetach errors: %v", dres.Errors)
}
if len(dres.Detached) != 3 {
t.Fatalf("expected 3 detached emails, got %v", dres.Detached)
}
for _, ib := range []*model.Inbound{ib2, ib3} {
list, err := svc.ListForInbound(nil, ib.Id)
if err != nil {
t.Fatalf("ListForInbound after detach(%d): %v", ib.Id, err)
}
if len(list) != 0 {
t.Fatalf("inbound %d should have no clients after detach, got %v", ib.Id, sortedEmails(list))
}
reloaded, _ := inboundSvc.GetInbound(ib.Id)
jsonClients, _ := inboundSvc.GetClients(reloaded)
if len(jsonClients) != 0 {
t.Fatalf("inbound %d settings JSON should be empty after detach, got %d", ib.Id, len(jsonClients))
}
}
for _, e := range emails {
rec, err := svc.GetRecordByEmail(nil, e)
if err != nil {
t.Fatalf("record %q should survive detach: %v", e, err)
}
ids, err := svc.GetInboundIdsForRecord(rec.Id)
if err != nil {
t.Fatalf("GetInboundIdsForRecord(%q): %v", e, err)
}
if len(ids) != 1 || ids[0] != ib1.Id {
t.Fatalf("record %q should remain attached only to source inbound %d, got %v", e, ib1.Id, ids)
}
}
}
// TestBulkDetach_SkipsUnattached verifies emails not on any requested inbound
// land in Skipped, not Detached, and produce no error.
func TestBulkDetach_SkipsUnattached(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "only-on-1@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "s1", Enable: true},
}
ib1 := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
ib2 := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`)
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
t.Fatalf("seed: %v", err)
}
dres, restart, err := svc.BulkDetach(inboundSvc, []string{"only-on-1@x"}, []int{ib2.Id})
if err != nil {
t.Fatalf("BulkDetach: %v", err)
}
if restart {
t.Fatalf("no-op detach should not require restart")
}
if len(dres.Detached) != 0 {
t.Fatalf("nothing should be detached, got %v", dres.Detached)
}
if len(dres.Skipped) != 1 || dres.Skipped[0] != "only-on-1@x" {
t.Fatalf("expected the email in Skipped, got %v", dres.Skipped)
}
if len(dres.Errors) != 0 {
t.Fatalf("unexpected errors: %v", dres.Errors)
}
}
// TestBulkAttachDetach_Trojan checks the protocol-specific key matching in the
// batched detach path (Trojan keys on password, not id).
func TestBulkAttachDetach_Trojan(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "t1@x", Password: "pw-t1", SubID: "t1", Enable: true},
{Email: "t2@x", Password: "pw-t2", SubID: "t2", Enable: true},
}
ib1 := mkInbound(t, 22001, model.Trojan, clientsSettings(t, source))
ib2 := mkInbound(t, 22002, model.Trojan, `{"clients":[]}`)
if err := svc.SyncInbound(nil, ib1.Id, source); err != nil {
t.Fatalf("seed: %v", err)
}
emails := emailsOf(source)
if res, _, err := svc.BulkAttach(inboundSvc, emails, []int{ib2.Id}); err != nil {
t.Fatalf("BulkAttach: %v", err)
} else if len(res.Errors) != 0 || len(res.Attached) != 2 {
t.Fatalf("attach result unexpected: attached=%v errors=%v", res.Attached, res.Errors)
}
list, _ := svc.ListForInbound(nil, ib2.Id)
if len(list) != 2 {
t.Fatalf("expected 2 trojan clients on ib2, got %v", sortedEmails(list))
}
dres, _, err := svc.BulkDetach(inboundSvc, emails, []int{ib2.Id})
if err != nil {
t.Fatalf("BulkDetach: %v", err)
}
if len(dres.Detached) != 2 || len(dres.Errors) != 0 {
t.Fatalf("detach result unexpected: detached=%v errors=%v", dres.Detached, dres.Errors)
}
if list, _ := svc.ListForInbound(nil, ib2.Id); len(list) != 0 {
t.Fatalf("trojan clients should be gone from ib2, got %v", sortedEmails(list))
}
}
+149
View File
@@ -0,0 +1,149 @@
package service
import (
"testing"
"time"
"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"
)
func mkTraffic(t *testing.T, inboundId int, email string, up, down, total, expiry int64, enable bool) {
t.Helper()
row := xray.ClientTraffic{
InboundId: inboundId,
Email: email,
Up: up,
Down: down,
Total: total,
ExpiryTime: expiry,
Enable: enable,
}
if err := database.GetDB().Create(&row).Error; err != nil {
t.Fatalf("create traffic %s: %v", email, err)
}
}
func trafficOf(t *testing.T, email string) xray.ClientTraffic {
t.Helper()
var row xray.ClientTraffic
if err := database.GetDB().Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("load traffic %s: %v", email, err)
}
return row
}
func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib := mkInbound(t, 21001, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
affected, err := svc.BulkResetTraffic(inboundSvc, []string{"alice@x", "bob@x"})
if err != nil {
t.Fatalf("BulkResetTraffic: %v", err)
}
if affected != 2 {
t.Fatalf("expected 2 affected, got %d", affected)
}
for _, e := range []string{"alice@x", "bob@x"} {
tr := trafficOf(t, e)
if tr.Up != 0 || tr.Down != 0 {
t.Fatalf("%s: expected up/down 0, got up=%d down=%d", e, tr.Up, tr.Down)
}
if !tr.Enable {
t.Fatalf("%s: expected re-enabled", e)
}
}
carol := trafficOf(t, "carol@x")
if carol.Up != 7 {
t.Fatalf("carol not in list should be untouched, got up=%d", carol.Up)
}
}
func TestDelDepletedRemovesOnlyDepleted(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
{Email: "bob@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "sb", Enable: true},
{Email: "carol@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "sc", Enable: true},
}
ib := mkInbound(t, 21002, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
past := time.Now().Add(-time.Hour).UnixMilli()
mkTraffic(t, ib.Id, "alice@x", 60, 60, 100, 0, true)
mkTraffic(t, ib.Id, "bob@x", 10, 10, 100, 0, true)
mkTraffic(t, ib.Id, "carol@x", 0, 0, 0, past, true)
deleted, _, err := svc.DelDepleted(inboundSvc)
if err != nil {
t.Fatalf("DelDepleted: %v", err)
}
if deleted != 2 {
t.Fatalf("expected 2 deleted (alice traffic-depleted, carol expired), got %d", deleted)
}
if _, err := svc.GetRecordByEmail(nil, "bob@x"); err != nil {
t.Fatalf("bob should survive: %v", err)
}
for _, e := range []string{"alice@x", "carol@x"} {
if _, err := svc.GetRecordByEmail(nil, e); err == nil {
t.Fatalf("%s should be deleted", e)
}
}
reloaded, _ := inboundSvc.GetInbound(ib.Id)
jsonClients, _ := inboundSvc.GetClients(reloaded)
if len(jsonClients) != 1 || jsonClients[0].Email != "bob@x" {
t.Fatalf("settings JSON should contain only bob, got %d clients", len(jsonClients))
}
}
func TestGetClientTrafficByEmailReadsClientsTable(t *testing.T) {
setupBulkDB(t)
svc := &ClientService{}
inboundSvc := &InboundService{}
source := []model.Client{
{Email: "alice@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "sa", Enable: true},
}
ib := mkInbound(t, 21003, model.VLESS, clientsSettings(t, source))
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
t.Fatalf("seed linkage: %v", err)
}
mkTraffic(t, ib.Id, "alice@x", 1, 2, 0, 0, true)
tr, err := inboundSvc.GetClientTrafficByEmail("alice@x")
if err != nil {
t.Fatalf("GetClientTrafficByEmail: %v", err)
}
if tr == nil {
t.Fatalf("expected traffic, got nil")
}
if tr.UUID != "11111111-1111-1111-1111-111111111111" {
t.Fatalf("UUID not enriched from clients table, got %q", tr.UUID)
}
if tr.SubId != "sa" {
t.Fatalf("SubId not enriched from clients table, got %q", tr.SubId)
}
}
+73
View File
@@ -0,0 +1,73 @@
// Package service implements the panel's business-logic layer.
//
// ClientService owns the lifecycle of VPN clients: creation, update, deletion,
// attach/detach to inbounds, bulk operations, group membership, traffic resets,
// and the paginated clients listing. Its surface is split across client_*.go
// files by responsibility (see each file's contents); they all belong to the
// same package, so the split is purely organizational. ClientService and
// InboundService are mutually dependent — most ClientService methods take an
// *InboundService and InboundService embeds a ClientService — which is why the
// client code lives in package service rather than a sub-package.
package service
import (
"encoding/json"
"errors"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
type ClientWithAttachments struct {
model.ClientRecord
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
}
// MarshalJSON is required because model.ClientRecord defines its own
// MarshalJSON. Go promotes the embedded method to the outer struct, so without
// this the encoder would call ClientRecord.MarshalJSON for the whole value and
// silently drop InboundIds and Traffic from the API response.
func (c ClientWithAttachments) MarshalJSON() ([]byte, error) {
rec, err := json.Marshal(c.ClientRecord)
if err != nil {
return nil, err
}
extras := struct {
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
}{InboundIds: c.InboundIds, Traffic: c.Traffic}
extra, err := json.Marshal(extras)
if err != nil {
return nil, err
}
if len(rec) < 2 || rec[len(rec)-1] != '}' || len(extra) <= 2 {
return rec, nil
}
const maxMarshalSize = 256 << 20
if len(rec) > maxMarshalSize || len(extra) > maxMarshalSize {
return rec, nil
}
out := make([]byte, 0, len(rec)+len(extra))
out = append(out, rec[:len(rec)-1]...)
if len(rec) > 2 {
out = append(out, ',')
}
out = append(out, extra[1:]...)
return out, nil
}
type ClientService struct{}
// ErrClientNotInInbound is returned (wrapped) when a client cannot be located
// in an inbound's settings during deletion. Deletion treats it as non-fatal so
// the operation stays idempotent and tolerant of pre-existing data drift
// between the clients table and the inbound's settings JSON.
var ErrClientNotInInbound = errors.New("client not found in inbound")
type ClientCreatePayload struct {
Client model.Client `json:"client"`
InboundIds []int `json:"inboundIds"`
}
const sqlInChunk = 400
File diff suppressed because it is too large Load Diff
+609
View File
@@ -0,0 +1,609 @@
package service
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func hasForbiddenClientChar(s string) bool {
for _, r := range s {
if r == '/' || r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
return true
}
}
return false
}
func validateClientEmail(email string) error {
if hasForbiddenClientChar(email) {
return common.NewError("client email contains an invalid character:", email)
}
return nil
}
func validateClientSubID(subID string) error {
if hasForbiddenClientChar(subID) {
return common.NewError("client subId contains an invalid character:", subID)
}
return nil
}
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
if payload == nil {
return false, common.NewError("empty payload")
}
client := payload.Client
if strings.TrimSpace(client.Email) == "" {
return false, common.NewError("client email is required")
}
if err := validateClientEmail(client.Email); err != nil {
return false, err
}
if err := validateClientSubID(client.SubID); err != nil {
return false, err
}
if len(payload.InboundIds) == 0 {
return false, common.NewError("at least one inbound is required")
}
if client.SubID == "" {
client.SubID = uuid.NewString()
}
if !client.Enable {
client.Enable = true
}
now := time.Now().UnixMilli()
if client.CreatedAt == 0 {
client.CreatedAt = now
}
client.UpdatedAt = now
existing := &model.ClientRecord{}
err := database.GetDB().Where("email = ?", client.Email).First(existing).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
emailTaken := !errors.Is(err, gorm.ErrRecordNotFound)
if emailTaken {
if existing.SubID == "" || existing.SubID != client.SubID {
return false, common.NewError("email already in use:", client.Email)
}
}
if client.SubID != "" {
var subTaken int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("sub_id = ? AND email <> ?", client.SubID, client.Email).
Count(&subTaken).Error; err != nil {
return false, err
}
if subTaken > 0 {
return false, common.NewError("subId already in use:", client.SubID)
}
}
needRestart := false
for _, ibId := range payload.InboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
if err := s.fillProtocolDefaults(&client, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(client, inbound)}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) fillProtocolDefaults(c *model.Client, ib *model.Inbound) error {
switch ib.Protocol {
case model.VMESS, model.VLESS:
if c.ID == "" {
c.ID = uuid.NewString()
}
case model.Trojan:
if c.Password == "" {
c.Password = strings.ReplaceAll(uuid.NewString(), "-", "")
}
case model.Shadowsocks:
method := shadowsocksMethodFromSettings(ib.Settings)
if c.Password == "" || !validShadowsocksClientKey(method, c.Password) {
c.Password = randomShadowsocksClientKey(method)
}
case model.Hysteria:
if c.Auth == "" {
c.Auth = strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
return nil
}
func clientWithInboundFlow(c model.Client, ib *model.Inbound) model.Client {
if !inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) {
c.Flow = ""
}
return c
}
func shadowsocksMethodFromSettings(settings string) string {
if settings == "" {
return ""
}
var m map[string]any
if err := json.Unmarshal([]byte(settings), &m); err != nil {
return ""
}
method, _ := m["method"].(string)
return method
}
func randomShadowsocksClientKey(method string) string {
if n := shadowsocksKeyBytes(method); n > 0 {
return random.Base64Bytes(n)
}
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
func validShadowsocksClientKey(method, key string) bool {
n := shadowsocksKeyBytes(method)
if n == 0 {
return key != ""
}
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
return false
}
return len(decoded) == n
}
func shadowsocksKeyBytes(method string) int {
switch method {
case "2022-blake3-aes-128-gcm":
return 16
case "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305":
return 32
}
return 0
}
func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
method, _ := settings["method"].(string)
is2022 := strings.HasPrefix(method, "2022-blake3-")
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if is2022 {
if _, hasKey := cm["method"]; hasKey {
delete(cm, "method")
clients[i] = cm
}
continue
}
if method == "" {
continue
}
if existing, _ := cm["method"].(string); existing != "" {
continue
}
cm["method"] = method
clients[i] = cm
}
}
func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
if len(inboundFilter) > 0 {
allow := make(map[int]struct{}, len(inboundFilter))
for _, fid := range inboundFilter {
allow[fid] = struct{}{}
}
filtered := inboundIds[:0:0]
for _, ibId := range inboundIds {
if _, ok := allow[ibId]; ok {
filtered = append(filtered, ibId)
}
}
inboundIds = filtered
}
if strings.TrimSpace(updated.Email) == "" {
return false, common.NewError("client email is required")
}
if err := validateClientEmail(updated.Email); err != nil {
return false, err
}
if err := validateClientSubID(updated.SubID); err != nil {
return false, err
}
if updated.SubID == "" {
updated.SubID = existing.SubID
}
if updated.SubID == "" {
updated.SubID = uuid.NewString()
}
updated.UpdatedAt = time.Now().UnixMilli()
if updated.CreatedAt == 0 {
updated.CreatedAt = existing.CreatedAt
}
// Preserve existing credentials when the caller omits them, so a partial
// update (e.g. only changing traffic/expiry) doesn't silently rotate the
// client's UUID/password/auth via fillProtocolDefaults. Supplying a new
// value still rotates it intentionally.
if updated.ID == "" {
updated.ID = existing.UUID
}
if updated.Password == "" {
updated.Password = existing.Password
}
if updated.Auth == "" {
updated.Auth = existing.Auth
}
if updated.Email != existing.Email {
var collisionCount int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("email = ? AND id <> ?", updated.Email, id).
Count(&collisionCount).Error; err != nil {
return false, err
}
if collisionCount > 0 {
return false, common.NewError("Duplicate email:", updated.Email)
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Update("email", updated.Email).Error; err != nil {
return false, err
}
}
if updated.SubID != "" {
var subCollision int64
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("sub_id = ? AND id <> ?", updated.SubID, id).
Count(&subCollision).Error; err != nil {
return false, err
}
if subCollision > 0 {
return false, common.NewError("Duplicate subId:", updated.SubID)
}
}
needRestart := false
for _, ibId := range inboundIds {
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
if errors.Is(getErr, gorm.ErrRecordNotFound) {
if err := database.GetDB().
Where("client_id = ? AND inbound_id = ?", id, ibId).
Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err
}
continue
}
return needRestart, getErr
}
if existing.Email == "" {
continue
}
if err := s.fillProtocolDefaults(&updated, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(updated, inbound)}})
if mErr != nil {
return needRestart, mErr
}
nr, upErr := s.UpdateInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
}, existing.Email)
if upErr != nil {
return needRestart, upErr
}
if nr {
needRestart = true
}
}
reverseStr := ""
if updated.Reverse != nil && strings.TrimSpace(updated.Reverse.Tag) != "" {
if b, mErr := json.Marshal(updated.Reverse); mErr == nil {
reverseStr = string(b)
}
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
Update("reverse", reverseStr).Error; err != nil {
return needRestart, err
}
if err := database.GetDB().Model(&model.ClientRecord{}).
Where("id = ?", id).
UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic bool) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
tombstoneClientEmail(existing.Email)
inboundIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
needRestart := false
for _, ibId := range inboundIds {
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
if errors.Is(getErr, gorm.ErrRecordNotFound) {
continue
}
return needRestart, getErr
}
// Always delete by email — the client's stable identity. This removes
// every matching entry from the inbound's settings even when the stored
// credential (UUID/password/auth) drifted from the inbound JSON, or a
// duplicate entry with the same email exists.
if existing.Email == "" {
continue
}
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, false)
if delErr != nil {
// The client is already absent from this inbound (data drift or a
// retried delete). Skip it — deletion stays idempotent.
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
return needRestart, delErr
}
if nr {
needRestart = true
}
}
db := database.GetDB()
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err
}
if !keepTraffic && existing.Email != "" {
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
}
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return needRestart, err
}
}
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
return needRestart, err
}
return needRestart, nil
}
func (s *ClientService) Attach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
clientWire := existing.ToClient()
flow, ffErr := s.EffectiveFlow(nil, id)
if ffErr != nil {
return false, ffErr
}
clientWire.Flow = flow
clientWire.UpdatedAt = time.Now().UnixMilli()
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; attached {
continue
}
inbound, getErr := inboundSvc.GetInbound(ibId)
if getErr != nil {
return needRestart, getErr
}
copyClient := *clientWire
if err := s.fillProtocolDefaults(&copyClient, inbound); err != nil {
return needRestart, err
}
settingsPayload, mErr := json.Marshal(map[string][]model.Client{"clients": {clientWithInboundFlow(copyClient, inbound)}})
if mErr != nil {
return needRestart, mErr
}
nr, addErr := s.AddInboundClient(inboundSvc, &model.Inbound{
Id: ibId,
Settings: string(settingsPayload),
})
if addErr != nil {
return needRestart, addErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) CreateOne(inboundSvc *InboundService, inboundId int, client model.Client) (bool, error) {
return s.Create(inboundSvc, &ClientCreatePayload{
Client: client,
InboundIds: []int{inboundId},
})
}
func (s *ClientService) DetachByEmail(inboundSvc *InboundService, inboundId int, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, []int{inboundId})
}
func (s *ClientService) AttachByEmail(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Attach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Detach(inboundSvc, rec.Id, inboundIds)
}
func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string, keepTraffic bool) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err == nil {
return s.Delete(inboundSvc, rec.Id, keepTraffic)
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
inboundIds, idsErr := s.findInboundIdsByClientEmail(email)
if idsErr != nil {
return false, idsErr
}
if len(inboundIds) == 0 {
return false, common.NewError(fmt.Sprintf("client %q not found in any inbound or client record", email))
}
needRestart := false
for _, ibId := range inboundIds {
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, email, false)
if delErr != nil {
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
return needRestart, delErr
}
if nr {
needRestart = true
}
}
if !keepTraffic {
db := database.GetDB()
if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
}
if err := db.Where("client_email = ?", email).Delete(&model.InboundClientIps{}).Error; err != nil {
return needRestart, err
}
}
return needRestart, nil
}
func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
}
func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {
existing, err := s.GetByID(id)
if err != nil {
return false, err
}
currentIds, err := s.GetInboundIdsForRecord(id)
if err != nil {
return false, err
}
have := make(map[int]struct{}, len(currentIds))
for _, x := range currentIds {
have[x] = struct{}{}
}
needRestart := false
for _, ibId := range inboundIds {
if _, attached := have[ibId]; !attached {
continue
}
if _, getErr := inboundSvc.GetInbound(ibId); getErr != nil {
return needRestart, getErr
}
// Detach by email — the client's stable identity (see Delete).
if existing.Email == "" {
continue
}
nr, delErr := s.DelInboundClientByEmail(inboundSvc, ibId, existing.Email, true)
if delErr != nil {
if errors.Is(delErr, ErrClientNotInInbound) {
continue
}
return needRestart, delErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
@@ -0,0 +1,57 @@
package service
import "testing"
func TestValidateClientEmail(t *testing.T) {
valid := []string{
"alice",
"alice@example.com",
"user-123_test.name",
"имя",
}
for _, email := range valid {
if err := validateClientEmail(email); err != nil {
t.Errorf("validateClientEmail(%q) = %v, want nil", email, err)
}
}
invalid := []string{
"i6dui/",
"a/b",
"client with spaces",
"back\\slash",
"tab\there",
"new\nline",
"\x7fdelete",
}
for _, email := range invalid {
if err := validateClientEmail(email); err == nil {
t.Errorf("validateClientEmail(%q) = nil, want error", email)
}
}
}
func TestValidateClientSubID(t *testing.T) {
valid := []string{
"",
"abc123",
"sub-id_value",
}
for _, subID := range valid {
if err := validateClientSubID(subID); err != nil {
t.Errorf("validateClientSubID(%q) = %v, want nil", subID, err)
}
}
invalid := []string{
"a/b",
"with space",
"back\\slash",
"new\nline",
}
for _, subID := range invalid {
if err := validateClientSubID(subID); err == nil {
t.Errorf("validateClientSubID(%q) = nil, want error", subID)
}
}
}
@@ -0,0 +1,257 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestClientWithInboundFlow_GatesByInboundCapability(t *testing.T) {
const vision = "xtls-rprx-vision"
cases := []struct {
name string
protocol model.Protocol
streamSettings string
wantFlow string
}{
{"vless tcp reality keeps flow", model.VLESS, `{"network":"tcp","security":"reality"}`, vision},
{"vless tcp tls keeps flow", model.VLESS, `{"network":"tcp","security":"tls"}`, vision},
{"vless ws tls clears flow", model.VLESS, `{"network":"ws","security":"tls"}`, ""},
{"vless grpc tls clears flow", model.VLESS, `{"network":"grpc","security":"tls"}`, ""},
{"vless tcp none clears flow", model.VLESS, `{"network":"tcp","security":"none"}`, ""},
{"vmess tcp tls clears flow", model.VMESS, `{"network":"tcp","security":"tls"}`, ""},
{"empty stream clears flow", model.VLESS, "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ib := &model.Inbound{Protocol: tc.protocol, StreamSettings: tc.streamSettings}
got := clientWithInboundFlow(model.Client{Email: "x@example.com", Flow: vision}, ib)
if got.Flow != tc.wantFlow {
t.Errorf("Flow = %q, want %q", got.Flow, tc.wantFlow)
}
})
}
}
func TestFlowIsolation_VisionDoesNotLeakToWsInbound(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()
wsTls := &model.Inbound{Tag: "vless-ws", Enable: true, Port: 30001, Protocol: model.VLESS, StreamSettings: `{"network":"ws","security":"tls"}`}
if err := db.Create(wsTls).Error; err != nil {
t.Fatalf("create ws+tls inbound: %v", err)
}
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 30002, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
if err := db.Create(reality).Error; err != nil {
t.Fatalf("create reality inbound: %v", err)
}
svc := ClientService{}
const email = "shared@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003"
const vision = "xtls-rprx-vision"
source := model.Client{Email: email, ID: uid, Enable: true, Flow: vision}
for _, ib := range []*model.Inbound{wsTls, reality} {
gated := clientWithInboundFlow(source, ib)
if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
realityList, err := svc.ListForInbound(nil, reality.Id)
if err != nil {
t.Fatalf("ListForInbound(reality): %v", err)
}
if len(realityList) != 1 || realityList[0].Flow != vision {
t.Errorf("Reality inbound should keep flow=%q, got %#v", vision, realityList)
}
wsList, err := svc.ListForInbound(nil, wsTls.Id)
if err != nil {
t.Fatalf("ListForInbound(ws): %v", err)
}
if len(wsList) != 1 || wsList[0].Flow != "" {
t.Errorf("WS+TLS inbound must not inherit Vision flow (#4628), got %#v", wsList)
}
}
func TestEffectiveFlow_NonFlowInboundSyncedLastDoesNotHideVision(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()
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 40001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
if err := db.Create(reality).Error; err != nil {
t.Fatalf("create reality inbound: %v", err)
}
hysteria := &model.Inbound{Tag: "hysteria", Enable: true, Port: 40002, Protocol: model.Hysteria, StreamSettings: `{"security":"tls"}`}
if err := db.Create(hysteria).Error; err != nil {
t.Fatalf("create hysteria inbound: %v", err)
}
svc := ClientService{}
const email = "shared@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c099"
const vision = "xtls-rprx-vision"
source := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, Flow: vision}
// Reproduce #4792 ordering: the flow-capable inbound (Reality) syncs first,
// the non-flow inbound (Hysteria) syncs last and wipes clients.Flow to "".
for _, ib := range []*model.Inbound{reality, hysteria} {
gated := clientWithInboundFlow(source, ib)
if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if rec.Flow != "" {
t.Logf("note: canonical clients.Flow = %q (denormalized, not authoritative)", rec.Flow)
}
got, err := svc.EffectiveFlow(nil, rec.Id)
if err != nil {
t.Fatalf("EffectiveFlow: %v", err)
}
if got != vision {
t.Errorf("EffectiveFlow = %q, want %q — the edit form would show a blank flow (#4792)", got, vision)
}
}
func TestEffectiveFlow_ClearedFlowStaysCleared(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()
reality := &model.Inbound{Tag: "vless-reality", Enable: true, Port: 41001, Protocol: model.VLESS, StreamSettings: `{"network":"tcp","security":"reality"}`}
if err := db.Create(reality).Error; err != nil {
t.Fatalf("create reality inbound: %v", err)
}
hysteria := &model.Inbound{Tag: "hysteria", Enable: true, Port: 41002, Protocol: model.Hysteria, StreamSettings: `{"security":"tls"}`}
if err := db.Create(hysteria).Error; err != nil {
t.Fatalf("create hysteria inbound: %v", err)
}
svc := ClientService{}
const email = "noflow@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c0aa"
// User chose no flow: every inbound carries "". A non-empty guard in
// SyncInbound would make this impossible to express; EffectiveFlow must
// still report "".
source := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, Flow: ""}
for _, ib := range []*model.Inbound{reality, hysteria} {
gated := clientWithInboundFlow(source, ib)
if err := svc.SyncInbound(nil, ib.Id, []model.Client{gated}); err != nil {
t.Fatalf("SyncInbound(%s): %v", ib.Tag, err)
}
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
got, err := svc.EffectiveFlow(nil, rec.Id)
if err != nil {
t.Fatalf("EffectiveFlow: %v", err)
}
if got != "" {
t.Errorf("EffectiveFlow = %q, want empty (cleared flow must stay cleared)", got)
}
}
func TestAttach_PreservesVisionFlowWhenCanonicalColumnZeroed(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 email = "vision@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111"
const sub = "subvision000001"
const vision = "xtls-rprx-vision"
const realityStream = `{"network":"tcp","security":"reality"}`
svc := ClientService{}
source := model.Client{Email: email, ID: uid, SubID: sub, Enable: true, Flow: vision}
reality1 := &model.Inbound{
Tag: "vless-reality-1", Enable: true, Port: 42001, Protocol: model.VLESS,
StreamSettings: realityStream,
Settings: clientsSettings(t, []model.Client{source}),
}
if err := db.Create(reality1).Error; err != nil {
t.Fatalf("create reality1: %v", err)
}
reality2 := &model.Inbound{
Tag: "vless-reality-2", Enable: true, Port: 42002, Protocol: model.VLESS,
StreamSettings: realityStream, Settings: `{"clients":[]}`,
}
if err := db.Create(reality2).Error; err != nil {
t.Fatalf("create reality2: %v", err)
}
wsTls := &model.Inbound{
Tag: "vless-ws", Enable: true, Port: 42003, Protocol: model.VLESS,
StreamSettings: `{"network":"ws","security":"tls"}`, Settings: `{"clients":[]}`,
}
if err := db.Create(wsTls).Error; err != nil {
t.Fatalf("create ws: %v", err)
}
if err := svc.SyncInbound(nil, reality1.Id, []model.Client{clientWithInboundFlow(source, reality1)}); err != nil {
t.Fatalf("SyncInbound(reality1): %v", err)
}
rec, err := svc.GetRecordByEmail(nil, email)
if err != nil {
t.Fatalf("GetRecordByEmail: %v", err)
}
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).Update("flow", "").Error; err != nil {
t.Fatalf("zero canonical flow: %v", err)
}
inboundSvc := &InboundService{}
if _, err := svc.Attach(inboundSvc, rec.Id, []int{reality2.Id, wsTls.Id}); err != nil {
t.Fatalf("Attach: %v", err)
}
reality2List, err := svc.ListForInbound(nil, reality2.Id)
if err != nil {
t.Fatalf("ListForInbound(reality2): %v", err)
}
if len(reality2List) != 1 || reality2List[0].Flow != vision {
t.Errorf("attached flow-capable inbound must inherit Vision via EffectiveFlow (#4834), got %#v", reality2List)
}
wsList, err := svc.ListForInbound(nil, wsTls.Id)
if err != nil {
t.Fatalf("ListForInbound(ws): %v", err)
}
if len(wsList) != 1 || wsList[0].Flow != "" {
t.Errorf("attached non-flow inbound must not receive Vision flow, got %#v", wsList)
}
}
@@ -0,0 +1,118 @@
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/web/runtime"
)
func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(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 nodeID = 1
const email = "node-user@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003"
const wantGroup = "vip"
const wantComment = "renewed manually"
id := nodeID
central := &model.Inbound{
UserId: 1,
NodeID: &id,
Tag: "n1-vless",
Enable: true,
Port: 20001,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"group":"` + wantGroup + `","comment":"` + wantComment + `"}]}`,
}
if err := db.Create(central).Error; err != nil {
t.Fatalf("create node inbound: %v", err)
}
if err := db.Create(&model.ClientRecord{
Email: email,
UUID: uid,
Enable: true,
Group: wantGroup,
Comment: wantComment,
}).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{
Tag: "n1-vless",
Enable: true,
Port: 20001,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true}]}`,
},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
var row model.ClientRecord
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("lookup client row after sync: %v", err)
}
if row.Group != wantGroup {
t.Errorf("group was wiped by node snapshot sync: got %q, want %q", row.Group, wantGroup)
}
if row.Comment != wantComment {
t.Errorf("comment was wiped by node snapshot sync: got %q, want %q", row.Comment, wantComment)
}
}
func TestSyncInbound_KeepsGroupWhenIncomingEmpty(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()
ib := &model.Inbound{Tag: "vless-grp", Enable: true, Port: 20002, Protocol: model.VLESS}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
svc := ClientService{}
const email = "grp-user@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c004"
const wantGroup = "vip"
withGroup := model.Client{Email: email, ID: uid, Enable: true, Group: wantGroup}
if err := svc.SyncInbound(nil, ib.Id, []model.Client{withGroup}); err != nil {
t.Fatalf("SyncInbound (set group): %v", err)
}
noGroup := model.Client{Email: email, ID: uid, Enable: true, Group: ""}
if err := svc.SyncInbound(nil, ib.Id, []model.Client{noGroup}); err != nil {
t.Fatalf("SyncInbound (group-less rebuild): %v", err)
}
var row model.ClientRecord
if err := db.Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("lookup client row: %v", err)
}
if row.Group != wantGroup {
t.Errorf("group must survive a group-less settings rebuild (it is managed via the Groups page, not Xray settings): got %q, want %q", row.Group, wantGroup)
}
}
+346
View File
@@ -0,0 +1,346 @@
package service
import (
"encoding/json"
"sort"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
type GroupSummary struct {
Name string `json:"name"`
ClientCount int `json:"clientCount"`
TrafficUsed int64 `json:"trafficUsed"`
}
func (s *ClientService) ListGroups() ([]GroupSummary, error) {
db := database.GetDB()
// email is unique in both clients and client_traffics, so the LEFT JOIN
// never double-counts a client's traffic.
var derived []GroupSummary
if err := db.Table("clients AS c").
Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
Where("c.group_name <> ''").
Group("c.group_name").
Scan(&derived).Error; err != nil {
return nil, err
}
var stored []model.ClientGroup
if err := db.Find(&stored).Error; err != nil {
return nil, err
}
type groupAgg struct {
count int
traffic int64
}
merged := make(map[string]groupAgg, len(derived)+len(stored))
for _, g := range stored {
merged[g.Name] = groupAgg{}
}
for _, g := range derived {
merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed}
}
out := make([]GroupSummary, 0, len(merged))
for name, agg := range merged {
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
})
return out, nil
}
func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
name = strings.TrimSpace(name)
if name == "" {
return []string{}, nil
}
db := database.GetDB()
var emails []string
if err := db.Model(&model.ClientRecord{}).
Where("group_name = ?", name).
Order("email ASC").
Pluck("email", &emails).Error; err != nil {
return nil, err
}
if emails == nil {
emails = []string{}
}
return emails, nil
}
func (s *ClientService) CreateGroup(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return common.NewError("group name is required")
}
db := database.GetDB()
var count int64
if err := db.Model(&model.ClientGroup{}).Where("name = ?", name).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return common.NewError("group already exists")
}
return db.Create(&model.ClientGroup{Name: name}).Error
}
func (s *ClientService) RenameGroup(oldName, newName string) (int, error) {
oldName = strings.TrimSpace(oldName)
newName = strings.TrimSpace(newName)
if oldName == "" {
return 0, common.NewError("old group name is required")
}
if newName == "" {
return 0, common.NewError("new group name is required")
}
if oldName == newName {
return 0, nil
}
return s.replaceGroupValue(oldName, newName)
}
func (s *ClientService) DeleteGroup(name string) (int, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, common.NewError("group name is required")
}
return s.replaceGroupValue(name, "")
}
func (s *ClientService) RemoveFromGroup(emails []string) (int, error) {
return s.AddToGroup(emails, "")
}
func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
group = strings.TrimSpace(group)
if len(emails) == 0 {
return 0, nil
}
db := database.GetDB()
if group != "" {
var exists int64
if err := db.Model(&model.ClientGroup{}).Where("name = ?", group).Count(&exists).Error; err != nil {
return 0, err
}
if exists == 0 {
var derived int64
if err := db.Model(&model.ClientRecord{}).Where("group_name = ?", group).Count(&derived).Error; err != nil {
return 0, err
}
if derived == 0 {
if err := db.Create(&model.ClientGroup{Name: group}).Error; err != nil {
return 0, err
}
}
}
}
var records []model.ClientRecord
for _, batch := range chunkStrings(emails, sqlInChunk) {
var rows []model.ClientRecord
if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
return 0, err
}
records = append(records, rows...)
}
if len(records) == 0 {
return 0, nil
}
affectedEmails := make([]string, 0, len(records))
for _, r := range records {
affectedEmails = append(affectedEmails, r.Email)
}
tx := db.Begin()
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
if err := tx.Model(&model.ClientRecord{}).
Where("email IN ?", batch).
UpdateColumn("group_name", group).Error; err != nil {
tx.Rollback()
return 0, err
}
}
var inboundIDs []int
inboundIDSeen := make(map[int]struct{})
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
var ids []int
if err := tx.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN ?", batch).
Distinct("client_inbounds.inbound_id").
Pluck("inbound_id", &ids).Error; err != nil {
tx.Rollback()
return 0, err
}
for _, id := range ids {
if _, ok := inboundIDSeen[id]; !ok {
inboundIDSeen[id] = struct{}{}
inboundIDs = append(inboundIDs, id)
}
}
}
emailSet := make(map[string]struct{}, len(affectedEmails))
for _, e := range affectedEmails {
emailSet[e] = struct{}{}
}
for _, ibID := range inboundIDs {
var ib model.Inbound
if err := tx.First(&ib, ibID).Error; err != nil {
tx.Rollback()
return 0, err
}
var settings map[string]any
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
modified := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
email, _ := cm["email"].(string)
if _, hit := emailSet[email]; !hit {
continue
}
if group == "" {
delete(cm, "group")
} else {
cm["group"] = group
}
clients[i] = cm
modified = true
}
if modified {
settings["clients"] = clients
newSettings, err := json.Marshal(settings)
if err != nil {
continue
}
ib.Settings = string(newSettings)
if err := tx.Save(&ib).Error; err != nil {
tx.Rollback()
return 0, err
}
}
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return len(records), nil
}
func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) {
db := database.GetDB()
if newName == "" {
if err := db.Where("name = ?", oldName).Delete(&model.ClientGroup{}).Error; err != nil {
return 0, err
}
} else {
if err := db.Model(&model.ClientGroup{}).Where("name = ?", oldName).Update("name", newName).Error; err != nil {
return 0, err
}
}
var records []model.ClientRecord
if err := db.Where("group_name = ?", oldName).Find(&records).Error; err != nil {
return 0, err
}
if len(records) == 0 {
return 0, nil
}
affectedEmails := make([]string, 0, len(records))
for _, r := range records {
affectedEmails = append(affectedEmails, r.Email)
}
tx := db.Begin()
if err := tx.Model(&model.ClientRecord{}).
Where("group_name = ?", oldName).
UpdateColumn("group_name", newName).Error; err != nil {
tx.Rollback()
return 0, err
}
var inboundIDs []int
inboundIDSeen := make(map[int]struct{})
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
var ids []int
if err := tx.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN ?", batch).
Distinct("client_inbounds.inbound_id").
Pluck("inbound_id", &ids).Error; err != nil {
tx.Rollback()
return 0, err
}
for _, id := range ids {
if _, ok := inboundIDSeen[id]; !ok {
inboundIDSeen[id] = struct{}{}
inboundIDs = append(inboundIDs, id)
}
}
}
for _, ibID := range inboundIDs {
var ib model.Inbound
if err := tx.First(&ib, ibID).Error; err != nil {
tx.Rollback()
return 0, err
}
var settings map[string]any
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
modified := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
continue
}
if g, ok := cm["group"].(string); ok && g == oldName {
if newName == "" {
delete(cm, "group")
} else {
cm["group"] = newName
}
clients[i] = cm
modified = true
}
}
if modified {
settings["clients"] = clients
newSettings, err := json.Marshal(settings)
if err != nil {
continue
}
ib.Settings = string(newSettings)
if err := tx.Save(&ib).Error; err != nil {
tx.Rollback()
return 0, err
}
}
}
if err := tx.Commit().Error; err != nil {
return 0, err
}
return len(records), nil
}
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/gorm"
)
func (s *ClientService) SyncInbound(tx *gorm.DB, inboundId int, clients []model.Client) error {
if tx == nil {
tx = database.GetDB()
}
if err := tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error; err != nil {
return err
}
emails := make([]string, 0, len(clients))
seen := make(map[string]struct{}, len(clients))
for i := range clients {
email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
if _, ok := seen[email]; ok {
continue
}
seen[email] = struct{}{}
emails = append(emails, email)
}
existing := make(map[string]*model.ClientRecord, len(emails))
const selectChunk = 400
for start := 0; start < len(emails); start += selectChunk {
end := min(start+selectChunk, len(emails))
var rows []model.ClientRecord
if err := tx.Where("email IN ?", emails[start:end]).Find(&rows).Error; err != nil {
return err
}
for i := range rows {
r := rows[i]
existing[r.Email] = &r
}
}
idByEmail := make(map[string]int, len(emails))
pending := make(map[string]*model.ClientRecord, len(emails))
toCreate := make([]*model.ClientRecord, 0, len(emails))
for i := range clients {
email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
incoming := clients[i].ToRecord()
row, ok := existing[email]
if !ok {
if _, dup := pending[email]; !dup {
pending[email] = incoming
toCreate = append(toCreate, incoming)
}
continue
}
before := *row
if incoming.UUID != "" {
row.UUID = incoming.UUID
}
if incoming.Password != "" {
row.Password = incoming.Password
}
if incoming.Auth != "" {
row.Auth = incoming.Auth
}
row.Flow = incoming.Flow
if incoming.Security != "" {
row.Security = incoming.Security
}
if incoming.Reverse != "" {
row.Reverse = incoming.Reverse
}
row.SubID = incoming.SubID
row.LimitIP = incoming.LimitIP
row.TotalGB = incoming.TotalGB
row.ExpiryTime = incoming.ExpiryTime
row.Enable = incoming.Enable
row.TgID = incoming.TgID
if incoming.Group != "" {
row.Group = incoming.Group
}
row.Comment = incoming.Comment
row.Reset = incoming.Reset
if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) {
row.CreatedAt = incoming.CreatedAt
}
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
row.UpdatedAt = preservedUpdatedAt
idByEmail[email] = row.Id
if *row == before {
continue
}
if err := tx.Save(row).Error; err != nil {
return err
}
if err := tx.Model(&model.ClientRecord{}).
Where("id = ?", row.Id).
UpdateColumn("updated_at", preservedUpdatedAt).Error; err != nil {
return err
}
}
if len(toCreate) > 0 {
if err := tx.CreateInBatches(toCreate, 200).Error; err != nil {
return err
}
for _, rec := range toCreate {
idByEmail[rec.Email] = rec.Id
}
}
links := make([]model.ClientInbound, 0, len(clients))
linked := make(map[int]struct{}, len(clients))
for i := range clients {
email := strings.TrimSpace(clients[i].Email)
if email == "" {
continue
}
id, ok := idByEmail[email]
if !ok {
continue
}
if _, dup := linked[id]; dup {
continue
}
linked[id] = struct{}{}
links = append(links, model.ClientInbound{
ClientId: id,
InboundId: inboundId,
FlowOverride: clients[i].Flow,
})
}
if len(links) > 0 {
if err := tx.CreateInBatches(links, 200).Error; err != nil {
return err
}
}
return nil
}
func (s *ClientService) DetachInbound(tx *gorm.DB, inboundId int) error {
if tx == nil {
tx = database.GetDB()
}
return tx.Where("inbound_id = ?", inboundId).Delete(&model.ClientInbound{}).Error
}
func (s *ClientService) ListForInbound(tx *gorm.DB, inboundId int) ([]model.Client, error) {
if tx == nil {
tx = database.GetDB()
}
type joinedRow struct {
model.ClientRecord
FlowOverride string
}
var rows []joinedRow
err := tx.Table("clients").
Select("clients.*, client_inbounds.flow_override AS flow_override").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", inboundId).
Order("clients.id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
out := make([]model.Client, 0, len(rows))
for i := range rows {
c := rows[i].ToClient()
c.Flow = rows[i].FlowOverride
out = append(out, *c)
}
return out, nil
}
+141
View File
@@ -0,0 +1,141 @@
package service
import (
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"gorm.io/gorm"
)
// Short-lived tombstone of just-deleted client emails so that a node snapshot
// arriving between delete and node-side processing doesn't resurrect them.
var (
recentlyDeletedMu sync.Mutex
recentlyDeleted = map[string]time.Time{}
)
const deleteTombstoneTTL = 90 * time.Second
var (
inboundMutationLocksMu sync.Mutex
inboundMutationLocks = map[int]*sync.Mutex{}
)
func lockInbound(inboundId int) *sync.Mutex {
inboundMutationLocksMu.Lock()
defer inboundMutationLocksMu.Unlock()
m, ok := inboundMutationLocks[inboundId]
if !ok {
m = &sync.Mutex{}
inboundMutationLocks[inboundId] = m
}
m.Lock()
return m
}
func compactOrphans(db *gorm.DB, clients []any) []any {
if len(clients) == 0 {
return clients
}
emails := make([]string, 0, len(clients))
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if e, _ := cm["email"].(string); e != "" {
emails = append(emails, e)
}
}
if len(emails) == 0 {
return clients
}
existing := make(map[string]struct{}, len(emails))
const orphanChunk = 400
for start := 0; start < len(emails); start += orphanChunk {
end := min(start+orphanChunk, len(emails))
var found []string
if err := db.Model(&model.ClientRecord{}).Where("email IN ?", emails[start:end]).Pluck("email", &found).Error; err != nil {
logger.Warning("compactOrphans pluck:", err)
return clients
}
for _, e := range found {
existing[e] = struct{}{}
}
}
if len(existing) == len(emails) {
return clients
}
out := make([]any, 0, len(existing))
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
out = append(out, c)
continue
}
e, _ := cm["email"].(string)
if e == "" {
out = append(out, c)
continue
}
if _, ok := existing[e]; ok {
out = append(out, c)
}
}
return out
}
func tombstoneClientEmail(email string) {
if email == "" {
return
}
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
recentlyDeleted[email] = time.Now()
cutoff := time.Now().Add(-deleteTombstoneTTL)
for e, ts := range recentlyDeleted {
if ts.Before(cutoff) {
delete(recentlyDeleted, e)
}
}
}
func tombstoneClientEmails(emails []string) {
if len(emails) == 0 {
return
}
now := time.Now()
cutoff := now.Add(-deleteTombstoneTTL)
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
for _, email := range emails {
if email != "" {
recentlyDeleted[email] = now
}
}
for e, ts := range recentlyDeleted {
if ts.Before(cutoff) {
delete(recentlyDeleted, e)
}
}
}
func isClientEmailTombstoned(email string) bool {
if email == "" {
return false
}
recentlyDeletedMu.Lock()
defer recentlyDeletedMu.Unlock()
ts, ok := recentlyDeleted[email]
if !ok {
return false
}
if time.Since(ts) > deleteTombstoneTTL {
delete(recentlyDeleted, email)
return false
}
return true
}
+188
View File
@@ -0,0 +1,188 @@
package service
import (
"encoding/json"
"strings"
"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"
"gorm.io/gorm"
)
func (s *ClientService) GetRecordByEmail(tx *gorm.DB, email string) (*model.ClientRecord, error) {
if tx == nil {
tx = database.GetDB()
}
row := &model.ClientRecord{}
err := tx.Where("email = ?", email).First(row).Error
if err != nil {
return nil, err
}
return row, nil
}
// EffectiveFlow returns the client's flow from the first flow-capable inbound
// it is attached to (lowest inbound_id with a non-empty flow_override). The
// canonical clients.Flow column is unreliable for multi-inbound clients: a
// non-flow inbound (Hysteria, WS, gRPC, …) carries an empty flow and, when its
// SyncInbound runs last, overwrites the column to "" even though a VLESS Reality
// inbound stored a real flow. The per-inbound flow_override is always correct,
// so derive the display flow from it (order-independent). See issue #4792.
func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error) {
if tx == nil {
tx = database.GetDB()
}
var flows []string
err := tx.Model(&model.ClientInbound{}).
Where("client_id = ? AND flow_override <> ?", recordId, "").
Order("inbound_id ASC").
Limit(1).
Pluck("flow_override", &flows).Error
if err != nil {
return "", err
}
if len(flows) == 0 {
return "", nil
}
return flows[0], nil
}
func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
if tx == nil {
tx = database.GetDB()
}
var ids []int
err := tx.Table("client_inbounds").
Select("client_inbounds.inbound_id").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ?", email).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) GetByID(id int) (*model.ClientRecord, error) {
row := &model.ClientRecord{}
if err := database.GetDB().Where("id = ?", id).First(row).Error; err != nil {
return nil, err
}
return row, nil
}
func (s *ClientService) GetInboundIdsForRecord(id int) ([]int, error) {
var ids []int
err := database.GetDB().Table("client_inbounds").
Where("client_id = ?", id).
Order("inbound_id ASC").
Pluck("inbound_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (s *ClientService) List() ([]ClientWithAttachments, error) {
db := database.GetDB()
var rows []model.ClientRecord
if err := db.Order("id ASC").Find(&rows).Error; err != nil {
return nil, err
}
if len(rows) == 0 {
return []ClientWithAttachments{}, nil
}
clientIds := make([]int, 0, len(rows))
emails := make([]string, 0, len(rows))
for i := range rows {
clientIds = append(clientIds, rows[i].Id)
if rows[i].Email != "" {
emails = append(emails, rows[i].Email)
}
}
attachments := make(map[int][]int, len(rows))
for _, batch := range chunkInts(clientIds, sqlInChunk) {
var links []model.ClientInbound
if err := db.Where("client_id IN ?", batch).Find(&links).Error; err != nil {
return nil, err
}
for _, l := range links {
attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
}
}
trafficByEmail := make(map[string]*xray.ClientTraffic, len(emails))
if len(emails) > 0 {
var stats []xray.ClientTraffic
for _, batch := range chunkStrings(emails, sqlInChunk) {
var batchStats []xray.ClientTraffic
if err := db.Where("email IN ?", batch).Find(&batchStats).Error; err != nil {
return nil, err
}
stats = append(stats, batchStats...)
}
for i := range stats {
trafficByEmail[stats[i].Email] = &stats[i]
}
}
out := make([]ClientWithAttachments, 0, len(rows))
for i := range rows {
out = append(out, ClientWithAttachments{
ClientRecord: rows[i],
InboundIds: attachments[rows[i].Id],
Traffic: trafficByEmail[rows[i].Email],
})
}
return out, nil
}
func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) bool {
if strings.TrimSpace(email) == "" {
return false
}
ids, err := s.GetInboundIdsForEmail(nil, email)
if err != nil {
return false
}
return inboundSvc.AnyNodePending(ids)
}
// findInboundIdsByClientEmail returns every inbound whose settings.clients[]
// JSON contains an entry with the given email. Driver-portable (no JSON
// operators) by parsing in Go — fine for the rare fallback path.
func (s *ClientService) findInboundIdsByClientEmail(email string) ([]int, error) {
var inbounds []model.Inbound
if err := database.GetDB().
Select("id, settings").
Where("settings LIKE ?", "%"+email+"%").
Find(&inbounds).Error; err != nil {
return nil, err
}
out := make([]int, 0, len(inbounds))
for _, ib := range inbounds {
var settings map[string]any
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
continue
}
clients, ok := settings["clients"].([]any)
if !ok {
continue
}
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
continue
}
if cEmail, _ := cm["email"].(string); cEmail == email {
out = append(out, ib.Id)
break
}
}
}
return out, nil
}
+581
View File
@@ -0,0 +1,581 @@
package service
import (
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// ClientSlim is the row-shape used by the clients page. It drops fields the
// table never reads (UUID, password, auth, flow, security, reverse, tgId)
// so the list payload stays compact even when the panel manages thousands
// of clients. Modals that need the full record still call /get/:email.
type ClientSlim struct {
Email string `json:"email"`
SubID string `json:"subId"`
Enable bool `json:"enable"`
TotalGB int64 `json:"totalGB"`
ExpiryTime int64 `json:"expiryTime"`
LimitIP int `json:"limitIp"`
Reset int `json:"reset"`
Group string `json:"group,omitempty"`
Comment string `json:"comment,omitempty"`
InboundIds []int `json:"inboundIds"`
Traffic *xray.ClientTraffic `json:"traffic,omitempty"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// ClientPageParams are the query params accepted by /panel/api/clients/list/paged.
// All fields are optional — the empty value means "no filter" / defaults.
//
// Filter / Protocol / Inbound accept either a single value or a comma-separated
// list; matching is OR within a field and AND across fields. The numeric range
// fields treat 0 as "unset" on the lower bound and 0 (or negative) as
// "unbounded" on the upper bound.
type ClientPageParams struct {
Page int `form:"page"`
PageSize int `form:"pageSize"`
Search string `form:"search"`
Filter string `form:"filter"`
Protocol string `form:"protocol"`
Inbound string `form:"inbound"`
Sort string `form:"sort"`
Order string `form:"order"`
ExpiryFrom int64 `form:"expiryFrom"`
ExpiryTo int64 `form:"expiryTo"`
UsageFrom int64 `form:"usageFrom"`
UsageTo int64 `form:"usageTo"`
AutoRenew string `form:"autoRenew"`
HasTgID string `form:"hasTgId"`
HasComment string `form:"hasComment"`
Group string `form:"group"`
}
// ClientPageResponse is the shape returned by ListPaged. `Total` is the
// row count in the DB; `Filtered` is the count after Search/Filter/Protocol
// were applied, before pagination. The page contains at most PageSize items.
// Summary is computed across the full DB row set so dashboard counters
// on the clients page stay stable as the user paginates/filters.
type ClientPageResponse struct {
Items []ClientSlim `json:"items"`
Total int `json:"total"`
Filtered int `json:"filtered"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
Summary ClientsSummary `json:"summary"`
Groups []string `json:"groups"`
}
// ClientsSummary collects per-bucket counts plus the matching email lists so
// the clients page can render the dashboard stat cards and their hover
// popovers without shipping the full client array.
type ClientsSummary struct {
Total int `json:"total"`
Active int `json:"active"`
Online []string `json:"online"`
Depleted []string `json:"depleted"`
Expiring []string `json:"expiring"`
Deactive []string `json:"deactive"`
}
const (
clientPageDefaultSize = 25
clientPageMaxSize = 200
)
// ListPaged loads every client (with traffic + attachments) into memory,
// applies the requested filter / search / protocol predicates, sorts, and
// returns the requested page along with total and filtered counts. The DB
// query itself is unchanged from List(); the win is that the response
// only carries 25-ish slim rows over the wire instead of all 2000 full
// records, which on real panels was the dominant cost.
func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *SettingService, params ClientPageParams) (*ClientPageResponse, error) {
all, err := s.List()
if err != nil {
return nil, err
}
total := len(all)
pageSize := params.PageSize
if pageSize <= 0 {
pageSize = clientPageDefaultSize
}
if pageSize > clientPageMaxSize {
pageSize = clientPageMaxSize
}
page := params.Page
if page <= 0 {
page = 1
}
protocols := parseCSVStrings(params.Protocol)
inboundIDs := parseCSVInts(params.Inbound)
buckets := parseCSVStrings(params.Filter)
var protocolByInbound map[int]string
if len(protocols) > 0 {
inbounds, err := inboundSvc.GetAllInbounds()
if err == nil {
protocolByInbound = make(map[int]string, len(inbounds))
for _, ib := range inbounds {
protocolByInbound[ib.Id] = string(ib.Protocol)
}
}
}
onlines := inboundSvc.GetOnlineClients()
onlineSet := make(map[string]struct{}, len(onlines))
for _, e := range onlines {
onlineSet[e] = struct{}{}
}
var expireDiffMs, trafficDiffBytes int64
if settingSvc != nil {
if v, err := settingSvc.GetExpireDiff(); err == nil {
expireDiffMs = int64(v) * 86400000
}
if v, err := settingSvc.GetTrafficDiff(); err == nil {
trafficDiffBytes = int64(v) * 1073741824
}
}
nowMs := time.Now().UnixMilli()
summary := buildClientsSummary(all, onlineSet, nowMs, expireDiffMs, trafficDiffBytes)
needle := strings.ToLower(strings.TrimSpace(params.Search))
filtered := make([]ClientWithAttachments, 0, len(all))
for _, c := range all {
if needle != "" && !clientMatchesSearch(c, needle) {
continue
}
if len(protocols) > 0 && !clientMatchesAnyProtocol(c, protocols, protocolByInbound) {
continue
}
if len(inboundIDs) > 0 && !clientMatchesAnyInbound(c, inboundIDs) {
continue
}
if len(buckets) > 0 && !clientMatchesAnyBucket(c, buckets, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
continue
}
if !clientMatchesExpiryRange(c, params.ExpiryFrom, params.ExpiryTo) {
continue
}
if !clientMatchesUsageRange(c, params.UsageFrom, params.UsageTo) {
continue
}
if !clientMatchesAutoRenew(c, params.AutoRenew) {
continue
}
if !clientMatchesHasTgID(c, params.HasTgID) {
continue
}
if !clientMatchesHasComment(c, params.HasComment) {
continue
}
if !clientMatchesAnyGroup(c, params.Group) {
continue
}
filtered = append(filtered, c)
}
sortClients(filtered, params.Sort, params.Order)
filteredCount := len(filtered)
start := (page - 1) * pageSize
end := start + pageSize
if start > filteredCount {
start = filteredCount
}
if end > filteredCount {
end = filteredCount
}
pageRows := filtered[start:end]
items := make([]ClientSlim, 0, len(pageRows))
for _, c := range pageRows {
items = append(items, toClientSlim(c))
}
groupRows, gErr := s.ListGroups()
if gErr != nil {
return nil, gErr
}
groups := make([]string, 0, len(groupRows))
for _, g := range groupRows {
groups = append(groups, g.Name)
}
return &ClientPageResponse{
Items: items,
Total: total,
Filtered: filteredCount,
Page: page,
PageSize: pageSize,
Summary: summary,
Groups: groups,
}, nil
}
func buildClientsSummary(all []ClientWithAttachments, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) ClientsSummary {
s := ClientsSummary{
Total: len(all),
Online: []string{},
Depleted: []string{},
Expiring: []string{},
Deactive: []string{},
}
for _, c := range all {
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
exhausted := c.TotalGB > 0 && used >= c.TotalGB
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
if c.Enable {
if _, ok := onlineSet[c.Email]; ok {
s.Online = append(s.Online, c.Email)
}
}
if exhausted || expired {
s.Depleted = append(s.Depleted, c.Email)
continue
}
if !c.Enable {
s.Deactive = append(s.Deactive, c.Email)
continue
}
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
if nearExpiry || nearLimit {
s.Expiring = append(s.Expiring, c.Email)
} else {
s.Active++
}
}
return s
}
func toClientSlim(c ClientWithAttachments) ClientSlim {
return ClientSlim{
Email: c.Email,
SubID: c.SubID,
Enable: c.Enable,
TotalGB: c.TotalGB,
ExpiryTime: c.ExpiryTime,
LimitIP: c.LimitIP,
Reset: c.Reset,
Group: c.Group,
Comment: c.Comment,
InboundIds: c.InboundIds,
Traffic: c.Traffic,
CreatedAt: c.CreatedAt,
UpdatedAt: c.UpdatedAt,
}
}
func clientMatchesSearch(c ClientWithAttachments, needle string) bool {
if needle == "" {
return true
}
candidates := [...]string{c.Email, c.SubID, c.Comment, c.UUID, c.Password, c.Auth}
for _, v := range candidates {
if v != "" && strings.Contains(strings.ToLower(v), needle) {
return true
}
}
return false
}
// parseCSVStrings splits a comma-separated list, trims/lower-cases each item,
// and drops blanks. Returns nil when the input has no usable entries — the
// caller can then skip the predicate entirely.
func parseCSVStrings(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
s := strings.ToLower(strings.TrimSpace(p))
if s != "" {
out = append(out, s)
}
}
if len(out) == 0 {
return nil
}
return out
}
// parseCSVInts is parseCSVStrings for positive integer IDs; non-numeric or
// non-positive entries are silently dropped.
func parseCSVInts(raw string) []int {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]int, 0, len(parts))
for _, p := range parts {
s := strings.TrimSpace(p)
if s == "" {
continue
}
if n, err := strconv.Atoi(s); err == nil && n > 0 {
out = append(out, n)
}
}
if len(out) == 0 {
return nil
}
return out
}
func clientMatchesAnyProtocol(c ClientWithAttachments, protocols []string, byInbound map[int]string) bool {
for _, id := range c.InboundIds {
p := byInbound[id]
if p == "" {
continue
}
if slices.Contains(protocols, strings.ToLower(p)) {
return true
}
}
return false
}
func clientMatchesAnyInbound(c ClientWithAttachments, inboundIds []int) bool {
for _, id := range c.InboundIds {
if slices.Contains(inboundIds, id) {
return true
}
}
return false
}
func clientMatchesAnyBucket(c ClientWithAttachments, buckets []string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
for _, b := range buckets {
if clientMatchesBucket(c, b, onlineSet, nowMs, expireDiffMs, trafficDiffBytes) {
return true
}
}
return false
}
func clientMatchesExpiryRange(c ClientWithAttachments, fromMs, toMs int64) bool {
if fromMs <= 0 && toMs <= 0 {
return true
}
// expiryTime of 0 means "never expires"; treat it as outside any bounded
// range so users filtering by date see only clients with concrete expiries.
if c.ExpiryTime == 0 {
return false
}
// Negative expiry is the "delayed start" sentinel; same treatment as never.
if c.ExpiryTime < 0 {
return false
}
if fromMs > 0 && c.ExpiryTime < fromMs {
return false
}
if toMs > 0 && c.ExpiryTime > toMs {
return false
}
return true
}
func clientMatchesUsageRange(c ClientWithAttachments, fromBytes, toBytes int64) bool {
if fromBytes <= 0 && toBytes <= 0 {
return true
}
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
if fromBytes > 0 && used < fromBytes {
return false
}
if toBytes > 0 && used > toBytes {
return false
}
return true
}
func clientMatchesAutoRenew(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "on":
return c.Reset > 0
case "off":
return c.Reset <= 0
}
return true
}
func clientMatchesHasTgID(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "yes":
return c.TgID != 0
case "no":
return c.TgID == 0
}
return true
}
func clientMatchesHasComment(c ClientWithAttachments, mode string) bool {
switch strings.ToLower(strings.TrimSpace(mode)) {
case "yes":
return strings.TrimSpace(c.Comment) != ""
case "no":
return strings.TrimSpace(c.Comment) == ""
}
return true
}
func clientMatchesAnyGroup(c ClientWithAttachments, csv string) bool {
groups := parseCSVStrings(csv)
if len(groups) == 0 {
return true
}
current := strings.TrimSpace(c.Group)
for _, g := range groups {
if g == "" {
if current == "" {
return true
}
continue
}
if strings.EqualFold(g, current) {
return true
}
}
return false
}
func clientMatchesBucket(c ClientWithAttachments, bucket string, onlineSet map[string]struct{}, nowMs, expireDiffMs, trafficDiffBytes int64) bool {
if bucket == "" {
return true
}
used := int64(0)
if c.Traffic != nil {
used = c.Traffic.Up + c.Traffic.Down
}
exhausted := c.TotalGB > 0 && used >= c.TotalGB
expired := c.ExpiryTime > 0 && c.ExpiryTime <= nowMs
switch bucket {
case "online":
if onlineSet == nil {
return false
}
_, ok := onlineSet[c.Email]
return ok && c.Enable
case "depleted":
return exhausted || expired
case "deactive":
return !c.Enable
case "active":
return c.Enable && !exhausted && !expired
case "expiring":
if !c.Enable || exhausted || expired {
return false
}
nearExpiry := c.ExpiryTime > 0 && c.ExpiryTime-nowMs < expireDiffMs
nearLimit := c.TotalGB > 0 && c.TotalGB-used < trafficDiffBytes
return nearExpiry || nearLimit
}
return true
}
func sortClients(rows []ClientWithAttachments, sortKey, order string) {
if sortKey == "" {
return
}
desc := order == "descend"
less := func(i, j int) bool {
a, b := rows[i], rows[j]
switch sortKey {
case "enable":
if a.Enable == b.Enable {
return false
}
return !a.Enable && b.Enable
case "email":
return strings.ToLower(a.Email) < strings.ToLower(b.Email)
case "inboundIds":
return len(a.InboundIds) < len(b.InboundIds)
case "traffic":
ua := int64(0)
if a.Traffic != nil {
ua = a.Traffic.Up + a.Traffic.Down
}
ub := int64(0)
if b.Traffic != nil {
ub = b.Traffic.Up + b.Traffic.Down
}
return ua < ub
case "remaining":
ra := int64(1<<62 - 1)
if a.TotalGB > 0 {
used := int64(0)
if a.Traffic != nil {
used = a.Traffic.Up + a.Traffic.Down
}
ra = a.TotalGB - used
}
rb := int64(1<<62 - 1)
if b.TotalGB > 0 {
used := int64(0)
if b.Traffic != nil {
used = b.Traffic.Up + b.Traffic.Down
}
rb = b.TotalGB - used
}
return ra < rb
case "expiryTime":
ea := int64(1<<62 - 1)
if a.ExpiryTime > 0 {
ea = a.ExpiryTime
}
eb := int64(1<<62 - 1)
if b.ExpiryTime > 0 {
eb = b.ExpiryTime
}
return ea < eb
case "createdAt":
if a.CreatedAt == b.CreatedAt {
return a.Id < b.Id
}
return a.CreatedAt < b.CreatedAt
case "updatedAt":
if a.UpdatedAt == b.UpdatedAt {
return a.Id < b.Id
}
return a.UpdatedAt < b.UpdatedAt
case "lastOnline":
la := int64(0)
if a.Traffic != nil {
la = a.Traffic.LastOnline
}
lb := int64(0)
if b.Traffic != nil {
lb = b.Traffic.LastOnline
}
if la == lb {
return a.Id < b.Id
}
return la < lb
}
return false
}
sort.SliceStable(rows, func(i, j int) bool {
if desc {
return less(j, i)
}
return less(i, j)
})
}
@@ -0,0 +1,113 @@
package service
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestSyncInbound_PreservesCredentialsAcrossProtocols(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()
vlessInbound := &model.Inbound{Tag: "vless-in", Enable: true, Port: 10001, Protocol: model.VLESS}
if err := db.Create(vlessInbound).Error; err != nil {
t.Fatalf("create vless inbound: %v", err)
}
hysteriaInbound := &model.Inbound{Tag: "hy-in", Enable: true, Port: 10002, Protocol: model.Hysteria}
if err := db.Create(hysteriaInbound).Error; err != nil {
t.Fatalf("create hysteria inbound: %v", err)
}
svc := ClientService{}
const sharedEmail = "shared@example.com"
const wantUUID = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c001"
const wantAuth = "h2-auth-token"
const wantFlow = "xtls-rprx-vision"
vlessClient := model.Client{Email: sharedEmail, ID: wantUUID, Enable: true, Flow: wantFlow}
if err := svc.SyncInbound(nil, vlessInbound.Id, []model.Client{vlessClient}); err != nil {
t.Fatalf("vless SyncInbound: %v", err)
}
hysteriaClient := model.Client{Email: sharedEmail, Auth: wantAuth, Enable: true}
if err := svc.SyncInbound(nil, hysteriaInbound.Id, []model.Client{hysteriaClient}); err != nil {
t.Fatalf("hysteria SyncInbound: %v", err)
}
var row model.ClientRecord
if err := db.Where("email = ?", sharedEmail).First(&row).Error; err != nil {
t.Fatalf("lookup client row: %v", err)
}
if row.UUID != wantUUID {
t.Errorf("UUID was clobbered by Hysteria sync: got %q, want %q", row.UUID, wantUUID)
}
if row.Auth != wantAuth {
t.Errorf("Auth not persisted: got %q, want %q", row.Auth, wantAuth)
}
vlessList, err := svc.ListForInbound(nil, vlessInbound.Id)
if err != nil {
t.Fatalf("ListForInbound(vless): %v", err)
}
if len(vlessList) != 1 || vlessList[0].Flow != wantFlow {
t.Errorf("VLESS inbound should still report flow=%q via FlowOverride, got %#v", wantFlow, vlessList)
}
hysteriaList, err := svc.ListForInbound(nil, hysteriaInbound.Id)
if err != nil {
t.Fatalf("ListForInbound(hysteria): %v", err)
}
if len(hysteriaList) != 1 || hysteriaList[0].Flow != "" {
t.Errorf("Hysteria inbound should report empty flow, got %#v", hysteriaList)
}
}
func TestSyncInbound_AllowsClearingFlow(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()
vless := &model.Inbound{Tag: "vless-in", Enable: true, Port: 10003, Protocol: model.VLESS}
if err := db.Create(vless).Error; err != nil {
t.Fatalf("create vless inbound: %v", err)
}
svc := ClientService{}
const email = "alice@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c002"
withFlow := model.Client{Email: email, ID: uid, Enable: true, Flow: "xtls-rprx-vision"}
if err := svc.SyncInbound(nil, vless.Id, []model.Client{withFlow}); err != nil {
t.Fatalf("vless SyncInbound (set flow): %v", err)
}
cleared := model.Client{Email: email, ID: uid, Enable: true, Flow: ""}
if err := svc.SyncInbound(nil, vless.Id, []model.Client{cleared}); err != nil {
t.Fatalf("vless SyncInbound (clear flow): %v", err)
}
list, err := svc.ListForInbound(nil, vless.Id)
if err != nil {
t.Fatalf("ListForInbound: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 client, got %d", len(list))
}
if list[0].Flow != "" {
t.Errorf("flow should be clearable on the owning inbound, got %q (Copilot review on #4545)", list[0].Flow)
}
}
+59
View File
@@ -0,0 +1,59 @@
package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestClientWithAttachmentsMarshalJSONIncludesExtras(t *testing.T) {
c := ClientWithAttachments{
ClientRecord: model.ClientRecord{Id: 1, Email: "alice@example.com"},
InboundIds: []int{3, 5},
Traffic: &xray.ClientTraffic{Email: "alice@example.com", Up: 1024, Down: 4096, Enable: true},
}
out, err := json.Marshal(c)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if parsed["email"] != "alice@example.com" {
t.Errorf("expected ClientRecord fields to survive, got %v", parsed)
}
ids, ok := parsed["inboundIds"].([]any)
if !ok {
t.Fatalf("expected inboundIds to be present as an array, got %T (%s)", parsed["inboundIds"], out)
}
if len(ids) != 2 {
t.Errorf("expected 2 inbound ids, got %d", len(ids))
}
if _, ok := parsed["traffic"].(map[string]any); !ok {
t.Errorf("expected traffic to be present as an object, got %T", parsed["traffic"])
}
}
func TestClientWithAttachmentsMarshalJSONOmitsAbsentTraffic(t *testing.T) {
c := ClientWithAttachments{
ClientRecord: model.ClientRecord{Id: 1, Email: "bob@example.com"},
InboundIds: nil,
}
out, err := json.Marshal(c)
if err != nil {
t.Fatalf("Marshal failed: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(out, &parsed); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
if _, present := parsed["traffic"]; present {
t.Errorf("expected traffic to be omitted when nil, got %v", parsed["traffic"])
}
if _, present := parsed["inboundIds"]; !present {
t.Errorf("expected inboundIds key to always be present, got %s", out)
}
}
+165
View File
@@ -0,0 +1,165 @@
package service
import (
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"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/xray"
"gorm.io/gorm"
)
func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email string) (bool, error) {
if email == "" {
return false, common.NewError("client email is required")
}
rec, err := s.GetRecordByEmail(nil, email)
if err != nil {
return false, err
}
inboundIds, err := s.GetInboundIdsForRecord(rec.Id)
if err != nil {
return false, err
}
needRestart := false
if !rec.Enable {
updated := rec.ToClient()
updated.Enable = true
nr, uErr := s.Update(inboundSvc, rec.Id, *updated)
if uErr != nil {
logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
}
if nr {
needRestart = true
}
}
if len(inboundIds) == 0 {
if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
return false, rErr
}
return needRestart, nil
}
for _, ibId := range inboundIds {
nr, rErr := inboundSvc.ResetClientTraffic(ibId, email)
if rErr != nil {
return needRestart, rErr
}
if nr {
needRestart = true
}
}
return needRestart, nil
}
func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []string) (int, error) {
if len(emails) == 0 {
return 0, nil
}
seen := map[string]struct{}{}
cleanEmails := make([]string, 0, len(emails))
for _, e := range emails {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if _, ok := seen[e]; ok {
continue
}
seen[e] = struct{}{}
cleanEmails = append(cleanEmails, e)
}
if len(cleanEmails) == 0 {
return 0, nil
}
for _, e := range cleanEmails {
rec, err := s.GetRecordByEmail(nil, e)
if err == nil && !rec.Enable {
updated := rec.ToClient()
updated.Enable = true
s.Update(inboundSvc, rec.Id, *updated)
}
}
affected := 0
err := submitTrafficWrite(func() error {
db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
res := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", batch).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if res.Error != nil {
return res.Error
}
affected += int(res.RowsAffected)
}
return nil
})
})
if err != nil {
return 0, err
}
return affected, nil
}
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) error {
return submitTrafficWrite(func() error {
return s.resetAllClientTrafficsLocked(id)
})
}
func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
db := database.GetDB()
now := time.Now().Unix() * 1000
if err := db.Transaction(func(tx *gorm.DB) error {
whereText := "inbound_id "
if id == -1 {
whereText += " > ?"
} else {
whereText += " = ?"
}
result := tx.Model(xray.ClientTraffic{}).
Where(whereText, id).
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
if result.Error != nil {
return result.Error
}
inboundWhereText := "id "
if id == -1 {
inboundWhereText += " > ?"
} else {
inboundWhereText += " = ?"
}
result = tx.Model(model.Inbound{}).
Where(inboundWhereText, id).
Update("last_traffic_reset_time", now)
return result.Error
}); err != nil {
return err
}
return nil
}
func (s *ClientService) ResetAllTraffics() (bool, error) {
res := database.GetDB().Model(&xray.ClientTraffic{}).
Where("1 = 1").
Updates(map[string]any{"up": 0, "down": 0})
if res.Error != nil {
return false, res.Error
}
return res.RowsAffected > 0, nil
}
+86
View File
@@ -0,0 +1,86 @@
{
"api": {
"services": [
"HandlerService",
"LoggerService",
"StatsService"
],
"tag": "api"
},
"inbounds": [{
"listen": "127.0.0.1",
"port": 62789,
"protocol": "tunnel",
"settings": {
"rewriteAddress": "127.0.0.1"
},
"tag": "api"
}],
"log": {
"access": "none",
"dnsLog": false,
"error": "",
"loglevel": "warning",
"maskAddress": ""
},
"metrics": {
"listen": "127.0.0.1:11111",
"tag": "metrics_out"
},
"outbounds": [{
"protocol": "freedom",
"settings": {
"domainStrategy": "AsIs",
"finalRules": [
{ "action": "allow" }
]
},
"tag": "direct"
},
{
"protocol": "blackhole",
"settings": {},
"tag": "blocked"
}
],
"policy": {
"levels": {
"0": {
"statsUserDownlink": true,
"statsUserUplink": true
}
},
"system": {
"statsInboundDownlink": true,
"statsInboundUplink": true,
"statsOutboundDownlink": false,
"statsOutboundUplink": false
}
},
"routing": {
"domainStrategy": "AsIs",
"rules": [{
"inboundTag": [
"api"
],
"outboundTag": "api",
"type": "field"
},
{
"ip": [
"geoip:private"
],
"outboundTag": "blocked",
"type": "field"
},
{
"outboundTag": "blocked",
"protocol": [
"bittorrent"
],
"type": "field"
}
]
},
"stats": {}
}
+154
View File
@@ -0,0 +1,154 @@
package service
import (
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/gorm"
)
type FallbackService struct{}
// FallbackInput is the payload shape POSTed by the inbound form.
type FallbackInput struct {
ChildId int `json:"childId"`
Name string `json:"name"`
Alpn string `json:"alpn"`
Path string `json:"path"`
Dest string `json:"dest"`
Xver int `json:"xver"`
SortOrder int `json:"sortOrder"`
}
// GetByMaster returns every fallback rule attached to the master inbound.
func (s *FallbackService) GetByMaster(masterId int) ([]model.InboundFallback, error) {
var rows []model.InboundFallback
err := database.GetDB().
Where("master_id = ?", masterId).
Order("sort_order ASC, id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
return rows, nil
}
// GetParentForChild finds the first fallback rule that points at childId.
// Used by client-link generation: when a child inbound is attached as a
// fallback, its client links should advertise the master's address+port
// and TLS instead of the child's loopback listen.
func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback, error) {
var row model.InboundFallback
err := database.GetDB().
Where("child_id = ?", childId).
Order("sort_order ASC, id ASC").
First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// SetByMaster replaces the master's entire fallback list atomically.
func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
return err
}
for i, c := range items {
childId := c.ChildId
if childId == masterId {
childId = 0
}
if childId <= 0 && strings.TrimSpace(c.Dest) == "" {
continue
}
row := model.InboundFallback{
MasterId: masterId,
ChildId: childId,
Name: c.Name,
Alpn: c.Alpn,
Path: c.Path,
Dest: c.Dest,
Xver: c.Xver,
SortOrder: c.SortOrder,
}
if row.SortOrder == 0 {
row.SortOrder = i
}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
})
}
func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
if tx == nil {
tx = database.GetDB()
}
var rows []model.InboundFallback
err := tx.Where("master_id = ?", masterId).
Order("sort_order ASC, id ASC").
Find(&rows).Error
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
childIds := make([]int, 0, len(rows))
for i := range rows {
childIds = append(childIds, rows[i].ChildId)
}
var children []model.Inbound
if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
return nil, err
}
byId := make(map[int]*model.Inbound, len(children))
for i := range children {
byId[children[i].Id] = &children[i]
}
out := make([]map[string]any, 0, len(rows))
for _, r := range rows {
dest := strings.TrimSpace(r.Dest)
if dest == "" {
child, ok := byId[r.ChildId]
if !ok {
continue
}
listen := strings.TrimSpace(child.Listen)
if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
listen = "127.0.0.1"
}
dest = fmt.Sprintf("%s:%d", listen, child.Port)
}
entry := map[string]any{
"dest": dest,
}
if r.Name != "" {
entry["name"] = r.Name
}
if r.Alpn != "" {
entry["alpn"] = r.Alpn
}
if r.Path != "" {
entry["path"] = r.Path
}
if r.Xver > 0 {
entry["xver"] = r.Xver
}
out = append(out, entry)
}
return out, nil
}
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
package service
import (
"encoding/json"
"sort"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) {
db := database.GetDB()
var ips []model.InboundClientIps
err := db.Model(&model.InboundClientIps{}).Find(&ips).Error
return ips, err
}
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
const clientIpStaleAfterSeconds = int64(30 * 60)
// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
type clientIpEntry struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
// mergeClientIpEntries unions old and incoming IP observations, dropping anything
// older than cutoff, keeping the most recent timestamp per IP, and returning the
// result sorted newest-first.
func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
ipMap := make(map[string]int64, len(old)+len(incoming))
for _, e := range old {
if e.Timestamp < cutoff {
continue
}
ipMap[e.IP] = e.Timestamp
}
for _, e := range incoming {
if e.Timestamp < cutoff {
continue
}
if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
ipMap[e.IP] = e.Timestamp
}
}
out := make([]clientIpEntry, 0, len(ipMap))
for ip, ts := range ipMap {
out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
}
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
return out
}
// MergeInboundClientIps folds client IPs synced from another node into the local
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
// without resurrecting stale entries. Existing rows are updated in place; brand-new
// clients (typically node-only clients with no local row) are created with a fresh
// local id.
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
db := database.GetDB()
var currentIps []model.InboundClientIps
if err := db.Model(&model.InboundClientIps{}).Find(&currentIps).Error; err != nil {
return err
}
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
for i := range currentIps {
currentMap[currentIps[i].ClientEmail] = &currentIps[i]
}
now := time.Now().Unix()
cutoff := now - clientIpStaleAfterSeconds
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
for _, incoming := range incomingIps {
if incoming.ClientEmail == "" || incoming.Ips == "" {
continue
}
var incomingEntries []clientIpEntry
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
current, exists := currentMap[incoming.ClientEmail]
if !exists {
// New client we've never seen locally. Drop stale entries up front and
// skip the row entirely if nothing is fresh, so we don't persist a row
// that is dead on arrival.
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
if len(fresh) == 0 {
continue
}
b, _ := json.Marshal(fresh)
incoming.Ips = string(b)
// Never carry the remote node's primary key into the local table: id
// spaces are independent across nodes and the remote id would collide
// with an unrelated local row. OnConflict guards the race where
// check_client_ip_job creates the same brand-new email between the
// snapshot above and this insert.
incoming.Id = 0
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "client_email"}},
DoNothing: true,
}).Create(&incoming).Error; err != nil {
tx.Rollback()
return err
}
continue
}
var oldEntries []clientIpEntry
if current.Ips != "" {
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
}
merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
b, _ := json.Marshal(merged)
mergedStr := string(b)
// A concurrent check_client_ip_job db.Save on the same row can interleave
// with this update (benign last-writer-wins; any dropped IP reappears on the
// next scan/sync), so only write when the blob actually changed.
if current.Ips != mergedStr {
if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
tx.Rollback()
return err
}
}
}
return tx.Commit().Error
}
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
return tx.Model(model.InboundClientIps{}).Where("client_email = ?", oldEmail).Update("client_email", newEmail).Error
}
func (s *InboundService) DelClientIPs(tx *gorm.DB, email string) error {
return tx.Where("client_email = ?", email).Delete(model.InboundClientIps{}).Error
}
func (s *InboundService) delClientIPsByEmails(tx *gorm.DB, emails []string) error {
const chunk = 400
for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails))
if err := tx.Where("client_email IN ?", emails[start:end]).Delete(model.InboundClientIps{}).Error; err != nil {
return err
}
}
return nil
}
func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error) {
db := database.GetDB()
InboundClientIps := &model.InboundClientIps{}
err := db.Model(model.InboundClientIps{}).Where("client_email = ?", clientEmail).First(InboundClientIps).Error
if err != nil {
return "", err
}
if InboundClientIps.Ips == "" {
return "", nil
}
// Try to parse as new format (with timestamps)
type IPWithTimestamp struct {
IP string `json:"ip"`
Timestamp int64 `json:"timestamp"`
}
var ipsWithTime []IPWithTimestamp
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
// If successfully parsed as new format, return with timestamps
if err == nil && len(ipsWithTime) > 0 {
return InboundClientIps.Ips, nil
}
// Otherwise, assume it's old format (simple string array)
// Try to parse as simple array and convert to new format
var oldIps []string
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
if err == nil && len(oldIps) > 0 {
// Convert old format to new format with current timestamp
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
for i, ip := range oldIps {
newIpsWithTime[i] = IPWithTimestamp{
IP: ip,
Timestamp: time.Now().Unix(),
}
}
result, _ := json.Marshal(newIpsWithTime)
return string(result), nil
}
// Return as-is if parsing fails
return InboundClientIps.Ips, nil
}
func (s *InboundService) ClearClientIps(clientEmail string) error {
db := database.GetDB()
result := db.Model(model.InboundClientIps{}).
Where("client_email = ?", clientEmail).
Update("ips", "")
err := result.Error
if err != nil {
return err
}
return nil
}
@@ -0,0 +1,211 @@
package service
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
// for a single test, mirroring the harness used by the other service tests.
func setupClientIpTestDB(t *testing.T) {
t.Helper()
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() })
}
func marshalIps(t *testing.T, entries ...clientIpEntry) string {
t.Helper()
b, err := json.Marshal(entries)
if err != nil {
t.Fatalf("marshal ips: %v", err)
}
return string(b)
}
// readClientIps returns the stored IP entries for an email as a map[ip]timestamp,
// plus whether the row exists at all.
func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
t.Helper()
var row model.InboundClientIps
err := database.GetDB().Where("client_email = ?", email).First(&row).Error
if database.IsNotFound(err) {
return nil, false
}
if err != nil {
t.Fatalf("read client ips for %s: %v", email, err)
}
var entries []clientIpEntry
if row.Ips != "" {
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
}
}
out := make(map[string]int64, len(entries))
for _, e := range entries {
out[e.IP] = e.Timestamp
}
return out, true
}
func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
// Local client occupies id 1.
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
if err := db.Create(local).Error; err != nil {
t.Fatalf("seed local row: %v", err)
}
// Incoming node-only client carries the remote node's id 1, which must not
// collide with the local row.
incoming := []model.InboundClientIps{{
Id: 1,
ClientEmail: "node@x",
Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
// Local row is untouched.
if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now {
t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok)
}
// Node row exists with its own ip and a freshly assigned id (not the remote 1).
var nodeRow model.InboundClientIps
if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil {
t.Fatalf("node@x not created: %v", err)
}
if nodeRow.Id == local.Id {
t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id)
}
if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now {
t.Fatalf("node@x missing expected ip: %v", ips)
}
}
func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
if err := db.Create(&model.InboundClientIps{
ClientEmail: "a@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
incoming := []model.InboundClientIps{{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
ips, _ := readClientIps(t, "a@x")
if len(ips) != 2 {
t.Fatalf("want 2 ips, got %v", ips)
}
if ips["1.1.1.1"] != now-50 {
t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"])
}
if ips["2.2.2.2"] != now-10 {
t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"])
}
}
func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
setupClientIpTestDB(t)
db := database.GetDB()
now := time.Now().Unix()
if err := db.Create(&model.InboundClientIps{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
clientIpEntry{IP: "fresh", Timestamp: now - 60},
),
}).Error; err != nil {
t.Fatalf("seed: %v", err)
}
incoming := []model.InboundClientIps{{
ClientEmail: "a@x",
Ips: marshalIps(t,
clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
clientIpEntry{IP: "incFresh", Timestamp: now - 10},
),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
ips, _ := readClientIps(t, "a@x")
if len(ips) != 2 {
t.Fatalf("want only fresh ips, got %v", ips)
}
if _, ok := ips["old"]; ok {
t.Fatalf("stale local ip not dropped: %v", ips)
}
if _, ok := ips["incStale"]; ok {
t.Fatalf("stale incoming ip not dropped: %v", ips)
}
if ips["fresh"] != now-60 || ips["incFresh"] != now-10 {
t.Fatalf("fresh ips wrong: %v", ips)
}
}
func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
incoming := []model.InboundClientIps{{
ClientEmail: "b@x",
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
}}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
if _, ok := readClientIps(t, "b@x"); ok {
t.Fatalf("all-stale node-only client should not create a row")
}
}
func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
setupClientIpTestDB(t)
now := time.Now().Unix()
incoming := []model.InboundClientIps{
{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
{ClientEmail: "c@x", Ips: ""},
}
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
t.Fatalf("merge: %v", err)
}
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 0 {
t.Fatalf("blank rows should be skipped, but %d row(s) created", count)
}
}
@@ -0,0 +1,159 @@
package service
import (
"path/filepath"
"testing"
"time"
"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"
)
// TestAddClientTraffic_MatchesByEmail covers two scenarios that share one fix:
// client_traffics is keyed by email (one shared row per email no matter how many
// inbounds the client is attached to), so local traffic must be applied by email
// regardless of which inbound_id the row happens to carry.
//
// - staleEmail: the row points at an inbound id that no longer exists (a deleted
// earlier incarnation, AddClientStat's OnConflict-DoNothing never refreshes it).
// - dualEmail: the client is attached to both a node inbound and the mother inbound,
// but the node inbound was attached first, so the shared row carries the node
// inbound's id (issue #4921). The old `inbound_id NOT IN (node inbounds)` filter
// dropped this client's local traffic, leaving it stuck at zero and offline.
//
// Both must have their local traffic counted.
func TestAddClientTraffic_MatchesByEmail(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 staleEmail = "stale-user"
const dualEmail = "dual-user"
localInbound := &model.Inbound{UserId: 1, Tag: "local-in", Enable: true, Port: 40001, Protocol: model.VLESS}
if err := db.Create(localInbound).Error; err != nil {
t.Fatalf("create local inbound: %v", err)
}
nodeID := 1
nodeInbound := &model.Inbound{UserId: 1, Tag: "node-in", Enable: true, Port: 40002, Protocol: model.VLESS, NodeID: &nodeID}
if err := db.Create(nodeInbound).Error; err != nil {
t.Fatalf("create node inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: staleEmail, Enable: true}).Error; err != nil {
t.Fatalf("create stale client_traffics: %v", err)
}
// Attached to both inbounds, but the node inbound won the OnConflict so the
// shared row is owned by the node inbound id.
if err := db.Create(&xray.ClientTraffic{InboundId: nodeInbound.Id, Email: dualEmail, Enable: true}).Error; err != nil {
t.Fatalf("create dual client_traffics: %v", err)
}
svc := InboundService{}
err := svc.addClientTraffic(db, []*xray.ClientTraffic{
{Email: staleEmail, Up: 10, Down: 20},
{Email: dualEmail, Up: 30, Down: 40},
})
if err != nil {
t.Fatalf("addClientTraffic: %v", err)
}
var stale xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", staleEmail).First(&stale).Error; err != nil {
t.Fatalf("reload stale row: %v", err)
}
if stale.Up != 10 || stale.Down != 20 {
t.Errorf("stale-pointer row not updated: up=%d down=%d, want 10/20", stale.Up, stale.Down)
}
if stale.LastOnline == 0 {
t.Errorf("stale-pointer row LastOnline not set")
}
var dual xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", dualEmail).First(&dual).Error; err != nil {
t.Fatalf("reload dual row: %v", err)
}
if dual.Up != 30 || dual.Down != 40 {
t.Errorf("node-owned row not updated by local traffic (issue #4921): up=%d down=%d, want 30/40", dual.Up, dual.Down)
}
if dual.LastOnline == 0 {
t.Errorf("node-owned row LastOnline not set (client stayed offline)")
}
}
// TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId covers "Start After
// First Use": a delayed-start client carries a negative expiry (the duration) that
// must convert to an absolute deadline on its first traffic tick. When the client's
// email-keyed client_traffics row still points at a deleted inbound (stale inbound_id
// after an inbound delete+recreate), the conversion used to resolve no inbound and
// silently skip, leaving the client perpetually "not started". The fix resolves the
// owning inbound via the client_inbounds link instead.
func TestAdjustTraffics_DelayedStartConvertsDespiteStaleInboundId(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 email = "delayed-user"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0d001"
const sevenDays = int64(7 * 86400000)
client := model.Client{Email: email, ID: uid, Auth: uid, Enable: true, ExpiryTime: -sevenDays}
inbound := &model.Inbound{
Tag: "vless-delayed", Enable: true, Port: 45001, Protocol: model.VLESS,
StreamSettings: `{"network":"tcp","security":"reality"}`,
Settings: clientsSettings(t, []model.Client{client}),
}
if err := db.Create(inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
svc := InboundService{}
if err := svc.clientService.SyncInbound(db, inbound.Id, []model.Client{client}); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
// The email-keyed traffic row survives an inbound delete+recreate pointing at a
// dead inbound id; client_inbounds still links the client to the live inbound.
if err := db.Create(&xray.ClientTraffic{InboundId: 9999, Email: email, Enable: true, ExpiryTime: -sevenDays}).Error; err != nil {
t.Fatalf("create stale traffic row: %v", err)
}
before := time.Now().UnixMilli()
if err := svc.addClientTraffic(db, []*xray.ClientTraffic{{Email: email, Up: 100, Down: 200}}); err != nil {
t.Fatalf("addClientTraffic: %v", err)
}
var row xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&row).Error; err != nil {
t.Fatalf("reload traffic row: %v", err)
}
if row.ExpiryTime <= 0 {
t.Fatalf("delayed-start expiry not converted: still %d (stale inbound_id skipped the conversion)", row.ExpiryTime)
}
if row.ExpiryTime < before+sevenDays-5000 || row.ExpiryTime > before+sevenDays+5000 {
t.Errorf("converted expiry = %d, want ~now+7d (%d)", row.ExpiryTime, before+sevenDays)
}
reloaded, err := svc.GetInbound(inbound.Id)
if err != nil {
t.Fatalf("GetInbound: %v", err)
}
cs, err := svc.GetClients(reloaded)
if err != nil {
t.Fatalf("GetClients: %v", err)
}
if len(cs) != 1 || cs[0].ExpiryTime <= 0 {
t.Errorf("inbound settings expiry not converted: %#v", cs)
}
}
+448
View File
@@ -0,0 +1,448 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"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/xray"
"gorm.io/gorm"
)
type CopyClientsResult struct {
Added []string `json:"added"`
Skipped []string `json:"skipped"`
Errors []string `json:"errors"`
}
// enrichClientStats parses each inbound's clients once, fills in the
// UUID/SubId fields on the preloaded ClientStats, and tops up rows owned by
// a sibling inbound (shared-email mode — the row is keyed on email so it
// only preloads on its owning inbound).
func (s *InboundService) enrichClientStats(db *gorm.DB, inbounds []*model.Inbound) {
if len(inbounds) == 0 {
return
}
clientsByInbound := make([][]model.Client, len(inbounds))
seenByInbound := make([]map[string]struct{}, len(inbounds))
missing := make(map[string]struct{})
for i, inbound := range inbounds {
clients, _ := s.GetClients(inbound)
clientsByInbound[i] = clients
seen := make(map[string]struct{}, len(inbound.ClientStats))
for _, st := range inbound.ClientStats {
if st.Email != "" {
seen[strings.ToLower(st.Email)] = struct{}{}
}
}
seenByInbound[i] = seen
for _, c := range clients {
if c.Email == "" {
continue
}
if _, ok := seen[strings.ToLower(c.Email)]; !ok {
missing[c.Email] = struct{}{}
}
}
}
if len(missing) > 0 {
emails := make([]string, 0, len(missing))
for e := range missing {
emails = append(emails, e)
}
var extra []xray.ClientTraffic
var loadErr error
for _, batch := range chunkStrings(emails, sqlInChunk) {
var page []xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
loadErr = err
break
}
extra = append(extra, page...)
}
if loadErr != nil {
logger.Warning("enrichClientStats:", loadErr)
} else {
byEmail := make(map[string]xray.ClientTraffic, len(extra))
for _, st := range extra {
byEmail[strings.ToLower(st.Email)] = st
}
for i, inbound := range inbounds {
for _, c := range clientsByInbound[i] {
if c.Email == "" {
continue
}
key := strings.ToLower(c.Email)
if _, ok := seenByInbound[i][key]; ok {
continue
}
if st, ok := byEmail[key]; ok {
inbound.ClientStats = append(inbound.ClientStats, st)
seenByInbound[i][key] = struct{}{}
}
}
}
}
}
for i, inbound := range inbounds {
clients := clientsByInbound[i]
if len(clients) == 0 || len(inbound.ClientStats) == 0 {
continue
}
cMap := make(map[string]model.Client, len(clients))
for _, c := range clients {
cMap[strings.ToLower(c.Email)] = c
}
for j := range inbound.ClientStats {
email := strings.ToLower(inbound.ClientStats[j].Email)
if c, ok := cMap[email]; ok {
inbound.ClientStats[j].UUID = c.ID
inbound.ClientStats[j].SubId = c.SubID
}
}
}
}
// emailUsedByOtherInbounds reports whether email lives in any inbound other
// than exceptInboundId. Empty email returns false.
func (s *InboundService) emailUsedByOtherInbounds(email string, exceptInboundId int) (bool, error) {
if email == "" {
return false, nil
}
db := database.GetDB()
var count int64
query := fmt.Sprintf(
"SELECT COUNT(*) %s WHERE inbounds.id != ? AND LOWER(%s) = LOWER(?)",
database.JSONClientsFromInbound(),
database.JSONFieldText("client.value", "email"),
)
if err := db.Raw(query, exceptInboundId, email).Scan(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func (s *InboundService) emailsUsedByOtherInbounds(emails []string, exceptInboundId int) (map[string]bool, error) {
shared := make(map[string]bool, len(emails))
want := make(map[string]struct{}, len(emails))
for _, e := range emails {
e = strings.ToLower(strings.TrimSpace(e))
if e != "" {
want[e] = struct{}{}
}
}
if len(want) == 0 {
return shared, nil
}
db := database.GetDB()
var rows []string
query := fmt.Sprintf(
"SELECT DISTINCT LOWER(%s) %s WHERE inbounds.id != ?",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
if err := db.Raw(query, exceptInboundId).Scan(&rows).Error; err != nil {
return nil, err
}
for _, e := range rows {
e = strings.ToLower(strings.TrimSpace(e))
if _, ok := want[e]; ok {
shared[e] = true
}
}
return shared, nil
}
func (s *InboundService) writeBackClientSubID(sourceInboundID int, client model.Client, subID string) (bool, error) {
client.SubID = subID
client.UpdatedAt = time.Now().UnixMilli()
if client.Email == "" {
return false, common.NewError("empty client email")
}
settingsBytes, err := json.Marshal(map[string][]model.Client{
"clients": {client},
})
if err != nil {
return false, err
}
updatePayload := &model.Inbound{
Id: sourceInboundID,
Settings: string(settingsBytes),
}
return s.clientService.UpdateInboundClient(s, updatePayload, client.Email)
}
func (s *InboundService) generateRandomCredential(targetProtocol model.Protocol) string {
switch targetProtocol {
case model.VMESS, model.VLESS:
return uuid.NewString()
default:
return strings.ReplaceAll(uuid.NewString(), "-", "")
}
}
func (s *InboundService) buildTargetClientFromSource(source model.Client, targetInbound *model.Inbound, email string, flow string) (model.Client, error) {
nowTs := time.Now().UnixMilli()
target := source
target.Email = email
target.CreatedAt = nowTs
target.UpdatedAt = nowTs
target.ID = ""
target.Password = ""
target.Auth = ""
target.Flow = ""
targetProtocol := targetInbound.Protocol
switch targetProtocol {
case model.VMESS:
target.ID = s.generateRandomCredential(targetProtocol)
case model.VLESS:
target.ID = s.generateRandomCredential(targetProtocol)
if (flow == "xtls-rprx-vision" || flow == "xtls-rprx-vision-udp443") &&
inboundCanEnableTlsFlow(string(targetProtocol), targetInbound.StreamSettings) {
target.Flow = flow
}
case model.Trojan, model.Shadowsocks:
target.Password = s.generateRandomCredential(targetProtocol)
case model.Hysteria:
target.Auth = s.generateRandomCredential(targetProtocol)
default:
target.ID = s.generateRandomCredential(targetProtocol)
}
return target, nil
}
func (s *InboundService) nextAvailableCopiedEmail(originalEmail string, targetID int, occupied map[string]struct{}) string {
base := fmt.Sprintf("%s_%d", originalEmail, targetID)
candidate := base
suffix := 0
for {
if _, exists := occupied[strings.ToLower(candidate)]; !exists {
occupied[strings.ToLower(candidate)] = struct{}{}
return candidate
}
suffix++
candidate = fmt.Sprintf("%s_%d", base, suffix)
}
}
func (s *InboundService) CopyInboundClients(targetInboundID int, sourceInboundID int, clientEmails []string, flow string) (*CopyClientsResult, bool, error) {
result := &CopyClientsResult{
Added: []string{},
Skipped: []string{},
Errors: []string{},
}
if targetInboundID == sourceInboundID {
return result, false, common.NewError("source and target inbounds must be different")
}
targetInbound, err := s.GetInbound(targetInboundID)
if err != nil {
return result, false, err
}
sourceInbound, err := s.GetInbound(sourceInboundID)
if err != nil {
return result, false, err
}
sourceClients, err := s.GetClients(sourceInbound)
if err != nil {
return result, false, err
}
if len(sourceClients) == 0 {
return result, false, nil
}
allowedEmails := map[string]struct{}{}
if len(clientEmails) > 0 {
for _, email := range clientEmails {
allowedEmails[strings.ToLower(strings.TrimSpace(email))] = struct{}{}
}
}
occupiedEmails := map[string]struct{}{}
allEmails, err := s.GetAllEmails()
if err != nil {
return result, false, err
}
for _, email := range allEmails {
clean := strings.Trim(email, "\"")
if clean != "" {
occupiedEmails[strings.ToLower(clean)] = struct{}{}
}
}
newClients := make([]model.Client, 0)
needRestart := false
for _, sourceClient := range sourceClients {
originalEmail := strings.TrimSpace(sourceClient.Email)
if originalEmail == "" {
continue
}
if len(allowedEmails) > 0 {
if _, ok := allowedEmails[strings.ToLower(originalEmail)]; !ok {
continue
}
}
if sourceClient.SubID == "" {
newSubID := uuid.NewString()
subNeedRestart, subErr := s.writeBackClientSubID(sourceInbound.Id, sourceClient, newSubID)
if subErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: failed to write source subId: %v", originalEmail, subErr))
continue
}
if subNeedRestart {
needRestart = true
}
sourceClient.SubID = newSubID
}
targetEmail := s.nextAvailableCopiedEmail(originalEmail, targetInboundID, occupiedEmails)
targetClient, buildErr := s.buildTargetClientFromSource(sourceClient, targetInbound, targetEmail, flow)
if buildErr != nil {
result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", originalEmail, buildErr))
continue
}
newClients = append(newClients, targetClient)
result.Added = append(result.Added, targetEmail)
}
if len(newClients) == 0 {
return result, needRestart, nil
}
settingsPayload, err := json.Marshal(map[string][]model.Client{
"clients": newClients,
})
if err != nil {
return result, needRestart, err
}
addNeedRestart, err := s.clientService.AddInboundClient(s, &model.Inbound{
Id: targetInboundID,
Settings: string(settingsPayload),
})
if err != nil {
return result, needRestart, err
}
if addNeedRestart {
needRestart = true
}
return result, needRestart, nil
}
func (s *InboundService) GetClientInboundByTrafficID(trafficId int) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).Where("id = ?", trafficId).Find(&traffics).Error
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with trafficId %d: %v", trafficId, err)
return nil, nil, err
}
if len(traffics) == 0 {
return nil, nil, nil
}
traffic = traffics[0]
inbound, err = s.GetInbound(traffic.InboundId)
if errors.Is(err, gorm.ErrRecordNotFound) {
// client_traffics.inbound_id goes stale when an inbound is deleted and
// recreated; fall back to the authoritative client_inbounds link by email.
ids, idErr := s.clientService.GetInboundIdsForEmail(db, traffic.Email)
if idErr != nil {
return traffic, nil, idErr
}
if len(ids) > 0 {
inbound, err = s.GetInbound(ids[0])
}
}
return traffic, inbound, err
}
func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.ClientTraffic, inbound *model.Inbound, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, nil, err
}
if len(traffics) == 0 {
return nil, nil, nil
}
traffic = traffics[0]
inbound, err = s.GetInbound(traffic.InboundId)
if errors.Is(err, gorm.ErrRecordNotFound) {
// client_traffics.inbound_id is a legacy single-inbound pointer that goes
// stale when an inbound is deleted and recreated: the email-keyed traffic
// row survives but still references the missing inbound. Fall back to the
// authoritative client_inbounds link so email lookups (reset, info, …) work.
ids, idErr := s.clientService.GetInboundIdsForEmail(db, email)
if idErr != nil {
return traffic, nil, idErr
}
if len(ids) > 0 {
inbound, err = s.GetInbound(ids[0])
}
}
return traffic, inbound, err
}
func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {
traffic, inbound, err := s.GetClientInboundByEmail(clientEmail)
if err != nil {
return nil, nil, err
}
if inbound == nil {
return nil, nil, common.NewError("Inbound Not Found For Email:", clientEmail)
}
clients, err := s.GetClients(inbound)
if err != nil {
return nil, nil, err
}
for _, client := range clients {
if client.Email == clientEmail {
return traffic, &client, nil
}
}
return nil, nil, common.NewError("Client Not Found In Inbound For Email:", clientEmail)
}
// EmailsByInbound returns the list of client emails currently configured on
// an inbound's settings.clients[]. Used by the "delete all clients" flow on
// the inbounds page, which then feeds the list into ClientService.BulkDelete.
func (s *InboundService) EmailsByInbound(inboundId int) ([]string, error) {
inbound, err := s.GetInbound(inboundId)
if err != nil {
return nil, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return nil, err
}
emails := make([]string, 0, len(clients))
for _, c := range clients {
if e := strings.TrimSpace(c.Email); e != "" {
emails = append(emails, e)
}
}
return emails, nil
}
+239
View File
@@ -0,0 +1,239 @@
package service
import (
"context"
"encoding/json"
"fmt"
"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/xray"
"gorm.io/gorm"
)
func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error) {
now := time.Now().Unix() * 1000
needRestart := false
if p != nil {
var tags []string
err := tx.Table("inbounds").
Select("inbounds.tag").
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
Scan(&tags).Error
if err != nil {
return false, 0, err
}
s.xrayApi.Init(p.GetAPIPort())
for _, tag := range tags {
err1 := s.xrayApi.DelInbound(tag)
if err1 == nil {
logger.Debug("Inbound disabled by api:", tag)
} else {
logger.Debug("Error in disabling inbound by api:", err1)
needRestart = true
}
}
s.xrayApi.Close()
}
result := tx.Model(model.Inbound{}).
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
Update("enable", false)
err := result.Error
count := result.RowsAffected
return needRestart, count, err
}
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, []int, error) {
now := time.Now().Unix() * 1000
needRestart := false
var depletedRows []xray.ClientTraffic
err := tx.Model(xray.ClientTraffic{}).
Where("((total > 0 AND up + down >= total) OR (expiry_time > 0 AND expiry_time <= ?)) AND enable = ?", now, true).
Find(&depletedRows).Error
if err != nil {
return false, 0, nil, err
}
if len(depletedRows) == 0 {
return false, 0, nil, nil
}
depletedEmails := make([]string, 0, len(depletedRows))
for i := range depletedRows {
if depletedRows[i].Email == "" {
continue
}
depletedEmails = append(depletedEmails, depletedRows[i].Email)
}
type target struct {
InboundID int `gorm:"column:inbound_id"`
NodeID *int `gorm:"column:node_id"`
Tag string
Email string
}
var targets []target
if len(depletedEmails) > 0 {
err = tx.Raw(`
SELECT inbounds.id AS inbound_id, inbounds.node_id AS node_id,
inbounds.tag AS tag, clients.email AS email
FROM clients
JOIN client_inbounds ON client_inbounds.client_id = clients.id
JOIN inbounds ON inbounds.id = client_inbounds.inbound_id
WHERE clients.email IN ?
`, depletedEmails).Scan(&targets).Error
if err != nil {
return false, 0, nil, err
}
}
var localTargets []target
localByInbound := make(map[int]map[string]struct{})
remoteByInbound := make(map[int][]target)
for _, t := range targets {
if t.NodeID == nil {
localTargets = append(localTargets, t)
if localByInbound[t.InboundID] == nil {
localByInbound[t.InboundID] = make(map[string]struct{})
}
localByInbound[t.InboundID][t.Email] = struct{}{}
} else {
remoteByInbound[t.InboundID] = append(remoteByInbound[t.InboundID], t)
}
}
if p != nil && len(localTargets) > 0 {
s.xrayApi.Init(p.GetAPIPort())
for _, t := range localTargets {
err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
if err1 == nil {
logger.Debug("Client disabled by api:", t.Email)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", t.Email)) {
logger.Debug("User is already disabled. Nothing to do more...")
} else {
logger.Debug("Error in disabling client by api:", err1)
needRestart = true
}
}
s.xrayApi.Close()
}
for inboundID, emails := range localByInbound {
if _, _, mErr := s.markClientsDisabledInSettings(tx, inboundID, emails); mErr != nil {
logger.Warning("disableInvalidClients: settings.JSON sync failed for inbound", inboundID, ":", mErr)
}
}
result := tx.Model(xray.ClientTraffic{}).
Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ?", now, true).
Update("enable", false)
err = result.Error
count := result.RowsAffected
if err != nil {
return needRestart, count, nil, err
}
if len(depletedEmails) > 0 {
if err := tx.Model(&model.ClientRecord{}).
Where("email IN ?", depletedEmails).
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
logger.Warning("disableInvalidClients update clients.enable:", err)
}
}
disabledNodeIDs := make(map[int]struct{})
for inboundID, group := range remoteByInbound {
emails := make(map[string]struct{}, len(group))
for _, t := range group {
emails[t.Email] = struct{}{}
}
if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
needRestart = true
} else {
for _, t := range group {
if t.NodeID != nil {
disabledNodeIDs[*t.NodeID] = struct{}{}
}
}
}
}
nodeIDs := make([]int, 0, len(disabledNodeIDs))
for nodeID := range disabledNodeIDs {
nodeIDs = append(nodeIDs, nodeID)
}
return needRestart, count, nodeIDs, nil
}
// markClientsDisabledInSettings flips client.enable=false in the inbound's
// stored settings JSON for the given emails and returns both the pre and
// post snapshots so a caller pushing to a remote node has the diff to hand.
func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID int, emails map[string]struct{}) (oldIb, newIb *model.Inbound, err error) {
var ib model.Inbound
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).First(&ib).Error; err != nil {
return nil, nil, err
}
snapshot := ib
settings := map[string]any{}
if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
return nil, nil, err
}
clients, _ := settings["clients"].([]any)
now := time.Now().Unix() * 1000
mutated := false
for i := range clients {
entry, ok := clients[i].(map[string]any)
if !ok {
continue
}
email, _ := entry["email"].(string)
if _, hit := emails[email]; !hit {
continue
}
if cur, _ := entry["enable"].(bool); cur == false {
continue
}
entry["enable"] = false
entry["updated_at"] = now
clients[i] = entry
mutated = true
}
if !mutated {
return &snapshot, &ib, nil
}
settings["clients"] = clients
bs, marshalErr := json.MarshalIndent(settings, "", " ")
if marshalErr != nil {
return nil, nil, marshalErr
}
ib.Settings = string(bs)
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundID).
Update("settings", ib.Settings).Error; err != nil {
return nil, nil, err
}
return &snapshot, &ib, nil
}
func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error {
oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
if err != nil {
return err
}
rt, err := s.runtimeFor(ib)
if err != nil {
return err
}
if err := rt.UpdateInbound(context.Background(), oldSnapshot, ib); err != nil {
return err
}
return nil
}
+253
View File
@@ -0,0 +1,253 @@
package service
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
db := database.GetDB()
query := fmt.Sprintf(
"DELETE FROM client_traffics WHERE email NOT IN (SELECT %s %s)",
database.JSONFieldText("client.value", "email"),
database.JSONClientsFromInbound(),
)
db.Exec(query)
}
func (s *InboundService) MigrationRequirements() {
db := database.GetDB()
tx := db.Begin()
var err error
defer func() {
if err == nil {
tx.Commit()
if !database.IsPostgres() {
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
logger.Warningf("VACUUM failed: %v", dbErr)
}
}
} else {
tx.Rollback()
}
}()
if tx.Migrator().HasColumn(&model.Inbound{}, "all_time") {
if err = tx.Migrator().DropColumn(&model.Inbound{}, "all_time"); err != nil {
return
}
}
if tx.Migrator().HasColumn(&xray.ClientTraffic{}, "all_time") {
if err = tx.Migrator().DropColumn(&xray.ClientTraffic{}, "all_time"); err != nil {
return
}
}
// Normalize "enable" columns to boolean on Postgres. Legacy SQLite data
// (0/1 integers), partial migrations, or mixed write paths (public API
// inbound updates that flow through UpdateClientStat + client syncs, plus
// node traffic merge deltas) can leave the column as integer or with mixed
// interpretation. This (combined with the dialect-aware
// ClientTrafficEnableMergeExpr) prevents type problems in the node traffic
// sync merge (SetRemoteTraffic) and makes the sync robust even when
// inbounds are updated via the public API (incl. ones carrying
// externalProxy in streamSettings). The same expression is also safe on
// SQLite (no PG :: casts).
if database.IsPostgres() {
// Use DO block so it is idempotent and doesn't fail if already boolean.
normalizeBool := func(table, col string) {
tx.Exec(fmt.Sprintf(`
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = '%s' AND column_name = '%s'
AND data_type <> 'boolean'
) THEN
ALTER TABLE %s ALTER COLUMN %s
TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
END IF;
END $$;`, table, col, table, col, col))
}
normalizeBool("inbounds", "enable")
normalizeBool("client_traffics", "enable")
normalizeBool("nodes", "enable")
normalizeBool("clients", "enable")
normalizeBool("api_tokens", "enabled")
normalizeBool("outbound_subscriptions", "enabled")
}
// Fix inbounds based problems
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error
if err != nil && err != gorm.ErrRecordNotFound {
return
}
for inbound_index := range inbounds {
settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
if raw, exists := settings["clients"]; exists && raw == nil {
settings["clients"] = []any{}
}
clients, ok := settings["clients"].([]any)
if ok {
// Fix Client configuration problems
newClients := make([]any, 0, len(clients))
hasVisionFlow := false
for client_index := range clients {
c := clients[client_index].(map[string]any)
// Add email='' if it is not exists
if _, ok := c["email"]; !ok {
c["email"] = ""
}
// Convert string tgId to int64
if _, ok := c["tgId"]; ok {
var tgId any = c["tgId"]
if tgIdStr, ok2 := tgId.(string); ok2 {
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
if err == nil {
c["tgId"] = tgIdInt64
}
}
}
// Remove "flow": "xtls-rprx-direct"
if _, ok := c["flow"]; ok {
if c["flow"] == "xtls-rprx-direct" {
c["flow"] = ""
}
}
if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
hasVisionFlow = true
}
// Backfill created_at and updated_at
if _, ok := c["created_at"]; !ok {
c["created_at"] = time.Now().Unix() * 1000
}
c["updated_at"] = time.Now().Unix() * 1000
newClients = append(newClients, any(c))
}
settings["clients"] = newClients
// Drop orphaned testseed: VLESS-only field, only meaningful when at least
// one client uses the exact xtls-rprx-vision flow. Older versions saved it
// for any non-empty flow (including the UDP variant) or kept it after the
// flow was cleared from the client modal — clean those up here.
if inbounds[inbound_index].Protocol == model.VLESS && !hasVisionFlow {
delete(settings, "testseed")
}
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return
}
inbounds[inbound_index].Settings = string(modifiedSettings)
}
// Add client traffic row for all clients which has email
modelClients, err := s.GetClients(inbounds[inbound_index])
if err != nil {
return
}
for _, modelClient := range modelClients {
if len(modelClient.Email) > 0 {
var count int64
tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
if count == 0 {
s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
}
}
}
// Heal clients table for installs where the one-shot seeder
// skipped clients due to a tgId-string unmarshal error.
if syncErr := s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); syncErr != nil {
logger.Warning("MigrationRequirements sync clients failed:", syncErr)
}
}
tx.Save(inbounds)
// Remove orphaned traffics
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
// Migrate old MultiDomain to External Proxy
var externalProxy []struct {
Id int
Port int
StreamSettings string // text column on both DBs; safer than []byte for cross-DB scan
}
externalProxyQuery := `select id, port, stream_settings
from inbounds
WHERE protocol in ('vmess','vless','trojan')
AND json_extract(stream_settings, '$.security') = 'tls'
AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`
if database.IsPostgres() {
externalProxyQuery = `select id, port, stream_settings
from inbounds
WHERE protocol in ('vmess','vless','trojan')
AND NULLIF(stream_settings, '')::jsonb #>> '{security}' = 'tls'
AND NULLIF(stream_settings, '')::jsonb #> '{tlsSettings,settings,domains}' IS NOT NULL`
}
err = tx.Raw(externalProxyQuery).Scan(&externalProxy).Error
if err != nil || len(externalProxy) == 0 {
return
}
for _, ep := range externalProxy {
var reverses any
var stream map[string]any
json.Unmarshal([]byte(ep.StreamSettings), &stream)
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
if domains, ok := settings["domains"].([]any); ok {
for _, domain := range domains {
if domainMap, ok := domain.(map[string]any); ok {
domainMap["forceTls"] = "same"
domainMap["port"] = ep.Port
domainMap["dest"] = domainMap["domain"].(string)
delete(domainMap, "domain")
}
}
}
reverses = settings["domains"]
delete(settings, "domains")
}
}
stream["externalProxy"] = reverses
newStream, _ := json.MarshalIndent(stream, " ", " ")
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
}
// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
// Must be cross-DB: INSTR/REPLACE work on SQLite; Postgres needs position().
tagCleanup := `UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE INSTR(tag, '0.0.0.0:') > 0;`
if database.IsPostgres() {
tagCleanup = `UPDATE inbounds
SET tag = REPLACE(tag, '0.0.0.0:', '')
WHERE position('0.0.0.0:' in tag) > 0;`
}
err = tx.Raw(tagCleanup).Error
if err != nil {
return
}
}
func (s *InboundService) MigrateDB() {
s.MigrationRequirements()
s.MigrationRemoveOrphanedTraffics()
}
@@ -0,0 +1,91 @@
package service
import (
"path/filepath"
"strings"
"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"
)
// TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound guards the
// PostgreSQL fix where the externalProxy detection query (executed via .Scan) errored on
// json_extract and rolled back the whole transaction — including the client_traffics
// backfill at inbound.go:3093-3106, leaving clients with no traffic rows. A MultiDomain
// inbound is present so that query returns rows and the function runs to completion; both
// the backfill and the MultiDomain→ExternalProxy migration must then commit.
func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(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 backfillEmail = "needsbackfill@example.com"
const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c010"
// Inbound A: a client present only in settings.clients, with no client_traffics row.
clientInbound := &model.Inbound{
UserId: 1,
Tag: "a-tag",
Enable: true,
Port: 30001,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"` + backfillEmail + `","id":"` + uid + `","enable":true}]}`,
StreamSettings: `{"network":"tcp","security":"none"}`,
}
if err := db.Create(clientInbound).Error; err != nil {
t.Fatalf("create client inbound: %v", err)
}
// Inbound B: a legacy MultiDomain inbound whose tag carries the 0.0.0.0: prefix.
// Its presence makes the externalProxy query return rows, so the function does not
// early-return and reaches the tag-cleanup statement.
multiDomainInbound := &model.Inbound{
UserId: 1,
Tag: "inbound-0.0.0.0:30002",
Enable: true,
Port: 30002,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
StreamSettings: `{"security":"tls","tlsSettings":{"settings":{"domains":[{"domain":"example.com"}]}}}`,
}
if err := db.Create(multiDomainInbound).Error; err != nil {
t.Fatalf("create multidomain inbound: %v", err)
}
var before int64
if err := db.Model(xray.ClientTraffic{}).Count(&before).Error; err != nil {
t.Fatalf("count client_traffics before: %v", err)
}
if before != 0 {
t.Fatalf("expected no client_traffics before migration, got %d", before)
}
svc := InboundService{}
svc.MigrationRequirements()
// The backfill must have committed: the settings-only client now owns a row.
// Before the fix this was rolled back whenever the externalProxy detection query
// errored (it does on Postgres via json_extract), so the MultiDomain inbound below
// is deliberately present to make that query return rows and run to completion.
var ct xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", backfillEmail).First(&ct).Error; err != nil {
t.Fatalf("client_traffics row not backfilled for %s: %v", backfillEmail, err)
}
// The MultiDomain→ExternalProxy migration must have committed too: the detection
// query ran (.Scan executes it) and the loop rewrote the inbound's streamSettings.
var refreshed model.Inbound
if err := db.First(&refreshed, multiDomainInbound.Id).Error; err != nil {
t.Fatalf("reload multidomain inbound: %v", err)
}
if !strings.Contains(refreshed.StreamSettings, "externalProxy") {
t.Errorf("MultiDomain migration did not commit; streamSettings = %q", refreshed.StreamSettings)
}
}
+852
View File
@@ -0,0 +1,852 @@
package service
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var reportedRemoteTagConflict sync.Map
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
mgr := runtime.GetManager()
if mgr == nil {
return nil, fmt.Errorf("runtime manager not initialised")
}
return mgr.RuntimeFor(ib.NodeID)
}
func (s *InboundService) nodePushPlan(ib *model.Inbound) (runtime.Runtime, bool, bool, error) {
if ib.NodeID == nil {
rt, err := s.runtimeFor(ib)
if err != nil {
return nil, false, false, nil
}
return rt, true, false, nil
}
nodeSvc := NodeService{}
enabled, status, _, _, err := nodeSvc.NodeSyncState(*ib.NodeID)
if err != nil {
return nil, false, false, err
}
if !enabled || status == "offline" {
return nil, false, true, nil
}
rt, err := s.runtimeFor(ib)
if err != nil {
return nil, false, true, nil
}
return rt, true, false, nil
}
func (s *InboundService) NodeIsPending(nodeID *int) bool {
if nodeID == nil {
return false
}
return (&NodeService{}).IsNodePending(*nodeID)
}
func (s *InboundService) AnyNodePending(inboundIds []int) bool {
if len(inboundIds) == 0 {
return false
}
nodeSvc := NodeService{}
for _, id := range inboundIds {
ib, err := s.GetInbound(id)
if err != nil || ib.NodeID == nil {
continue
}
if nodeSvc.IsNodePending(*ib.NodeID) {
return true
}
}
return false
}
func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
if rt == nil || nodeID <= 0 {
return nil
}
db := database.GetDB()
var inbounds []*model.Inbound
if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
return err
}
remoteTags, err := rt.ListRemoteTags(ctx)
if err != nil {
return err
}
prefix := nodeTagPrefix(&nodeID)
desiredTags := make(map[string]struct{}, len(inbounds)*2)
for _, ib := range inbounds {
desiredTags[ib.Tag] = struct{}{}
if prefix != "" {
if stripped, found := strings.CutPrefix(ib.Tag, prefix); found {
desiredTags[stripped] = struct{}{}
} else {
desiredTags[prefix+ib.Tag] = struct{}{}
}
}
if err := rt.UpdateInbound(ctx, ib, ib); err != nil {
return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
}
}
for _, tag := range remoteTags {
if _, want := desiredTags[tag]; want {
continue
}
if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
return fmt.Errorf("reconcile delete %q: %w", tag, err)
}
}
return nil
}
const resetGracePeriodMs int64 = 30000
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
// Xray's stats counters often report a zero delta for an active session across
// a single poll, so a 5s grace would still drop the client on the next tick.
// ~4 polls of slack keeps idle-but-connected clients visible without lingering
// long after a real disconnect.
const onlineGracePeriodMs int64 = 20000
type nodeTrafficCounter struct {
Up int64
Down int64
}
func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email string, up, down int64) error {
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_id"}, {Name: "email"}},
DoUpdates: clause.AssignmentColumns([]string{"up", "down"}),
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
}
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
var structuralChange bool
err := submitTrafficWrite(func() error {
var inner error
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty)
return inner
})
return structuralChange, err
}
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
if snap == nil || nodeID <= 0 {
return false, nil
}
db := database.GetDB()
now := time.Now().UnixMilli()
// originGuidFor attributes a synced inbound to the panel that physically
// hosts it: inbounds the node forwards from its own sub-nodes already carry
// a non-empty OriginNodeGuid (kept as-is across hops); the node's own local
// inbounds report empty, so they are attributed to the node's own GUID. An
// empty result (old-build node with no GUID yet) leaves attribution to the
// node_id fallback downstream (#4983).
var nodeRow model.Node
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
originGuidFor := func(snapIb *model.Inbound) string {
if snapIb.OriginNodeGuid != "" {
return snapIb.OriginNodeGuid
}
return nodeRow.Guid
}
var central []model.Inbound
if err := db.Model(model.Inbound{}).
Where("node_id = ?", nodeID).
Find(&central).Error; err != nil {
return false, err
}
// Index under the stored tag and its prefix-flipped form so a snap matches
// whether the n<id>- prefix lives on the node side, the central side, or
// neither — a mismatch must never spawn a duplicate central inbound.
tagToCentral := make(map[string]*model.Inbound, len(central)*2)
prefix := nodeTagPrefix(&nodeID)
for i := range central {
tagToCentral[central[i].Tag] = &central[i]
if prefix != "" {
if stripped, found := strings.CutPrefix(central[i].Tag, prefix); found {
tagToCentral[stripped] = &central[i]
} else {
tagToCentral[prefix+central[i].Tag] = &central[i]
}
}
}
var centralClientStats []xray.ClientTraffic
if len(central) > 0 {
ids := make([]int, 0, len(central))
for i := range central {
ids = append(ids, central[i].Id)
}
if err := db.Model(xray.ClientTraffic{}).
Where("inbound_id IN ?", ids).
Find(&centralClientStats).Error; err != nil {
return false, err
}
}
type csKey struct {
inboundID int
email string
}
centralCS := make(map[csKey]*xray.ClientTraffic, len(centralClientStats))
centralCSByEmail := make(map[string]*xray.ClientTraffic, len(centralClientStats))
for i := range centralClientStats {
centralCS[csKey{centralClientStats[i].InboundId, centralClientStats[i].Email}] = &centralClientStats[i]
centralCSByEmail[centralClientStats[i].Email] = &centralClientStats[i]
}
nodeBaselines := make(map[string]nodeTrafficCounter)
var baselineRows []model.NodeClientTraffic
if err := db.Model(&model.NodeClientTraffic{}).
Where("node_id = ?", nodeID).
Find(&baselineRows).Error; err != nil {
return false, err
}
for i := range baselineRows {
nodeBaselines[baselineRows[i].Email] = nodeTrafficCounter{Up: baselineRows[i].Up, Down: baselineRows[i].Down}
}
var existingEmailsList []string
if err := db.Model(xray.ClientTraffic{}).Pluck("email", &existingEmailsList).Error; err != nil {
return false, err
}
existingEmails := make(map[string]struct{}, len(existingEmailsList))
for _, e := range existingEmailsList {
existingEmails[e] = struct{}{}
}
var defaultUserId int
if len(central) > 0 {
defaultUserId = central[0].UserId
} else {
var u model.User
if err := db.Model(model.User{}).Order("id asc").First(&u).Error; err == nil {
defaultUserId = u.Id
} else {
defaultUserId = 1
}
}
tx := db.Begin()
committed := false
defer func() {
if !committed {
tx.Rollback()
}
}()
structuralChange := false
snapTags := make(map[string]struct{}, len(snap.Inbounds))
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
}
snapTags[snapIb.Tag] = struct{}{}
// Record the prefix-flipped form too so the orphan sweep below keeps a
// central inbound whether its tag carries the n<id>- prefix or not.
if prefix != "" {
if stripped, found := strings.CutPrefix(snapIb.Tag, prefix); found {
snapTags[stripped] = struct{}{}
} else {
snapTags[prefix+snapIb.Tag] = struct{}{}
}
}
c, ok := tagToCentral[snapIb.Tag]
if !ok {
if dirty {
continue
}
// Try snap.Tag first; on collision fall back to the n<id>-
// prefixed form so local+node can both own the same port.
pickFreeTag := func() (string, error) {
candidates := []string{snapIb.Tag}
if prefix != "" && !strings.HasPrefix(snapIb.Tag, prefix) {
candidates = append(candidates, prefix+snapIb.Tag)
}
for _, t := range candidates {
var owner model.Inbound
err := tx.Where("tag = ?", t).First(&owner).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return t, nil
}
if err != nil {
return "", err
}
}
return "", nil
}
chosenTag, err := pickFreeTag()
if err != nil {
logger.Warningf("setRemoteTraffic: check tag %q failed: %v", snapIb.Tag, err)
continue
}
if chosenTag == "" {
key := fmt.Sprintf("%d:%s", nodeID, snapIb.Tag)
if _, seen := reportedRemoteTagConflict.LoadOrStore(key, struct{}{}); !seen {
logger.Warningf(
"setRemoteTraffic: tag %q from node %d collides with an existing inbound even after the n%d- prefix — skipping (rename one side to remove the duplicate)",
snapIb.Tag, nodeID, nodeID,
)
}
continue
}
newIb := model.Inbound{
UserId: defaultUserId,
NodeID: &nodeID,
OriginNodeGuid: originGuidFor(snapIb),
Tag: chosenTag,
Listen: snapIb.Listen,
Port: snapIb.Port,
Protocol: snapIb.Protocol,
Settings: snapIb.Settings,
StreamSettings: snapIb.StreamSettings,
Sniffing: snapIb.Sniffing,
TrafficReset: snapIb.TrafficReset,
LastTrafficResetTime: snapIb.LastTrafficResetTime,
Enable: snapIb.Enable,
Remark: snapIb.Remark,
Total: snapIb.Total,
ExpiryTime: snapIb.ExpiryTime,
Up: snapIb.Up,
Down: snapIb.Down,
}
if err := tx.Create(&newIb).Error; err != nil {
logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
continue
}
tagToCentral[snapIb.Tag] = &newIb
if newIb.Tag != snapIb.Tag {
tagToCentral[newIb.Tag] = &newIb
}
structuralChange = true
continue
}
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
updates := map[string]any{}
if !dirty {
updates["enable"] = snapIb.Enable
updates["remark"] = snapIb.Remark
updates["listen"] = snapIb.Listen
updates["port"] = snapIb.Port
updates["protocol"] = snapIb.Protocol
updates["total"] = snapIb.Total
updates["expiry_time"] = snapIb.ExpiryTime
updates["settings"] = snapIb.Settings
updates["stream_settings"] = snapIb.StreamSettings
updates["sniffing"] = snapIb.Sniffing
updates["traffic_reset"] = snapIb.TrafficReset
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
}
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
updates["up"] = snapIb.Up
updates["down"] = snapIb.Down
}
// Physical-home attribution is independent of config-dirty state, so
// keep it current even while the node has pending offline edits. Writes
// once to backfill an existing row, then stays equal (#4983).
if og := originGuidFor(snapIb); c.OriginNodeGuid != og {
updates["origin_node_guid"] = og
}
if !dirty && (c.Settings != snapIb.Settings ||
c.Remark != snapIb.Remark ||
c.Listen != snapIb.Listen ||
c.Port != snapIb.Port ||
c.Total != snapIb.Total ||
c.ExpiryTime != snapIb.ExpiryTime ||
c.Enable != snapIb.Enable) {
structuralChange = true
}
if len(updates) > 0 {
if err := tx.Model(model.Inbound{}).
Where("id = ?", c.Id).
Updates(updates).Error; err != nil {
return false, err
}
}
}
for _, c := range central {
if dirty {
continue
}
if _, kept := snapTags[c.Tag]; kept {
continue
}
var goneEmails []string
if err := tx.Model(xray.ClientTraffic{}).
Where("inbound_id = ?", c.Id).
Pluck("email", &goneEmails).Error; err != nil {
return false, err
}
if len(goneEmails) > 0 {
// Chunk to avoid SQLite bind var limit when a node has many clients
// removed (e.g. after API bulk delete or structural change on node inbound).
for _, batch := range chunkStrings(goneEmails, sqliteMaxVars) {
if err := tx.Where("node_id = ? AND email IN ?", nodeID, batch).
Delete(&model.NodeClientTraffic{}).Error; err != nil {
return false, err
}
}
}
if err := tx.Where("inbound_id = ?", c.Id).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
}
if err := s.clientService.DetachInbound(tx, c.Id); err != nil {
return false, err
}
if err := tx.Where("id = ?", c.Id).
Delete(&model.Inbound{}).Error; err != nil {
return false, err
}
delete(tagToCentral, c.Tag)
structuralChange = true
}
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
}
c, ok := tagToCentral[snapIb.Tag]
if !ok {
continue
}
snapEmails := make(map[string]struct{}, len(snapIb.ClientStats))
for _, cs := range snapIb.ClientStats {
snapEmails[cs.Email] = struct{}{}
base, seen := nodeBaselines[cs.Email]
var deltaUp, deltaDown int64
if seen {
if deltaUp = cs.Up - base.Up; deltaUp < 0 {
deltaUp = cs.Up
}
if deltaDown = cs.Down - base.Down; deltaDown < 0 {
deltaDown = cs.Down
}
}
if _, rowExists := existingEmails[cs.Email]; !rowExists {
if dirty {
continue
}
row := &xray.ClientTraffic{
InboundId: c.Id,
Email: cs.Email,
Enable: cs.Enable,
Total: cs.Total,
ExpiryTime: cs.ExpiryTime,
Reset: cs.Reset,
Up: cs.Up,
Down: cs.Down,
LastOnline: cs.LastOnline,
}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).
Create(row).Error; err != nil {
return false, err
}
centralCS[csKey{c.Id, cs.Email}] = row
centralCSByEmail[cs.Email] = row
existingEmails[cs.Email] = struct{}{}
structuralChange = true
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
return false, err
}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
continue
}
if existing := centralCSByEmail[cs.Email]; existing != nil &&
(existing.Enable != cs.Enable ||
existing.Total != cs.Total ||
existing.ExpiryTime != cs.ExpiryTime ||
existing.Reset != cs.Reset) {
structuralChange = true
}
enableExpr := database.ClientTrafficEnableMergeExpr()
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
SET up = up + ?, down = down + ?, enable = %s, total = ?, expiry_time = ?, reset = ?,
last_online = %s
WHERE email = ?`,
enableExpr,
database.GreatestExpr("last_online", "?"),
),
deltaUp, deltaDown, cs.Enable, cs.Total, cs.ExpiryTime, cs.Reset,
cs.LastOnline, cs.Email,
).Error; err != nil {
return false, err
}
if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, cs.Up, cs.Down); err != nil {
return false, err
}
nodeBaselines[cs.Email] = nodeTrafficCounter{Up: cs.Up, Down: cs.Down}
}
for k, existing := range centralCS {
if dirty {
continue
}
if k.inboundID != c.Id {
continue
}
if _, kept := snapEmails[k.email]; kept {
continue
}
if err := tx.Where("node_id = ? AND email = ?", nodeID, existing.Email).
Delete(&model.NodeClientTraffic{}).Error; err != nil {
return false, err
}
if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email).
Delete(&xray.ClientTraffic{}).Error; err != nil {
return false, err
}
structuralChange = true
}
}
type oldSet struct {
inboundID int
emails map[string]struct{}
}
var perInboundOld []oldSet
for _, snapIb := range snap.Inbounds {
if snapIb == nil {
continue
}
c, ok := tagToCentral[snapIb.Tag]
if !ok {
continue
}
if dirty {
continue
}
var oldEmailsRows []string
if err := tx.Table("clients").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", c.Id).
Pluck("email", &oldEmailsRows).Error; err == nil {
oldEmails := make(map[string]struct{}, len(oldEmailsRows))
for _, e := range oldEmailsRows {
if e != "" {
oldEmails[e] = struct{}{}
}
}
perInboundOld = append(perInboundOld, oldSet{inboundID: c.Id, emails: oldEmails})
}
clients, gcErr := s.GetClients(snapIb)
if gcErr != nil {
logger.Warningf("setRemoteTraffic: parse clients for tag %q failed: %v", snapIb.Tag, gcErr)
continue
}
csEnableByEmail := make(map[string]bool, len(snapIb.ClientStats))
for _, cs := range snapIb.ClientStats {
csEnableByEmail[cs.Email] = cs.Enable
}
filtered := clients[:0]
for i := range clients {
if isClientEmailTombstoned(clients[i].Email) {
continue
}
if cse, hit := csEnableByEmail[clients[i].Email]; hit && !cse {
clients[i].Enable = false
}
filtered = append(filtered, clients[i])
}
localEmails := make([]string, 0, len(filtered))
for i := range filtered {
if filtered[i].Email != "" {
localEmails = append(localEmails, filtered[i].Email)
}
}
if len(localEmails) > 0 {
var localMeta []struct {
Email string
Comment string `gorm:"column:comment"`
}
if err := tx.Table("clients").
Select("email, comment").
Where("email IN ?", localEmails).
Find(&localMeta).Error; err == nil {
commentByEmail := make(map[string]string, len(localMeta))
for _, m := range localMeta {
commentByEmail[m.Email] = m.Comment
}
for i := range filtered {
if cmt, ok := commentByEmail[filtered[i].Email]; ok {
filtered[i].Comment = cmt
}
}
}
}
if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil {
logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err)
}
}
for _, old := range perInboundOld {
var stillAttached []string
if err := tx.Table("clients").
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
Where("client_inbounds.inbound_id = ?", old.inboundID).
Pluck("email", &stillAttached).Error; err != nil {
continue
}
stillSet := make(map[string]struct{}, len(stillAttached))
for _, e := range stillAttached {
stillSet[e] = struct{}{}
}
for email := range old.emails {
if _, kept := stillSet[email]; kept {
continue
}
var attachmentCount int64
if err := tx.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email = ?", email).
Count(&attachmentCount).Error; err != nil {
continue
}
if attachmentCount > 0 {
continue
}
if err := tx.Where("email = ?", email).Delete(&model.ClientRecord{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientRecord %q failed: %v", email, err)
}
if err := tx.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete ClientTraffic %q failed: %v", email, err)
}
if err := tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error; err != nil {
logger.Warningf("setRemoteTraffic: delete NodeClientTraffic %q failed: %v", email, err)
}
structuralChange = true
}
}
if err := tx.Commit().Error; err != nil {
return false, err
}
committed = true
if p != nil {
tree := snap.OnlineTree
if len(tree) == 0 && len(snap.OnlineEmails) > 0 {
// Old-build node (no GUID tree): key its flat online list under its
// own effective identity so attribution still works for that branch.
effectiveGuid := nodeRow.Guid
if effectiveGuid == "" {
effectiveGuid = synthNodeGuid(nodeID)
}
tree = map[string][]string{effectiveGuid: snap.OnlineEmails}
}
p.SetNodeOnlineTree(nodeID, tree)
}
return structuralChange, nil
}
func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
if err != nil {
logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
return
}
if !restartOnDisable {
return
}
for _, nodeID := range nodeIDs {
nodeIDCopy := nodeID
rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
if rtErr != nil {
logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
continue
}
if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
}
}
}
func (s *InboundService) GetOnlineClients() []string {
if p == nil {
return []string{}
}
return p.GetOnlineClients()
}
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
// node that physically hosts each set: this panel's own clients under its own
// GUID, plus every node in the tree under its GUID (#4983). Replaces the old
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
if p == nil {
return map[string][]string{}
}
out := p.GetMergedNodeTrees()
if local := p.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
}
return out
}
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
// the grace window for THIS panel, under its own GUID. Remote nodes don't
// report per-inbound activity, so a GUID missing from the map means "don't
// gate" for that node's inbounds.
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
if p == nil {
return map[string][]string{}
}
active := p.GetLocalActiveInbounds()
if len(active) == 0 {
return map[string][]string{}
}
guid := s.panelGuid()
if guid == "" {
return map[string][]string{}
}
return map[string][]string{guid: active}
}
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
if p != nil {
p.SetNodeOnlineTree(nodeID, tree)
}
}
func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
if p != nil {
p.ClearNodeOnlineClients(nodeID)
}
}
// panelGuid returns this panel's stable self-identifier, used to key the local
// panel's own clients in the per-node online maps (#4983).
func (s *InboundService) panelGuid() string {
guid, _ := (&SettingService{}).GetPanelGuid()
return guid
}
// synthNodeGuid is the stable per-node fallback identity for a directly-attached
// node whose panel hasn't reported a panelGuid yet (old build). Node ids are
// master-local, so this only composes for direct nodes — exactly the pre-#4983
// flat-topology case where an old-build node appears.
func synthNodeGuid(nodeID int) string {
return fmt.Sprintf("node:%d", nodeID)
}
// mergeEmails returns the deduped union of two email slices.
func mergeEmails(a, b []string) []string {
if len(a) == 0 {
return b
}
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]string, 0, len(a)+len(b))
for _, e := range a {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
for _, e := range b {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
return out
}
func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
db := database.GetDB()
var rows []xray.ClientTraffic
err := db.Model(&xray.ClientTraffic{}).Select("email, last_online").Find(&rows).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
result := make(map[string]int64, len(rows))
for _, r := range rows {
result[r.Email] = r.LastOnline
}
return result, nil
}
// RefreshLocalOnlineClients folds the emails and inbound tags active on this
// panel's own xray this poll into the local online/active sets, applying the
// grace window and pruning stale entries. Pass nil to only prune. See
// xray.Process for why the local sets are kept separate from the shared
// last_online column.
func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
if p != nil {
p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
}
}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB()
// Step 1: Get ClientTraffic records for emails in the input list.
// Chunked to stay under SQLite's bind-variable limit on huge inputs.
uniqEmails := uniqueNonEmptyStrings(emails)
clients := make([]xray.ClientTraffic, 0, len(uniqEmails))
for _, batch := range chunkStrings(uniqEmails, sqliteMaxVars) {
var page []xray.ClientTraffic
if err := db.Where("email IN ?", batch).Find(&page).Error; err != nil && err != gorm.ErrRecordNotFound {
return nil, nil, err
}
clients = append(clients, page...)
}
// Step 2: Sort clients by (Up + Down) descending
sort.Slice(clients, func(i, j int) bool {
return (clients[i].Up + clients[i].Down) > (clients[j].Up + clients[j].Down)
})
// Step 3: Extract sorted valid emails and track found ones
validEmails := make([]string, 0, len(clients))
found := make(map[string]bool)
for _, client := range clients {
validEmails = append(validEmails, client.Email)
found[client.Email] = true
}
// Step 4: Identify emails that were not found in the database
extraEmails := make([]string, 0)
for _, email := range emails {
if !found[email] {
extraEmails = append(extraEmails, email)
}
}
return validEmails, extraEmails, nil
}
+78
View File
@@ -0,0 +1,78 @@
package service
import (
"encoding/json"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
// the client UI can generate a valid PSK (base64 of the method's key length)
// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
func inboundShadowsocksMethod(protocol, settings string) string {
if protocol != string(model.Shadowsocks) || settings == "" {
return ""
}
var s struct {
Method string `json:"method"`
}
if err := json.Unmarshal([]byte(settings), &s); err != nil {
return ""
}
return s.Method
}
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend:
// XTLS Vision is only valid for VLESS on TCP with tls or reality.
func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
if protocol != string(model.VLESS) {
return false
}
if streamSettings == "" {
return false
}
var stream struct {
Network string `json:"network"`
Security string `json:"security"`
}
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return false
}
if stream.Network != "tcp" {
return false
}
return stream.Security == "tls" || stream.Security == "reality"
}
// inboundCanHostFallbacks gates the settings.fallbacks injection.
// Xray only honors fallbacks on VLESS and Trojan inbounds carried over
// TCP transport with TLS or Reality security.
func inboundCanHostFallbacks(ib *model.Inbound) bool {
if ib == nil {
return false
}
if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan {
return false
}
return inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) ||
(ib.Protocol == model.Trojan && trojanStreamSupportsFallbacks(ib.StreamSettings))
}
// trojanStreamSupportsFallbacks mirrors the Trojan side of the same gate
// (Trojan reuses XTLS-Vision capable streams: tcp + tls or reality).
func trojanStreamSupportsFallbacks(streamSettings string) bool {
if streamSettings == "" {
return false
}
var stream struct {
Network string `json:"network"`
Security string `json:"security"`
}
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
return false
}
if stream.Network != "tcp" {
return false
}
return stream.Security == "tls" || stream.Security == "reality"
}
+50
View File
@@ -0,0 +1,50 @@
package service
import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
)
type SubLinkProvider interface {
SubLinksForSubId(host, subId string) ([]string, error)
LinksForClient(host string, inbound *model.Inbound, email string) []string
}
var registeredSubLinkProvider SubLinkProvider
func RegisterSubLinkProvider(p SubLinkProvider) {
registeredSubLinkProvider = p
}
func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
if registeredSubLinkProvider == nil {
return nil, common.NewError("sub link provider not registered")
}
return registeredSubLinkProvider.SubLinksForSubId(host, subId)
}
func (s *InboundService) GetAllClientLinks(host string, email string) ([]string, error) {
if email == "" {
return nil, common.NewError("client email is required")
}
if registeredSubLinkProvider == nil {
return nil, common.NewError("sub link provider not registered")
}
rec, err := s.clientService.GetRecordByEmail(nil, email)
if err != nil {
return nil, err
}
inboundIds, err := s.clientService.GetInboundIdsForRecord(rec.Id)
if err != nil {
return nil, err
}
var links []string
for _, ibId := range inboundIds {
inbound, getErr := s.GetInbound(ibId)
if getErr != nil {
return nil, getErr
}
links = append(links, registeredSubLinkProvider.LinksForClient(host, inbound, email)...)
}
return links, nil
}
+971
View File
@@ -0,0 +1,971 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
var disabledNodeIDs []int
err = submitTrafficWrite(func() error {
var inner error
needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
return inner
})
if err == nil && len(disabledNodeIDs) > 0 {
s.restartRemoteNodesOnDisable(disabledNodeIDs)
}
return
}
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
var err error
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
tx.Rollback()
} else {
tx.Commit()
}
}()
err = s.addInboundTraffic(tx, inboundTraffics)
if err != nil {
return false, false, nil, err
}
err = s.addClientTraffic(tx, clientTraffics)
if err != nil {
return false, false, nil, err
}
needRestart0, count, err := s.autoRenewClients(tx)
if err != nil {
logger.Warning("Error in renew clients:", err)
} else if count > 0 {
logger.Debugf("%v clients renewed", count)
}
disabledClientsCount := int64(0)
needRestart1, count, disabledNodeIDs, err := s.disableInvalidClients(tx)
if err != nil {
logger.Warning("Error in disabling invalid clients:", err)
} else if count > 0 {
logger.Debugf("%v clients disabled", count)
disabledClientsCount = count
}
needRestart2, count, err := s.disableInvalidInbounds(tx)
if err != nil {
logger.Warning("Error in disabling invalid inbounds:", err)
} else if count > 0 {
logger.Debugf("%v inbounds disabled", count)
}
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, disabledNodeIDs, nil
}
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
if len(traffics) == 0 {
return nil
}
var err error
for _, traffic := range traffics {
if traffic.IsInbound {
err = tx.Model(&model.Inbound{}).Where("tag = ? AND node_id IS NULL", traffic.Tag).
Updates(map[string]any{
"up": gorm.Expr("up + ?", traffic.Up),
"down": gorm.Expr("down + ?", traffic.Down),
}).Error
if err != nil {
return err
}
}
}
return nil
}
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 {
return nil
}
emails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
emails = append(emails, traffic.Email)
}
dbClientTraffics := make([]*xray.ClientTraffic, 0, len(traffics))
// Match purely by email. client_traffics is email-keyed (one shared row per
// email regardless of how many inbounds the client is attached to), and these
// emails come from the local xray's report, so they always belong to a client
// attached to a local inbound. The old `inbound_id NOT IN (node inbounds)`
// filter dropped the local traffic of a client attached to both a node and the
// mother inbound whenever the node inbound happened to be attached first — its
// shared row then carried the node inbound's id (AddClientStat uses OnConflict
// DoNothing and never refreshes it), so the local poll skipped it entirely.
err = tx.Model(xray.ClientTraffic{}).
Where("email IN (?)", emails).
Find(&dbClientTraffics).Error
if err != nil {
return err
}
// Avoid empty slice error
if len(dbClientTraffics) == 0 {
return nil
}
dbClientTraffics, err = s.adjustTraffics(tx, dbClientTraffics)
if err != nil {
return err
}
// Index by email for O(N) merge — the previous nested loop was O(N²)
// and dominated each cron tick on inbounds with thousands of active
// clients (7500 × 7500 = 56M string comparisons every 10 seconds).
trafficByEmail := make(map[string]*xray.ClientTraffic, len(traffics))
for i := range traffics {
if traffics[i] != nil {
trafficByEmail[traffics[i].Email] = traffics[i]
}
}
now := time.Now().UnixMilli()
for dbTraffic_index := range dbClientTraffics {
t, ok := trafficByEmail[dbClientTraffics[dbTraffic_index].Email]
if !ok {
continue
}
dbClientTraffics[dbTraffic_index].Up += t.Up
dbClientTraffics[dbTraffic_index].Down += t.Down
if t.Up+t.Down > 0 {
dbClientTraffics[dbTraffic_index].LastOnline = now
}
}
err = tx.Save(dbClientTraffics).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
}
return nil
}
func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.ClientTraffic) ([]*xray.ClientTraffic, error) {
now := time.Now().UnixMilli()
// "Start After First Use" stores a negative expiry (the duration). On the
// first traffic tick it becomes an absolute deadline of now+duration. Compute
// it once per email so every inbound the client is attached to lands on the
// same value (recomputing per inbound would skip all but the first one).
newExpiryByEmail := make(map[string]int64, len(dbClientTraffics))
for traffic_index := range dbClientTraffics {
if dbClientTraffics[traffic_index].ExpiryTime < 0 {
newExpiryByEmail[dbClientTraffics[traffic_index].Email] = now - dbClientTraffics[traffic_index].ExpiryTime
}
}
if len(newExpiryByEmail) == 0 {
return dbClientTraffics, nil
}
delayedEmails := make([]string, 0, len(newExpiryByEmail))
for email := range newExpiryByEmail {
delayedEmails = append(delayedEmails, email)
}
// Resolve the owning inbounds through the client_inbounds link, which is
// authoritative. client_traffics.inbound_id goes stale when an inbound is
// deleted and recreated, which would leave the negative expiry unconverted.
var inboundIds []int
err := tx.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN (?)", delayedEmails).
Distinct().
Pluck("client_inbounds.inbound_id", &inboundIds).Error
if err != nil {
return nil, err
}
if len(inboundIds) == 0 {
return dbClientTraffics, nil
}
var inbounds []*model.Inbound
err = tx.Model(model.Inbound{}).Where("id IN (?)", inboundIds).Find(&inbounds).Error
if err != nil {
return nil, err
}
for inbound_index := range inbounds {
settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
clients, ok := settings["clients"].([]any)
if ok {
var newClients []any
for client_index := range clients {
c := clients[client_index].(map[string]any)
email, _ := c["email"].(string)
if newExpiry, ok := newExpiryByEmail[email]; ok {
c["expiryTime"] = newExpiry
c["updated_at"] = now
}
if _, ok := c["created_at"]; !ok {
c["created_at"] = now
}
if _, ok := c["updated_at"]; !ok {
c["updated_at"] = now
}
newClients = append(newClients, any(c))
}
settings["clients"] = newClients
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return nil, err
}
inbounds[inbound_index].Settings = string(modifiedSettings)
}
}
for traffic_index := range dbClientTraffics {
if newExpiry, ok := newExpiryByEmail[dbClientTraffics[traffic_index].Email]; ok {
dbClientTraffics[traffic_index].ExpiryTime = newExpiry
}
}
err = tx.Save(inbounds).Error
if err != nil {
logger.Warning("AddClientTraffic update inbounds ", err)
logger.Error(inbounds)
} else {
for _, ib := range inbounds {
if ib == nil {
continue
}
cs, gcErr := s.GetClients(ib)
if gcErr != nil {
logger.Warning("AddClientTraffic sync clients: GetClients failed", gcErr)
continue
}
if syncErr := s.clientService.SyncInbound(tx, ib.Id, cs); syncErr != nil {
logger.Warning("AddClientTraffic sync clients: SyncInbound failed", syncErr)
}
}
}
return dbClientTraffics, nil
}
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
// check for time expired
var traffics []*xray.ClientTraffic
now := time.Now().Unix() * 1000
var err, err1 error
err = tx.Model(xray.ClientTraffic{}).
Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now).
Where("inbound_id NOT IN (?)", tx.Model(&model.Inbound{}).Select("id").Where("node_id IS NOT NULL")).
Find(&traffics).Error
if err != nil {
return false, 0, err
}
// return if there is no client to renew
if len(traffics) == 0 {
return false, 0, nil
}
var inbound_ids []int
var inbounds []*model.Inbound
needRestart := false
var clientsToAdd []struct {
protocol string
tag string
client map[string]any
}
// Resolve the inbounds to renew through the client_inbounds link rather than
// client_traffics.inbound_id, which goes stale after an inbound is deleted and
// recreated and would otherwise skip the renew entirely.
renewEmails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
renewEmails = append(renewEmails, traffic.Email)
}
for _, batch := range chunkStrings(renewEmails, sqliteMaxVars) {
var ids []int
if err = tx.Table("client_inbounds").
Joins("JOIN clients ON clients.id = client_inbounds.client_id").
Where("clients.email IN ?", batch).
Distinct().
Pluck("client_inbounds.inbound_id", &ids).Error; err != nil {
return false, 0, err
}
inbound_ids = append(inbound_ids, ids...)
}
// Dedupe so an inbound hosting N expired clients is fetched and saved once
// per tick instead of N times across chunk boundaries.
inbound_ids = uniqueInts(inbound_ids)
// Chunked to stay under SQLite's bind-variable limit when many inbounds
// are touched in a single tick.
for _, batch := range chunkInts(inbound_ids, sqliteMaxVars) {
var page []*model.Inbound
if err = tx.Model(model.Inbound{}).Where("id IN ?", batch).Find(&page).Error; err != nil {
return false, 0, err
}
inbounds = append(inbounds, page...)
}
for inbound_index := range inbounds {
settings := map[string]any{}
json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
clients := settings["clients"].([]any)
for client_index := range clients {
c := clients[client_index].(map[string]any)
for traffic_index, traffic := range traffics {
if traffic.Email == c["email"].(string) {
newExpiryTime := traffic.ExpiryTime
for newExpiryTime < now {
newExpiryTime += (int64(traffic.Reset) * 86400000)
}
c["expiryTime"] = newExpiryTime
traffics[traffic_index].ExpiryTime = newExpiryTime
traffics[traffic_index].Down = 0
traffics[traffic_index].Up = 0
if !traffic.Enable {
traffics[traffic_index].Enable = true
c["enable"] = true
clientsToAdd = append(clientsToAdd,
struct {
protocol string
tag string
client map[string]any
}{
protocol: string(inbounds[inbound_index].Protocol),
tag: inbounds[inbound_index].Tag,
client: c,
})
}
clients[client_index] = any(c)
break
}
}
}
settings["clients"] = clients
newSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return false, 0, err
}
inbounds[inbound_index].Settings = string(newSettings)
}
err = tx.Save(inbounds).Error
if err != nil {
return false, 0, err
}
for _, ib := range inbounds {
if ib == nil {
continue
}
cs, gcErr := s.GetClients(ib)
if gcErr != nil {
logger.Warning("autoRenewClients sync clients: GetClients failed", gcErr)
continue
}
if syncErr := s.clientService.SyncInbound(tx, ib.Id, cs); syncErr != nil {
logger.Warning("autoRenewClients sync clients: SyncInbound failed", syncErr)
}
}
err = tx.Save(traffics).Error
if err != nil {
return false, 0, err
}
if p != nil {
err1 = s.xrayApi.Init(p.GetAPIPort())
if err1 != nil {
return true, int64(len(traffics)), nil
}
for _, clientToAdd := range clientsToAdd {
err1 = s.xrayApi.AddUser(clientToAdd.protocol, clientToAdd.tag, clientToAdd.client)
if err1 != nil {
needRestart = true
}
}
s.xrayApi.Close()
}
return needRestart, int64(len(traffics)), nil
}
// AddClientStat inserts a per-client accounting row, no-op on email
// conflict. Xray reports traffic per email, so the surviving row acts as
// the shared accumulator for inbounds that re-use the same identity.
func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model.Client) error {
clientTraffic := xray.ClientTraffic{
InboundId: inboundId,
Email: client.Email,
Total: client.TotalGB,
ExpiryTime: client.ExpiryTime,
Enable: client.Enable,
Reset: client.Reset,
}
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}).
Create(&clientTraffic).Error
}
func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *model.Client) error {
result := tx.Model(xray.ClientTraffic{}).
Where("email = ?", email).
Updates(map[string]any{
"enable": client.Enable,
"email": client.Email,
"total": client.TotalGB,
"expiry_time": client.ExpiryTime,
"reset": client.Reset,
})
err := result.Error
return err
}
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
return err
}
return tx.Where("email = ?", email).Delete(&model.NodeClientTraffic{}).Error
}
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
const chunk = 400
for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails))
batch := emails[start:end]
if err := tx.Where("email IN ?", batch).Delete(xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
}
return nil
}
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
return submitTrafficWrite(func() error {
db := database.GetDB()
return db.Model(xray.ClientTraffic{}).
Where("email = ?", clientEmail).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error
})
}
func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRestart bool, err error) {
err = submitTrafficWrite(func() error {
var inner error
needRestart, inner = s.resetClientTrafficLocked(id, clientEmail)
return inner
})
return
}
func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, error) {
needRestart := false
traffic, err := s.GetClientTrafficByEmail(clientEmail)
if err != nil {
return false, err
}
if !traffic.Enable {
inbound, err := s.GetInbound(id)
if err != nil {
return false, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return false, err
}
for _, client := range clients {
if client.Email == clientEmail && client.Enable {
rt, push, dirty, perr := s.nodePushPlan(inbound)
if perr != nil {
return false, perr
}
if !push {
if inbound.NodeID != nil {
if dirty {
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
}
} else {
needRestart = true
}
break
}
cipher := ""
if string(inbound.Protocol) == "shadowsocks" {
var oldSettings map[string]any
err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
if err != nil {
return false, err
}
cipher = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
"security": client.Security,
"flow": client.Flow,
"password": client.Password,
"cipher": cipher,
})
if err1 == nil {
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
} else if inbound.NodeID != nil {
logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
logger.Warning("mark node dirty failed:", dErr)
}
} else {
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
needRestart = true
}
break
}
}
}
traffic.Up = 0
traffic.Down = 0
traffic.Enable = true
db := database.GetDB()
err = db.Save(traffic).Error
if err != nil {
return false, err
}
now := time.Now().UnixMilli()
_ = db.Model(model.Inbound{}).
Where("id = ?", id).
Update("last_traffic_reset_time", now).Error
inbound, err := s.GetInbound(id)
if err == nil && inbound != nil && inbound.NodeID != nil {
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
if e := rt.ResetClientTraffic(context.Background(), inbound, clientEmail); e != nil {
logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)
}
} else {
logger.Warning("ResetClientTraffic: runtime lookup failed:", rterr)
}
}
return needRestart, nil
}
func (s *InboundService) ResetAllTraffics() error {
return submitTrafficWrite(func() error {
return s.resetAllTrafficsLocked()
})
}
func (s *InboundService) resetAllTrafficsLocked() error {
db := database.GetDB()
now := time.Now().UnixMilli()
if err := db.Model(model.Inbound{}).
Where("user_id > ?", 0).
Updates(map[string]any{
"up": 0,
"down": 0,
"last_traffic_reset_time": now,
}).Error; err != nil {
return err
}
nodes, err := (&NodeService{}).GetAll()
if err == nil {
for _, node := range nodes {
if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
if e := rt.ResetAllTraffics(context.Background()); e != nil {
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
}
}
}
}
return nil
}
func (s *InboundService) ResetInboundTraffic(id int) error {
return submitTrafficWrite(func() error {
db := database.GetDB()
if err := db.Model(model.Inbound{}).
Where("id = ?", id).
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
return err
}
inbound, err := s.GetInbound(id)
if err == nil && inbound != nil && inbound.NodeID != nil {
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
}
} else {
logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
}
}
return nil
})
}
func (s *InboundService) DelDepletedClients(id int) (err error) {
db := database.GetDB()
tx := db.Begin()
defer func() {
if err == nil {
tx.Commit()
} else {
tx.Rollback()
}
}()
// Collect depleted emails globally — a shared-email row owned by one
// inbound depletes every sibling that lists the email.
now := time.Now().Unix() * 1000
depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
var depletedRows []xray.ClientTraffic
err = db.Model(xray.ClientTraffic{}).
Where(depletedClause, now).
Find(&depletedRows).Error
if err != nil {
return err
}
if len(depletedRows) == 0 {
return nil
}
depletedEmails := make(map[string]struct{}, len(depletedRows))
for _, r := range depletedRows {
if r.Email == "" {
continue
}
depletedEmails[strings.ToLower(r.Email)] = struct{}{}
}
if len(depletedEmails) == 0 {
return nil
}
var inbounds []*model.Inbound
inboundQuery := db.Model(model.Inbound{})
if id >= 0 {
inboundQuery = inboundQuery.Where("id = ?", id)
}
if err = inboundQuery.Find(&inbounds).Error; err != nil {
return err
}
for _, inbound := range inbounds {
var settings map[string]any
if err = json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
return err
}
rawClients, ok := settings["clients"].([]any)
if !ok {
continue
}
newClients := make([]any, 0, len(rawClients))
removed := 0
for _, client := range rawClients {
c, ok := client.(map[string]any)
if !ok {
newClients = append(newClients, client)
continue
}
email, _ := c["email"].(string)
if _, isDepleted := depletedEmails[strings.ToLower(email)]; isDepleted {
removed++
continue
}
newClients = append(newClients, client)
}
if removed == 0 {
continue
}
if len(newClients) == 0 {
s.DelInbound(inbound.Id)
continue
}
settings["clients"] = newClients
ns, mErr := json.MarshalIndent(settings, "", " ")
if mErr != nil {
return mErr
}
inbound.Settings = string(ns)
if err = tx.Save(inbound).Error; err != nil {
return err
}
survivingClients, gcErr := s.GetClients(inbound)
if gcErr != nil {
err = gcErr
return err
}
if err = s.clientService.SyncInbound(tx, inbound.Id, survivingClients); err != nil {
return err
}
}
// Drop now-orphaned rows. With id >= 0, a row is safe to drop only when
// no out-of-scope inbound still references the email.
if id < 0 {
err = tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error
return err
}
emails := make([]string, 0, len(depletedEmails))
for e := range depletedEmails {
emails = append(emails, e)
}
var stillReferenced []string
emailExpr := database.JSONFieldText("client.value", "email")
stillQuery := fmt.Sprintf(
"SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?",
emailExpr,
database.JSONClientsFromInbound(),
emailExpr,
)
if err = tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil {
return err
}
stillSet := make(map[string]struct{}, len(stillReferenced))
for _, e := range stillReferenced {
stillSet[e] = struct{}{}
}
toDelete := make([]string, 0, len(emails))
for _, e := range emails {
if _, kept := stillSet[e]; !kept {
toDelete = append(toDelete, e)
}
}
if len(toDelete) > 0 {
if err = tx.Where("LOWER(email) IN ?", toDelete).Delete(xray.ClientTraffic{}).Error; err != nil {
return err
}
}
return nil
}
func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffic, error) {
db := database.GetDB()
var inbounds []*model.Inbound
// Retrieve inbounds where settings contain the given tgId
err := db.Model(model.Inbound{}).Where("settings LIKE ?", fmt.Sprintf(`%%"tgId": %d%%`, tgId)).Find(&inbounds).Error
if err != nil && 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 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
}
func (s *InboundService) GetActiveClientTraffics(emails []string) ([]*xray.ClientTraffic, error) {
uniq := uniqueNonEmptyStrings(emails)
if len(uniq) == 0 {
return nil, nil
}
db := database.GetDB()
traffics := make([]*xray.ClientTraffic, 0, len(uniq))
for _, batch := range chunkStrings(uniq, sqliteMaxVars) {
var page []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil {
return nil, err
}
traffics = append(traffics, page...)
}
return traffics, nil
}
// GetAllClientTraffics returns the full set of client_traffics rows so the
// websocket broadcasters can ship a complete snapshot every cycle. The old
// delta-only path (GetActiveClientTraffics on activeEmails) silently dropped
// the per-client section whenever no client moved bytes in the cycle or a
// node sync failed, leaving client rows in the UI stuck at stale numbers.
func (s *InboundService) GetAllClientTraffics() ([]*xray.ClientTraffic, error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Find(&traffics).Error; err != nil {
return nil, err
}
return traffics, nil
}
type InboundTrafficSummary struct {
Id int `json:"id"`
Up int64 `json:"up"`
Down int64 `json:"down"`
Total int64 `json:"total"`
Enable bool `json:"enable"`
}
func (s *InboundService) GetInboundsTrafficSummary() ([]InboundTrafficSummary, error) {
db := database.GetDB()
var summaries []InboundTrafficSummary
if err := db.Model(&model.Inbound{}).
Select("id, up, down, total, enable").
Find(&summaries).Error; err != nil {
return nil, err
}
return summaries, nil
}
func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.ClientTraffic, err error) {
db := database.GetDB()
var traffics []*xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Find(&traffics).Error; err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
if len(traffics) == 0 {
return nil, nil
}
t := traffics[0]
if rec, rErr := s.clientService.GetRecordByEmail(db, email); rErr == nil && rec != nil {
c := rec.ToClient()
t.UUID = c.ID
t.SubId = c.SubID
return t, nil
}
t2, client, err := s.GetClientByEmail(email)
if err != nil {
logger.Warningf("Error retrieving ClientTraffic with email %s: %v", email, err)
return nil, err
}
if t2 != nil && client != nil {
t2.UUID = client.ID
t2.SubId = client.SubID
return t2, nil
}
return nil, nil
}
func (s *InboundService) UpdateClientTrafficByEmail(email string, upload int64, download int64) error {
return submitTrafficWrite(func() error {
db := database.GetDB()
err := db.Model(xray.ClientTraffic{}).
Where("email = ?", email).
Updates(map[string]any{
"up": upload,
"down": download,
}).Error
if err != nil {
logger.Warningf("Error updating ClientTraffic with email %s: %v", email, err)
}
return err
})
}
func (s *InboundService) SearchClientTraffic(query string) (traffic *xray.ClientTraffic, err error) {
db := database.GetDB()
inbound := &model.Inbound{}
traffic = &xray.ClientTraffic{}
// Search for inbound settings that contain the query
err = db.Model(model.Inbound{}).Where("settings LIKE ?", "%\""+query+"\"%").First(inbound).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
logger.Warningf("Inbound settings containing query %s not found: %v", query, err)
return nil, err
}
logger.Errorf("Error searching for inbound settings with query %s: %v", query, err)
return nil, err
}
traffic.InboundId = inbound.Id
// Unmarshal settings to get clients
settings := map[string][]model.Client{}
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
logger.Errorf("Error unmarshalling inbound settings for inbound ID %d: %v", inbound.Id, err)
return nil, err
}
clients := settings["clients"]
for _, client := range clients {
if (client.ID == query || client.Password == query) && client.Email != "" {
traffic.Email = client.Email
break
}
}
if traffic.Email == "" {
logger.Warningf("No client found with query %s in inbound ID %d", query, inbound.Id)
return nil, gorm.ErrRecordNotFound
}
// Retrieve ClientTraffic based on the found email
err = db.Model(xray.ClientTraffic{}).Where("email = ?", traffic.Email).First(traffic).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
logger.Warningf("ClientTraffic for email %s not found: %v", traffic.Email, err)
return nil, err
}
logger.Errorf("Error retrieving ClientTraffic for email %s: %v", traffic.Email, err)
return nil, err
}
return traffic, nil
}
@@ -0,0 +1,100 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// changing an inbound's port must re-derive an auto-generated tag, both in
// the persisted row and in the value returned to the caller (the API
// response the UI renders). The UI round-trips the old tag in a hidden
// field, so the update arrives carrying the stale tag.
func TestUpdateInbound_RegeneratesAutoTagOnPortChange(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
var existing model.Inbound
if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
svc := &InboundService{}
update := existing
update.Port = 33000
update.Tag = "in-22435-tcp"
got, _, err := svc.UpdateInbound(&update)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
var reloaded model.Inbound
if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Tag != "in-33000-tcp" {
t.Fatalf("persisted tag = %q, want in-33000-tcp", reloaded.Tag)
}
if got.Tag != "in-33000-tcp" {
t.Fatalf("returned tag = %q, want in-33000-tcp", got.Tag)
}
}
// a node-scoped inbound (tag carries the "n1-" prefix) must keep that prefix
// when its port changes, even if the caller omits nodeId in the update body —
// the node can't be migrated, so the stored NodeID drives the tag. The runtime
// manager isn't wired in unit tests, so UpdateInbound returns a runtime error
// for node inbounds before persisting; we assert on the tag it computed (set on
// the returned object) which is what the save would use.
func TestUpdateInbound_NodeTagKeepsPrefixWhenNodeIdOmitted(t *testing.T) {
setupConflictDB(t)
seedInboundConflictNode(t, "n1-in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, intPtr(1))
var existing model.Inbound
if err := database.GetDB().Where("tag = ?", "n1-in-443-tcp").First(&existing).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
svc := &InboundService{}
update := existing
update.Port = 8443
update.Tag = "n1-in-443-tcp"
update.NodeID = nil
got, _, _ := svc.UpdateInbound(&update)
if got.Tag != "n1-in-8443-tcp" {
t.Fatalf("node prefix must survive a port change, got %q", got.Tag)
}
}
// a tag the user set by hand (doesn't match the canonical shape) survives a
// port change untouched.
func TestUpdateInbound_KeepsCustomTagOnPortChange(t *testing.T) {
setupConflictDB(t)
seedInboundConflict(t, "my-custom-tag", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`)
var existing model.Inbound
if err := database.GetDB().Where("tag = ?", "my-custom-tag").First(&existing).Error; err != nil {
t.Fatalf("read seeded row: %v", err)
}
svc := &InboundService{}
update := existing
update.Port = 33000
update.Tag = "my-custom-tag"
got, _, err := svc.UpdateInbound(&update)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
var reloaded model.Inbound
if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Tag != "my-custom-tag" {
t.Fatalf("persisted tag = %q, want my-custom-tag", reloaded.Tag)
}
if got.Tag != "my-custom-tag" {
t.Fatalf("returned tag = %q, want my-custom-tag", got.Tag)
}
}
+74
View File
@@ -0,0 +1,74 @@
package service
// sqliteMaxVars is a safe ceiling for the number of bind parameters in a
// single SQL statement. SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 on builds
// before 3.32 and 32766 after; staying under 999 keeps queries portable
// across forks/old binaries and also bounds per-query memory on truly large
// installs (>32k clients) where even modern SQLite would refuse a single IN.
const sqliteMaxVars = 900
// uniqueNonEmptyStrings returns a deduplicated copy of in with empty strings
// removed, preserving the order of first occurrence.
func uniqueNonEmptyStrings(in []string) []string {
if len(in) == 0 {
return nil
}
seen := make(map[string]struct{}, len(in))
out := make([]string, 0, len(in))
for _, v := range in {
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// uniqueInts returns a deduplicated copy of in, preserving order of first occurrence.
func uniqueInts(in []int) []int {
if len(in) == 0 {
return nil
}
seen := make(map[int]struct{}, len(in))
out := make([]int, 0, len(in))
for _, v := range in {
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
// chunkStrings splits s into consecutive sub-slices of at most size elements.
// Returns nil for an empty input or non-positive size.
func chunkStrings(s []string, size int) [][]string {
if size <= 0 || len(s) == 0 {
return nil
}
out := make([][]string, 0, (len(s)+size-1)/size)
for i := 0; i < len(s); i += size {
end := min(i+size, len(s))
out = append(out, s[i:end])
}
return out
}
// chunkInts splits s into consecutive sub-slices of at most size elements.
// Returns nil for an empty input or non-positive size.
func chunkInts(s []int, size int) [][]int {
if size <= 0 || len(s) == 0 {
return nil
}
out := make([][]int, 0, (len(s)+size-1)/size)
for i := 0; i < len(s); i += size {
end := min(i+size, len(s))
out = append(out, s[i:end])
}
return out
}
@@ -0,0 +1,784 @@
package integration
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"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/netproxy"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
const (
customGeoTypeGeosite = "geosite"
customGeoTypeGeoip = "geoip"
minDatBytes = 64
customGeoProbeTimeout = 12 * time.Second
)
var (
customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
reservedCustomAliases = map[string]struct{}{
"geoip": {}, "geosite": {},
"geoip_ir": {}, "geosite_ir": {},
"geoip_ru": {}, "geosite_ru": {},
}
ErrCustomGeoInvalidType = errors.New("custom_geo_invalid_type")
ErrCustomGeoAliasRequired = errors.New("custom_geo_alias_required")
ErrCustomGeoAliasPattern = errors.New("custom_geo_alias_pattern")
ErrCustomGeoAliasReserved = errors.New("custom_geo_alias_reserved")
ErrCustomGeoURLRequired = errors.New("custom_geo_url_required")
ErrCustomGeoInvalidURL = errors.New("custom_geo_invalid_url")
ErrCustomGeoURLScheme = errors.New("custom_geo_url_scheme")
ErrCustomGeoURLHost = errors.New("custom_geo_url_host")
ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
ErrCustomGeoNotFound = errors.New("custom_geo_not_found")
ErrCustomGeoDownload = errors.New("custom_geo_download")
ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked")
ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal")
)
type CustomGeoUpdateAllItem struct {
Id int `json:"id"`
Alias string `json:"alias"`
FileName string `json:"fileName"`
}
type CustomGeoUpdateAllFailure struct {
Id int `json:"id"`
Alias string `json:"alias"`
FileName string `json:"fileName"`
Err string `json:"error"`
}
type CustomGeoUpdateAllResult struct {
Succeeded []CustomGeoUpdateAllItem `json:"succeeded"`
Failed []CustomGeoUpdateAllFailure `json:"failed"`
}
type CustomGeoService struct {
serverService *service.ServerService
updateAllGetAll func() ([]model.CustomGeoResource, error)
updateAllApply func(id int, onStartup bool) (string, error)
updateAllRestart func() error
getPanelProxy func() (string, error)
}
func NewCustomGeoService() *CustomGeoService {
s := &CustomGeoService{
serverService: &service.ServerService{},
}
s.updateAllGetAll = s.GetAll
s.updateAllApply = s.applyDownloadAndPersist
s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
s.getPanelProxy = (&service.SettingService{}).GetPanelProxy
return s
}
func NormalizeAliasKey(alias string) string {
return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
}
func (s *CustomGeoService) fileNameFor(typ, alias string) string {
if typ == customGeoTypeGeoip {
return fmt.Sprintf("geoip_%s.dat", alias)
}
return fmt.Sprintf("geosite_%s.dat", alias)
}
func (s *CustomGeoService) validateType(typ string) error {
if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
return ErrCustomGeoInvalidType
}
return nil
}
func (s *CustomGeoService) validateAlias(alias string) error {
if alias == "" {
return ErrCustomGeoAliasRequired
}
if !customGeoAliasPattern.MatchString(alias) {
return ErrCustomGeoAliasPattern
}
if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
return ErrCustomGeoAliasReserved
}
return nil
}
func (s *CustomGeoService) sanitizeURL(raw string) (string, error) {
if raw == "" {
return "", ErrCustomGeoURLRequired
}
u, err := url.Parse(raw)
if err != nil {
return "", ErrCustomGeoInvalidURL
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", ErrCustomGeoURLScheme
}
if u.Host == "" {
return "", ErrCustomGeoURLHost
}
if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
return "", err
}
// Reconstruct URL from parsed components to break taint propagation.
clean := &url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: u.Path,
RawPath: u.RawPath,
RawQuery: u.RawQuery,
Fragment: u.Fragment,
}
return clean.String(), nil
}
func localDatFileNeedsRepair(path string) bool {
safePath, err := sanitizeDestPath(path)
if err != nil {
return true
}
fi, err := os.Stat(safePath)
if err != nil {
return true
}
if fi.IsDir() {
return true
}
return fi.Size() < int64(minDatBytes)
}
func CustomGeoLocalFileNeedsRepair(path string) bool {
return localDatFileNeedsRepair(path)
}
func isBlockedIP(ip net.IP) bool {
return netsafe.IsBlockedIP(ip)
}
// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
func checkSSRFDefault(ctx context.Context, hostname string) error {
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
if err != nil {
return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
}
for _, ipAddr := range ips {
if isBlockedIP(ipAddr.IP) {
return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
}
}
return nil
}
// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
var checkSSRF = checkSSRFDefault
func ssrfSafeTransport() http.RoundTripper {
base, ok := http.DefaultTransport.(*http.Transport)
if !ok {
base = &http.Transport{}
}
cloned := base.Clone()
cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
}
if err := checkSSRF(ctx, host); err != nil {
return nil, err
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, addr)
}
return cloned
}
func (s *CustomGeoService) httpClient(timeout time.Duration) *http.Client {
proxyURL := ""
if s.getPanelProxy != nil {
if p, err := s.getPanelProxy(); err != nil {
logger.Warning("custom geo: read panel proxy:", err)
} else {
proxyURL = strings.TrimSpace(p)
}
}
if proxyURL != "" {
client, err := netproxy.NewHTTPClient(proxyURL, timeout)
if err != nil {
logger.Warningf("custom geo: invalid panel proxy %q, using direct connection: %v", proxyURL, err)
} else {
return client
}
}
return &http.Client{Timeout: timeout, Transport: ssrfSafeTransport()}
}
func (s *CustomGeoService) probeCustomGeoURLWithGET(rawURL string) error {
sanitizedURL, err := s.sanitizeURL(rawURL)
if err != nil {
return err
}
client := s.httpClient(customGeoProbeTimeout)
req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
if err != nil {
return err
}
req.Header.Set("Range", "bytes=0-0")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
switch resp.StatusCode {
case http.StatusOK, http.StatusPartialContent:
return nil
default:
return fmt.Errorf("get range status %d", resp.StatusCode)
}
}
func (s *CustomGeoService) probeCustomGeoURL(rawURL string) error {
sanitizedURL, err := s.sanitizeURL(rawURL)
if err != nil {
return err
}
client := s.httpClient(customGeoProbeTimeout)
req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
_ = resp.Body.Close()
sc := resp.StatusCode
if sc >= 200 && sc < 300 {
return nil
}
if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
return s.probeCustomGeoURLWithGET(rawURL)
}
return fmt.Errorf("head status %d", sc)
}
func (s *CustomGeoService) EnsureOnStartup() {
list, err := s.GetAll()
if err != nil {
logger.Warning("custom geo startup: load list:", err)
return
}
n := len(list)
if n == 0 {
logger.Info("custom geo startup: no custom geofiles configured")
return
}
logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
for i := range list {
r := &list[i]
sanitizedURL, err := s.sanitizeURL(r.Url)
if err != nil {
logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
continue
}
r.Url = sanitizedURL
s.syncLocalPath(r)
localPath := r.LocalPath
if !localDatFileNeedsRepair(localPath) {
logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
continue
}
logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
if err := s.probeCustomGeoURL(r.Url); err != nil {
logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
}
_, _ = s.applyDownloadAndPersist(r.Id, true)
}
}
func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
safeDestPath, err := sanitizeDestPath(destPath)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
if err != nil {
return false, "", err
}
if skipped {
if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
return true, lm, nil
}
return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
}
return false, lm, nil
}
// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
// It resolves symlinks to prevent symlink-based escapes.
// Returns the cleaned absolute path that is safe to use in file operations.
func sanitizeDestPath(destPath string) (string, error) {
baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
if err != nil {
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
}
// Resolve symlinks in base directory to get the real path.
if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
baseDirAbs = resolved
}
destPathAbs, err := filepath.Abs(destPath)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
}
// Resolve symlinks for the parent directory of the destination path.
destDir := filepath.Dir(destPathAbs)
if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
}
// Verify the resolved path is within the safe base directory using prefix check.
safeDirPrefix := baseDirAbs + string(filepath.Separator)
if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
return "", ErrCustomGeoPathTraversal
}
return destPathAbs, nil
}
func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
safeDestPath, err := sanitizeDestPath(destPath)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
sanitizedURL, err := s.sanitizeURL(resourceURL)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
var req *http.Request
req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
if !forceFull {
if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
if !fi.ModTime().IsZero() {
req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
} else if lastModifiedHeader != "" {
if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
}
}
}
}
client := s.httpClient(10 * time.Minute)
// lgtm[go/request-forgery]
resp, err := client.Do(req)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
defer resp.Body.Close()
var serverModTime time.Time
if lm := resp.Header.Get("Last-Modified"); lm != "" {
if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
serverModTime = parsed
newLastModified = lm
}
}
updateModTime := func() {
if !serverModTime.IsZero() {
_ = os.Chtimes(safeDestPath, serverModTime, serverModTime)
}
}
if resp.StatusCode == http.StatusNotModified {
if forceFull {
return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
}
updateModTime()
return true, newLastModified, nil
}
if resp.StatusCode != http.StatusOK {
return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
}
binDir := filepath.Dir(safeDestPath)
if err = os.MkdirAll(binDir, 0o755); err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
out, err := os.Create(safeTmpPath)
if err != nil {
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
n, err := io.Copy(out, resp.Body)
closeErr := out.Close()
if err != nil {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
if closeErr != nil {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
}
if n < minDatBytes {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
}
if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
_ = os.Remove(safeTmpPath)
return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
}
updateModTime()
if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
newLastModified = resp.Header.Get("Last-Modified")
}
return false, newLastModified, nil
}
func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
if r.LocalPath != "" {
return r.LocalPath
}
return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
}
func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
r.LocalPath = p
}
func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
s.syncLocalPath(r)
safePath, err := sanitizeDestPath(r.LocalPath)
if err != nil {
return err
}
r.LocalPath = safePath
return nil
}
func removeSafePathIfExists(path string) error {
safePath, err := sanitizeDestPath(path)
if err != nil {
return err
}
if _, err := os.Stat(safePath); err == nil {
if err := os.Remove(safePath); err != nil {
return err
}
}
return nil
}
func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
if err := s.validateType(r.Type); err != nil {
return err
}
if err := s.validateAlias(r.Alias); err != nil {
return err
}
sanitizedURL, err := s.sanitizeURL(r.Url)
if err != nil {
return err
}
r.Url = sanitizedURL
var existing int64
database.GetDB().Model(&model.CustomGeoResource{}).
Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
if existing > 0 {
return ErrCustomGeoDuplicateAlias
}
if err := s.syncAndSanitizeLocalPath(r); err != nil {
return err
}
skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
if err != nil {
return err
}
now := time.Now().Unix()
r.LastUpdatedAt = now
r.LastModified = lm
if err = database.GetDB().Create(r).Error; err != nil {
_ = removeSafePathIfExists(r.LocalPath)
return err
}
logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
if err = s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo create: restart xray:", err)
}
return nil
}
func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
var cur model.CustomGeoResource
if err := database.GetDB().First(&cur, id).Error; err != nil {
if database.IsNotFound(err) {
return ErrCustomGeoNotFound
}
return err
}
if err := s.validateType(r.Type); err != nil {
return err
}
if err := s.validateAlias(r.Alias); err != nil {
return err
}
sanitizedURL, err := s.sanitizeURL(r.Url)
if err != nil {
return err
}
r.Url = sanitizedURL
if cur.Type != r.Type || cur.Alias != r.Alias {
var cnt int64
database.GetDB().Model(&model.CustomGeoResource{}).
Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
Count(&cnt)
if cnt > 0 {
return ErrCustomGeoDuplicateAlias
}
}
oldPath := s.resolveDestPath(&cur)
r.Id = id
if err := s.syncAndSanitizeLocalPath(r); err != nil {
return err
}
if oldPath != r.LocalPath && oldPath != "" {
if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
}
}
_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
if err != nil {
return err
}
r.LastUpdatedAt = time.Now().Unix()
r.LastModified = lm
err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
"geo_type": r.Type,
"alias": r.Alias,
"url": r.Url,
"local_path": r.LocalPath,
"last_updated_at": r.LastUpdatedAt,
"last_modified": r.LastModified,
}).Error
if err != nil {
return err
}
logger.Infof("custom geo updated id=%d", id)
if err = s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo update: restart xray:", err)
}
return nil
}
func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
var r model.CustomGeoResource
if err := database.GetDB().First(&r, id).Error; err != nil {
if database.IsNotFound(err) {
return "", ErrCustomGeoNotFound
}
return "", err
}
displayName = s.fileNameFor(r.Type, r.Alias)
p := s.resolveDestPath(&r)
if _, err := sanitizeDestPath(p); err != nil {
return displayName, err
}
if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
return displayName, err
}
if p != "" {
if err := removeSafePathIfExists(p); err != nil {
logger.Warningf("custom geo delete file %s: %v", p, err)
}
}
logger.Infof("custom geo deleted id=%d", id)
if err := s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo delete: restart xray:", err)
}
return displayName, nil
}
func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
var list []model.CustomGeoResource
err := database.GetDB().Order("id asc").Find(&list).Error
return list, err
}
func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
var r model.CustomGeoResource
if err := database.GetDB().First(&r, id).Error; err != nil {
if database.IsNotFound(err) {
return "", ErrCustomGeoNotFound
}
return "", err
}
displayName = s.fileNameFor(r.Type, r.Alias)
if err := s.syncAndSanitizeLocalPath(&r); err != nil {
return displayName, err
}
sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
if sanitizeErr != nil {
return displayName, sanitizeErr
}
skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified)
if err != nil {
if onStartup {
logger.Warningf("custom geo startup download id=%d: %v", id, err)
} else {
logger.Warningf("custom geo manual update id=%d: %v", id, err)
}
return displayName, err
}
now := time.Now().Unix()
updates := map[string]any{
"last_modified": lm,
"local_path": r.LocalPath,
"last_updated_at": now,
}
if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
if onStartup {
logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
} else {
logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
}
return displayName, err
}
if skipped {
if onStartup {
logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
} else {
logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
}
} else {
if onStartup {
logger.Infof("custom geo startup download ok id=%d", id)
} else {
logger.Infof("custom geo manual update ok id=%d", id)
}
}
return displayName, nil
}
func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
displayName, err := s.applyDownloadAndPersist(id, false)
if err != nil {
return displayName, err
}
if err = s.serverService.RestartXrayService(); err != nil {
logger.Warning("custom geo manual update: restart xray:", err)
}
return displayName, nil
}
func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
var list []model.CustomGeoResource
var err error
if s.updateAllGetAll != nil {
list, err = s.updateAllGetAll()
} else {
list, err = s.GetAll()
}
if err != nil {
return nil, err
}
res := &CustomGeoUpdateAllResult{}
if len(list) == 0 {
return res, nil
}
for _, r := range list {
var name string
var applyErr error
if s.updateAllApply != nil {
name, applyErr = s.updateAllApply(r.Id, false)
} else {
name, applyErr = s.applyDownloadAndPersist(r.Id, false)
}
if applyErr != nil {
res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
})
continue
}
res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
Id: r.Id, Alias: r.Alias, FileName: name,
})
}
if len(res.Succeeded) > 0 {
var restartErr error
if s.updateAllRestart != nil {
restartErr = s.updateAllRestart()
} else {
restartErr = s.serverService.RestartXrayService()
}
if restartErr != nil {
logger.Warning("custom geo update all: restart xray:", restartErr)
}
}
return res, nil
}
type CustomGeoAliasItem struct {
Alias string `json:"alias"`
Type string `json:"type"`
FileName string `json:"fileName"`
ExtExample string `json:"extExample"`
}
type CustomGeoAliasesResponse struct {
Geosite []CustomGeoAliasItem `json:"geosite"`
Geoip []CustomGeoAliasItem `json:"geoip"`
}
func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
list, err := s.GetAll()
if err != nil {
logger.Warning("custom geo GetAliasesForUI:", err)
return CustomGeoAliasesResponse{}, err
}
var out CustomGeoAliasesResponse
for _, r := range list {
fn := s.fileNameFor(r.Type, r.Alias)
ex := fmt.Sprintf("ext:%s:tag", fn)
item := CustomGeoAliasItem{
Alias: r.Alias,
Type: r.Type,
FileName: fn,
ExtExample: ex,
}
if r.Type == customGeoTypeGeoip {
out.Geoip = append(out.Geoip, item)
} else {
out.Geosite = append(out.Geosite, item)
}
}
return out, nil
}
@@ -0,0 +1,348 @@
package integration
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// disableSSRFCheck disables the SSRF guard for the duration of a test,
// allowing httptest servers on localhost. It restores the original on cleanup.
func disableSSRFCheck(t *testing.T) {
t.Helper()
orig := checkSSRF
checkSSRF = func(_ context.Context, _ string) error { return nil }
t.Cleanup(func() { checkSSRF = orig })
}
func TestNormalizeAliasKey(t *testing.T) {
if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
t.Fatalf("got %q", got)
}
if got := NormalizeAliasKey("a-b_c"); got != "a_b_c" {
t.Fatalf("got %q", got)
}
}
func TestNewCustomGeoService(t *testing.T) {
s := NewCustomGeoService()
if err := s.validateAlias("ok_alias-1"); err != nil {
t.Fatal(err)
}
}
func TestTriggerUpdateAllAllSuccess(t *testing.T) {
s := CustomGeoService{}
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
return []model.CustomGeoResource{
{Id: 1, Alias: "a"},
{Id: 2, Alias: "b"},
}, nil
}
s.updateAllApply = func(id int, onStartup bool) (string, error) {
return fmt.Sprintf("geo_%d.dat", id), nil
}
restartCalls := 0
s.updateAllRestart = func() error {
restartCalls++
return nil
}
res, err := s.TriggerUpdateAll()
if err != nil {
t.Fatal(err)
}
if len(res.Succeeded) != 2 || len(res.Failed) != 0 {
t.Fatalf("unexpected result: %+v", res)
}
if restartCalls != 1 {
t.Fatalf("expected 1 restart, got %d", restartCalls)
}
}
func TestTriggerUpdateAllPartialSuccess(t *testing.T) {
s := CustomGeoService{}
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
return []model.CustomGeoResource{
{Id: 1, Alias: "ok"},
{Id: 2, Alias: "bad"},
}, nil
}
s.updateAllApply = func(id int, onStartup bool) (string, error) {
if id == 2 {
return "geo_2.dat", ErrCustomGeoDownload
}
return "geo_1.dat", nil
}
restartCalls := 0
s.updateAllRestart = func() error {
restartCalls++
return nil
}
res, err := s.TriggerUpdateAll()
if err != nil {
t.Fatal(err)
}
if len(res.Succeeded) != 1 || len(res.Failed) != 1 {
t.Fatalf("unexpected result: %+v", res)
}
if restartCalls != 1 {
t.Fatalf("expected 1 restart, got %d", restartCalls)
}
}
func TestTriggerUpdateAllAllFailure(t *testing.T) {
s := CustomGeoService{}
s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
return []model.CustomGeoResource{
{Id: 1, Alias: "a"},
{Id: 2, Alias: "b"},
}, nil
}
s.updateAllApply = func(id int, onStartup bool) (string, error) {
return fmt.Sprintf("geo_%d.dat", id), ErrCustomGeoDownload
}
restartCalls := 0
s.updateAllRestart = func() error {
restartCalls++
return nil
}
res, err := s.TriggerUpdateAll()
if err != nil {
t.Fatal(err)
}
if len(res.Succeeded) != 0 || len(res.Failed) != 2 {
t.Fatalf("unexpected result: %+v", res)
}
if restartCalls != 0 {
t.Fatalf("expected 0 restart, got %d", restartCalls)
}
}
func TestCustomGeoValidateAlias(t *testing.T) {
s := CustomGeoService{}
if err := s.validateAlias(""); !errors.Is(err, ErrCustomGeoAliasRequired) {
t.Fatal("empty alias")
}
if err := s.validateAlias("Bad"); !errors.Is(err, ErrCustomGeoAliasPattern) {
t.Fatal("uppercase")
}
if err := s.validateAlias("a b"); !errors.Is(err, ErrCustomGeoAliasPattern) {
t.Fatal("space")
}
if err := s.validateAlias("ok_alias-1"); err != nil {
t.Fatal(err)
}
if err := s.validateAlias("geoip"); !errors.Is(err, ErrCustomGeoAliasReserved) {
t.Fatal("reserved")
}
}
func TestCustomGeoValidateURL(t *testing.T) {
s := CustomGeoService{}
if _, err := s.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
t.Fatal("empty")
}
if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
t.Fatal("ftp")
}
if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil {
t.Fatal(err)
} else if sanitized != "https://example.com/a.dat" {
t.Fatalf("unexpected sanitized URL: %s", sanitized)
}
}
func TestCustomGeoValidateType(t *testing.T) {
s := CustomGeoService{}
if err := s.validateType("geosite"); err != nil {
t.Fatal(err)
}
if err := s.validateType("x"); !errors.Is(err, ErrCustomGeoInvalidType) {
t.Fatal("bad type")
}
}
func TestCustomGeoDownloadToPath(t *testing.T) {
disableSSRFCheck(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test", "1")
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
defer ts.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geoip_t.dat")
s := CustomGeoService{}
skipped, _, err := s.downloadToPath(ts.URL, dest, "")
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("expected download")
}
st, err := os.Stat(dest)
if err != nil || st.Size() < minDatBytes {
t.Fatalf("file %v", err)
}
skipped2, _, err2 := s.downloadToPath(ts.URL, dest, "")
if err2 != nil || !skipped2 {
t.Fatalf("304 expected skipped=%v err=%v", skipped2, err2)
}
}
func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
disableSSRFCheck(t)
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Last-Modified", lm)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
defer ts.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geoip_rebuild.dat")
s := CustomGeoService{}
skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("must not treat as not-modified when local file is missing")
}
if _, err := os.Stat(dest); err != nil {
t.Fatal("file should exist after container-style rebuild")
}
}
func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
disableSSRFCheck(t)
lm := "Wed, 21 Oct 2015 07:28:00 GMT"
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Last-Modified", lm)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
defer ts.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geoip_bad.dat")
if err := os.WriteFile(dest, make([]byte, minDatBytes-1), 0o644); err != nil {
t.Fatal(err)
}
s := CustomGeoService{}
skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
if err != nil {
t.Fatal(err)
}
if skipped {
t.Fatal("corrupt local file must be re-downloaded, not 304")
}
st, err := os.Stat(dest)
if err != nil || st.Size() < minDatBytes {
t.Fatalf("file repaired: %v", err)
}
}
func TestCustomGeoFileNameFor(t *testing.T) {
s := CustomGeoService{}
if s.fileNameFor("geoip", "a") != "geoip_a.dat" {
t.Fatal("geoip name")
}
if s.fileNameFor("geosite", "b") != "geosite_b.dat" {
t.Fatal("geosite name")
}
}
func TestLocalDatFileNeedsRepair(t *testing.T) {
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
t.Fatal("missing")
}
smallPath := filepath.Join(dir, "small.dat")
if err := os.WriteFile(smallPath, make([]byte, minDatBytes-1), 0o644); err != nil {
t.Fatal(err)
}
if !localDatFileNeedsRepair(smallPath) {
t.Fatal("small")
}
okPath := filepath.Join(dir, "ok.dat")
if err := os.WriteFile(okPath, make([]byte, minDatBytes), 0o644); err != nil {
t.Fatal(err)
}
if localDatFileNeedsRepair(okPath) {
t.Fatal("ok size")
}
dirPath := filepath.Join(dir, "isdir.dat")
if err := os.Mkdir(dirPath, 0o755); err != nil {
t.Fatal(err)
}
if !localDatFileNeedsRepair(dirPath) {
t.Fatal("dir should need repair")
}
if !CustomGeoLocalFileNeedsRepair(dirPath) {
t.Fatal("exported wrapper dir")
}
if CustomGeoLocalFileNeedsRepair(okPath) {
t.Fatal("exported wrapper ok file")
}
}
func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
disableSSRFCheck(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
t.Fatal(err)
}
}
func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
disableSSRFCheck(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
if r.Method == http.MethodGet && r.Header.Get("Range") != "" {
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte{0})
return
}
w.WriteHeader(http.StatusBadRequest)
}))
defer ts.Close()
if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
t.Fatal(err)
}
}
+151
View File
@@ -0,0 +1,151 @@
package integration
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
type NordService struct {
service.SettingService
}
var nordHTTPClient = &http.Client{Timeout: 15 * time.Second}
// maxResponseSize limits the maximum size of NordVPN API responses (10 MB).
const maxResponseSize = 10 << 20
func (s *NordService) GetCountries() (string, error) {
resp, err := nordHTTPClient.Get("https://api.nordvpn.com/v1/countries")
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return "", err
}
return string(body), nil
}
func (s *NordService) GetServers(countryId string) (string, error) {
// Validate countryId is numeric to prevent URL injection
for _, c := range countryId {
if c < '0' || c > '9' {
return "", common.NewError("invalid country ID")
}
}
url := fmt.Sprintf("https://api.nordvpn.com/v2/servers?limit=0&filters[servers_technologies][id]=35&filters[country_id]=%s", countryId)
resp, err := nordHTTPClient.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return "", err
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return string(body), nil
}
servers, ok := data["servers"].([]any)
if !ok {
return string(body), nil
}
var filtered []any
for _, s := range servers {
if server, ok := s.(map[string]any); ok {
if load, ok := server["load"].(float64); ok && load > 7 {
filtered = append(filtered, s)
}
}
}
data["servers"] = filtered
result, _ := json.Marshal(data)
return string(result), nil
}
func (s *NordService) SetKey(privateKey string) (string, error) {
if privateKey == "" {
return "", common.NewError("private key cannot be empty")
}
nordData := map[string]string{
"private_key": privateKey,
"token": "",
}
data, _ := json.Marshal(nordData)
err := s.SettingService.SetNord(string(data))
if err != nil {
return "", err
}
return string(data), nil
}
func (s *NordService) GetCredentials(token string) (string, error) {
url := "https://api.nordvpn.com/v1/users/services/credentials"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.SetBasicAuth("token", token)
resp, err := nordHTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", common.NewErrorf("NordVPN API error: %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
if err != nil {
return "", err
}
var creds map[string]any
if err := json.Unmarshal(body, &creds); err != nil {
return "", err
}
privateKey, ok := creds["nordlynx_private_key"].(string)
if !ok || privateKey == "" {
return "", common.NewError("failed to retrieve NordLynx private key")
}
nordData := map[string]string{
"private_key": privateKey,
"token": token,
}
data, _ := json.Marshal(nordData)
err = s.SettingService.SetNord(string(data))
if err != nil {
return "", err
}
return string(data), nil
}
func (s *NordService) GetNordData() (string, error) {
return s.SettingService.GetNord()
}
func (s *NordService) DelNordData() error {
return s.SettingService.SetNord("")
}
@@ -0,0 +1,103 @@
package integration
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
)
func recordingProxy(t *testing.T, hits *int64) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(hits, 1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
}
func originServer(t *testing.T, hits *int64) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(hits, 1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(make([]byte, minDatBytes+1))
}))
}
func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
var proxyHits, originHits int64
proxy := recordingProxy(t, &proxyHits)
defer proxy.Close()
origin := originServer(t, &originHits)
defer origin.Close()
client, err := netproxy.NewHTTPClient(proxy.URL, 5*time.Second)
if err != nil {
t.Fatal(err)
}
resp, err := client.Get(origin.URL)
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if atomic.LoadInt64(&proxyHits) != 1 {
t.Fatalf("expected panel proxy to be hit once, got %d (origin hits=%d)", proxyHits, originHits)
}
}
func TestPanelProxy_CustomGeoDownloadUsesProxy(t *testing.T) {
disableSSRFCheck(t)
var proxyHits, originHits int64
proxy := recordingProxy(t, &proxyHits)
defer proxy.Close()
origin := originServer(t, &originHits)
defer origin.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geosite_repro.dat")
s := CustomGeoService{getPanelProxy: func() (string, error) { return proxy.URL, nil }}
if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
t.Fatalf("download failed: %v", err)
}
if _, err := os.Stat(dest); err != nil {
t.Fatalf("expected file to be written: %v", err)
}
if got := atomic.LoadInt64(&proxyHits); got != 1 {
t.Fatalf("custom geo download did not route through the Panel Network Proxy "+
"(proxy hits=%d, origin hits=%d)", got, atomic.LoadInt64(&originHits))
}
}
func TestPanelProxy_CustomGeoDownloadDirectWhenUnset(t *testing.T) {
disableSSRFCheck(t)
var proxyHits, originHits int64
proxy := recordingProxy(t, &proxyHits)
defer proxy.Close()
origin := originServer(t, &originHits)
defer origin.Close()
dir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", dir)
dest := filepath.Join(dir, "geosite_direct.dat")
s := CustomGeoService{}
if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
t.Fatalf("download failed: %v", err)
}
if atomic.LoadInt64(&proxyHits) != 0 || atomic.LoadInt64(&originHits) != 1 {
t.Fatalf("expected direct connection (proxy=0, origin=1), got proxy=%d origin=%d",
atomic.LoadInt64(&proxyHits), atomic.LoadInt64(&originHits))
}
}
+267
View File
@@ -0,0 +1,267 @@
package integration
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
)
// WarpService provides business logic for Cloudflare WARP integration.
// It manages WARP configuration and connectivity settings.
type WarpService struct {
service.SettingService
}
const (
warpAPIBase = "https://api.cloudflareclient.com/v0a4005"
warpClientVer = "a-6.30-3596"
)
func (s *WarpService) GetWarpData() (string, error) {
return s.SettingService.GetWarp()
}
func (s *WarpService) DelWarpData() error {
return s.SettingService.SetWarp("")
}
func (s *WarpService) GetWarpConfig() (string, error) {
warpData, err := s.loadWarpCreds()
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/reg/%s", warpAPIBase, warpData["device_id"])
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
body, err := s.doWarpRequest(req)
if err != nil {
return "", err
}
return string(body), nil
}
func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error) {
hostName, _ := os.Hostname()
reqBody, err := json.Marshal(map[string]any{
"key": publicKey,
"tos": time.Now().UTC().Format("2006-01-02T15:04:05.000Z"),
"type": "PC",
"model": "x-ui",
"name": hostName,
})
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPost, warpAPIBase+"/reg", bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("CF-Client-Version", warpClientVer)
req.Header.Set("Content-Type", "application/json")
body, err := s.doWarpRequest(req)
if err != nil {
return "", err
}
var rsp map[string]any
if err := json.Unmarshal(body, &rsp); err != nil {
return "", err
}
deviceID, ok := rsp["id"].(string)
if !ok {
return "", common.NewError("warp register: missing 'id' in response")
}
token, ok := rsp["token"].(string)
if !ok {
return "", common.NewError("warp register: missing 'token' in response")
}
account, ok := rsp["account"].(map[string]any)
if !ok {
return "", common.NewError("warp register: missing 'account' in response")
}
license, ok := account["license"].(string)
if !ok {
return "", common.NewError("warp register: missing 'account.license' in response")
}
warpData := map[string]string{
"access_token": token,
"device_id": deviceID,
"license_key": license,
"private_key": secretKey,
}
if config, ok := rsp["config"].(map[string]any); ok {
if clientID, ok := config["client_id"].(string); ok {
warpData["client_id"] = clientID
}
}
warpJSON, err := json.MarshalIndent(warpData, "", " ")
if err != nil {
return "", err
}
if err := s.SettingService.SetWarp(string(warpJSON)); err != nil {
return "", err
}
result, err := json.MarshalIndent(map[string]any{
"data": warpData,
"config": json.RawMessage(body),
}, "", " ")
if err != nil {
return "", err
}
return string(result), nil
}
func (s *WarpService) SetWarpLicense(license string) (string, error) {
warpData, err := s.loadWarpCreds()
if err != nil {
return "", err
}
url := fmt.Sprintf("%s/reg/%s/account", warpAPIBase, warpData["device_id"])
reqBody, err := json.Marshal(map[string]string{"license": license})
if err != nil {
return "", err
}
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
req.Header.Set("Content-Type", "application/json")
body, err := s.doWarpRequest(req)
if err != nil {
return "", err
}
var response map[string]any
if err := json.Unmarshal(body, &response); err != nil {
return "", err
}
if _, ok := response["id"].(string); !ok {
return "", common.NewErrorf("warp set license failed: unexpected response: %s", string(body))
}
warpData["license_key"] = license
newWarpData, err := json.MarshalIndent(warpData, "", " ")
if err != nil {
return "", err
}
if err := s.SettingService.SetWarp(string(newWarpData)); err != nil {
return "", err
}
return string(newWarpData), nil
}
func (s *WarpService) ChangeWarpIP() (string, error) {
warpDataMap, err := s.loadWarpCreds()
if err != nil {
return "", err
}
privKey, pubKey, err := wireguard.GenerateWireguardKeypair()
if err != nil {
return "", err
}
result, err := s.RegWarp(privKey, pubKey)
if err != nil {
return "", err
}
var parsed struct {
Data map[string]string `json:"data"`
Config map[string]interface{} `json:"config"`
}
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
return "", err
}
xraySvc := service.XraySettingService{}
if err := xraySvc.UpdateWarpXraySetting(parsed.Data, parsed.Config); err != nil {
return "", err
}
if license, ok := warpDataMap["license_key"]; ok && len(license) >= 26 {
if _, licErr := s.SetWarpLicense(license); licErr != nil {
logger.Warning("ChangeWarpIP: failed to re-apply WARP license: ", licErr)
}
}
return result, nil
}
// loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
func (s *WarpService) loadWarpCreds() (map[string]string, error) {
warp, err := s.SettingService.GetWarp()
if err != nil {
return nil, err
}
var data map[string]string
if err := json.Unmarshal([]byte(warp), &data); err != nil {
return nil, err
}
if data["access_token"] == "" || data["device_id"] == "" {
return nil, common.NewError("warp not registered: missing access_token or device_id")
}
return data, nil
}
// doWarpRequest sends the request and returns the response body on 2xx.
// Non-2xx responses are returned as errors including the status code and body.
func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
client := s.NewProxiedHTTPClient(15 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if msg := parseWarpError(body); msg != "" {
return nil, common.NewError(msg)
}
return nil, common.NewErrorf("warp api %s %s returned status %d: %s",
req.Method, req.URL.Path, resp.StatusCode, string(body))
}
return body, nil
}
func parseWarpError(body []byte) string {
var env struct {
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
if err := json.Unmarshal(body, &env); err != nil {
return ""
}
if len(env.Errors) == 0 || env.Errors[0].Message == "" {
return ""
}
return env.Errors[0].Message
}
+237
View File
@@ -0,0 +1,237 @@
package service
import (
"encoding/gob"
"os"
"path/filepath"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// MetricSample is one point of any time-series we keep in memory.
// The frontend deserializes both keys, so they must stay short.
type MetricSample struct {
T int64 `json:"t"`
V float64 `json:"v"`
}
// metricCapacityDefault caps each ring buffer at ~5h worth of @2s samples
// or ~25h worth of @10s samples. Plenty for the bucketed aggregation
// view and small enough that the working set per metric stays under
// ~150 KiB.
const metricCapacityDefault = 9000
// metricHistory is a thread-safe, in-memory ring buffer keyed by
// arbitrary strings. Two singletons live below: one for system-wide
// host metrics, one for per-node metrics. Keeping them in this file
// (rather than scattered across services) makes the storage model
// easy to reason about and avoids double-locking.
type metricHistory struct {
mu sync.Mutex
metrics map[string][]MetricSample
}
func newMetricHistory() *metricHistory {
return &metricHistory{metrics: map[string][]MetricSample{}}
}
// append stores a single sample for the given metric, deduping when
// two appends happen within the same wall-clock second (which can
// happen if the cron tick is faster than the metric's natural rate).
func (h *metricHistory) append(metric string, t time.Time, v float64) {
h.mu.Lock()
defer h.mu.Unlock()
buf := h.metrics[metric]
p := MetricSample{T: t.Unix(), V: v}
if n := len(buf); n > 0 && buf[n-1].T == p.T {
buf[n-1] = p
} else {
buf = append(buf, p)
}
if len(buf) > metricCapacityDefault {
buf = buf[len(buf)-metricCapacityDefault:]
}
h.metrics[metric] = buf
}
// drop removes the entire history for one metric. Used when a node is
// deleted so its old samples don't linger forever in the singleton.
func (h *metricHistory) drop(metric string) {
h.mu.Lock()
delete(h.metrics, metric)
h.mu.Unlock()
}
// snapshot returns a deep copy of every series, safe to serialize without
// holding the lock during disk I/O.
func (h *metricHistory) snapshot() map[string][]MetricSample {
h.mu.Lock()
defer h.mu.Unlock()
out := make(map[string][]MetricSample, len(h.metrics))
for k, v := range h.metrics {
cp := make([]MetricSample, len(v))
copy(cp, v)
out[k] = cp
}
return out
}
// restore replaces the in-memory series with a previously persisted set,
// re-applying the per-series capacity cap so a tampered or oversized file
// can't grow the working set unbounded.
func (h *metricHistory) restore(data map[string][]MetricSample) {
h.mu.Lock()
defer h.mu.Unlock()
for k, v := range data {
if len(v) > metricCapacityDefault {
v = v[len(v)-metricCapacityDefault:]
}
h.metrics[k] = v
}
}
// aggregate returns up to maxPoints buckets of size bucketSeconds,
// each bucket carrying the arithmetic mean of the underlying samples.
// Bucket alignment is to absolute Unix-second boundaries so two
// concurrent calls (e.g. two browser tabs) see identical x-axes.
func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints int) []map[string]any {
if bucketSeconds <= 0 || maxPoints <= 0 {
return []map[string]any{}
}
cutoff := time.Now().Add(-time.Duration(bucketSeconds*maxPoints) * time.Second).Unix()
h.mu.Lock()
hist := h.metrics[metric]
startIdx := 0
for i := len(hist) - 1; i >= 0; i-- {
if hist[i].T < cutoff {
startIdx = i + 1
break
}
}
if startIdx >= len(hist) {
h.mu.Unlock()
return []map[string]any{}
}
tmp := make([]MetricSample, len(hist)-startIdx)
copy(tmp, hist[startIdx:])
h.mu.Unlock()
if len(tmp) == 0 {
return []map[string]any{}
}
bSize := int64(bucketSeconds)
curBucket := (tmp[0].T / bSize) * bSize
var out []map[string]any
var acc []float64
flush := func(ts int64) {
if len(acc) == 0 {
return
}
sum := 0.0
for _, v := range acc {
sum += v
}
out = append(out, map[string]any{"t": ts, "v": sum / float64(len(acc))})
acc = acc[:0]
}
for _, p := range tmp {
b := (p.T / bSize) * bSize
if b != curBucket {
flush(curBucket)
curBucket = b
}
acc = append(acc, p.V)
}
flush(curBucket)
if len(out) > maxPoints {
out = out[len(out)-maxPoints:]
}
if out == nil {
return []map[string]any{}
}
return out
}
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.)
// fed by ServerService.RefreshStatus every 2s. nodeMetrics holds
// per-node CPU/Mem fed by NodeHeartbeatJob every 10s. Both are
// process-local — survival across panel restart is not required.
var (
systemMetrics = newMetricHistory()
nodeMetrics = newMetricHistory()
xrayMetrics = newMetricHistory()
)
// SystemMetricKeys lists the metric names ServerService writes on every
// status sample. Exposed for documentation/test purposes; the
// controller validates incoming names against an allow-list.
var SystemMetricKeys = []string{
"cpu", "mem", "swap", "netUp", "netDown", "pktUp", "pktDown", "diskRead", "diskWrite", "diskUsage", "tcpCount", "udpCount", "online", "load1", "load5", "load15",
}
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
var NodeMetricKeys = []string{"cpu", "mem"}
// XrayMetricKeys lists series sourced from xray's /debug/vars expvar
// endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence
// as the system metrics, but only when the xray config has a `metrics`
// block configured.
var XrayMetricKeys = []string{
"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
}
// systemMetricsStorePath is where the host time-series is persisted between
// restarts. It lives next to the database so a single volume mount carries
// both. Only systemMetrics is persisted — node and xray series are cheap to
// rebuild and tied to live connections.
func systemMetricsStorePath() string {
return filepath.Join(config.GetDBFolderPath(), "system_metrics.gob")
}
// PersistSystemMetrics writes the host time-series to disk via a temp file +
// rename so a crash mid-write can't corrupt the previous snapshot. Called on a
// timer and at shutdown.
func PersistSystemMetrics() error {
path := systemMetricsStorePath()
tmp := path + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if err := gob.NewEncoder(f).Encode(systemMetrics.snapshot()); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}
// RestoreSystemMetrics loads a previously persisted host time-series on startup.
// A missing file is not an error (first boot). Aggregation already windows by
// time, so any gap from downtime is handled by the readers.
func RestoreSystemMetrics() {
path := systemMetricsStorePath()
f, err := os.Open(path)
if err != nil {
if !os.IsNotExist(err) {
logger.Warning("restore system metrics failed:", err)
}
return
}
defer f.Close()
var data map[string][]MetricSample
if err := gob.NewDecoder(f).Decode(&data); err != nil {
logger.Warning("decode system metrics failed:", err)
return
}
systemMetrics.restore(data)
}
+711
View File
@@ -0,0 +1,711 @@
package service
import (
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
)
type HeartbeatPatch struct {
Status string
LastHeartbeat int64
LatencyMs int
XrayVersion string
PanelVersion string
Guid string
CpuPct float64
MemPct float64
UptimeSecs uint64
LastError string
// XrayState and XrayError come from the remote /panel/api/server/status when the
// panel API is reachable. They allow distinguishing panel connectivity from
// Xray core health on the node.
XrayState string
XrayError string
}
type NodeService struct{}
var nodeHTTPClient = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
},
}
// nodeHTTPClientFor returns the HTTP client used to reach a node, honoring its
// per-node TLS verification mode. "verify" (or any http node) uses the shared
// client with default certificate validation. "skip" disables validation.
// "pin" disables the default chain check but verifies the leaf certificate's
// SHA-256 against the stored pin, keeping MITM protection for self-signed certs.
func nodeHTTPClientFor(n *model.Node) (*http.Client, error) {
mode := n.TlsVerifyMode
if mode == "" {
mode = "verify"
}
if mode == "verify" || n.Scheme == "http" {
return nodeHTTPClient, nil
}
tlsCfg := &tls.Config{InsecureSkipVerify: true}
if mode == "pin" {
want, err := decodeCertPin(n.PinnedCertSha256)
if err != nil {
return nil, err
}
tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error {
if len(cs.PeerCertificates) == 0 {
return common.NewError("node presented no certificate")
}
sum := sha256.Sum256(cs.PeerCertificates[0].Raw)
if subtle.ConstantTimeCompare(sum[:], want) != 1 {
return common.NewError("node certificate does not match pinned SHA-256")
}
return nil
}
}
return &http.Client{
Transport: &http.Transport{
MaxIdleConns: 64,
MaxIdleConnsPerHost: 4,
IdleConnTimeout: 60 * time.Second,
DialContext: netsafe.SSRFGuardedDialContext,
TLSClientConfig: tlsCfg,
},
}, nil
}
// decodeCertPin accepts a SHA-256 certificate hash as base64 (the format used
// by Xray's pinnedPeerCertSha256) or hex with optional colons (the openssl
// -fingerprint style) and returns the 32 raw bytes.
func decodeCertPin(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, common.NewError("certificate pin is empty")
}
if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size {
return b, nil
}
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} {
if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size {
return b, nil
}
}
return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)")
}
// FetchCertFingerprint connects to the node over HTTPS without verifying the
// certificate and returns the leaf certificate's SHA-256 as base64, so the UI
// can offer a "fetch and pin current certificate" action.
func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) {
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
return "", err
}
scheme := n.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if scheme != "https" {
return "", common.NewError("certificate pinning is only available for https nodes")
}
if n.Port <= 0 || n.Port > 65535 {
return "", common.NewError("node port must be 1-65535")
}
probeURL := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
http.MethodGet, probeURL.String(), nil)
if err != nil {
return "", err
}
client := &http.Client{
Transport: &http.Transport{
DialContext: netsafe.SSRFGuardedDialContext,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check]
},
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
return "", common.NewError("node did not present a TLS certificate")
}
sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw)
return base64.StdEncoding.EncodeToString(sum[:]), nil
}
func (s *NodeService) GetAll() ([]*model.Node, error) {
db := database.GetDB()
var nodes []*model.Node
err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error
if err != nil || len(nodes) == 0 {
return nodes, err
}
type inboundRow struct {
Id int
NodeID int `gorm:"column:node_id"`
}
var inboundRows []inboundRow
if err := db.Table("inbounds").
Select("id, node_id").
Where("node_id IS NOT NULL").
Scan(&inboundRows).Error; err != nil {
return nodes, nil
}
if len(inboundRows) == 0 {
return nodes, nil
}
inboundsByNode := make(map[int][]int, len(nodes))
nodeByInbound := make(map[int]int, len(inboundRows))
for _, row := range inboundRows {
inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
nodeByInbound[row.Id] = row.NodeID
}
type clientCountRow struct {
NodeID int `gorm:"column:node_id"`
Count int `gorm:"column:count"`
}
var clientCounts []clientCountRow
if err := db.Raw(`
SELECT inbounds.node_id AS node_id, COUNT(DISTINCT client_inbounds.client_id) AS count
FROM inbounds
JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id
WHERE inbounds.node_id IS NOT NULL
GROUP BY inbounds.node_id
`).Scan(&clientCounts).Error; err == nil {
for _, row := range clientCounts {
for _, n := range nodes {
if n.Id == row.NodeID {
n.ClientCount = row.Count
break
}
}
}
}
now := time.Now().UnixMilli()
type trafficRow struct {
InboundID int `gorm:"column:inbound_id"`
Email string
Enable bool
Total int64
Up int64
Down int64
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var trafficRows []trafficRow
inboundIDs := make([]int, 0, len(nodeByInbound))
for id := range nodeByInbound {
inboundIDs = append(inboundIDs, id)
}
// Chunk the IN clause to avoid "too many SQL variables" on SQLite
// when there are many node-owned inbounds (common with many nodes).
// sqliteMaxVars is defined in this package (inbound.go).
for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
var page []trafficRow
if err := db.Table("client_traffics").
Select("inbound_id, email, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", batch).
Scan(&page).Error; err == nil {
trafficRows = append(trafficRows, page...)
}
}
depletedByNode := make(map[int]int)
if len(trafficRows) > 0 {
for _, row := range trafficRows {
nodeID, ok := nodeByInbound[row.InboundID]
if !ok {
continue
}
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
if expired || exhausted || !row.Enable {
depletedByNode[nodeID]++
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
for _, n := range nodes {
n.InboundCount = len(inboundsByNode[n.Id])
n.DepletedCount = depletedByNode[n.Id]
// Online is attributed to the node that physically hosts the client
// (by GUID): a client on a sub-node counts under the sub-node, not
// the intermediate node it syncs through (#4983).
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
}
return nodes, nil
}
func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
svc := InboundService{}
byGuid := svc.GetOnlineClientsByGuid()
out := make(map[string]map[string]struct{}, len(byGuid))
for guid, emails := range byGuid {
set := make(map[string]struct{}, len(emails))
for _, email := range emails {
set[email] = struct{}{}
}
out[guid] = set
}
return out
}
// effectiveNodeGuid is a node's stable online-attribution key: its reported
// panelGuid, or a master-local synthetic id when the node is an old build that
// hasn't reported one yet (#4983).
func effectiveNodeGuid(n *model.Node) string {
if n.Guid != "" {
return n.Guid
}
return synthNodeGuid(n.Id)
}
func (s *NodeService) GetById(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil {
return nil, err
}
return n, nil
}
// NodeExists reports whether a node with the given id exists on this panel.
// Used to drop stale, cross-panel node references on inbound import. A Count
// query distinguishes "no such node" (count 0, no error) from a real DB error.
func (s *NodeService) NodeExists(id int) (bool, error) {
if id <= 0 {
return false, nil
}
var count int64
if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func normalizeBasePath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
return "/"
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
if !strings.HasSuffix(p, "/") {
p = p + "/"
}
return p
}
func (s *NodeService) normalize(n *model.Node) error {
n.Name = strings.TrimSpace(n.Name)
n.ApiToken = strings.TrimSpace(n.ApiToken)
if n.Name == "" {
return common.NewError("node name is required")
}
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
return common.NewError(err.Error())
}
n.Address = addr
if n.Port <= 0 || n.Port > 65535 {
return common.NewError("node port must be 1-65535")
}
if n.Scheme != "http" && n.Scheme != "https" {
n.Scheme = "https"
}
if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
n.TlsVerifyMode = "verify"
}
n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
if n.TlsVerifyMode == "pin" {
if _, err := decodeCertPin(n.PinnedCertSha256); err != nil {
return common.NewError(err.Error())
}
}
n.BasePath = normalizeBasePath(n.BasePath)
return nil
}
func (s *NodeService) Create(n *model.Node) error {
if err := s.normalize(n); err != nil {
return err
}
db := database.GetDB()
return db.Create(n).Error
}
func (s *NodeService) Update(id int, in *model.Node) error {
if err := s.normalize(in); err != nil {
return err
}
db := database.GetDB()
existing := &model.Node{}
if err := db.Where("id = ?", id).First(existing).Error; err != nil {
return err
}
updates := map[string]any{
"name": in.Name,
"remark": in.Remark,
"scheme": in.Scheme,
"address": in.Address,
"port": in.Port,
"base_path": in.BasePath,
"api_token": in.ApiToken,
"enable": in.Enable,
"allow_private_address": in.AllowPrivateAddress,
"tls_verify_mode": in.TlsVerifyMode,
"pinned_cert_sha256": in.PinnedCertSha256,
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
if mgr := runtime.GetManager(); mgr != nil {
mgr.InvalidateNode(id)
}
return nil
}
func (s *NodeService) Delete(id int) error {
db := database.GetDB()
if err := db.Where("id = ?", id).Delete(model.Node{}).Error; err != nil {
return err
}
if err := db.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
return err
}
if mgr := runtime.GetManager(); mgr != nil {
mgr.InvalidateNode(id)
}
nodeMetrics.drop(nodeMetricKey(id, "cpu"))
nodeMetrics.drop(nodeMetricKey(id, "mem"))
return nil
}
func (s *NodeService) SetEnable(id int, enable bool) error {
db := database.GetDB()
return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
}
// GetWebCertFiles asks a node for its own web TLS certificate/key file paths,
// used by "Set Cert from Panel" so a node-assigned inbound gets paths that
// exist on the node rather than the central panel. See issue #4854.
func (s *NodeService) GetWebCertFiles(id int) (*runtime.WebCertFiles, error) {
n, err := s.GetById(id)
if err != nil || n == nil {
return nil, fmt.Errorf("node not found")
}
if !n.Enable {
return nil, fmt.Errorf("node is disabled")
}
mgr := runtime.GetManager()
if mgr == nil {
return nil, fmt.Errorf("runtime manager unavailable")
}
remote, err := mgr.RemoteFor(n)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return remote.GetWebCertFiles(ctx)
}
// NodeUpdateResult reports the outcome of triggering a panel self-update on one
// node so the UI can show per-node success/failure for a bulk request.
type NodeUpdateResult struct {
Id int `json:"id"`
Name string `json:"name"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
}
// UpdatePanels triggers the official self-updater on each given node. Only
// enabled, online nodes are eligible — an offline node can't be reached, so it
// is reported as skipped rather than silently dropped.
func (s *NodeService) UpdatePanels(ids []int) ([]NodeUpdateResult, error) {
mgr := runtime.GetManager()
if mgr == nil {
return nil, fmt.Errorf("runtime manager unavailable")
}
results := make([]NodeUpdateResult, 0, len(ids))
for _, id := range ids {
n, err := s.GetById(id)
if err != nil || n == nil {
results = append(results, NodeUpdateResult{Id: id, OK: false, Error: "node not found"})
continue
}
res := NodeUpdateResult{Id: id, Name: n.Name}
switch {
case !n.Enable:
res.Error = "node is disabled"
case n.Status != "online":
res.Error = "node is offline"
default:
remote, remoteErr := mgr.RemoteFor(n)
if remoteErr != nil {
res.Error = remoteErr.Error()
break
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
updErr := remote.UpdatePanel(ctx)
cancel()
if updErr != nil {
res.Error = updErr.Error()
} else {
res.OK = true
}
}
results = append(results, res)
}
return results, nil
}
func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
db := database.GetDB()
updates := map[string]any{
"status": p.Status,
"last_heartbeat": p.LastHeartbeat,
"latency_ms": p.LatencyMs,
"xray_version": p.XrayVersion,
"panel_version": p.PanelVersion,
"cpu_pct": p.CpuPct,
"mem_pct": p.MemPct,
"uptime_secs": p.UptimeSecs,
"last_error": p.LastError,
"xray_state": p.XrayState,
"xray_error": p.XrayError,
}
// Only learn the GUID; never clear a known one if an old-build node (or a
// failed probe) reports none, so the stable identity survives blips.
if p.Guid != "" {
updates["guid"] = p.Guid
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
if p.Status == "online" {
now := time.Unix(p.LastHeartbeat, 0)
nodeMetrics.append(nodeMetricKey(id, "cpu"), now, p.CpuPct)
nodeMetrics.append(nodeMetricKey(id, "mem"), now, p.MemPct)
}
return nil
}
func (s *NodeService) MarkNodeDirty(id int) error {
if id <= 0 {
return nil
}
return database.GetDB().Model(model.Node{}).
Where("id = ?", id).
Updates(map[string]any{
"config_dirty": true,
"config_dirty_at": time.Now().UnixMilli(),
}).Error
}
func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
if id <= 0 {
return nil
}
return database.GetDB().Model(model.Node{}).
Where("id = ? AND config_dirty_at = ?", id, dirtyAt).
Update("config_dirty", false).Error
}
func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) {
if id <= 0 {
return false, "", false, 0, errors.New("invalid node id")
}
var row model.Node
err = database.GetDB().Model(model.Node{}).
Select("enable", "status", "config_dirty", "config_dirty_at").
Where("id = ?", id).
First(&row).Error
if err != nil {
return false, "", false, 0, err
}
return row.Enable, row.Status, row.ConfigDirty, row.ConfigDirtyAt, nil
}
func (s *NodeService) IsNodePending(id int) bool {
enabled, status, dirty, _, err := s.NodeSyncState(id)
if err != nil {
return false
}
return !enabled || status != "online" || dirty
}
func nodeMetricKey(id int, metric string) string {
return "node:" + strconv.Itoa(id) + ":" + metric
}
func (s *NodeService) AggregateNodeMetric(id int, metric string, bucketSeconds int, maxPoints int) []map[string]any {
return nodeMetrics.aggregate(nodeMetricKey(id, metric), bucketSeconds, maxPoints)
}
func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) {
patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()}
addr, err := netsafe.NormalizeHost(n.Address)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
scheme := n.Scheme
if scheme != "http" && scheme != "https" {
scheme = "https"
}
if n.Port <= 0 || n.Port > 65535 {
patch.LastError = "node port must be 1-65535"
return patch, errors.New(patch.LastError)
}
probeURL := &url.URL{
Scheme: scheme,
Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)),
Path: normalizeBasePath(n.BasePath) + "panel/api/server/status",
}
req, err := http.NewRequestWithContext(
netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress),
http.MethodGet, probeURL.String(), nil)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
if n.ApiToken != "" {
req.Header.Set("Authorization", "Bearer "+n.ApiToken)
}
req.Header.Set("Accept", "application/json")
client, err := nodeHTTPClientFor(n)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
patch.LastError = err.Error()
return patch, err
}
defer resp.Body.Close()
patch.LatencyMs = int(time.Since(start) / time.Millisecond)
if resp.StatusCode != http.StatusOK {
patch.LastError = fmt.Sprintf("HTTP %d from remote panel", resp.StatusCode)
return patch, errors.New(patch.LastError)
}
var envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj *struct {
CpuPct float64 `json:"cpu"`
Mem struct {
Current uint64 `json:"current"`
Total uint64 `json:"total"`
} `json:"mem"`
Xray struct {
Version string `json:"version"`
State string `json:"state"`
ErrorMsg string `json:"errorMsg"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
} `json:"obj"`
}
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
patch.LastError = "decode response: " + err.Error()
return patch, err
}
if !envelope.Success || envelope.Obj == nil {
patch.LastError = "remote returned success=false: " + envelope.Msg
return patch, errors.New(patch.LastError)
}
o := envelope.Obj
patch.CpuPct = o.CpuPct
if o.Mem.Total > 0 {
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
}
patch.XrayVersion = o.Xray.Version
patch.XrayState = o.Xray.State
patch.XrayError = o.Xray.ErrorMsg
patch.PanelVersion = o.PanelVersion
patch.Guid = o.PanelGuid
patch.UptimeSecs = o.Uptime
return patch, nil
}
type ProbeResultUI struct {
Status string `json:"status" example:"online"`
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
Error string `json:"error"`
// XrayState/XrayError are populated on successful probes even when the node's
// Xray core is not healthy. The UI uses them for a distinct "panel ok, xray failed" indicator.
XrayState string `json:"xrayState"`
XrayError string `json:"xrayError"`
}
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
r := ProbeResultUI{
LatencyMs: p.LatencyMs,
XrayVersion: p.XrayVersion,
PanelVersion: p.PanelVersion,
CpuPct: p.CpuPct,
MemPct: p.MemPct,
UptimeSecs: p.UptimeSecs,
Error: FriendlyProbeError(p.LastError),
XrayState: p.XrayState,
XrayError: p.XrayError,
}
if ok {
r.Status = "online"
} else {
r.Status = "offline"
}
return r
}
func FriendlyProbeError(msg string) string {
if strings.Contains(msg, "server gave HTTP response to HTTPS client") {
return "the server speaks HTTP, not HTTPS; set the node scheme to http"
}
return msg
}
@@ -0,0 +1,209 @@
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/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func initTrafficTestDB(t *testing.T) *gorm.DB {
t.Helper()
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() })
return database.GetDB()
}
func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) {
t.Helper()
nid := nodeID
ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid}
if err := db.Create(ib).Error; err != nil {
t.Fatalf("create node inbound %q: %v", tag, err)
}
}
func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) {
t.Helper()
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
}
}
func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic {
t.Helper()
var ct xray.ClientTraffic
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil {
t.Fatalf("read client_traffics %q: %v", email, err)
}
return ct
}
func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) {
t.Helper()
if ct.Up != wantUp || ct.Down != wantDown {
t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown)
}
}
func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
createNodeInbound(t, db, 2, "n2-in", 41002)
svc := &InboundService{}
const email = "shared"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 100, 100, "after baselines")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 210, 210, "after both nodes grow")
}
func TestSingleNode_MirrorsCorrectly(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "solo"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 500, 600, "first sync")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 700, 800, "second sync mirrors cumulative")
}
func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "legacy"
var ib model.Inbound
if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
t.Fatalf("load inbound: %v", err)
}
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil {
t.Fatalf("seed pre-existing row: %v", err)
}
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues")
}
func TestNodeCounterReset_Clamped(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
svc := &InboundService{}
const email = "restart"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true})
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 950, 950, "before node reset")
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true})
ct := readTraffic(t, db, email)
if ct.Up < 0 || ct.Down < 0 {
t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down)
}
assertUpDown(t, ct, 1000, 1000, "after node counter reset (clamped)")
}
func TestCentralReset_NoReAdd(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInbound(t, db, 1, "n1-in", 41001)
createNodeInbound(t, db, 2, "n2-in", 41002)
svc := &InboundService{}
const email = "reset"
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true})
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true})
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
t.Fatalf("simulate central reset: %v", err)
}
syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true})
syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true})
assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue")
}
func TestDelClientStat_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
const email = "gone"
if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil {
t.Fatalf("seed client_traffics: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil {
t.Fatalf("seed node baseline 1: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil {
t.Fatalf("seed node baseline 2: %v", err)
}
if err := svc.DelClientStat(db, email); err != nil {
t.Fatalf("DelClientStat: %v", err)
}
var cnt int64
if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil {
t.Fatalf("count baselines: %v", err)
}
if cnt != 0 {
t.Errorf("expected node baselines cleaned, found %d", cnt)
}
}
func TestNodeDelete_CleansNodeBaselines(t *testing.T) {
db := initTrafficTestDB(t)
nodeSvc := NodeService{}
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil {
t.Fatalf("seed node 7 a: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil {
t.Fatalf("seed node 7 b: %v", err)
}
if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil {
t.Fatalf("seed node 8 c: %v", err)
}
if err := nodeSvc.Delete(7); err != nil {
t.Fatalf("NodeService.Delete(7): %v", err)
}
var sevenCnt, eightCnt int64
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt)
db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt)
if sevenCnt != 0 {
t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt)
}
if eightCnt != 1 {
t.Errorf("node 8 baseline should survive, found %d", eightCnt)
}
}
+104
View File
@@ -0,0 +1,104 @@
package service
import (
"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/web/runtime"
)
// While a node is config-dirty (a local edit committed before it could be
// mirrored to the node), the traffic pull must not overwrite the central
// inbound's config columns from the node's stale snapshot — only traffic
// counters may advance. Otherwise a reconnecting node reverts the edit.
func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
id := node.Id
const desiredSettings = `{"clients":[{"email":"a@x"}]}`
central := &model.Inbound{
UserId: 1,
NodeID: &id,
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: desiredSettings,
}
if err := db.Create(central).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[{"email":"b@x"}]}`,
Up: 500,
Down: 700,
}},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(id, snap, true); err != nil {
t.Fatalf("setRemoteTrafficLocked dirty: %v", err)
}
var got model.Inbound
if err := db.First(&got, central.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
if got.Settings != desiredSettings {
t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings)
}
if got.Up != 500 || got.Down != 700 {
t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down)
}
}
// ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
// edit that re-dirties the node during a reconcile is not silently cleared.
func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
node := &model.Node{Name: "n2", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"}
if err := db.Create(node).Error; err != nil {
t.Fatalf("create node: %v", err)
}
nodeSvc := NodeService{}
if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
t.Fatalf("MarkNodeDirty: %v", err)
}
_, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
if err != nil {
t.Fatalf("NodeSyncState: %v", err)
}
if !dirty {
t.Fatal("node should be dirty after MarkNodeDirty")
}
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
t.Fatalf("ClearNodeDirty stale token: %v", err)
}
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
t.Fatal("stale-token clear must not clear the dirty flag")
}
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
t.Fatalf("ClearNodeDirty matching token: %v", err)
}
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
t.Fatal("matching-token clear must clear the dirty flag")
}
}
@@ -0,0 +1,71 @@
package service
import (
"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/web/runtime"
)
// #4983: a synced inbound's OriginNodeGuid must point at the panel that
// physically hosts it. A node's own local inbound (empty origin in its
// snapshot) is attributed to the node's own GUID; an inbound the node forwards
// from its own sub-node (non-empty origin) keeps that deeper GUID across the
// hop — so a chained Node1->Node2->Node3 attributes Node3's inbounds to Node3.
func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 1
if err := db.Create(&model.Node{
Id: nodeID,
Name: "node2",
Address: "10.0.0.2",
Port: 2053,
ApiToken: "t",
Guid: "node2-guid",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{ // node2's own local inbound — reports no origin
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
},
{ // forwarded from node2's sub-node (node3) — carries node3's guid
Tag: "in-8443-tcp",
Enable: true,
Port: 8443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
OriginNodeGuid: "node3-guid",
},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
origin := func(tag string) string {
var ib model.Inbound
if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
t.Fatalf("load inbound %q: %v", tag, err)
}
return ib.OriginNodeGuid
}
if og := origin("in-443-tcp"); og != "node2-guid" {
t.Fatalf("local inbound origin = %q, want node2-guid (the node's own GUID)", og)
}
if og := origin("in-8443-tcp"); og != "node3-guid" {
t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og)
}
}
@@ -0,0 +1,66 @@
package service
import (
"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/web/runtime"
)
// A node-backed inbound whose central tag carries the n<id>- prefix must
// survive a snapshot in which the node reports the bare tag (prefix lives on
// the central side only). Before the fix the orphan sweep matched snapTags
// exactly, so it deleted and recreated the inbound on every sync — churning
// its id and dropping traffic for that cycle.
func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 1
id := nodeID
central := &model.Inbound{
UserId: 1,
NodeID: &id,
Tag: "n1-in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
}
if err := db.Create(central).Error; err != nil {
t.Fatalf("create node inbound: %v", err)
}
centralID := central.Id
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
Up: 1000,
Down: 2000,
}},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
var rows []model.Inbound
if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil {
t.Fatalf("list node inbounds: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected exactly 1 node inbound (no churn), got %d", len(rows))
}
if rows[0].Id != centralID {
t.Fatalf("inbound was deleted+recreated: id %d -> %d", centralID, rows[0].Id)
}
if rows[0].Up != 1000 || rows[0].Down != 2000 {
t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down)
}
}

Some files were not shown because too many files have changed in this diff Show More