mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 07:40:59 +00:00
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:
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¶ms); 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 10k–20k+ 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(©Client, 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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
@@ -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(¤tIps).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
|
||||
for i := range currentIps {
|
||||
currentMap[currentIps[i].ClientEmail] = ¤tIps[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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(¢ral).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] = ¢ral[i]
|
||||
if prefix != "" {
|
||||
if stripped, found := strings.CutPrefix(central[i].Tag, prefix); found {
|
||||
tagToCentral[stripped] = ¢ral[i]
|
||||
} else {
|
||||
tagToCentral[prefix+central[i].Tag] = ¢ral[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(¢ralClientStats).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}] = ¢ralClientStats[i]
|
||||
centralCSByEmail[centralClientStats[i].Email] = ¢ralClientStats[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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user