mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-10 23:25:02 +03: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,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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestNormalizeBasePath(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"", "/"},
|
||||
{" ", "/"},
|
||||
{"/", "/"},
|
||||
{"/panel", "/panel/"},
|
||||
{"panel", "/panel/"},
|
||||
{"panel/", "/panel/"},
|
||||
{"/panel/", "/panel/"},
|
||||
{" /panel ", "/panel/"},
|
||||
{"/a/b/c", "/a/b/c/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.in, func(t *testing.T) {
|
||||
got := normalizeBasePath(c.in)
|
||||
if got != c.want {
|
||||
t.Fatalf("normalizeBasePath(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeMetricKey(t *testing.T) {
|
||||
cases := []struct {
|
||||
id int
|
||||
metric string
|
||||
want string
|
||||
}{
|
||||
{1, "cpu", "node:1:cpu"},
|
||||
{42, "mem", "node:42:mem"},
|
||||
{0, "anything", "node:0:anything"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := nodeMetricKey(c.id, c.metric)
|
||||
if got != c.want {
|
||||
t.Fatalf("nodeMetricKey(%d, %q) = %q, want %q", c.id, c.metric, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatPatch_ToUI_OnlineCopiesFields(t *testing.T) {
|
||||
p := HeartbeatPatch{
|
||||
Status: "ignored-source",
|
||||
LatencyMs: 42,
|
||||
XrayVersion: "1.8.4",
|
||||
PanelVersion: "3.0.0",
|
||||
CpuPct: 12.5,
|
||||
MemPct: 33.3,
|
||||
UptimeSecs: 12345,
|
||||
LastError: "",
|
||||
}
|
||||
ui := p.ToUI(true)
|
||||
if ui.Status != "online" {
|
||||
t.Fatalf("Status = %q, want online", ui.Status)
|
||||
}
|
||||
if ui.LatencyMs != 42 || ui.XrayVersion != "1.8.4" || ui.PanelVersion != "3.0.0" {
|
||||
t.Fatalf("scalar copy mismatch: %+v", ui)
|
||||
}
|
||||
if ui.CpuPct != 12.5 || ui.MemPct != 33.3 || ui.UptimeSecs != 12345 {
|
||||
t.Fatalf("metric copy mismatch: %+v", ui)
|
||||
}
|
||||
if ui.Error != "" {
|
||||
t.Fatalf("Error = %q, want empty", ui.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeartbeatPatch_ToUI_OfflinePreservesError(t *testing.T) {
|
||||
p := HeartbeatPatch{LastError: "connection refused"}
|
||||
ui := p.ToUI(false)
|
||||
if ui.Status != "offline" {
|
||||
t.Fatalf("Status = %q, want offline", ui.Status)
|
||||
}
|
||||
if ui.Error != "connection refused" {
|
||||
t.Fatalf("Error = %q, want %q", ui.Error, "connection refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeService_Normalize_Valid(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
n := &model.Node{
|
||||
Name: " primary ",
|
||||
ApiToken: " abc ",
|
||||
Address: "example.com",
|
||||
Port: 8443,
|
||||
Scheme: "",
|
||||
BasePath: "panel",
|
||||
}
|
||||
if err := s.normalize(n); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n.Name != "primary" {
|
||||
t.Fatalf("Name not trimmed: %q", n.Name)
|
||||
}
|
||||
if n.ApiToken != "abc" {
|
||||
t.Fatalf("ApiToken not trimmed: %q", n.ApiToken)
|
||||
}
|
||||
if n.Scheme != "https" {
|
||||
t.Fatalf("empty Scheme should default to https, got %q", n.Scheme)
|
||||
}
|
||||
if n.BasePath != "/panel/" {
|
||||
t.Fatalf("BasePath = %q, want /panel/", n.BasePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeService_Normalize_KeepsValidScheme(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
n := &model.Node{Name: "n", Address: "example.com", Port: 80, Scheme: "http"}
|
||||
if err := s.normalize(n); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n.Scheme != "http" {
|
||||
t.Fatalf("Scheme = %q, want http", n.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeService_Normalize_RejectsEmptyName(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
n := &model.Node{Name: " ", Address: "example.com", Port: 443}
|
||||
if err := s.normalize(n); err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeService_Normalize_RejectsBadHost(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
n := &model.Node{Name: "n", Address: "bad host name with spaces", Port: 443}
|
||||
if err := s.normalize(n); err == nil {
|
||||
t.Fatal("expected error for invalid host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeService_Normalize_RejectsOutOfRangePort(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
for _, port := range []int{0, -1, 65536, 100000} {
|
||||
n := &model.Node{Name: "n", Address: "example.com", Port: port}
|
||||
if err := s.normalize(n); err == nil {
|
||||
t.Fatalf("expected error for port %d", port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) {
|
||||
s := &NodeService{}
|
||||
n := &model.Node{Name: "n", Address: "example.com", Port: 443, Scheme: "ftp"}
|
||||
if err := s.normalize(n); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if n.Scheme != "https" {
|
||||
t.Fatalf("Scheme = %q, want https", n.Scheme)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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/web/runtime"
|
||||
)
|
||||
|
||||
// LocalDescendants returns this panel's read-only summaries of the nodes it
|
||||
// directly manages, so a parent panel can surface them as transitive sub-nodes
|
||||
// (#4983). Only nodes with a known GUID are included — a stable identity is
|
||||
// required to attribute them one hop up. Not recursive: each panel reports its
|
||||
// own direct nodes, and a master walks one level via each direct node's
|
||||
// endpoint, which covers the Node1 -> Node2 -> Node3 case.
|
||||
func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
|
||||
selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
||||
db := database.GetDB()
|
||||
var nodes []*model.Node
|
||||
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]model.NodeSummary, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
if n.Guid == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, model.NodeSummary{
|
||||
Guid: n.Guid,
|
||||
ParentGuid: selfGuid,
|
||||
Name: n.Name,
|
||||
Address: n.Address,
|
||||
Scheme: n.Scheme,
|
||||
Port: n.Port,
|
||||
Status: n.Status,
|
||||
LastHeartbeat: n.LastHeartbeat,
|
||||
LatencyMs: n.LatencyMs,
|
||||
PanelVersion: n.PanelVersion,
|
||||
XrayVersion: n.XrayVersion,
|
||||
XrayState: n.XrayState,
|
||||
XrayError: n.XrayError,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
nodeDescendantsMu sync.RWMutex
|
||||
nodeDescendantsCache = map[int][]model.NodeSummary{}
|
||||
)
|
||||
|
||||
// RefreshDescendants pulls a direct node's published sub-node summaries and
|
||||
// caches them keyed by node id. Best-effort: a fetch error keeps the last good
|
||||
// set (the node may be briefly unreachable). Called from the heartbeat job.
|
||||
func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) {
|
||||
if n == nil {
|
||||
return
|
||||
}
|
||||
mgr := runtime.GetManager()
|
||||
if mgr == nil {
|
||||
return
|
||||
}
|
||||
rt, err := mgr.RemoteFor(n)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
summaries, err := rt.GetDescendants(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
nodeDescendantsMu.Lock()
|
||||
if len(summaries) == 0 {
|
||||
delete(nodeDescendantsCache, n.Id)
|
||||
} else {
|
||||
nodeDescendantsCache[n.Id] = summaries
|
||||
}
|
||||
nodeDescendantsMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearDescendants drops a node's cached sub-node summaries (its probe failed).
|
||||
func (s *NodeService) ClearDescendants(nodeID int) {
|
||||
nodeDescendantsMu.Lock()
|
||||
delete(nodeDescendantsCache, nodeID)
|
||||
nodeDescendantsMu.Unlock()
|
||||
}
|
||||
|
||||
func cachedDescendants() []model.NodeSummary {
|
||||
nodeDescendantsMu.RLock()
|
||||
defer nodeDescendantsMu.RUnlock()
|
||||
out := make([]model.NodeSummary, 0)
|
||||
for _, list := range nodeDescendantsCache {
|
||||
out = append(out, list...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned
|
||||
// from them, with per-GUID counts so each node shows only the inbounds/online
|
||||
// it physically hosts (#4983). Direct nodes carry the master's own GUID as
|
||||
// ParentGuid; a transitive node carries its parent node's GUID. Transitive
|
||||
// nodes are read-only projections (Id == 0). Used by the Nodes page and the
|
||||
// heartbeat broadcast — never for probing/syncing, which stay on GetAll.
|
||||
func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
|
||||
nodes, err := s.GetAll()
|
||||
if err != nil {
|
||||
return nodes, err
|
||||
}
|
||||
selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
||||
directGuids := make(map[string]struct{}, len(nodes))
|
||||
for _, n := range nodes {
|
||||
n.ParentGuid = selfGuid
|
||||
if n.Guid != "" {
|
||||
directGuids[n.Guid] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var transitive []*model.Node
|
||||
for _, sum := range cachedDescendants() {
|
||||
if sum.Guid == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := directGuids[sum.Guid]; ok {
|
||||
continue // already shown as a direct node
|
||||
}
|
||||
if _, ok := seen[sum.Guid]; ok {
|
||||
continue
|
||||
}
|
||||
seen[sum.Guid] = struct{}{}
|
||||
transitive = append(transitive, &model.Node{
|
||||
Guid: sum.Guid,
|
||||
ParentGuid: sum.ParentGuid,
|
||||
Name: sum.Name,
|
||||
Address: sum.Address,
|
||||
Scheme: sum.Scheme,
|
||||
Port: sum.Port,
|
||||
Status: sum.Status,
|
||||
LastHeartbeat: sum.LastHeartbeat,
|
||||
LatencyMs: sum.LatencyMs,
|
||||
PanelVersion: sum.PanelVersion,
|
||||
XrayVersion: sum.XrayVersion,
|
||||
XrayState: sum.XrayState,
|
||||
XrayError: sum.XrayError,
|
||||
Transitive: true,
|
||||
})
|
||||
}
|
||||
if len(transitive) == 0 {
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
all := make([]*model.Node, 0, len(nodes)+len(transitive))
|
||||
all = append(all, nodes...)
|
||||
all = append(all, transitive...)
|
||||
s.recountByGuid(all, selfGuid)
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
|
||||
// in the tree, keyed by the GUID that physically hosts each inbound, so a direct
|
||||
// node shows only its own inbounds and each transitive node shows its own
|
||||
// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so
|
||||
// this only changes behaviour once a transitive node exists.
|
||||
func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
|
||||
db := database.GetDB()
|
||||
type ibRow struct {
|
||||
Id int
|
||||
NodeID *int `gorm:"column:node_id"`
|
||||
OriginNodeGuid string `gorm:"column:origin_node_guid"`
|
||||
}
|
||||
var ibRows []ibRow
|
||||
if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
|
||||
return
|
||||
}
|
||||
effByInbound := make(map[int]string, len(ibRows))
|
||||
inboundCountByGuid := make(map[string]int)
|
||||
ids := make([]int, 0, len(ibRows))
|
||||
for _, r := range ibRows {
|
||||
guid := r.OriginNodeGuid
|
||||
if guid == "" {
|
||||
if r.NodeID != nil {
|
||||
guid = synthNodeGuid(*r.NodeID)
|
||||
} else {
|
||||
guid = selfGuid
|
||||
}
|
||||
}
|
||||
effByInbound[r.Id] = guid
|
||||
inboundCountByGuid[guid]++
|
||||
ids = append(ids, r.Id)
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
depletedByGuid := make(map[string]int)
|
||||
if len(ids) > 0 {
|
||||
type tRow struct {
|
||||
InboundID int `gorm:"column:inbound_id"`
|
||||
Enable bool
|
||||
Total int64
|
||||
Up int64
|
||||
Down int64
|
||||
ExpiryTime int64 `gorm:"column:expiry_time"`
|
||||
}
|
||||
var tRows []tRow
|
||||
if err := db.Table("client_traffics").
|
||||
Select("inbound_id, enable, total, up, down, expiry_time").
|
||||
Where("inbound_id IN ?", ids).Scan(&tRows).Error; err == nil {
|
||||
for _, row := range tRows {
|
||||
guid, ok := effByInbound[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 {
|
||||
depletedByGuid[guid]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onlineByGuid := s.onlineEmailsByGuid()
|
||||
for _, n := range nodes {
|
||||
guid := effectiveNodeGuid(n)
|
||||
n.InboundCount = inboundCountByGuid[guid]
|
||||
n.OnlineCount = len(onlineByGuid[guid])
|
||||
n.DepletedCount = depletedByGuid[guid]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
// #4983: a transitive sub-node learned from a direct node must surface as its
|
||||
// own read-only entry nested under its parent, and per-GUID counts must split a
|
||||
// direct node's own inbounds from its sub-nodes'.
|
||||
func TestGetNodeTree_SurfacesTransitiveNodeNestedUnderParent(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
db := database.GetDB()
|
||||
|
||||
svc := NodeService{}
|
||||
selfGuid, _ := (&SettingService{}).GetPanelGuid()
|
||||
|
||||
if err := db.Create(&model.Node{
|
||||
Id: 1, Name: "Node2", Address: "10.0.0.2", Port: 2053,
|
||||
ApiToken: "t", Guid: "node2-guid", Status: "online",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create node: %v", err)
|
||||
}
|
||||
|
||||
// Node2's own inbound and a transitive inbound physically on Node3
|
||||
// (managed through Node2, so node_id = Node2 but origin = Node3).
|
||||
nid := 1
|
||||
if err := db.Create(&model.Inbound{Tag: "n1-own", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node2-guid"}).Error; err != nil {
|
||||
t.Fatalf("create own inbound: %v", err)
|
||||
}
|
||||
if err := db.Create(&model.Inbound{Tag: "n1-via", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node3-guid"}).Error; err != nil {
|
||||
t.Fatalf("create transitive inbound: %v", err)
|
||||
}
|
||||
|
||||
// The heartbeat learned that Node2 manages Node3.
|
||||
nodeDescendantsMu.Lock()
|
||||
nodeDescendantsCache[1] = []model.NodeSummary{{
|
||||
Guid: "node3-guid", ParentGuid: "node2-guid", Name: "Node3", Address: "10.0.0.3", Status: "online",
|
||||
}}
|
||||
nodeDescendantsMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
nodeDescendantsMu.Lock()
|
||||
nodeDescendantsCache = map[int][]model.NodeSummary{}
|
||||
nodeDescendantsMu.Unlock()
|
||||
})
|
||||
|
||||
tree, err := svc.GetNodeTree()
|
||||
if err != nil {
|
||||
t.Fatalf("GetNodeTree: %v", err)
|
||||
}
|
||||
|
||||
var node2, node3 *model.Node
|
||||
for _, n := range tree {
|
||||
switch n.Guid {
|
||||
case "node2-guid":
|
||||
node2 = n
|
||||
case "node3-guid":
|
||||
node3 = n
|
||||
}
|
||||
}
|
||||
if node2 == nil || node3 == nil {
|
||||
t.Fatalf("expected Node2 + transitive Node3, got %d nodes", len(tree))
|
||||
}
|
||||
if node2.ParentGuid != selfGuid {
|
||||
t.Errorf("Node2 parent = %q, want this panel's GUID %q", node2.ParentGuid, selfGuid)
|
||||
}
|
||||
if !node3.Transitive || node3.ParentGuid != "node2-guid" {
|
||||
t.Errorf("Node3 should be transitive under node2-guid, got transitive=%v parent=%q", node3.Transitive, node3.ParentGuid)
|
||||
}
|
||||
if node3.Id != 0 {
|
||||
t.Errorf("transitive node must be a read-only projection (Id 0), got Id=%d", node3.Id)
|
||||
}
|
||||
if node2.InboundCount != 1 {
|
||||
t.Errorf("Node2 should host only its own inbound, got InboundCount=%d", node2.InboundCount)
|
||||
}
|
||||
if node3.InboundCount != 1 {
|
||||
t.Errorf("transitive Node3 should host its 1 inbound, got %d", node3.InboundCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"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/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OutboundService provides business logic for managing Xray outbound configurations.
|
||||
// It handles outbound traffic monitoring and statistics.
|
||||
type OutboundService struct{}
|
||||
|
||||
// httpTestSemaphore serialises HTTP-mode probes (each one spawns a temp xray
|
||||
// instance, which is too expensive to run in parallel). TCP-mode probes are
|
||||
// dial-only and don't need the semaphore.
|
||||
var httpTestSemaphore sync.Mutex
|
||||
|
||||
func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
|
||||
var err error
|
||||
db := database.GetDB()
|
||||
tx := db.Begin()
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
} else {
|
||||
tx.Commit()
|
||||
}
|
||||
}()
|
||||
|
||||
err = s.addOutboundTraffic(tx, traffics)
|
||||
if err != nil {
|
||||
return err, false
|
||||
}
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (s *OutboundService) addOutboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
|
||||
if len(traffics) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
for _, traffic := range traffics {
|
||||
if traffic.IsOutbound {
|
||||
|
||||
var outbound model.OutboundTraffics
|
||||
|
||||
err = tx.Model(&model.OutboundTraffics{}).Where("tag = ?", traffic.Tag).
|
||||
FirstOrCreate(&outbound).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outbound.Tag = traffic.Tag
|
||||
outbound.Up = outbound.Up + traffic.Up
|
||||
outbound.Down = outbound.Down + traffic.Down
|
||||
outbound.Total = outbound.Up + outbound.Down
|
||||
|
||||
err = tx.Save(&outbound).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OutboundService) GetOutboundsTraffic() ([]*model.OutboundTraffics, error) {
|
||||
db := database.GetDB()
|
||||
var traffics []*model.OutboundTraffics
|
||||
|
||||
err := db.Model(model.OutboundTraffics{}).Find(&traffics).Error
|
||||
if err != nil {
|
||||
logger.Warning("Error retrieving OutboundTraffics: ", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return traffics, nil
|
||||
}
|
||||
|
||||
func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
||||
db := database.GetDB()
|
||||
|
||||
whereText := "tag "
|
||||
if tag == "-alltags-" {
|
||||
whereText += " <> ?"
|
||||
} else {
|
||||
whereText += " = ?"
|
||||
}
|
||||
|
||||
result := db.Model(model.OutboundTraffics{}).
|
||||
Where(whereText, tag).
|
||||
Updates(map[string]any{"up": 0, "down": 0, "total": 0})
|
||||
|
||||
err := result.Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOutboundResult represents the result of testing an outbound.
|
||||
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
|
||||
// probes; HTTP mode reports the round-trip delay measured by xray's
|
||||
// burstObservatory probe.
|
||||
type TestOutboundResult struct {
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
|
||||
}
|
||||
|
||||
// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
|
||||
// dial outcome for outbounds that expose multiple servers/peers.
|
||||
type TestEndpointResult struct {
|
||||
Address string `json:"address"`
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TestOutbound dispatches to the chosen probe mode:
|
||||
// - mode="tcp": dial the outbound's host:port directly. No xray spin-up,
|
||||
// parallel-safe, ~100ms per endpoint. Doesn't validate the proxy
|
||||
// protocol — only that the remote is reachable on TCP.
|
||||
// - mode="" or "http": spin a temp xray instance, route a real HTTP
|
||||
// request through it, return delay + a DNS/Connect/TLS/TTFB breakdown.
|
||||
// Authoritative but expensive and serialised by httpTestSemaphore.
|
||||
//
|
||||
// allOutboundsJSON is only consulted in HTTP mode (it backs
|
||||
// sockopt.dialerProxy chains during test).
|
||||
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
|
||||
if mode == "tcp" {
|
||||
// A bare TCP dial only proves reachability for TCP-based proxies.
|
||||
// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
|
||||
// unauthenticated packets, so a raw dial can't tell "reachable" from
|
||||
// "dead" — route them through the authoritative xray handshake probe.
|
||||
var ob map[string]any
|
||||
if json.Unmarshal([]byte(outboundJSON), &ob) == nil && outboundTransportIsUDP(ob) {
|
||||
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
|
||||
}
|
||||
return s.testOutboundTCP(outboundJSON)
|
||||
}
|
||||
return s.testOutboundHTTP(outboundJSON, testURL, allOutboundsJSON)
|
||||
}
|
||||
|
||||
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||
}
|
||||
tag, _ := ob["tag"].(string)
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
|
||||
}
|
||||
|
||||
endpoints := extractOutboundEndpoints(ob)
|
||||
if len(endpoints) == 0 {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
|
||||
}
|
||||
|
||||
results := make([]TestEndpointResult, len(endpoints))
|
||||
var wg sync.WaitGroup
|
||||
for i := range endpoints {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var bestDelay int64 = -1
|
||||
var firstErr string
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
if bestDelay < 0 || r.Delay < bestDelay {
|
||||
bestDelay = r.Delay
|
||||
}
|
||||
} else if firstErr == "" {
|
||||
firstErr = r.Error
|
||||
}
|
||||
}
|
||||
|
||||
out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
|
||||
if bestDelay >= 0 {
|
||||
out.Success = true
|
||||
out.Delay = bestDelay
|
||||
} else {
|
||||
out.Error = firstErr
|
||||
if out.Error == "" {
|
||||
out.Error = "All endpoints unreachable"
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
|
||||
r := TestEndpointResult{Address: endpoint}
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", endpoint, timeout)
|
||||
r.Delay = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r
|
||||
}
|
||||
conn.Close()
|
||||
r.Success = true
|
||||
return r
|
||||
}
|
||||
|
||||
// outboundTransportIsUDP reports whether the outbound's proxy speaks UDP
|
||||
// (wireguard, hysteria, or a kcp/quic/hysteria stream transport). A bare
|
||||
// UDP dial can't probe these — they ignore unauthenticated packets, so a
|
||||
// dial neither proves reachability nor measures latency. Such outbounds
|
||||
// must go through the real xray handshake probe instead.
|
||||
func outboundTransportIsUDP(ob map[string]any) bool {
|
||||
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
|
||||
return true
|
||||
}
|
||||
if stream, ok := ob["streamSettings"].(map[string]any); ok {
|
||||
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractOutboundEndpoints(ob map[string]any) []string {
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
settings, _ := ob["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
addServer := func(addr any, port any) {
|
||||
host, _ := addr.(string)
|
||||
p := numAsInt(port)
|
||||
if host != "" && p > 0 {
|
||||
out = append(out, fmt.Sprintf("%s:%d", host, p))
|
||||
}
|
||||
}
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
if vnext, ok := settings["vnext"].([]any); ok {
|
||||
for _, v := range vnext {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
addServer(vm["address"], vm["port"])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "vless":
|
||||
addServer(settings["address"], settings["port"])
|
||||
case "hysteria":
|
||||
addServer(settings["address"], settings["port"])
|
||||
case "trojan", "shadowsocks", "http", "socks":
|
||||
if servers, ok := settings["servers"].([]any); ok {
|
||||
for _, sv := range servers {
|
||||
if sm, ok := sv.(map[string]any); ok {
|
||||
addServer(sm["address"], sm["port"])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "wireguard":
|
||||
if peers, ok := settings["peers"].([]any); ok {
|
||||
for _, p := range peers {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
if ep, _ := pm["endpoint"].(string); ep != "" {
|
||||
out = append(out, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numAsInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case string:
|
||||
if i, err := strconv.Atoi(n); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// testOutboundHTTP spins up a temporary xray instance whose only job is
|
||||
// to run a burstObservatory probe against the target outbound, then polls
|
||||
// xray's metrics /debug/vars endpoint until that outbound is reported
|
||||
// alive (success) or the deadline expires (failure). The probe lives
|
||||
// inside xray, so the measured delay and any failure reason reflect what
|
||||
// xray itself sees over the real proxy chain — no SOCKS round-trip on
|
||||
// the client side.
|
||||
func (s *OutboundService) testOutboundHTTP(outboundJSON string, testURL string, allOutboundsJSON string) (*TestOutboundResult, error) {
|
||||
if testURL == "" {
|
||||
testURL = "https://www.google.com/generate_204"
|
||||
}
|
||||
|
||||
if !httpTestSemaphore.TryLock() {
|
||||
return &TestOutboundResult{
|
||||
Mode: "http",
|
||||
Success: false,
|
||||
Error: "Another outbound test is already running, please wait",
|
||||
}, nil
|
||||
}
|
||||
defer httpTestSemaphore.Unlock()
|
||||
|
||||
var testOutbound map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &testOutbound); err != nil {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||
}
|
||||
outboundTag, _ := testOutbound["tag"].(string)
|
||||
if outboundTag == "" {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: "Outbound has no tag"}, nil
|
||||
}
|
||||
if protocol, _ := testOutbound["protocol"].(string); protocol == "blackhole" || outboundTag == "blocked" {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: "Blocked/blackhole outbound cannot be tested"}, nil
|
||||
}
|
||||
|
||||
var allOutbounds []any
|
||||
if allOutboundsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Invalid allOutbounds JSON: %v", err)}, nil
|
||||
}
|
||||
}
|
||||
// The outbound under test must be present in the config so burstObservatory
|
||||
// has something with outboundTag to probe. allOutbounds is the template's
|
||||
// outbounds (for dialerProxy chains); subscription outbounds are injected at
|
||||
// runtime and aren't part of it, so without this the probe targets a tag that
|
||||
// doesn't exist in the config and every test times out. Append (don't replace)
|
||||
// so manual outbounds' dialerProxy chains keep resolving.
|
||||
if !outboundsContainTag(allOutbounds, outboundTag) {
|
||||
allOutbounds = append(allOutbounds, testOutbound)
|
||||
}
|
||||
|
||||
metricsPort, err := findAvailablePort()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to find available port: %v", err)}, nil
|
||||
}
|
||||
|
||||
testConfig := s.createTestConfig(outboundTag, allOutbounds, metricsPort, testURL)
|
||||
|
||||
testConfigPath, err := createTestConfigPath()
|
||||
if err != nil {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to create test config path: %v", err)}, nil
|
||||
}
|
||||
defer os.Remove(testConfigPath)
|
||||
|
||||
testProcess := xray.NewTestProcess(testConfig, testConfigPath)
|
||||
defer func() {
|
||||
if testProcess.IsRunning() {
|
||||
testProcess.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
if err := testProcess.Start(); err != nil {
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Failed to start test xray instance: %v", err)}, nil
|
||||
}
|
||||
|
||||
if err := waitForPort(metricsPort, 5*time.Second); err != nil {
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
|
||||
}
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray failed to start metrics listener: %v", err)}, nil
|
||||
}
|
||||
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}, nil
|
||||
}
|
||||
|
||||
return pollObservatoryResult(testProcess, metricsPort, outboundTag, 12*time.Second), nil
|
||||
}
|
||||
|
||||
// outboundsContainTag reports whether any outbound in the slice has the given tag.
|
||||
func outboundsContainTag(outbounds []any, tag string) bool {
|
||||
for _, ob := range outbounds {
|
||||
if m, ok := ob.(map[string]any); ok {
|
||||
if t, _ := m["tag"].(string); t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// createTestConfig builds a probe-only xray config: the original outbounds
|
||||
// are kept as-is so dialerProxy chains still resolve, a burstObservatory
|
||||
// is wired to probe the target tag, and a metrics listener exposes the
|
||||
// observatory snapshot via /debug/vars. No inbound or routing rules are
|
||||
// needed — burstObservatory issues the probe traffic itself.
|
||||
func (s *OutboundService) createTestConfig(outboundTag string, allOutbounds []any, metricsPort int, probeURL string) *xray.Config {
|
||||
processedOutbounds := make([]any, len(allOutbounds))
|
||||
for i, ob := range allOutbounds {
|
||||
outbound, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
processedOutbounds[i] = ob
|
||||
continue
|
||||
}
|
||||
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
|
||||
if settings, ok := outbound["settings"].(map[string]any); ok {
|
||||
settings["noKernelTun"] = true
|
||||
} else {
|
||||
outbound["settings"] = map[string]any{"noKernelTun": true}
|
||||
}
|
||||
}
|
||||
processedOutbounds[i] = outbound
|
||||
}
|
||||
outboundsJSON, _ := json.Marshal(processedOutbounds)
|
||||
|
||||
routingJSON, _ := json.Marshal(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{},
|
||||
})
|
||||
|
||||
burstObservatoryJSON, _ := json.Marshal(map[string]any{
|
||||
"subjectSelector": []string{outboundTag},
|
||||
"pingConfig": map[string]any{
|
||||
"destination": probeURL,
|
||||
"interval": "1s",
|
||||
"connectivity": "",
|
||||
"timeout": "5s",
|
||||
"samplingCount": 1,
|
||||
},
|
||||
})
|
||||
|
||||
metricsJSON, _ := json.Marshal(map[string]any{
|
||||
"tag": "test-metrics",
|
||||
"listen": fmt.Sprintf("127.0.0.1:%d", metricsPort),
|
||||
})
|
||||
|
||||
logConfig := map[string]any{
|
||||
"loglevel": "warning",
|
||||
"access": "none",
|
||||
"error": "none",
|
||||
"dnsLog": false,
|
||||
}
|
||||
logJSON, _ := json.Marshal(logConfig)
|
||||
|
||||
cfg := &xray.Config{
|
||||
LogConfig: json_util.RawMessage(logJSON),
|
||||
InboundConfigs: []xray.InboundConfig{},
|
||||
OutboundConfigs: json_util.RawMessage(string(outboundsJSON)),
|
||||
RouterConfig: json_util.RawMessage(string(routingJSON)),
|
||||
Policy: json_util.RawMessage(`{}`),
|
||||
Stats: json_util.RawMessage(`{}`),
|
||||
BurstObservatory: json_util.RawMessage(string(burstObservatoryJSON)),
|
||||
Metrics: json_util.RawMessage(string(metricsJSON)),
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
// observatoryEntry mirrors the per-outbound shape published by xray's
|
||||
// observatory under /debug/vars.
|
||||
type observatoryEntry struct {
|
||||
Alive bool `json:"alive"`
|
||||
Delay int64 `json:"delay"`
|
||||
LastSeenTime int64 `json:"last_seen_time"`
|
||||
LastTryTime int64 `json:"last_try_time"`
|
||||
OutboundTag string `json:"outbound_tag"`
|
||||
}
|
||||
|
||||
// pollObservatoryResult repeatedly reads /debug/vars and returns as soon
|
||||
// as the target outbound reports alive=true. burstObservatory updates the
|
||||
// snapshot after each ping (interval=1s, timeout=5s), so a healthy
|
||||
// outbound usually surfaces within ~2s and the timeout caps the wait for
|
||||
// truly dead ones.
|
||||
func pollObservatoryResult(testProcess *xray.Process, metricsPort int, tag string, timeout time.Duration) *TestOutboundResult {
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/debug/vars", metricsPort)
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
deadline := time.Now().Add(timeout)
|
||||
var lastEntry observatoryEntry
|
||||
var sawEntry bool
|
||||
for time.Now().Before(deadline) {
|
||||
if !testProcess.IsRunning() {
|
||||
result := testProcess.GetResult()
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: fmt.Sprintf("Xray process exited: %s", result)}
|
||||
}
|
||||
entry, ok := fetchObservatoryEntry(client, url, tag)
|
||||
if ok {
|
||||
if entry.Alive {
|
||||
delay := entry.Delay
|
||||
if delay <= 0 {
|
||||
delay = 1
|
||||
}
|
||||
return &TestOutboundResult{Mode: "http", Success: true, Delay: delay}
|
||||
}
|
||||
lastEntry = entry
|
||||
sawEntry = true
|
||||
}
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
}
|
||||
|
||||
msg := "Probe timed out — outbound did not become reachable"
|
||||
if sawEntry && lastEntry.LastTryTime > 0 {
|
||||
msg = fmt.Sprintf("All probes failed (last attempt %ds ago)", time.Now().Unix()-lastEntry.LastTryTime)
|
||||
}
|
||||
return &TestOutboundResult{Mode: "http", Success: false, Error: msg}
|
||||
}
|
||||
|
||||
func fetchObservatoryEntry(client *http.Client, url, tag string) (observatoryEntry, bool) {
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return observatoryEntry{}, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return observatoryEntry{}, false
|
||||
}
|
||||
var payload struct {
|
||||
Observatory map[string]observatoryEntry `json:"observatory"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return observatoryEntry{}, false
|
||||
}
|
||||
if entry, ok := payload.Observatory[tag]; ok {
|
||||
return entry, true
|
||||
}
|
||||
for _, entry := range payload.Observatory {
|
||||
if entry.OutboundTag == tag {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
return observatoryEntry{}, false
|
||||
}
|
||||
|
||||
// waitForPort polls until the given TCP port is accepting connections or the timeout expires.
|
||||
func waitForPort(port int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("port %d not ready after %v", port, timeout)
|
||||
}
|
||||
|
||||
// findAvailablePort finds an available port for testing
|
||||
func findAvailablePort() (int, error) {
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
return addr.Port, nil
|
||||
}
|
||||
|
||||
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
|
||||
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
|
||||
func createTestConfigPath() (string, error) {
|
||||
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := tmpFile.Name()
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"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/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
)
|
||||
|
||||
// OutboundSubscriptionService manages remote outbound subscriptions.
|
||||
type OutboundSubscriptionService struct {
|
||||
settingService SettingService
|
||||
}
|
||||
|
||||
// NewOutboundSubscriptionService returns a service for managing outbound subscriptions.
|
||||
func NewOutboundSubscriptionService() *OutboundSubscriptionService {
|
||||
return &OutboundSubscriptionService{}
|
||||
}
|
||||
|
||||
// List returns all subscriptions (newest first).
|
||||
func (s *OutboundSubscriptionService) List() ([]*model.OutboundSubscription, error) {
|
||||
db := database.GetDB()
|
||||
var subs []*model.OutboundSubscription
|
||||
if err := db.Model(&model.OutboundSubscription{}).Order("priority asc, id asc").Find(&subs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sub := range subs {
|
||||
sub.OutboundCount = countOutbounds(sub.LastFetchedOutbounds)
|
||||
// Don't ship the heavy raw blobs to the list view.
|
||||
sub.LastFetchedOutbounds = ""
|
||||
sub.LinkIdentities = ""
|
||||
}
|
||||
return subs, nil
|
||||
}
|
||||
|
||||
// countOutbounds returns the number of outbounds in a stored LastFetchedOutbounds
|
||||
// JSON array (0 for empty/invalid).
|
||||
func countOutbounds(raw string) int {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return 0
|
||||
}
|
||||
var arr []any
|
||||
if json.Unmarshal([]byte(raw), &arr) != nil {
|
||||
return 0
|
||||
}
|
||||
return len(arr)
|
||||
}
|
||||
|
||||
// Get returns a single subscription by id.
|
||||
func (s *OutboundSubscriptionService) Get(id int) (*model.OutboundSubscription, error) {
|
||||
db := database.GetDB()
|
||||
var sub model.OutboundSubscription
|
||||
if err := db.First(&sub, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
// Create persists a new subscription. It does not fetch immediately; the caller
|
||||
// can call Refresh on the returned id if desired.
|
||||
var defaultPrefixRe = regexp.MustCompile(`^sub(\d+)-$`)
|
||||
|
||||
// defaultPrefixNumber returns the smallest positive integer N that is not already
|
||||
// in use as a "subN-" tag prefix among the given subscriptions. This is used to
|
||||
// auto-name a subscription's outbounds when the user leaves the prefix blank, so
|
||||
// deleting a subscription frees its number for reuse instead of letting the
|
||||
// number grow forever with the auto-increment DB id. A subscription with a blank
|
||||
// prefix reserves its own id (it falls back to id-based "sub<id>-" tags).
|
||||
func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int {
|
||||
used := map[int]bool{}
|
||||
for _, sub := range subs {
|
||||
if sub.Id == excludeId {
|
||||
continue
|
||||
}
|
||||
if sub.TagPrefix == "" {
|
||||
used[sub.Id] = true
|
||||
continue
|
||||
}
|
||||
if m := defaultPrefixRe.FindStringSubmatch(sub.TagPrefix); m != nil {
|
||||
if n, err := strconv.Atoi(m[1]); err == nil {
|
||||
used[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
n := 1
|
||||
for used[n] {
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
|
||||
// subscription, picking the smallest free N (excludeId skips a subscription's
|
||||
// own current prefix when editing).
|
||||
func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
|
||||
var subs []*model.OutboundSubscription
|
||||
_ = database.GetDB().Find(&subs).Error
|
||||
return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
|
||||
}
|
||||
|
||||
func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
|
||||
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
|
||||
if err != nil {
|
||||
return nil, common.NewError("invalid subscription URL:", err)
|
||||
}
|
||||
if cleanURL == "" {
|
||||
return nil, common.NewError("subscription URL is required")
|
||||
}
|
||||
if updateInterval <= 0 {
|
||||
updateInterval = 600
|
||||
}
|
||||
prefix := strings.TrimSpace(tagPrefix)
|
||||
if prefix == "" {
|
||||
prefix = s.nextDefaultSubPrefix(0)
|
||||
}
|
||||
// New subscriptions go to the end of the priority order.
|
||||
var count int64
|
||||
database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
|
||||
sub := &model.OutboundSubscription{
|
||||
Remark: strings.TrimSpace(remark),
|
||||
Url: cleanURL,
|
||||
Enabled: enabled,
|
||||
AllowPrivate: allowPrivate,
|
||||
Prepend: prepend,
|
||||
Priority: int(count),
|
||||
TagPrefix: prefix,
|
||||
UpdateInterval: updateInterval,
|
||||
}
|
||||
if err := database.GetDB().Create(sub).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
// Update updates editable fields.
|
||||
func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
|
||||
sub, err := s.Get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
|
||||
if err != nil {
|
||||
return common.NewError("invalid subscription URL:", err)
|
||||
}
|
||||
if cleanURL == "" {
|
||||
return common.NewError("subscription URL is required")
|
||||
}
|
||||
if updateInterval <= 0 {
|
||||
updateInterval = 600
|
||||
}
|
||||
prefix := strings.TrimSpace(tagPrefix)
|
||||
if prefix == "" {
|
||||
prefix = s.nextDefaultSubPrefix(sub.Id)
|
||||
}
|
||||
sub.Remark = strings.TrimSpace(remark)
|
||||
sub.Url = cleanURL
|
||||
sub.Enabled = enabled
|
||||
sub.AllowPrivate = allowPrivate
|
||||
sub.Prepend = prepend
|
||||
sub.TagPrefix = prefix
|
||||
sub.UpdateInterval = updateInterval
|
||||
return database.GetDB().Save(sub).Error
|
||||
}
|
||||
|
||||
// Delete removes a subscription.
|
||||
func (s *OutboundSubscriptionService) Delete(id int) error {
|
||||
return database.GetDB().Delete(&model.OutboundSubscription{}, id).Error
|
||||
}
|
||||
|
||||
// GetLastOutbounds returns the last successfully fetched outbounds for a subscription
|
||||
// (as raw interface slice ready for JSON merge). Returns nil slice when none.
|
||||
func (s *OutboundSubscriptionService) GetLastOutbounds(id int) ([]any, error) {
|
||||
sub, err := s.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var arr []any
|
||||
if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
|
||||
// Refresh fetches the subscription URL, parses the links, assigns stable tags,
|
||||
// persists the results, and returns the generated outbounds.
|
||||
func (s *OutboundSubscriptionService) Refresh(id int) ([]any, error) {
|
||||
sub, err := s.Get(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
outbounds, err := s.fetchAndStore(sub)
|
||||
return outbounds, err
|
||||
}
|
||||
|
||||
// RefreshAllEnabled fetches every enabled subscription whose due time has passed
|
||||
// (lastUpdated + updateInterval <= now). It returns the number of subscriptions
|
||||
// that were actually refreshed.
|
||||
func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
|
||||
db := database.GetDB()
|
||||
var subs []*model.OutboundSubscription
|
||||
if err := db.Where("enabled = ?", true).Find(&subs).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
refreshed := 0
|
||||
for _, sub := range subs {
|
||||
due := sub.LastUpdated + int64(sub.UpdateInterval)
|
||||
if sub.LastUpdated == 0 || due <= now {
|
||||
if _, err := s.fetchAndStore(sub); err != nil {
|
||||
logger.Warningf("outbound sub %d (%s) refresh failed: %v", sub.Id, sub.Remark, err)
|
||||
// continue with others
|
||||
} else {
|
||||
refreshed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// fetchAndStore does the actual network + parse + stability + persist work.
|
||||
func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscription) ([]any, error) {
|
||||
// Re-sanitize on every fetch (handles legacy rows + defense in depth against
|
||||
// any direct DB tampering). Private targets are blocked unless this
|
||||
// subscription was explicitly created with AllowPrivate.
|
||||
cleanURL, err := SanitizePublicHTTPURL(sub.Url, sub.AllowPrivate)
|
||||
if err != nil {
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
}
|
||||
if cleanURL == "" {
|
||||
return nil, common.NewError("subscription has no valid URL")
|
||||
}
|
||||
sub.Url = cleanURL // persist the cleaned version
|
||||
|
||||
client := s.settingService.NewProxiedHTTPClient(30 * time.Second)
|
||||
// Re-validate every redirect hop: the initial host is checked above, but a
|
||||
// redirect could still point at a private/internal address (SSRF). Cap the
|
||||
// redirect chain as well.
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
if sub.AllowPrivate {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
return rejectPrivateHost(ctx, req.URL.Hostname())
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", sub.Url, nil)
|
||||
if err != nil {
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "3x-ui-outbound-sub/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
err := fmt.Errorf("http %d", resp.StatusCode)
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parsed, identities, err := link.ParseSubscriptionBody(body)
|
||||
if err != nil {
|
||||
s.recordError(sub, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Load previous identities -> tags for stability
|
||||
prev := map[string]string{}
|
||||
if strings.TrimSpace(sub.LinkIdentities) != "" {
|
||||
_ = json.Unmarshal([]byte(sub.LinkIdentities), &prev)
|
||||
}
|
||||
|
||||
// Also load previous outbounds so we can reuse tags even for identities we
|
||||
// temporarily lost (defensive).
|
||||
prevTagByIndex := map[int]string{}
|
||||
if strings.TrimSpace(sub.LastFetchedOutbounds) != "" {
|
||||
var prevObs []any
|
||||
if json.Unmarshal([]byte(sub.LastFetchedOutbounds), &prevObs) == nil {
|
||||
for i, o := range prevObs {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if tag, _ := m["tag"].(string); tag != "" {
|
||||
prevTagByIndex[i] = tag
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assign tags with stability (identity reuse, positional fallback, then a
|
||||
// fresh allocation), keeping tags unique within this batch. Extracted into a
|
||||
// pure function so it can be unit-tested without network/DB. Tags are written
|
||||
// back into the parsed outbounds in place.
|
||||
assigned := assignStableTags(parsed, identities, prev, prevTagByIndex, sub.Id, sub.TagPrefix)
|
||||
|
||||
// Persist identities for next time
|
||||
newIdent := map[string]string{}
|
||||
for i, id := range identities {
|
||||
newIdent[id] = assigned[i]
|
||||
}
|
||||
identJSON, _ := json.Marshal(newIdent)
|
||||
|
||||
// Persist the outbounds (as compact JSON array)
|
||||
obsJSON, _ := json.Marshal(parsed)
|
||||
|
||||
sub.LastFetchedOutbounds = string(obsJSON)
|
||||
sub.LinkIdentities = string(identJSON)
|
||||
sub.LastUpdated = time.Now().Unix()
|
||||
sub.LastError = ""
|
||||
|
||||
if err := database.GetDB().Save(sub).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Return as []any for the config merger
|
||||
result := make([]any, len(parsed))
|
||||
for i := range parsed {
|
||||
result[i] = parsed[i]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *OutboundSubscriptionService) recordError(sub *model.OutboundSubscription, err error) {
|
||||
sub.LastError = err.Error()
|
||||
_ = database.GetDB().Model(sub).Update("last_error", sub.LastError).Error
|
||||
}
|
||||
|
||||
// assignStableTags assigns a tag to each parsed outbound, preferring stability:
|
||||
// 1. reuse the tag previously mapped to the link's identity (prev),
|
||||
// 2. else reuse the tag at the same position from the last fetch (prevTagByIndex),
|
||||
// 3. else allocate a fresh tag from the prefix + remark (link.SuggestTag).
|
||||
//
|
||||
// Tags are kept unique within the batch by appending "-N" on collision, and are
|
||||
// written back into parsed[i]["tag"]. The returned slice holds the assigned tags
|
||||
// in order. When tagPrefix is empty a "sub<subID>-" prefix is used for fresh tags.
|
||||
func assignStableTags(parsed []link.Outbound, identities []string, prev map[string]string, prevTagByIndex map[int]string, subID int, tagPrefix string) []string {
|
||||
used := map[string]bool{} // uniqueness within this refresh batch
|
||||
assigned := make([]string, len(parsed))
|
||||
for i := range parsed {
|
||||
id := ""
|
||||
if i < len(identities) {
|
||||
id = identities[i]
|
||||
}
|
||||
candidate := ""
|
||||
if old, ok := prev[id]; ok && old != "" {
|
||||
candidate = old
|
||||
}
|
||||
if candidate == "" {
|
||||
// try to reuse by rough positional match from previous fetch (best effort)
|
||||
if old, ok := prevTagByIndex[i]; ok && old != "" {
|
||||
candidate = old
|
||||
}
|
||||
}
|
||||
if candidate == "" {
|
||||
// fresh allocation
|
||||
prefix := tagPrefix
|
||||
if prefix == "" {
|
||||
prefix = fmt.Sprintf("sub%d-", subID)
|
||||
}
|
||||
remark := ""
|
||||
if m, ok := parsed[i]["tag"].(string); ok {
|
||||
remark = m
|
||||
}
|
||||
candidate = link.SuggestTag(prefix, remark, i)
|
||||
}
|
||||
// ensure local uniqueness inside this batch
|
||||
final := candidate
|
||||
for k := 1; used[final]; k++ {
|
||||
final = fmt.Sprintf("%s-%d", candidate, k)
|
||||
}
|
||||
used[final] = true
|
||||
assigned[i] = final
|
||||
|
||||
// write back the tag into the outbound
|
||||
parsed[i]["tag"] = final
|
||||
}
|
||||
return assigned
|
||||
}
|
||||
|
||||
// AllActiveOutbounds returns the concatenation of the last-fetched outbounds
|
||||
// for every enabled subscription. This is the set that should be merged into
|
||||
// the final Xray config. Order: subscription creation order (by id asc) so
|
||||
// that later subscriptions can shadow earlier ones if the admin uses colliding
|
||||
// prefixes (last writer wins inside xray, but we try to keep tags unique).
|
||||
func (s *OutboundSubscriptionService) AllActiveOutbounds() ([]any, error) {
|
||||
prepend, appendList, err := s.activeOutboundsSplit()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(prepend, appendList...), nil
|
||||
}
|
||||
|
||||
// activeOutboundsSplit returns the active subscription outbounds split into those
|
||||
// that should be placed BEFORE the manual template outbounds (Prepend) and those
|
||||
// placed AFTER. Within each group, subscriptions are ordered by Priority (then id)
|
||||
// so the admin can control the merged order.
|
||||
func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, appendList []any, err error) {
|
||||
db := database.GetDB()
|
||||
var subs []*model.OutboundSubscription
|
||||
if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
|
||||
continue
|
||||
}
|
||||
var arr []any
|
||||
if err := json.Unmarshal([]byte(sub.LastFetchedOutbounds), &arr); err != nil {
|
||||
logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
|
||||
continue
|
||||
}
|
||||
if sub.Prepend {
|
||||
prepend = append(prepend, arr...)
|
||||
} else {
|
||||
appendList = append(appendList, arr...)
|
||||
}
|
||||
}
|
||||
return prepend, appendList, nil
|
||||
}
|
||||
|
||||
// Move shifts a subscription one step up or down in the priority order and
|
||||
// re-normalizes all priorities to a 0..n-1 sequence.
|
||||
func (s *OutboundSubscriptionService) Move(id int, up bool) error {
|
||||
db := database.GetDB()
|
||||
var subs []*model.OutboundSubscription
|
||||
if err := db.Order("priority asc, id asc").Find(&subs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
idx := -1
|
||||
for i, sub := range subs {
|
||||
if sub.Id == id {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx == -1 {
|
||||
return common.NewError("subscription not found")
|
||||
}
|
||||
swap := idx + 1
|
||||
if up {
|
||||
swap = idx - 1
|
||||
}
|
||||
if swap < 0 || swap >= len(subs) {
|
||||
return nil // already at the edge
|
||||
}
|
||||
subs[idx], subs[swap] = subs[swap], subs[idx]
|
||||
for i, sub := range subs {
|
||||
if sub.Priority != i {
|
||||
if err := db.Model(sub).Update("priority", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AllActiveOutboundTags returns only the tags of active subscription outbounds.
|
||||
// Useful for populating balancer / routing selectors without shipping full objects.
|
||||
func (s *OutboundSubscriptionService) AllActiveOutboundTags() ([]string, error) {
|
||||
obs, err := s.AllActiveOutbounds()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags := make([]string, 0, len(obs))
|
||||
for _, o := range obs {
|
||||
if m, ok := o.(map[string]any); ok {
|
||||
if t, _ := m["tag"].(string); t != "" {
|
||||
tags = append(tags, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
/*
|
||||
Tag stability strategy (important for balancers and routing rules)
|
||||
|
||||
When a subscription is refreshed we try very hard to keep the *same* tag for the
|
||||
same logical outbound so that existing balancers and routing rules keep working.
|
||||
|
||||
How we do it:
|
||||
- On every successful parse we compute a stable "identity" for each link
|
||||
(the core of the URI with the remark fragment removed, or for vmess the inner
|
||||
JSON without the "ps" field).
|
||||
- We persist a map identity -> tag in the LinkIdentities column.
|
||||
- On the next refresh, if we see the same identity again we reuse the previous tag,
|
||||
even if the remark changed or minor parameters moved.
|
||||
- Only when we have never seen the identity before do we allocate a fresh tag
|
||||
using the user-supplied TagPrefix + slug(remark) (or an index fallback).
|
||||
- Within one refresh we still deduplicate with -N suffixes.
|
||||
|
||||
Consequences for balancers / routing:
|
||||
- If you use an *exact* tag in a balancer selector or a routing rule, that
|
||||
specific server will continue to be used after refreshes (as long as the
|
||||
provider still returns a link that produces the same identity).
|
||||
- If you use a *prefix/wildcard* selector (e.g. "hk-*", "sg-.*"), then any
|
||||
*new* servers that the subscription later returns will automatically be
|
||||
eligible for that balancer on the next Xray reload — this is the recommended
|
||||
way to "subscribe to a pool".
|
||||
- When a server disappears from the subscription, its tag simply stops
|
||||
existing in the final outbounds array. The balancer will have fewer
|
||||
candidates. If you configured a `fallbackTag` on the balancer, Xray will use
|
||||
it. Otherwise connections that would have used the missing member may fail
|
||||
or be routed by the next rule.
|
||||
- If the provider rotates credentials/UUIDs/hosts for a server, the identity
|
||||
changes → we treat it as a brand new outbound and give it a new tag. Any
|
||||
balancer/rule that referenced the *old* tag will no longer see it. This is
|
||||
an inherent limitation of subscription-based outbounds.
|
||||
|
||||
We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
|
||||
outbounds are always injected at runtime in GetXrayConfig.
|
||||
*/
|
||||
@@ -0,0 +1,156 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
||||
)
|
||||
|
||||
func TestDefaultPrefixNumber(t *testing.T) {
|
||||
mk := func(id int, prefix string) *model.OutboundSubscription {
|
||||
return &model.OutboundSubscription{Id: id, TagPrefix: prefix}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
subs []*model.OutboundSubscription
|
||||
excludeId int
|
||||
want int
|
||||
}{
|
||||
{"no subscriptions starts at 1", nil, 0, 1},
|
||||
{"sequential prefixes give the next", []*model.OutboundSubscription{mk(1, "sub1-"), mk(2, "sub2-")}, 0, 3},
|
||||
{"reuses the lowest freed number", []*model.OutboundSubscription{mk(2, "sub2-")}, 0, 1},
|
||||
{"legacy blank prefix reserves its id", []*model.OutboundSubscription{mk(1, ""), mk(5, "sub3-")}, 0, 2},
|
||||
{"custom prefixes are ignored", []*model.OutboundSubscription{mk(1, "hk-"), mk(2, "jp-")}, 0, 1},
|
||||
{"excludes the edited subscription", []*model.OutboundSubscription{mk(5, "sub2-")}, 5, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := defaultPrefixNumber(c.subs, c.excludeId); got != c.want {
|
||||
t.Fatalf("got %d, want %d", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssignStableTags(t *testing.T) {
|
||||
t.Run("reuses the tag mapped to a known identity", func(t *testing.T) {
|
||||
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
|
||||
prev := map[string]string{"id-abc": "sub1-keepme"}
|
||||
got := assignStableTags(parsed, []string{"id-abc"}, prev, nil, 1, "")
|
||||
if got[0] != "sub1-keepme" {
|
||||
t.Fatalf("got %q, want sub1-keepme", got[0])
|
||||
}
|
||||
if parsed[0]["tag"] != "sub1-keepme" {
|
||||
t.Fatalf("tag was not written back into the outbound: %v", parsed[0]["tag"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to the previous tag at the same position", func(t *testing.T) {
|
||||
parsed := []link.Outbound{{"tag": "JP-Tokyo"}}
|
||||
got := assignStableTags(parsed, []string{"id-new"}, map[string]string{}, map[int]string{0: "sub1-oldpos"}, 1, "")
|
||||
if got[0] != "sub1-oldpos" {
|
||||
t.Fatalf("got %q, want sub1-oldpos", got[0])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allocates a fresh tag with the default sub<id>- prefix", func(t *testing.T) {
|
||||
parsed := []link.Outbound{{"tag": "Tokyo"}}
|
||||
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 7, "")
|
||||
want := link.SuggestTag("sub7-", "Tokyo", 0)
|
||||
if got[0] != want {
|
||||
t.Fatalf("got %q, want %q", got[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses a custom prefix for fresh tags", func(t *testing.T) {
|
||||
parsed := []link.Outbound{{"tag": "Tokyo"}}
|
||||
got := assignStableTags(parsed, []string{"id-x"}, nil, nil, 1, "hk-")
|
||||
want := link.SuggestTag("hk-", "Tokyo", 0)
|
||||
if got[0] != want {
|
||||
t.Fatalf("got %q, want %q", got[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("disambiguates colliding tags with a -N suffix", func(t *testing.T) {
|
||||
parsed := []link.Outbound{{"tag": "Same"}, {"tag": "Same"}}
|
||||
got := assignStableTags(parsed, []string{"id1", "id2"}, nil, nil, 1, "p-")
|
||||
base := link.SuggestTag("p-", "Same", 0)
|
||||
if got[0] != base {
|
||||
t.Fatalf("got[0] = %q, want %q", got[0], base)
|
||||
}
|
||||
if got[1] != base+"-1" {
|
||||
t.Fatalf("got[1] = %q, want %q", got[1], base+"-1")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOutboundsContainTag covers the guard that ensures the outbound under test
|
||||
// is present in the HTTP-probe config. Subscription outbounds aren't part of the
|
||||
// template outbounds the frontend sends as allOutbounds, so the probe must append
|
||||
// the tested outbound when its tag is missing (otherwise burstObservatory has
|
||||
// nothing to probe and every subscription test times out).
|
||||
func TestOutboundsContainTag(t *testing.T) {
|
||||
template := []any{
|
||||
map[string]any{"tag": "direct", "protocol": "freedom"},
|
||||
map[string]any{"tag": "blocked", "protocol": "blackhole"},
|
||||
}
|
||||
if !outboundsContainTag(template, "direct") {
|
||||
t.Fatal("expected tag 'direct' to be found")
|
||||
}
|
||||
if outboundsContainTag(template, "sub1-tokyo") {
|
||||
t.Fatal("expected subscription tag to be absent from template outbounds")
|
||||
}
|
||||
if outboundsContainTag(nil, "anything") {
|
||||
t.Fatal("expected empty slice to contain no tags")
|
||||
}
|
||||
// Tolerates non-map / untagged entries without panicking.
|
||||
mixed := []any{"not-a-map", map[string]any{"protocol": "freedom"}}
|
||||
if outboundsContainTag(mixed, "direct") {
|
||||
t.Fatal("expected no match among untagged/non-map entries")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes covers the SSRF guard used
|
||||
// when fetching subscription URLs. All rejected cases use literal IPs or bad
|
||||
// schemes so the test never performs real DNS resolution.
|
||||
func TestSanitizePublicHTTPURLRejectsPrivateAndBadSchemes(t *testing.T) {
|
||||
rejected := []string{
|
||||
"http://127.0.0.1/sub", // loopback
|
||||
"http://10.0.0.1/x", // private
|
||||
"http://192.168.1.1", // private
|
||||
"http://169.254.169.254/latest/meta-data", // link-local (cloud metadata)
|
||||
"http://[::1]:8080/sub", // IPv6 loopback
|
||||
"http://0.0.0.0", // unspecified
|
||||
"ftp://example.com/x", // unsupported scheme
|
||||
"file:///etc/passwd", // unsupported scheme
|
||||
}
|
||||
for _, raw := range rejected {
|
||||
if _, err := SanitizePublicHTTPURL(raw, false); err == nil {
|
||||
t.Errorf("expected %q to be rejected, got nil error", raw)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("allows a public literal IP without DNS", func(t *testing.T) {
|
||||
got, err := SanitizePublicHTTPURL("http://8.8.8.8/sub", false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "http://8.8.8.8/sub" {
|
||||
t.Fatalf("got %q, want http://8.8.8.8/sub", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// outboundsContainTag mirrors the small helper in the outbound subpackage so
|
||||
// these subscription tests can assert on tag presence without importing it.
|
||||
func outboundsContainTag(outbounds []any, tag string) bool {
|
||||
for _, ob := range outbounds {
|
||||
if m, ok := ob.(map[string]any); ok {
|
||||
if t, _ := m["tag"].(string); t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"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"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
)
|
||||
|
||||
type ApiTokenService struct{}
|
||||
|
||||
const apiTokenLength = 48
|
||||
|
||||
type ApiTokenView struct {
|
||||
Id int `json:"id" example:"2"`
|
||||
Name string `json:"name" example:"central-panel-a"`
|
||||
Token string `json:"token,omitempty" example:"new-token-string"`
|
||||
Enabled bool `json:"enabled" example:"true"`
|
||||
CreatedAt int64 `json:"createdAt" example:"1736000000"`
|
||||
}
|
||||
|
||||
// toView builds the metadata view returned by List. It never carries the
|
||||
// token value: only a SHA-256 hash is stored, and the plaintext is shown
|
||||
// exactly once at creation time.
|
||||
func toView(t *model.ApiToken) *ApiTokenView {
|
||||
return &ApiTokenView{
|
||||
Id: t.Id,
|
||||
Name: t.Name,
|
||||
Enabled: t.Enabled,
|
||||
CreatedAt: t.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) List() ([]*ApiTokenView, error) {
|
||||
db := database.GetDB()
|
||||
var rows []*model.ApiToken
|
||||
if err := db.Model(model.ApiToken{}).Order("id asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]*ApiTokenView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toView(r))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, common.NewError("token name is required")
|
||||
}
|
||||
if len(name) > 64 {
|
||||
return nil, common.NewError("token name must be 64 characters or fewer")
|
||||
}
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(model.ApiToken{}).Where("name = ?", name).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewError("a token with that name already exists")
|
||||
}
|
||||
plaintext := random.Seq(apiTokenLength)
|
||||
row := &model.ApiToken{
|
||||
Name: name,
|
||||
Token: crypto.HashTokenSHA256(plaintext),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := db.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
view := toView(row)
|
||||
view.Token = plaintext
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) Delete(id int) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
db := database.GetDB()
|
||||
return db.Where("id = ?", id).Delete(model.ApiToken{}).Error
|
||||
}
|
||||
|
||||
func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
|
||||
if id <= 0 {
|
||||
return common.NewError("invalid token id")
|
||||
}
|
||||
db := database.GetDB()
|
||||
res := db.Model(model.ApiToken{}).Where("id = ?", id).Update("enabled", enabled)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("token not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match returns true when the presented bearer token matches any enabled
|
||||
// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
|
||||
// value is hashed before a constant-time compare per row keeps a remote
|
||||
// attacker from timing the comparison byte-by-byte.
|
||||
func (s *ApiTokenService) Match(presented string) bool {
|
||||
if presented == "" {
|
||||
return false
|
||||
}
|
||||
db := database.GetDB()
|
||||
var rows []*model.ApiToken
|
||||
if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
presentedHash := []byte(crypto.HashTokenSHA256(presented))
|
||||
matched := false
|
||||
for _, r := range rows {
|
||||
if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
return matched
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
)
|
||||
|
||||
// PanelService provides business logic for panel management operations.
|
||||
// It handles panel restart, updates, and system-level panel controls.
|
||||
type PanelService struct{}
|
||||
|
||||
// PanelUpdateInfo contains the current and latest available panel versions.
|
||||
type PanelUpdateInfo struct {
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
UpdateAvailable bool `json:"updateAvailable"`
|
||||
}
|
||||
|
||||
const (
|
||||
panelUpdaterURL = "https://raw.githubusercontent.com/MHSanaei/3x-ui/main/update.sh"
|
||||
maxPanelUpdaterBytes = 2 << 20
|
||||
)
|
||||
|
||||
func (s *PanelService) RestartPanel(delay time.Duration) error {
|
||||
go func() {
|
||||
time.Sleep(delay)
|
||||
if global.TriggerRestart() {
|
||||
return
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
logger.Error("panel restart: no restart hook registered (SIGHUP unsupported on Windows)")
|
||||
return
|
||||
}
|
||||
p, err := os.FindProcess(syscall.Getpid())
|
||||
if err != nil {
|
||||
logger.Error("panel restart: FindProcess failed:", err)
|
||||
return
|
||||
}
|
||||
if err := p.Signal(syscall.SIGHUP); err != nil {
|
||||
logger.Error("failed to send SIGHUP signal:", err)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpdateInfo checks GitHub for the latest 3x-ui release.
|
||||
func (s *PanelService) GetUpdateInfo() (*PanelUpdateInfo, error) {
|
||||
latest, err := fetchLatestPanelVersion()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
current := config.GetVersion()
|
||||
return &PanelUpdateInfo{
|
||||
CurrentVersion: current,
|
||||
LatestVersion: latest,
|
||||
UpdateAvailable: isNewerVersion(latest, current),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartUpdate starts the official updater outside of the current web request.
|
||||
func (s *PanelService) StartUpdate() error {
|
||||
if runtime.GOOS != "linux" {
|
||||
return fmt.Errorf("panel web update is supported only on Linux installations")
|
||||
}
|
||||
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
return fmt.Errorf("bash is required to run the panel updater: %w", err)
|
||||
}
|
||||
|
||||
scriptPath, err := downloadPanelUpdater()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mainFolder, serviceFolder := resolveUpdateFolders()
|
||||
updateScript := fmt.Sprintf("set -e; trap 'rm -f %s' EXIT; %s %s", shellQuote(scriptPath), shellQuote(bash), shellQuote(scriptPath))
|
||||
|
||||
if systemdRun, err := exec.LookPath("systemd-run"); err == nil {
|
||||
unitName := fmt.Sprintf("x-ui-web-update-%d", time.Now().Unix())
|
||||
cmd := exec.Command(systemdRun,
|
||||
"--unit", unitName,
|
||||
"--setenv", "XUI_MAIN_FOLDER="+mainFolder,
|
||||
"--setenv", "XUI_SERVICE="+serviceFolder,
|
||||
bash, "-lc", updateScript,
|
||||
)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
output := strings.TrimSpace(string(out))
|
||||
if !strings.Contains(output, "System has not been booted with systemd") &&
|
||||
!strings.Contains(output, "Failed to connect to bus") {
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("failed to start panel update job: %w: %s", err, output)
|
||||
}
|
||||
logger.Warning("systemd-run is unavailable, falling back to detached update process:", output)
|
||||
} else {
|
||||
logger.Infof("started panel update job via systemd-run unit %s", unitName)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(bash, "-lc", updateScript)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"XUI_MAIN_FOLDER="+mainFolder,
|
||||
"XUI_SERVICE="+serviceFolder,
|
||||
)
|
||||
setDetachedProcess(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = os.Remove(scriptPath)
|
||||
return fmt.Errorf("failed to start panel update job: %w", err)
|
||||
}
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
logger.Warning("failed to release panel update process:", err)
|
||||
}
|
||||
logger.Infof("started panel update job with pid %d", cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadPanelUpdater() (string, error) {
|
||||
client := (&service.SettingService{}).NewProxiedHTTPClient(15 * time.Second)
|
||||
resp, err := client.Get(panelUpdaterURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download panel updater: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("download panel updater: unexpected HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
file, err := os.CreateTemp("", "3x-ui-update-*.sh")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := file.Name()
|
||||
ok := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !ok {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
|
||||
n, err := io.Copy(file, io.LimitReader(resp.Body, maxPanelUpdaterBytes+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("write panel updater: %w", err)
|
||||
}
|
||||
if n > maxPanelUpdaterBytes {
|
||||
return "", fmt.Errorf("panel updater exceeds %d bytes", maxPanelUpdaterBytes)
|
||||
}
|
||||
if err := file.Chmod(0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ok = true
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func fetchLatestPanelVersion() (string, error) {
|
||||
client := (&service.SettingService{}).NewProxiedHTTPClient(10 * time.Second)
|
||||
resp, err := client.Get("https://api.github.com/repos/MHSanaei/3x-ui/releases/latest")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
var release service.Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if release.TagName == "" {
|
||||
return "", fmt.Errorf("latest panel release tag is empty")
|
||||
}
|
||||
return release.TagName, nil
|
||||
}
|
||||
|
||||
func resolveUpdateFolders() (string, string) {
|
||||
mainFolder := os.Getenv("XUI_MAIN_FOLDER")
|
||||
if mainFolder == "" {
|
||||
if exePath, err := os.Executable(); err == nil {
|
||||
mainFolder = filepath.Dir(exePath)
|
||||
}
|
||||
}
|
||||
if mainFolder == "" {
|
||||
mainFolder = "/usr/local/x-ui"
|
||||
}
|
||||
|
||||
serviceFolder := os.Getenv("XUI_SERVICE")
|
||||
if serviceFolder == "" {
|
||||
serviceFolder = "/etc/systemd/system"
|
||||
}
|
||||
return mainFolder, serviceFolder
|
||||
}
|
||||
|
||||
func isNewerVersion(latest string, current string) bool {
|
||||
cmp, ok := compareVersionStrings(latest, current)
|
||||
if !ok {
|
||||
return normalizeVersionTag(latest) != normalizeVersionTag(current)
|
||||
}
|
||||
return cmp > 0
|
||||
}
|
||||
|
||||
func compareVersionStrings(a string, b string) (int, bool) {
|
||||
aParts, okA := parseVersionParts(a)
|
||||
bParts, okB := parseVersionParts(b)
|
||||
if !okA || !okB {
|
||||
return 0, false
|
||||
}
|
||||
for i := range len(aParts) {
|
||||
if aParts[i] > bParts[i] {
|
||||
return 1, true
|
||||
}
|
||||
if aParts[i] < bParts[i] {
|
||||
return -1, true
|
||||
}
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func parseVersionParts(version string) ([3]int, bool) {
|
||||
var result [3]int
|
||||
parts := strings.Split(normalizeVersionTag(version), ".")
|
||||
if len(parts) != 3 {
|
||||
return result, false
|
||||
}
|
||||
for i, part := range parts {
|
||||
n, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return result, false
|
||||
}
|
||||
result[i] = n
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func normalizeVersionTag(version string) string {
|
||||
return strings.TrimPrefix(strings.TrimSpace(version), "v")
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package panel
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func setDetachedProcess(cmd *exec.Cmd) {}
|
||||
@@ -0,0 +1,41 @@
|
||||
package panel
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsNewerVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
latest string
|
||||
current string
|
||||
want bool
|
||||
}{
|
||||
{"v2.9.4", "2.9.3", true},
|
||||
{"v2.10.0", "2.9.9", true},
|
||||
{"v2.9.3", "2.9.3", false},
|
||||
{"v2.9.2", "2.9.3", false},
|
||||
{"v3.0.0", "2.9.3", true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := isNewerVersion(tc.latest, tc.current); got != tc.want {
|
||||
t.Fatalf("isNewerVersion(%q, %q) = %v, want %v", tc.latest, tc.current, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareVersionStringsRejectsUnexpectedFormats(t *testing.T) {
|
||||
if _, ok := compareVersionStrings("latest", "2.9.3"); ok {
|
||||
t.Fatal("expected non-semver latest tag to be rejected")
|
||||
}
|
||||
if _, ok := compareVersionStrings("v2.9", "2.9.3"); ok {
|
||||
t.Fatal("expected short version to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShellQuote(t *testing.T) {
|
||||
if got := shellQuote("/usr/bin/curl"); got != "'/usr/bin/curl'" {
|
||||
t.Fatalf("unexpected quote result: %s", got)
|
||||
}
|
||||
if got := shellQuote("/tmp/a'b"); got != "'/tmp/a'\\''b'" {
|
||||
t.Fatalf("unexpected quote result with single quote: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build linux
|
||||
|
||||
package panel
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func setDetachedProcess(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package panel
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"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/crypto"
|
||||
ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/xlzd/gotp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UserService provides business logic for user management and authentication.
|
||||
// It handles user creation, login, password management, and 2FA operations.
|
||||
type UserService struct {
|
||||
settingService service.SettingService
|
||||
}
|
||||
|
||||
// GetFirstUser retrieves the first user from the database.
|
||||
// This is typically used for initial setup or when there's only one admin user.
|
||||
func (s *UserService) GetFirstUser() (*model.User, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
user := &model.User{}
|
||||
err := db.Model(model.User{}).
|
||||
First(user).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) CheckUser(username string, password string, twoFactorCode string) (*model.User, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
user := &model.User{}
|
||||
|
||||
err := db.Model(model.User{}).
|
||||
Where("username = ?", username).
|
||||
First(user).
|
||||
Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New("invalid credentials")
|
||||
} else if err != nil {
|
||||
logger.Warning("check user err:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||
if !ldapEnabled {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
|
||||
host, _ := s.settingService.GetLdapHost()
|
||||
port, _ := s.settingService.GetLdapPort()
|
||||
useTLS, _ := s.settingService.GetLdapUseTLS()
|
||||
bindDN, _ := s.settingService.GetLdapBindDN()
|
||||
ldapPass, _ := s.settingService.GetLdapPassword()
|
||||
baseDN, _ := s.settingService.GetLdapBaseDN()
|
||||
userFilter, _ := s.settingService.GetLdapUserFilter()
|
||||
userAttr, _ := s.settingService.GetLdapUserAttr()
|
||||
|
||||
cfg := ldaputil.Config{
|
||||
Host: host,
|
||||
Port: port,
|
||||
UseTLS: useTLS,
|
||||
BindDN: bindDN,
|
||||
Password: ldapPass,
|
||||
BaseDN: baseDN,
|
||||
UserFilter: userFilter,
|
||||
UserAttr: userAttr,
|
||||
}
|
||||
ok, err := ldaputil.AuthenticateUser(cfg, username, password)
|
||||
if err != nil || !ok {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
}
|
||||
|
||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||
if err != nil {
|
||||
logger.Warning("check two factor err:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if twoFactorEnable {
|
||||
twoFactorToken, err := s.settingService.GetTwoFactorToken()
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("check two factor token err:", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if gotp.NewDefaultTOTP(twoFactorToken).Now() != twoFactorCode {
|
||||
return nil, errors.New("invalid 2fa code")
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) BumpLoginEpoch() error {
|
||||
db := database.GetDB()
|
||||
return db.Model(model.User{}).
|
||||
Where("1 = 1").
|
||||
Update("login_epoch", gorm.Expr("login_epoch + 1")).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(id int, username string, password string) error {
|
||||
db := database.GetDB()
|
||||
hashedPassword, err := crypto.HashPasswordAsBcrypt(password)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if twoFactorEnable {
|
||||
s.settingService.SetTwoFactorEnable(false)
|
||||
s.settingService.SetTwoFactorToken("")
|
||||
}
|
||||
|
||||
return db.Model(model.User{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"username": username,
|
||||
"password": hashedPassword,
|
||||
"login_epoch": gorm.Expr("login_epoch + 1"),
|
||||
}).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateFirstUser(username string, password string) error {
|
||||
if username == "" {
|
||||
return errors.New("username can not be empty")
|
||||
} else if password == "" {
|
||||
return errors.New("password can not be empty")
|
||||
}
|
||||
hashedPassword, er := crypto.HashPasswordAsBcrypt(password)
|
||||
|
||||
if er != nil {
|
||||
return er
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
user := &model.User{}
|
||||
err := db.Model(model.User{}).First(user).Error
|
||||
if database.IsNotFound(err) {
|
||||
user.Username = username
|
||||
user.Password = hashedPassword
|
||||
return db.Model(model.User{}).Create(user).Error
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
user.Username = username
|
||||
user.Password = hashedPassword
|
||||
user.LoginEpoch++
|
||||
return db.Save(user).Error
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package service: WebSocketService owns the per-connection pump goroutines
|
||||
// and bridges the HTTP-layer controller to the broadcast hub. The controller
|
||||
// handles the upgrade handshake and authentication, then hands the raw
|
||||
// connection to this service which takes ownership of its lifecycle.
|
||||
package panel
|
||||
|
||||
import (
|
||||
"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/web/websocket"
|
||||
|
||||
"github.com/google/uuid"
|
||||
ws "github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
wsWriteWait = 10 * time.Second
|
||||
wsPongWait = 60 * time.Second
|
||||
wsPingPeriod = (wsPongWait * 9) / 10
|
||||
wsClientReadLimit = 512
|
||||
)
|
||||
|
||||
// WebSocketService manages WebSocket client connections. It owns the
|
||||
// read/write pumps for each accepted connection and registers/unregisters
|
||||
// clients with the hub.
|
||||
type WebSocketService struct {
|
||||
hub *websocket.Hub
|
||||
}
|
||||
|
||||
// NewWebSocketService creates a service backed by the given hub.
|
||||
func NewWebSocketService(hub *websocket.Hub) *WebSocketService {
|
||||
return &WebSocketService{hub: hub}
|
||||
}
|
||||
|
||||
// HandleConnection takes ownership of an upgraded WebSocket connection:
|
||||
// registers a new client, starts the read/write pumps, and returns
|
||||
// immediately. The connection is closed when both pumps exit.
|
||||
func (s *WebSocketService) HandleConnection(conn *ws.Conn, remoteIP string) {
|
||||
if s == nil || s.hub == nil || conn == nil {
|
||||
if conn != nil {
|
||||
conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
client := websocket.NewClient(uuid.New().String())
|
||||
s.hub.Register(client)
|
||||
logger.Debugf("WebSocket client %s registered from %s", client.ID, remoteIP)
|
||||
|
||||
go s.writePump(client, conn)
|
||||
go s.readPump(client, conn)
|
||||
}
|
||||
|
||||
// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
|
||||
// running. Clients send no commands today; frames are discarded.
|
||||
func (s *WebSocketService) readPump(client *websocket.Client, conn *ws.Conn) {
|
||||
defer func() {
|
||||
if r := common.Recover("WebSocket readPump panic"); r != nil {
|
||||
logger.Error("WebSocket readPump panic recovered:", r)
|
||||
}
|
||||
s.hub.Unregister(client)
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
conn.SetReadLimit(wsClientReadLimit)
|
||||
conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
conn.SetPongHandler(func(string) error {
|
||||
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
|
||||
})
|
||||
|
||||
for {
|
||||
if _, _, err := conn.ReadMessage(); err != nil {
|
||||
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
|
||||
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writePump pushes hub messages to the connection and emits keepalive pings.
|
||||
func (s *WebSocketService) writePump(client *websocket.Client, conn *ws.Conn) {
|
||||
ticker := time.NewTicker(wsPingPeriod)
|
||||
defer func() {
|
||||
if r := common.Recover("WebSocket writePump panic"); r != nil {
|
||||
logger.Error("WebSocket writePump panic recovered:", r)
|
||||
}
|
||||
ticker.Stop()
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case msg, ok := <-client.Send:
|
||||
conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if !ok {
|
||||
conn.WriteMessage(ws.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
|
||||
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
case <-ticker.C:
|
||||
conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
|
||||
if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
|
||||
logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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 transportBits uint8
|
||||
|
||||
const (
|
||||
transportTCP transportBits = 1 << iota
|
||||
transportUDP
|
||||
)
|
||||
|
||||
func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
|
||||
// protocols that ignore streamSettings entirely.
|
||||
switch protocol {
|
||||
case model.Hysteria, model.WireGuard:
|
||||
return transportUDP
|
||||
case model.MTProto:
|
||||
return transportTCP
|
||||
}
|
||||
|
||||
var bits transportBits
|
||||
|
||||
// peek at streamSettings.network to spot udp-based transports.
|
||||
// parse errors are non-fatal: missing or weird streamSettings just
|
||||
// keeps the default tcp bit below.
|
||||
network := ""
|
||||
if streamSettings != "" {
|
||||
var ss map[string]any
|
||||
if json.Unmarshal([]byte(streamSettings), &ss) == nil {
|
||||
if n, _ := ss["network"].(string); n != "" {
|
||||
network = n
|
||||
}
|
||||
}
|
||||
}
|
||||
switch network {
|
||||
case "kcp", "quic":
|
||||
bits |= transportUDP
|
||||
default:
|
||||
bits |= transportTCP
|
||||
}
|
||||
|
||||
// a few protocols carry their L4 choice in settings instead of (or in
|
||||
// addition to) streamSettings: SS / Tunnel via a CSV field that wins
|
||||
// outright, Mixed via an additive udp boolean.
|
||||
if settings != "" {
|
||||
var st map[string]any
|
||||
if json.Unmarshal([]byte(settings), &st) == nil {
|
||||
switch protocol {
|
||||
case model.Shadowsocks, model.Tunnel:
|
||||
key := "network"
|
||||
if protocol == model.Tunnel {
|
||||
key = "allowedNetwork"
|
||||
}
|
||||
if n, ok := st[key].(string); ok && n != "" {
|
||||
bits = 0
|
||||
for part := range strings.SplitSeq(n, ",") {
|
||||
switch strings.TrimSpace(part) {
|
||||
case "tcp":
|
||||
bits |= transportTCP
|
||||
case "udp":
|
||||
bits |= transportUDP
|
||||
}
|
||||
}
|
||||
}
|
||||
case model.Mixed:
|
||||
// socks/http "mixed" inbound: settings.udp=true means it
|
||||
// also relays udp on the same port (socks5 udp associate).
|
||||
if udpOn, _ := st["udp"].(bool); udpOn {
|
||||
bits |= transportUDP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// safety net: never return zero, even if every parse failed.
|
||||
if bits == 0 {
|
||||
bits = transportTCP
|
||||
}
|
||||
return bits
|
||||
}
|
||||
|
||||
func listenOverlaps(a, b string) bool {
|
||||
if isAnyListen(a) || isAnyListen(b) {
|
||||
return true
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
func isAnyListen(s string) bool {
|
||||
return s == "" || s == "0.0.0.0" || s == "::" || s == "::0"
|
||||
}
|
||||
|
||||
type portConflictDetail struct {
|
||||
InboundID int
|
||||
Remark string
|
||||
Tag string
|
||||
Listen string
|
||||
Port int
|
||||
Transports transportBits
|
||||
}
|
||||
|
||||
// String renders the detail as a single-line, user-facing summary.
|
||||
func (d *portConflictDetail) String() string {
|
||||
name := d.Remark
|
||||
if name == "" {
|
||||
name = d.Tag
|
||||
}
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("#%d", d.InboundID)
|
||||
} else {
|
||||
name = fmt.Sprintf("'%s' (#%d)", name, d.InboundID)
|
||||
}
|
||||
listen := d.Listen
|
||||
if isAnyListen(listen) {
|
||||
listen = "*"
|
||||
}
|
||||
return fmt.Sprintf("port %d (%s) already used by inbound %s on %s",
|
||||
d.Port, transportTagSuffix(d.Transports), name, listen)
|
||||
}
|
||||
|
||||
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
|
||||
db := database.GetDB()
|
||||
|
||||
var candidates []*model.Inbound
|
||||
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
|
||||
if ignoreId > 0 {
|
||||
q = q.Where("id != ?", ignoreId)
|
||||
}
|
||||
if err := q.Find(&candidates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
||||
for _, c := range candidates {
|
||||
if !sameNode(c.NodeID, inbound.NodeID) {
|
||||
continue
|
||||
}
|
||||
if !listenOverlaps(c.Listen, inbound.Listen) {
|
||||
continue
|
||||
}
|
||||
existingBits := inboundTransports(c.Protocol, c.StreamSettings, c.Settings)
|
||||
shared := existingBits & newBits
|
||||
if shared == 0 {
|
||||
continue
|
||||
}
|
||||
return &portConflictDetail{
|
||||
InboundID: c.Id,
|
||||
Remark: c.Remark,
|
||||
Tag: c.Tag,
|
||||
Listen: c.Listen,
|
||||
Port: c.Port,
|
||||
Transports: shared,
|
||||
}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func sameNode(a, b *int) bool {
|
||||
if a == nil && b == nil {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
func baseInboundTag(port int) string {
|
||||
return fmt.Sprintf("in-%v", port)
|
||||
}
|
||||
|
||||
func transportTagSuffix(b transportBits) string {
|
||||
switch b {
|
||||
case transportTCP:
|
||||
return "tcp"
|
||||
case transportUDP:
|
||||
return "udp"
|
||||
case transportTCP | transportUDP:
|
||||
return "tcpudp"
|
||||
}
|
||||
return "any"
|
||||
}
|
||||
|
||||
// nodeTagPrefix scopes a tag to one remote node so the same listen+port
|
||||
// can live on the central panel and on a node without bumping the global
|
||||
// UNIQUE(inbounds.tag) constraint. nil → "" (local panel).
|
||||
func nodeTagPrefix(nodeID *int) string {
|
||||
if nodeID == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("n%d-", *nodeID)
|
||||
}
|
||||
|
||||
func composeInboundTag(port int, nodeID *int, bits transportBits) string {
|
||||
return nodeTagPrefix(nodeID) + baseInboundTag(port) + "-" + transportTagSuffix(bits)
|
||||
}
|
||||
|
||||
func isAutoGeneratedTag(tag string, port int, nodeID *int, bits transportBits) bool {
|
||||
base := composeInboundTag(port, nodeID, bits)
|
||||
if tag == base {
|
||||
return true
|
||||
}
|
||||
suffix, ok := strings.CutPrefix(tag, base+"-")
|
||||
if !ok || suffix == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range suffix {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||
bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
|
||||
candidate := composeInboundTag(inbound.Port, inbound.NodeID, bits)
|
||||
exists, err := s.tagExists(candidate, ignoreId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
|
||||
for i := 2; i < 100; i++ {
|
||||
c := fmt.Sprintf("%s-%d", candidate, i)
|
||||
exists, err = s.tagExists(c, ignoreId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
|
||||
}
|
||||
|
||||
func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
|
||||
if inbound.Tag != "" {
|
||||
taken, err := s.tagExists(inbound.Tag, ignoreId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !taken {
|
||||
return inbound.Tag, nil
|
||||
}
|
||||
}
|
||||
return s.generateInboundTag(inbound, ignoreId)
|
||||
}
|
||||
|
||||
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
|
||||
db := database.GetDB()
|
||||
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
|
||||
if ignoreId > 0 {
|
||||
q = q.Where("id != ?", ignoreId)
|
||||
}
|
||||
var count int64
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// the panel logger is a process-wide singleton. init it once per test
|
||||
// binary so a stray warning from gorm doesn't blow up on a nil logger.
|
||||
var portConflictLoggerOnce sync.Once
|
||||
|
||||
// setupConflictDB wires a temp sqlite db so checkPortConflict can read
|
||||
// real candidates. closes the db before t.TempDir cleans up so windows
|
||||
// doesn't refuse to remove the file.
|
||||
func setupConflictDB(t *testing.T) {
|
||||
t.Helper()
|
||||
portConflictLoggerOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||
|
||||
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() {
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Logf("CloseDB warning: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func seedInboundConflict(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string) {
|
||||
t.Helper()
|
||||
seedInboundConflictNode(t, tag, listen, port, protocol, streamSettings, settings, nil)
|
||||
}
|
||||
|
||||
func seedInboundConflictNode(t *testing.T, tag, listen string, port int, protocol model.Protocol, streamSettings, settings string, nodeID *int) {
|
||||
t.Helper()
|
||||
in := &model.Inbound{
|
||||
Tag: tag,
|
||||
Enable: true,
|
||||
Listen: listen,
|
||||
Port: port,
|
||||
Protocol: protocol,
|
||||
StreamSettings: streamSettings,
|
||||
Settings: settings,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
if err := database.GetDB().Create(in).Error; err != nil {
|
||||
t.Fatalf("seed inbound %s: %v", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
//go:fix inline
|
||||
func intPtr(v int) *int { return new(v) }
|
||||
|
||||
func TestInboundTransports(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
protocol model.Protocol
|
||||
streamSettings string
|
||||
settings string
|
||||
want transportBits
|
||||
}{
|
||||
{"vless default tcp", model.VLESS, `{"network":"tcp"}`, ``, transportTCP},
|
||||
{"vless ws (still tcp)", model.VLESS, `{"network":"ws"}`, ``, transportTCP},
|
||||
{"vless kcp is udp", model.VLESS, `{"network":"kcp"}`, ``, transportUDP},
|
||||
{"vless empty stream defaults to tcp", model.VLESS, ``, ``, transportTCP},
|
||||
{"vless garbage stream stays tcp", model.VLESS, `not json`, ``, transportTCP},
|
||||
|
||||
{"vmess default tcp", model.VMESS, `{"network":"tcp"}`, ``, transportTCP},
|
||||
{"trojan grpc is tcp", model.Trojan, `{"network":"grpc"}`, ``, transportTCP},
|
||||
|
||||
{"hysteria forced udp", model.Hysteria, `{"network":"tcp"}`, ``, transportUDP},
|
||||
{"wireguard forced udp", model.WireGuard, ``, ``, transportUDP},
|
||||
|
||||
{"shadowsocks tcp,udp", model.Shadowsocks, ``, `{"network":"tcp,udp"}`, transportTCP | transportUDP},
|
||||
{"shadowsocks udp only", model.Shadowsocks, ``, `{"network":"udp"}`, transportUDP},
|
||||
{"shadowsocks tcp only", model.Shadowsocks, ``, `{"network":"tcp"}`, transportTCP},
|
||||
{"shadowsocks empty network falls back to streamSettings", model.Shadowsocks, `{"network":"tcp"}`, `{}`, transportTCP},
|
||||
|
||||
{"mixed udp on", model.Mixed, `{"network":"tcp"}`, `{"udp":true}`, transportTCP | transportUDP},
|
||||
{"mixed udp off", model.Mixed, `{"network":"tcp"}`, `{"udp":false}`, transportTCP},
|
||||
{"mixed udp missing", model.Mixed, `{"network":"tcp"}`, `{}`, transportTCP},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := inboundTransports(c.protocol, c.streamSettings, c.settings)
|
||||
if got != c.want {
|
||||
t.Fatalf("got bits %#b, want %#b", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenOverlaps(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"", "", true},
|
||||
{"0.0.0.0", "", true},
|
||||
{"0.0.0.0", "1.2.3.4", true},
|
||||
{"::", "1.2.3.4", true},
|
||||
{"::0", "fe80::1", true},
|
||||
{"1.2.3.4", "1.2.3.4", true},
|
||||
{"1.2.3.4", "5.6.7.8", false},
|
||||
{"1.2.3.4", "::1", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := listenOverlaps(c.a, c.b); got != c.want {
|
||||
t.Errorf("listenOverlaps(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// the actual case from #4103: tcp/443 vless reality and udp/443
|
||||
// hysteria must be allowed to coexist on the same port.
|
||||
func TestCheckPortConflict_TCPandUDPCoexistOnSamePort(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "vless-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
hyst2 := &model.Inbound{
|
||||
Tag: "hyst2-443-udp",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
}
|
||||
exist, err := svc.checkPortConflict(hyst2, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if exist != nil {
|
||||
t.Fatalf("vless/tcp and hysteria2/udp on the same port must be allowed to coexist")
|
||||
}
|
||||
}
|
||||
|
||||
// two tcp inbounds on the same port still conflict.
|
||||
func TestCheckPortConflict_TCPCollidesWithTCP(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "vless-443-a", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
other := &model.Inbound{
|
||||
Tag: "vless-443-b",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.Trojan,
|
||||
StreamSettings: `{"network":"ws"}`,
|
||||
}
|
||||
exist, err := svc.checkPortConflict(other, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if exist == nil {
|
||||
t.Fatalf("two tcp inbounds on the same port must still conflict")
|
||||
}
|
||||
}
|
||||
|
||||
// two udp inbounds (e.g. hysteria2 vs wireguard) on the same port also
|
||||
// conflict, since they fight for the same socket.
|
||||
func TestCheckPortConflict_UDPCollidesWithUDP(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "hyst2-443", "0.0.0.0", 443, model.Hysteria, ``, ``)
|
||||
|
||||
svc := &InboundService{}
|
||||
wg := &model.Inbound{
|
||||
Tag: "wg-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.WireGuard,
|
||||
}
|
||||
exist, err := svc.checkPortConflict(wg, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if exist == nil {
|
||||
t.Fatalf("two udp inbounds on the same port must conflict")
|
||||
}
|
||||
}
|
||||
|
||||
// shadowsocks listening on tcp+udp eats the whole port for both
|
||||
// transports, so neither a tcp nor a udp neighbour is allowed.
|
||||
func TestCheckPortConflict_ShadowsocksDualListenBlocksBoth(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "ss-443-dual", "0.0.0.0", 443, model.Shadowsocks, ``, `{"network":"tcp,udp"}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
|
||||
tcpClash := &model.Inbound{
|
||||
Tag: "vless-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(tcpClash, 0); err != nil || exist == nil {
|
||||
t.Fatalf("tcp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
|
||||
}
|
||||
|
||||
udpClash := &model.Inbound{
|
||||
Tag: "hyst2-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || exist == nil {
|
||||
t.Fatalf("udp inbound should clash with shadowsocks tcp,udp; exist=%v err=%v", exist, err)
|
||||
}
|
||||
}
|
||||
|
||||
// different ports never conflict regardless of transport.
|
||||
func TestCheckPortConflict_DifferentPortNeverConflicts(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
other := &model.Inbound{
|
||||
Tag: "vless-444",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 444,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
|
||||
t.Fatalf("different port must not conflict; exist=%v err=%v", exist, err)
|
||||
}
|
||||
}
|
||||
|
||||
// specific listen addresses on the same port don't clash with each other,
|
||||
// but do clash with any-address on the same port (preserved from the old
|
||||
// check).
|
||||
func TestCheckPortConflict_ListenOverlapPreserved(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "vless-1.2.3.4", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
|
||||
// different specific address, same port + transport: no conflict.
|
||||
other := &model.Inbound{
|
||||
Tag: "vless-5.6.7.8",
|
||||
Listen: "5.6.7.8",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(other, 0); err != nil || exist != nil {
|
||||
t.Fatalf("different specific listen must not conflict; exist=%v err=%v", exist, err)
|
||||
}
|
||||
|
||||
// any-address vs specific on same transport: conflict (any-addr wins).
|
||||
anyAddr := &model.Inbound{
|
||||
Tag: "vless-any",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(anyAddr, 0); err != nil || exist == nil {
|
||||
t.Fatalf("any-addr on same port+transport must conflict with specific; exist=%v err=%v", exist, err)
|
||||
}
|
||||
}
|
||||
|
||||
// even with a stale legacy tag owning "in-443", a new UDP-side
|
||||
// inbound gets a fully qualified canonical tag and does not collide.
|
||||
func TestGenerateInboundTag_DisambiguatesByTransportOnSamePort(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "in-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
udp := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
}
|
||||
got, err := svc.generateInboundTag(udp, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("generateInboundTag: %v", err)
|
||||
}
|
||||
if got != "in-443-udp" {
|
||||
t.Fatalf("expected in-443-udp, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// when the port is free, the canonical tag carries the transport so
|
||||
// tcp/8443 and udp/8443 get distinct tags out of the box.
|
||||
func TestGenerateInboundTag_KeepsBaseTagWhenFree(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
in := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.generateInboundTag(in, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("generateInboundTag: %v", err)
|
||||
}
|
||||
if got != "in-8443-tcp" {
|
||||
t.Fatalf("expected in-8443-tcp, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// updating an inbound on its own port must not flag its own tag as taken;
|
||||
// that's what ignoreId is for.
|
||||
func TestGenerateInboundTag_IgnoresSelfOnUpdate(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
var existing model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "in-443-tcp").First(&existing).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
got, err := svc.generateInboundTag(&existing, existing.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("generateInboundTag: %v", err)
|
||||
}
|
||||
if got != "in-443-tcp" {
|
||||
t.Fatalf("self-update must keep base tag, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// the listen address never appears in the tag; the transport suffix still
|
||||
// keeps a udp inbound distinct from a tcp one on the same port.
|
||||
func TestGenerateInboundTag_ListenIgnoredTransportDisambiguates(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "in-443-tcp", "1.2.3.4", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
udp := &model.Inbound{
|
||||
Listen: "1.2.3.4",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
}
|
||||
got, err := svc.generateInboundTag(udp, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("generateInboundTag: %v", err)
|
||||
}
|
||||
if got != "in-443-udp" {
|
||||
t.Fatalf("expected in-443-udp, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// inbounds bound to different nodes run on different physical machines,
|
||||
// so the same port + transport must be allowed across nodes. covers
|
||||
// local-vs-remote, remote-A-vs-remote-B, and the still-clashing
|
||||
// same-node case.
|
||||
func TestCheckPortConflict_NodeScope(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflictNode(t, "local-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||
seedInboundConflictNode(t, "node1-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`, new(1))
|
||||
|
||||
svc := &InboundService{}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
nodeID *int
|
||||
want bool
|
||||
}{
|
||||
{"new local same port + tcp clashes with local", nil, true},
|
||||
{"new remote on different node from local is fine", new(2), false},
|
||||
{"new remote on existing node 1 clashes", new(1), true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
candidate := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
NodeID: c.nodeID,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if (got != nil) != c.want {
|
||||
t.Fatalf("got conflict=%v, want %v", got != nil, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// when the caller passes an explicit non-empty Tag that doesn't collide,
|
||||
// resolveInboundTag returns it verbatim. this is the cross-panel path:
|
||||
// the central panel picks a tag, pushes the inbound to a node, and the
|
||||
// node must keep that exact tag so the eventual traffic sync-back can
|
||||
// match the row by tag. previously the node regenerated and the two
|
||||
// panels diverged, causing a UNIQUE constraint failure on sync.
|
||||
func TestResolveInboundTag_RespectsCallerTagWhenFree(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflictNode(t, "in-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||
seedInboundConflictNode(t, "in-5000-udp", "0.0.0.0", 5000, model.Hysteria, ``, ``, nil)
|
||||
|
||||
svc := &InboundService{}
|
||||
pushed := &model.Inbound{
|
||||
Tag: "custom-pushed-tag",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 5000,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
NodeID: intPtr(1),
|
||||
}
|
||||
got, err := svc.resolveInboundTag(pushed, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInboundTag: %v", err)
|
||||
}
|
||||
if got != "custom-pushed-tag" {
|
||||
t.Fatalf("caller tag must be preserved when free, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// when the caller leaves Tag empty (the local UI path) resolveInboundTag
|
||||
// falls back to generateInboundTag, which emits the canonical
|
||||
// "in-<port>-<transport>" shape.
|
||||
func TestResolveInboundTag_GeneratesWhenTagEmpty(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
in := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 8443,
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.resolveInboundTag(in, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInboundTag: %v", err)
|
||||
}
|
||||
if got != "in-8443-tcp" {
|
||||
t.Fatalf("expected generated in-8443-tcp, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// when the caller's Tag collides (e.g. a node that was used standalone
|
||||
// happens to already own the tag the central panel picked),
|
||||
// resolveInboundTag falls back to generateInboundTag rather than
|
||||
// failing — the inbound still lands, just under a slightly different
|
||||
// tag that the central will pick up via the AddInbound response.
|
||||
func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflictNode(t, "in-5000-tcp", "0.0.0.0", 5000, model.VLESS, `{"network":"tcp"}`, `{}`, nil)
|
||||
|
||||
svc := &InboundService{}
|
||||
pushed := &model.Inbound{
|
||||
Tag: "in-5000-tcp",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 5000,
|
||||
Protocol: model.Hysteria,
|
||||
StreamSettings: ``,
|
||||
Settings: ``,
|
||||
}
|
||||
got, err := svc.resolveInboundTag(pushed, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveInboundTag: %v", err)
|
||||
}
|
||||
if got == "in-5000-tcp" {
|
||||
t.Fatalf("colliding caller tag must be replaced, but resolver kept %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// inbounds bound to a remote node get the canonical tag prefixed with
|
||||
// "n<id>-" so the same listen+port+transport can live on the central
|
||||
// panel and on the node simultaneously without bumping the global
|
||||
// UNIQUE(inbounds.tag) constraint.
|
||||
func TestGenerateInboundTag_NodePrefix(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
in := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
NodeID: intPtr(1),
|
||||
}
|
||||
got, err := svc.generateInboundTag(in, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("generateInboundTag: %v", err)
|
||||
}
|
||||
if got != "n1-in-443-tcp" {
|
||||
t.Fatalf("expected n1-in-443-tcp, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// a node-prefixed inbound shouldn't collide with a same-port local one:
|
||||
// the prefix scopes the tag to that specific node.
|
||||
func TestGenerateInboundTag_NodePrefixedDoesNotCollideWithLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
in := &model.Inbound{
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
NodeID: intPtr(1),
|
||||
}
|
||||
got, err := svc.generateInboundTag(in, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("generateInboundTag: %v", err)
|
||||
}
|
||||
if got != "n1-in-443-tcp" {
|
||||
t.Fatalf("expected n1-in-443-tcp, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// updating an inbound must not see itself as a conflict, that's what
|
||||
// ignoreId is for.
|
||||
func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "vless-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
|
||||
var existing model.Inbound
|
||||
if err := database.GetDB().Where("tag = ?", "vless-443").First(&existing).Error; err != nil {
|
||||
t.Fatalf("read seeded row: %v", err)
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
if exist, err := svc.checkPortConflict(&existing, existing.Id); err != nil || exist != nil {
|
||||
t.Fatalf("self-update must not be flagged as conflict; exist=%v err=%v", exist, err)
|
||||
}
|
||||
}
|
||||
|
||||
// streamSettings.network=quic rides on UDP at L4, so a QUIC inbound must
|
||||
// conflict with a UDP-only neighbour (hysteria) on the same port but not
|
||||
// with a TCP-only one. covers the gap left by the original kcp-only check.
|
||||
func TestCheckPortConflict_QUICTreatedAsUDP(t *testing.T) {
|
||||
quic := &model.Inbound{
|
||||
Tag: "vless-quic-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"quic"}`,
|
||||
}
|
||||
|
||||
t.Run("conflicts with hysteria/udp", func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "hyst-443", "0.0.0.0", 443, model.Hysteria, ``, ``)
|
||||
svc := &InboundService{}
|
||||
if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist == nil {
|
||||
t.Fatalf("quic on same port as hysteria must conflict; exist=%v err=%v", exist, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("coexists with vless/tcp", func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "vless-tcp-443", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{}`)
|
||||
svc := &InboundService{}
|
||||
if exist, err := svc.checkPortConflict(quic, 0); err != nil || exist != nil {
|
||||
t.Fatalf("quic and tcp on same port must coexist; exist=%v err=%v", exist, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// tunnel (dokodemo-door) carries its L4 transport list in
|
||||
// settings.allowedNetwork, not settings.network. verify the predicate
|
||||
// picks the right field for each protocol.
|
||||
func TestCheckPortConflict_TunnelAllowedNetwork(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "tunnel-udp-443", "0.0.0.0", 443, model.Tunnel, ``, `{"allowedNetwork":"udp"}`)
|
||||
|
||||
svc := &InboundService{}
|
||||
|
||||
// tcp inbound on same port should coexist with udp-only tunnel.
|
||||
tcpNeighbour := &model.Inbound{
|
||||
Tag: "vless-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(tcpNeighbour, 0); err != nil || exist != nil {
|
||||
t.Fatalf("tunnel/udp and vless/tcp on same port must coexist; exist=%v err=%v", exist, err)
|
||||
}
|
||||
|
||||
// udp neighbour (hysteria) on same port must conflict.
|
||||
udpNeighbour := &model.Inbound{
|
||||
Tag: "hyst-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.Hysteria,
|
||||
}
|
||||
if exist, err := svc.checkPortConflict(udpNeighbour, 0); err != nil || exist == nil {
|
||||
t.Fatalf("tunnel/udp and hysteria on same port must conflict; exist=%v err=%v", exist, err)
|
||||
}
|
||||
}
|
||||
|
||||
// the rich conflict detail surfaced to the user must name the offending
|
||||
// inbound (by remark when available) and the shared L4 transport(s).
|
||||
func TestCheckPortConflict_DetailMessage(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seeded := &model.Inbound{
|
||||
Tag: "vless-443",
|
||||
Remark: "my-vless",
|
||||
Enable: true,
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.VLESS,
|
||||
StreamSettings: `{"network":"tcp"}`,
|
||||
Settings: `{}`,
|
||||
}
|
||||
if err := database.GetDB().Create(seeded).Error; err != nil {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "trojan-443",
|
||||
Listen: "0.0.0.0",
|
||||
Port: 443,
|
||||
Protocol: model.Trojan,
|
||||
StreamSettings: `{"network":"ws"}`,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
if err != nil || got == nil {
|
||||
t.Fatalf("expected conflict, got=%v err=%v", got, err)
|
||||
}
|
||||
msg := got.String()
|
||||
if !strings.Contains(msg, "my-vless") {
|
||||
t.Fatalf("message should mention the conflicting inbound's remark; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "tcp") {
|
||||
t.Fatalf("message should mention the shared L4 transport; got %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "443") {
|
||||
t.Fatalf("message should mention the port; got %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// isAutoGeneratedTag must recognise the tags generateInboundTag emits (so an
|
||||
// edit that changes port/transport re-derives them) while leaving user-typed
|
||||
// or cross-panel tags untouched.
|
||||
func TestIsAutoGeneratedTag(t *testing.T) {
|
||||
tcp := transportTCP
|
||||
cases := []struct {
|
||||
name string
|
||||
tag string
|
||||
port int
|
||||
nodeID *int
|
||||
bits transportBits
|
||||
want bool
|
||||
}{
|
||||
{"canonical", "in-443-tcp", 443, nil, tcp, true},
|
||||
{"canonical udp", "in-443-udp", 443, nil, transportUDP, true},
|
||||
{"dedup suffix", "in-443-tcp-2", 443, nil, tcp, true},
|
||||
{"node prefixed", "n1-in-443-tcp", 443, intPtr(1), tcp, true},
|
||||
{"legacy listen-scoped is now custom", "in-127.0.0.1:443-tcp", 443, nil, tcp, false},
|
||||
{"custom tag", "my-cool-tag", 443, nil, tcp, false},
|
||||
{"stale port", "in-443-tcp", 8443, nil, tcp, false},
|
||||
{"stale transport", "in-443-tcp", 443, nil, transportUDP, false},
|
||||
{"non-numeric suffix", "in-443-tcp-x", 443, nil, tcp, false},
|
||||
{"empty suffix", "in-443-tcp-", 443, nil, tcp, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := isAutoGeneratedTag(c.tag, c.port, c.nodeID, c.bits); got != c.want {
|
||||
t.Fatalf("isAutoGeneratedTag(%q) = %v, want %v", c.tag, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseVlessEncAuthsAddsStableIDs(t *testing.T) {
|
||||
output := `
|
||||
Authentication: X25519, not Post-Quantum
|
||||
{
|
||||
"decryption": "mlkem768x25519plus.native.600s.server-x25519",
|
||||
"encryption": "mlkem768x25519plus.native.0rtt.client-x25519"
|
||||
}
|
||||
|
||||
Authentication: ML-KEM-768, Post-Quantum
|
||||
{
|
||||
"decryption": "mlkem768x25519plus.native.600s.server-mlkem",
|
||||
"encryption": "mlkem768x25519plus.native.0rtt.client-mlkem"
|
||||
}
|
||||
`
|
||||
|
||||
auths := parseVlessEncAuths(output)
|
||||
if len(auths) != 2 {
|
||||
t.Fatalf("expected 2 auth blocks, got %d", len(auths))
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
index int
|
||||
id string
|
||||
label string
|
||||
decryption string
|
||||
encryption string
|
||||
}{
|
||||
{
|
||||
index: 0,
|
||||
id: "x25519",
|
||||
label: "X25519, not Post-Quantum",
|
||||
decryption: "mlkem768x25519plus.native.600s.server-x25519",
|
||||
encryption: "mlkem768x25519plus.native.0rtt.client-x25519",
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
id: "mlkem768",
|
||||
label: "ML-KEM-768, Post-Quantum",
|
||||
decryption: "mlkem768x25519plus.native.600s.server-mlkem",
|
||||
encryption: "mlkem768x25519plus.native.0rtt.client-mlkem",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
auth := auths[test.index]
|
||||
if auth["id"] != test.id {
|
||||
t.Errorf("auth[%d] id = %q, want %q", test.index, auth["id"], test.id)
|
||||
}
|
||||
if auth["label"] != test.label {
|
||||
t.Errorf("auth[%d] label = %q, want %q", test.index, auth["label"], test.label)
|
||||
}
|
||||
if auth["decryption"] != test.decryption {
|
||||
t.Errorf("auth[%d] decryption = %q, want %q", test.index, auth["decryption"], test.decryption)
|
||||
}
|
||||
if auth["encryption"] != test.encryption {
|
||||
t.Errorf("auth[%d] encryption = %q, want %q", test.index, auth["encryption"], test.encryption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseVlessEncAuthsHandlesMissingTrailingComma(t *testing.T) {
|
||||
output := `
|
||||
Authentication: X25519, not Post-Quantum
|
||||
"decryption": "server"
|
||||
"encryption": "client"
|
||||
`
|
||||
|
||||
auths := parseVlessEncAuths(output)
|
||||
if len(auths) != 1 {
|
||||
t.Fatalf("expected 1 auth block, got %d", len(auths))
|
||||
}
|
||||
if auths[0]["decryption"] != "server" {
|
||||
t.Fatalf("decryption = %q, want server", auths[0]["decryption"])
|
||||
}
|
||||
if auths[0]["encryption"] != "client" {
|
||||
t.Fatalf("encryption = %q, want client", auths[0]["encryption"])
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func setupSettingTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetAllSettingViewRedactsSecrets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&model.ApiToken{Name: "test", Token: "api-secret", Enabled: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if view.TgBotToken != "" || view.TwoFactorToken != "" || view.LdapPassword != "" {
|
||||
t.Fatalf("settings view leaked secrets: %#v", view)
|
||||
}
|
||||
if !view.HasTgBotToken || !view.HasTwoFactorToken || !view.HasLdapPassword || !view.HasApiToken {
|
||||
t.Fatalf("settings view did not report configured secret flags: %#v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
s := &SettingService{}
|
||||
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("twoFactorEnable", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.saveSetting("twoFactorToken", "totp-secret"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
view, err := s.GetAllSettingView()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings := &view.AllSetting
|
||||
if err := s.UpdateAllSetting(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||
t.Fatalf("tg token = %q, want preserved secret", got)
|
||||
}
|
||||
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
||||
t.Fatalf("ldap password = %q, want preserved secret", got)
|
||||
}
|
||||
if got, _ := s.GetTwoFactorToken(); got != "totp-secret" {
|
||||
t.Fatalf("2fa token = %q, want preserved secret", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
||||
t.Fatal("expected localhost URL to be blocked")
|
||||
}
|
||||
if got, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", true); err != nil || got != "http://127.0.0.1:8080/hook" {
|
||||
t.Fatalf("allowPrivate result = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
|
||||
// BuildSubURIBase is the single source of truth for the scheme://host[:port]
|
||||
// prefix shown both on the panel's Client Information page and on the
|
||||
// subscription page. The cases pin scheme selection (sub TLS cert/key),
|
||||
// Sub Domain preference, standard-port omission, and IPv6 bracketing.
|
||||
func TestBuildSubURIBase(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
s := &SettingService{}
|
||||
|
||||
set := func(subDomain, port, cert, key string) {
|
||||
if err := s.saveSetting("subDomain", subDomain); err != nil {
|
||||
t.Fatalf("set subDomain: %v", err)
|
||||
}
|
||||
if err := s.saveSetting("subPort", port); err != nil {
|
||||
t.Fatalf("set subPort: %v", err)
|
||||
}
|
||||
if err := s.saveSetting("subCertFile", cert); err != nil {
|
||||
t.Fatalf("set subCertFile: %v", err)
|
||||
}
|
||||
if err := s.saveSetting("subKeyFile", key); err != nil {
|
||||
t.Fatalf("set subKeyFile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
subDomain, port string
|
||||
cert, key string
|
||||
host string
|
||||
want string
|
||||
}{
|
||||
{"no domain, plain, non-standard port", "", "2096", "", "", "panel.example.com", "http://panel.example.com:2096"},
|
||||
{"host carries a port — stripped, sub port applied", "", "2096", "", "", "panel.example.com:9999", "http://panel.example.com:2096"},
|
||||
{"sub domain preferred over host", "sub.cdn.com", "2096", "", "", "panel.example.com", "http://sub.cdn.com:2096"},
|
||||
{"tls + 443 omits the port", "sub.cdn.com", "443", "/c.crt", "/k.key", "panel.example.com", "https://sub.cdn.com"},
|
||||
{"plain + 80 omits the port", "", "80", "", "", "x.com", "http://x.com"},
|
||||
{"tls on a non-standard port keeps it", "", "2096", "/c.crt", "/k.key", "x.com", "https://x.com:2096"},
|
||||
{"ipv6 host is bracketed", "", "2096", "", "", "::1", "http://[::1]:2096"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
set(c.subDomain, c.port, c.cert, c.key)
|
||||
if got := s.BuildSubURIBase(c.host); got != c.want {
|
||||
t.Fatalf("BuildSubURIBase(%q) = %q, want %q", c.host, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func syncInboundOld(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
|
||||
}
|
||||
for i := range clients {
|
||||
c := clients[i]
|
||||
email := strings.TrimSpace(c.Email)
|
||||
if email == "" {
|
||||
continue
|
||||
}
|
||||
incoming := c.ToRecord()
|
||||
row := &model.ClientRecord{}
|
||||
err := tx.Where("email = ?", email).First(row).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if err := tx.Create(incoming).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
row = incoming
|
||||
} else {
|
||||
row.Flow = incoming.Flow
|
||||
row.SubID = incoming.SubID
|
||||
row.LimitIP = incoming.LimitIP
|
||||
row.TotalGB = incoming.TotalGB
|
||||
row.ExpiryTime = incoming.ExpiryTime
|
||||
row.Enable = incoming.Enable
|
||||
row.TgID = incoming.TgID
|
||||
row.Comment = incoming.Comment
|
||||
row.Reset = incoming.Reset
|
||||
preservedUpdatedAt := max(incoming.UpdatedAt, row.UpdatedAt)
|
||||
row.UpdatedAt = preservedUpdatedAt
|
||||
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
|
||||
}
|
||||
}
|
||||
link := model.ClientInbound{ClientId: row.Id, InboundId: inboundId, FlowOverride: c.Flow}
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeScaleClients(n int) []model.Client {
|
||||
out := make([]model.Client, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: fmt.Sprintf("user-%07d@scale", i),
|
||||
SubID: fmt.Sprintf("sub-%07d", i),
|
||||
Enable: true,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSyncInboundPostgresScale(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")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 10000, 20000, 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 RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{
|
||||
Tag: fmt.Sprintf("scale-%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)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("seed SyncInbound: %v", err)
|
||||
}
|
||||
seed := time.Since(start)
|
||||
|
||||
clients[n/2].Enable = !clients[n/2].Enable
|
||||
start = time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("toggle SyncInbound (new): %v", err)
|
||||
}
|
||||
toggleNew := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
|
||||
t.Fatalf("noop SyncInbound (new): %v", err)
|
||||
}
|
||||
noopNew := time.Since(start)
|
||||
|
||||
toggleOld := time.Duration(0)
|
||||
if n <= 10000 {
|
||||
clients[n/2].Enable = !clients[n/2].Enable
|
||||
start = time.Now()
|
||||
if err := syncInboundOld(db, ib.Id, clients); err != nil {
|
||||
t.Fatalf("toggle SyncInbound (old): %v", err)
|
||||
}
|
||||
toggleOld = time.Since(start)
|
||||
}
|
||||
|
||||
var linkCount, recCount int64
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
if int(linkCount) != n || int(recCount) != n {
|
||||
t.Fatalf("row mismatch: links=%d records=%d want %d", linkCount, recCount, n)
|
||||
}
|
||||
|
||||
oldStr, speedup := "skipped", ""
|
||||
if toggleOld > 0 {
|
||||
oldStr = toggleOld.Round(time.Millisecond).String()
|
||||
speedup = fmt.Sprintf(" speedup=%.0fx", float64(toggleOld)/float64(maxDur(toggleNew, time.Millisecond)))
|
||||
}
|
||||
t.Logf("N=%-7d seed=%-10v toggle_new=%-10v noop_new=%-10v toggle_old=%-10s%s",
|
||||
n, seed.Round(time.Millisecond), toggleNew.Round(time.Millisecond),
|
||||
noopNew.Round(time.Millisecond), oldStr, speedup)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func maxDur(d, floor time.Duration) time.Duration {
|
||||
if d < floor {
|
||||
return floor
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func TestAddDelClientPostgresScale(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")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 20000, 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 RESTART IDENTITY CASCADE").Error; err != nil {
|
||||
t.Fatalf("truncate: %v", err)
|
||||
}
|
||||
|
||||
clients := makeScaleClients(n)
|
||||
ib := &model.Inbound{
|
||||
Tag: fmt.Sprintf("adddel-%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)
|
||||
}
|
||||
|
||||
newC := model.Client{
|
||||
ID: uuid.NewString(),
|
||||
Email: "added-client@scale",
|
||||
SubID: "added-sub",
|
||||
Enable: true,
|
||||
}
|
||||
addData := &model.Inbound{Id: ib.Id, Protocol: model.VLESS, Settings: clientsSettings(t, []model.Client{newC})}
|
||||
start := time.Now()
|
||||
if _, err := svc.AddInboundClient(inboundSvc, addData); err != nil {
|
||||
t.Fatalf("AddInboundClient: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
delEmail := clients[n/2].Email
|
||||
start = time.Now()
|
||||
if _, err := svc.DelInboundClientByEmail(inboundSvc, ib.Id, delEmail, false); err != nil {
|
||||
t.Fatalf("DelInboundClientByEmail: %v", err)
|
||||
}
|
||||
delDur := time.Since(start)
|
||||
|
||||
var recCount, linkCount int64
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
|
||||
t.Logf("N=%-7d add=%-10v del=%-10v records=%d links=%d", n,
|
||||
addDur.Round(time.Millisecond), delDur.Round(time.Millisecond), recCount, linkCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupAndListPostgresScale(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")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
sizes := []int{5000, 100000}
|
||||
|
||||
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{Tag: fmt.Sprintf("grp-%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)
|
||||
}
|
||||
db.Exec("ANALYZE")
|
||||
emails := make([]string, n)
|
||||
for i := 0; i < n; i++ {
|
||||
emails[i] = clients[i].Email
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if _, err := svc.AddToGroup(emails, "benchgroup"); err != nil {
|
||||
t.Fatalf("AddToGroup: %v", err)
|
||||
}
|
||||
addDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
if _, err := svc.RemoveFromGroup(emails); err != nil {
|
||||
t.Fatalf("RemoveFromGroup: %v", err)
|
||||
}
|
||||
rmDur := time.Since(start)
|
||||
|
||||
start = time.Now()
|
||||
list, err := svc.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
listDur := time.Since(start)
|
||||
if len(list) != n {
|
||||
t.Fatalf("List returned %d, want %d", len(list), n)
|
||||
}
|
||||
|
||||
t.Logf("N=%-7d bulkAdd=%-9v bulkRemove=%-9v list=%-9v", n,
|
||||
addDur.Round(time.Millisecond), rmDur.Round(time.Millisecond), listDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelAllClientsPostgresScale(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")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 50000, 100000}
|
||||
|
||||
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{Tag: fmt.Sprintf("delall-%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)
|
||||
}
|
||||
|
||||
emails, err := inboundSvc.EmailsByInbound(ib.Id)
|
||||
if err != nil {
|
||||
t.Fatalf("EmailsByInbound: %v", err)
|
||||
}
|
||||
start := time.Now()
|
||||
res, _, err := svc.BulkDelete(inboundSvc, emails, false)
|
||||
if err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
dur := time.Since(start)
|
||||
|
||||
var recCount, linkCount int64
|
||||
db.Model(&model.ClientRecord{}).Count(&recCount)
|
||||
db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&linkCount)
|
||||
if recCount != 0 || linkCount != 0 {
|
||||
t.Fatalf("after delAll: records=%d links=%d want 0/0", recCount, linkCount)
|
||||
}
|
||||
t.Logf("N=%-7d delAllClients=%-10v deleted=%d", n, dur.Round(time.Millisecond), res.Deleted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkOpsPostgresScale(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")
|
||||
}
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatalf("InitDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = database.CloseDB() })
|
||||
|
||||
svc := &ClientService{}
|
||||
inboundSvc := &InboundService{}
|
||||
sizes := []int{5000, 20000, 50000, 100000}
|
||||
const m = 2000
|
||||
|
||||
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)
|
||||
exp := time.Now().AddDate(1, 0, 0).UnixMilli()
|
||||
for i := range clients {
|
||||
clients[i].ExpiryTime = exp
|
||||
clients[i].TotalGB = 100 << 30
|
||||
}
|
||||
ib := &model.Inbound{Tag: fmt.Sprintf("bulk-%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)
|
||||
}
|
||||
ib2 := &model.Inbound{Tag: fmt.Sprintf("bulk2-%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)
|
||||
}
|
||||
|
||||
emailsM := make([]string, m)
|
||||
for i := 0; i < m; i++ {
|
||||
emailsM[i] = clients[i].Email
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
|
||||
t.Fatalf("BulkAdjust: %v", err)
|
||||
}
|
||||
adjustDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkAttach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkAttach: %v", err)
|
||||
}
|
||||
attachDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkDetach(inboundSvc, emailsM, []int{ib2.Id}); err != nil {
|
||||
t.Fatalf("BulkDetach: %v", err)
|
||||
}
|
||||
detachDur := time.Since(t0)
|
||||
|
||||
payloads := make([]ClientCreatePayload, m)
|
||||
for i := 0; i < m; i++ {
|
||||
payloads[i] = ClientCreatePayload{
|
||||
Client: model.Client{ID: uuid.NewString(), Email: fmt.Sprintf("bulknew-%07d@scale", i), SubID: fmt.Sprintf("bnsub-%07d", i), Enable: true},
|
||||
InboundIds: []int{ib.Id},
|
||||
}
|
||||
}
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkCreate(inboundSvc, payloads); err != nil {
|
||||
t.Fatalf("BulkCreate: %v", err)
|
||||
}
|
||||
createDur := time.Since(t0)
|
||||
|
||||
t0 = time.Now()
|
||||
if _, _, err := svc.BulkDelete(inboundSvc, emailsM, false); err != nil {
|
||||
t.Fatalf("BulkDelete: %v", err)
|
||||
}
|
||||
deleteDur := time.Since(t0)
|
||||
|
||||
t.Logf("N=%-6d M=%d adjust=%-9v attach=%-9v detach=%-9v create=%-9v delete=%-9v", n, m,
|
||||
adjustDur.Round(time.Millisecond), attachDur.Round(time.Millisecond), detachDur.Round(time.Millisecond),
|
||||
createDur.Round(time.Millisecond), deleteDur.Round(time.Millisecond))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"embed"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
"github.com/valyala/fasthttp"
|
||||
"github.com/valyala/fasthttp/fasthttpproxy"
|
||||
)
|
||||
|
||||
var (
|
||||
bot *telego.Bot
|
||||
|
||||
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
|
||||
botCancel context.CancelFunc
|
||||
// tgBotMutex protects concurrent access to botCancel variable
|
||||
tgBotMutex sync.Mutex
|
||||
// botWG waits for the OnReceive Long Polling goroutine to finish.
|
||||
botWG sync.WaitGroup
|
||||
|
||||
botHandler *th.BotHandler
|
||||
adminIds []int64
|
||||
isRunning bool
|
||||
hostname string
|
||||
hashStorage *global.HashStorage
|
||||
|
||||
// Performance improvements
|
||||
messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
|
||||
optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts
|
||||
|
||||
// Simple cache for frequently accessed data
|
||||
statusCache struct {
|
||||
data *service.Status
|
||||
timestamp time.Time
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
serverStatsCache struct {
|
||||
data string
|
||||
timestamp time.Time
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// clients data to adding new client. receiver_inbound_IDs is the set of
|
||||
// inbounds the new client will be attached to; receiver_inbound_ID mirrors
|
||||
// the primary pick for the legacy attach-picker entry point. Per-protocol
|
||||
// secrets (UUID, password, flow, method) are filled per-inbound on submit
|
||||
// by ClientService.fillProtocolDefaults, so the bot only tracks universal
|
||||
// client fields here.
|
||||
receiver_inbound_ID int
|
||||
receiver_inbound_IDs []int
|
||||
client_Email string
|
||||
client_LimitIP int
|
||||
client_TotalGB int64
|
||||
client_ExpiryTime int64
|
||||
client_Enable bool
|
||||
client_TgID string
|
||||
client_SubID string
|
||||
client_Comment string
|
||||
client_Reset int
|
||||
)
|
||||
|
||||
var userStates = make(map[int64]string)
|
||||
|
||||
// LoginStatus represents the result of a login attempt.
|
||||
type LoginStatus byte
|
||||
|
||||
// Login status constants
|
||||
const (
|
||||
LoginSuccess LoginStatus = 1 // Login was successful
|
||||
LoginFail LoginStatus = 0 // Login failed
|
||||
EmptyTelegramUserID = int64(0) // Default value for empty Telegram user ID
|
||||
)
|
||||
|
||||
// LoginAttempt contains safe metadata for panel login notifications.
|
||||
// It intentionally does not include attempted passwords.
|
||||
type LoginAttempt struct {
|
||||
Username string
|
||||
IP string
|
||||
Time string
|
||||
Status LoginStatus
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Tgbot provides business logic for Telegram bot integration.
|
||||
// It handles bot commands, user interactions, and status reporting via Telegram.
|
||||
type Tgbot struct {
|
||||
inboundService service.InboundService
|
||||
clientService service.ClientService
|
||||
settingService service.SettingService
|
||||
serverService service.ServerService
|
||||
xrayService service.XrayService
|
||||
lastStatus *service.Status
|
||||
}
|
||||
|
||||
// NewTgbot creates a new Tgbot instance.
|
||||
func (t *Tgbot) NewTgbot() *Tgbot {
|
||||
return new(Tgbot)
|
||||
}
|
||||
|
||||
// I18nBot retrieves a localized message for the bot interface.
|
||||
func (t *Tgbot) I18nBot(name string, params ...string) string {
|
||||
return locale.I18n(locale.Bot, name, params...)
|
||||
}
|
||||
|
||||
// GetHashStorage returns the hash storage instance for callback queries.
|
||||
func (t *Tgbot) GetHashStorage() *global.HashStorage {
|
||||
return hashStorage
|
||||
}
|
||||
|
||||
// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old)
|
||||
func (t *Tgbot) getCachedStatus() (*service.Status, bool) {
|
||||
statusCache.mutex.RLock()
|
||||
defer statusCache.mutex.RUnlock()
|
||||
|
||||
if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second {
|
||||
return statusCache.data, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// setCachedStatus updates the status cache
|
||||
func (t *Tgbot) setCachedStatus(status *service.Status) {
|
||||
statusCache.mutex.Lock()
|
||||
defer statusCache.mutex.Unlock()
|
||||
|
||||
statusCache.data = status
|
||||
statusCache.timestamp = time.Now()
|
||||
}
|
||||
|
||||
// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old)
|
||||
func (t *Tgbot) getCachedServerStats() (string, bool) {
|
||||
serverStatsCache.mutex.RLock()
|
||||
defer serverStatsCache.mutex.RUnlock()
|
||||
|
||||
if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second {
|
||||
return serverStatsCache.data, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// setCachedServerStats updates the server stats cache
|
||||
func (t *Tgbot) setCachedServerStats(stats string) {
|
||||
serverStatsCache.mutex.Lock()
|
||||
defer serverStatsCache.mutex.Unlock()
|
||||
|
||||
serverStatsCache.data = stats
|
||||
serverStatsCache.timestamp = time.Now()
|
||||
}
|
||||
|
||||
// Start initializes and starts the Telegram bot with the provided translation files.
|
||||
func (t *Tgbot) Start(i18nFS embed.FS) error {
|
||||
// Initialize localizer
|
||||
err := locale.InitLocalizer(i18nFS, &t.settingService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If Start is called again (e.g. during reload), ensure any previous long-polling
|
||||
// loop is stopped before creating a new bot / receiver.
|
||||
StopBot()
|
||||
|
||||
// Initialize hash storage to store callback queries
|
||||
hashStorage = global.NewHashStorage(20 * time.Minute)
|
||||
|
||||
// Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
|
||||
messageWorkerPool = make(chan struct{}, 10)
|
||||
|
||||
// Initialize optimized HTTP client with connection pooling
|
||||
optimizedHTTPClient = &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
},
|
||||
}
|
||||
|
||||
t.SetHostname()
|
||||
|
||||
// Get Telegram bot token
|
||||
tgBotToken, err := t.settingService.GetTgBotToken()
|
||||
if err != nil || tgBotToken == "" {
|
||||
logger.Warning("Failed to get Telegram bot token:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get Telegram bot chat ID(s)
|
||||
tgBotID, err := t.settingService.GetTgBotChatId()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get Telegram bot chat ID:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
parsedAdminIds := make([]int64, 0)
|
||||
// Parse admin IDs from comma-separated string
|
||||
if tgBotID != "" {
|
||||
for adminID := range strings.SplitSeq(tgBotID, ",") {
|
||||
id, err := strconv.ParseInt(adminID, 10, 64)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to parse admin ID from Telegram bot chat ID:", err)
|
||||
return err
|
||||
}
|
||||
parsedAdminIds = append(parsedAdminIds, int64(id))
|
||||
}
|
||||
}
|
||||
tgBotMutex.Lock()
|
||||
adminIds = parsedAdminIds
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
// Get Telegram bot proxy URL
|
||||
tgBotProxy, err := t.settingService.GetTgBotProxy()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get Telegram bot proxy URL:", err)
|
||||
}
|
||||
|
||||
// Fall back to the panel-wide proxy when no dedicated bot proxy is set.
|
||||
if tgBotProxy == "" {
|
||||
panelProxy, perr := t.settingService.GetPanelProxy()
|
||||
if perr != nil {
|
||||
logger.Warning("Failed to get panel proxy URL:", perr)
|
||||
} else if isSupportedBotProxyScheme(panelProxy) {
|
||||
tgBotProxy = panelProxy
|
||||
}
|
||||
}
|
||||
|
||||
// Get Telegram bot API server URL
|
||||
tgBotAPIServer, err := t.settingService.GetTgBotAPIServer()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get Telegram bot API server URL:", err)
|
||||
}
|
||||
|
||||
// Create new Telegram bot instance
|
||||
bot, err = t.NewBot(tgBotToken, tgBotProxy, tgBotAPIServer)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize Telegram bot API:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
t.trySetBotCommands(bot)
|
||||
|
||||
// Start receiving Telegram bot messages
|
||||
tgBotMutex.Lock()
|
||||
alreadyRunning := isRunning || botCancel != nil
|
||||
tgBotMutex.Unlock()
|
||||
if !alreadyRunning {
|
||||
logger.Info("Telegram bot receiver started")
|
||||
go t.OnReceive()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r)
|
||||
}
|
||||
}()
|
||||
|
||||
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
|
||||
Commands: []telego.BotCommand{
|
||||
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
|
||||
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
|
||||
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
|
||||
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to set bot commands:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isSupportedBotProxyScheme(proxyUrl string) bool {
|
||||
return strings.HasPrefix(proxyUrl, "socks5://") ||
|
||||
strings.HasPrefix(proxyUrl, "http://") ||
|
||||
strings.HasPrefix(proxyUrl, "https://")
|
||||
}
|
||||
|
||||
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
|
||||
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
|
||||
client := &fasthttp.Client{
|
||||
// Connection timeouts
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
MaxIdleConnDuration: 60 * time.Second,
|
||||
MaxConnDuration: 0, // unlimited, but controlled by MaxIdleConnDuration
|
||||
MaxIdemponentCallAttempts: 3,
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
MaxConnsPerHost: 100,
|
||||
MaxConnWaitTimeout: 10 * time.Second,
|
||||
DisableHeaderNamesNormalizing: false,
|
||||
DisablePathNormalizing: false,
|
||||
// Retry on connection errors
|
||||
RetryIf: func(request *fasthttp.Request) bool {
|
||||
// Retry on connection errors for GET requests
|
||||
return string(request.Header.Method()) == "GET" || string(request.Header.Method()) == "POST"
|
||||
},
|
||||
}
|
||||
|
||||
if proxyUrl != "" {
|
||||
if strings.HasPrefix(proxyUrl, "socks5://") {
|
||||
client.Dial = fasthttpproxy.FasthttpSocksDialer(proxyUrl)
|
||||
} else {
|
||||
client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxyUrl)
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
// NewBot creates a new Telegram bot instance with optional proxy and API server settings.
|
||||
func (t *Tgbot) NewBot(token string, proxyUrl string, apiServerUrl string) (*telego.Bot, error) {
|
||||
// Validate proxy URL if provided
|
||||
if proxyUrl != "" {
|
||||
if !isSupportedBotProxyScheme(proxyUrl) {
|
||||
logger.Warning("Unsupported proxy scheme (want socks5:// or http(s)://), ignoring proxy")
|
||||
proxyUrl = "" // Clear invalid proxy
|
||||
} else if _, err := url.Parse(proxyUrl); err != nil {
|
||||
logger.Warningf("Can't parse proxy URL, ignoring proxy: %v", err)
|
||||
proxyUrl = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Validate API server URL if provided
|
||||
if apiServerUrl != "" {
|
||||
safeURL, err := service.SanitizePublicHTTPURL(apiServerUrl, false)
|
||||
if err != nil {
|
||||
logger.Warningf("Invalid or blocked API server URL, using default: %v", err)
|
||||
apiServerUrl = ""
|
||||
} else {
|
||||
apiServerUrl = safeURL
|
||||
}
|
||||
}
|
||||
|
||||
// Create robust fasthttp client
|
||||
client := t.createRobustFastHTTPClient(proxyUrl)
|
||||
|
||||
// Build bot options
|
||||
var options []telego.BotOption
|
||||
options = append(options, telego.WithFastHTTPClient(client))
|
||||
|
||||
if apiServerUrl != "" {
|
||||
options = append(options, telego.WithAPIServer(apiServerUrl))
|
||||
}
|
||||
|
||||
return telego.NewBot(token, options...)
|
||||
}
|
||||
|
||||
// IsRunning checks if the Telegram bot is currently running.
|
||||
func (t *Tgbot) IsRunning() bool {
|
||||
tgBotMutex.Lock()
|
||||
defer tgBotMutex.Unlock()
|
||||
return isRunning
|
||||
}
|
||||
|
||||
// SetHostname sets the hostname for the bot.
|
||||
func (t *Tgbot) SetHostname() {
|
||||
host, err := os.Hostname()
|
||||
if err != nil {
|
||||
logger.Error("get hostname error:", err)
|
||||
hostname = ""
|
||||
return
|
||||
}
|
||||
hostname = host
|
||||
}
|
||||
|
||||
// Stop safely stops the Telegram bot's Long Polling operation.
|
||||
// This method now calls the global StopBot function and cleans up other resources.
|
||||
func (t *Tgbot) Stop() {
|
||||
StopBot()
|
||||
logger.Info("Stop Telegram receiver ...")
|
||||
tgBotMutex.Lock()
|
||||
adminIds = nil
|
||||
tgBotMutex.Unlock()
|
||||
}
|
||||
|
||||
// StopBot safely stops the Telegram bot's Long Polling operation by cancelling its context.
|
||||
// This is the global function called from main.go's signal handler and t.Stop().
|
||||
func StopBot() {
|
||||
// Don't hold the mutex while cancelling/waiting.
|
||||
tgBotMutex.Lock()
|
||||
cancel := botCancel
|
||||
botCancel = nil
|
||||
handler := botHandler
|
||||
botHandler = nil
|
||||
isRunning = false
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
if handler != nil {
|
||||
handler.Stop()
|
||||
}
|
||||
|
||||
if cancel != nil {
|
||||
logger.Info("Sending cancellation signal to Telegram bot...")
|
||||
// Cancels the context passed to UpdatesViaLongPolling; this closes updates channel
|
||||
// and lets botHandler.Start() exit cleanly.
|
||||
cancel()
|
||||
botWG.Wait()
|
||||
logger.Info("Telegram bot successfully stopped.")
|
||||
}
|
||||
}
|
||||
|
||||
// encodeQuery encodes the query string if it's longer than 64 characters.
|
||||
func (t *Tgbot) encodeQuery(query string) string {
|
||||
// NOTE: we only need to hash for more than 64 chars
|
||||
if len(query) <= 64 {
|
||||
return query
|
||||
}
|
||||
|
||||
return hashStorage.SaveHash(query)
|
||||
}
|
||||
|
||||
// decodeQuery decodes a hashed query string back to its original form.
|
||||
func (t *Tgbot) decodeQuery(query string) (string, error) {
|
||||
if !hashStorage.IsMD5(query) {
|
||||
return query, nil
|
||||
}
|
||||
|
||||
decoded, exists := hashStorage.GetValue(query)
|
||||
if !exists {
|
||||
return "", common.NewError("hash not found in storage!")
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// randomLowerAndNum generates a random string of lowercase letters and numbers.
|
||||
func (t *Tgbot) randomLowerAndNum(length int) string {
|
||||
charset := "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
bytes := make([]byte, length)
|
||||
for i := range bytes {
|
||||
randomIndex, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
bytes[i] = charset[randomIndex.Int64()]
|
||||
}
|
||||
return string(bytes)
|
||||
}
|
||||
|
||||
// int64Contains checks if an int64 slice contains a specific item.
|
||||
func int64Contains(slice []int64, item int64) bool {
|
||||
return slices.Contains(slice, item)
|
||||
}
|
||||
|
||||
// isSingleWord checks if the text contains only a single word.
|
||||
func (t *Tgbot) isSingleWord(text string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
re := regexp.MustCompile(`\s+`)
|
||||
return re.MatchString(text)
|
||||
}
|
||||
@@ -0,0 +1,783 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
"github.com/skip2/go-qrcode"
|
||||
)
|
||||
|
||||
// BuildClientDraftMessage builds a protocol-neutral summary of the in-progress
|
||||
// client (email, attached inbounds, traffic limit, expiry, ip limit, comment)
|
||||
// shown in the multi-inbound add flow. Per-protocol secrets (UUID, password,
|
||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
||||
// never has to track them per inbound itself.
|
||||
func (t *Tgbot) BuildClientDraftMessage() string {
|
||||
now := time.Now().UnixMilli()
|
||||
|
||||
expiry := ""
|
||||
switch {
|
||||
case client_ExpiryTime == 0:
|
||||
expiry = t.I18nBot("tgbot.unlimited")
|
||||
case client_ExpiryTime < 0:
|
||||
expiry = fmt.Sprintf("%d %s", client_ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
||||
default:
|
||||
diff := client_ExpiryTime - now
|
||||
if diff > 172800000 {
|
||||
expiry = time.UnixMilli(client_ExpiryTime).Format("2006-01-02 15:04:05")
|
||||
} else {
|
||||
expiry = fmt.Sprintf("%d %s", diff/3600000, t.I18nBot("tgbot.hours"))
|
||||
}
|
||||
}
|
||||
|
||||
traffic := "♾️ Unlimited(Reset)"
|
||||
if client_TotalGB > 0 {
|
||||
traffic = common.FormatTraffic(client_TotalGB)
|
||||
}
|
||||
|
||||
ipLimit := "♾️ Unlimited(Reset)"
|
||||
if client_LimitIP > 0 {
|
||||
ipLimit = fmt.Sprint(client_LimitIP)
|
||||
}
|
||||
|
||||
attached := t.describeAttachedInbounds(receiver_inbound_IDs)
|
||||
if attached == "" {
|
||||
attached = "—"
|
||||
}
|
||||
|
||||
comment := client_Comment
|
||||
if comment == "" {
|
||||
comment = "—"
|
||||
}
|
||||
|
||||
tgID := client_TgID
|
||||
if tgID == "" {
|
||||
tgID = "—"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("📝 *New client draft*\r\n")
|
||||
b.WriteString(fmt.Sprintf("📧 Email: `%s`\r\n", client_Email))
|
||||
b.WriteString(fmt.Sprintf("🔗 Attached: %s\r\n", attached))
|
||||
b.WriteString(fmt.Sprintf("📊 Traffic: %s\r\n", traffic))
|
||||
b.WriteString(fmt.Sprintf("📅 Expire: %s\r\n", expiry))
|
||||
b.WriteString(fmt.Sprintf("🔢 IP limit: %s\r\n", ipLimit))
|
||||
b.WriteString(fmt.Sprintf("👤 TG user: %s\r\n", tgID))
|
||||
b.WriteString(fmt.Sprintf("💬 Comment: %s\r\n", comment))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// describeAttachedInbounds returns a short "remark1, remark2" list for the given
|
||||
// inbound ids, falling back to "#id" when an inbound can't be loaded.
|
||||
func (t *Tgbot) describeAttachedInbounds(ids []int) string {
|
||||
if len(ids) == 0 {
|
||||
return ""
|
||||
}
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
ib, err := t.inboundService.GetInbound(id)
|
||||
if err != nil || ib == nil {
|
||||
parts = append(parts, fmt.Sprintf("#%d", id))
|
||||
continue
|
||||
}
|
||||
label := ib.Remark
|
||||
if label == "" {
|
||||
label = fmt.Sprintf("#%d", id)
|
||||
}
|
||||
parts = append(parts, label)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// SubmitAddClient sends the in-progress client to ClientService.Create with
|
||||
// the full set of attached inbound ids. Per-inbound fillProtocolDefaults on
|
||||
// the panel generates UUID/password/auth per protocol, so the bot only
|
||||
// supplies the universal fields it actually collected.
|
||||
func (t *Tgbot) SubmitAddClient() (bool, error) {
|
||||
inboundIDs := receiver_inbound_IDs
|
||||
if len(inboundIDs) == 0 && receiver_inbound_ID > 0 {
|
||||
inboundIDs = []int{receiver_inbound_ID}
|
||||
}
|
||||
if len(inboundIDs) == 0 {
|
||||
return false, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
tgIDInt, _ := strconv.ParseInt(client_TgID, 10, 64)
|
||||
client := model.Client{
|
||||
Email: client_Email,
|
||||
Enable: client_Enable,
|
||||
LimitIP: client_LimitIP,
|
||||
TotalGB: client_TotalGB,
|
||||
ExpiryTime: client_ExpiryTime,
|
||||
SubID: client_SubID,
|
||||
Comment: client_Comment,
|
||||
Reset: client_Reset,
|
||||
TgID: tgIDInt,
|
||||
}
|
||||
|
||||
return t.clientService.Create(&t.inboundService, &service.ClientCreatePayload{
|
||||
Client: client,
|
||||
InboundIds: inboundIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// buildSubscriptionURLs builds the HTML sub page URL and JSON subscription URL for a client email
|
||||
func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
||||
// Resolve subId from client email
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
_ = traffic
|
||||
if err != nil || client == nil {
|
||||
return "", "", errors.New("client not found")
|
||||
}
|
||||
|
||||
// Gather settings to construct absolute URLs
|
||||
subURI, _ := t.settingService.GetSubURI()
|
||||
subJsonURI, _ := t.settingService.GetSubJsonURI()
|
||||
subDomain, _ := t.settingService.GetSubDomain()
|
||||
subPort, _ := t.settingService.GetSubPort()
|
||||
subPath, _ := t.settingService.GetSubPath()
|
||||
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
||||
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
|
||||
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
||||
subCertFile, _ := t.settingService.GetSubCertFile()
|
||||
|
||||
tls := (subKeyFile != "" && subCertFile != "")
|
||||
scheme := "http"
|
||||
if tls {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
if subDomain == "" {
|
||||
// try panel domain, otherwise OS hostname
|
||||
if d, err := t.settingService.GetWebDomain(); err == nil && d != "" {
|
||||
subDomain = d
|
||||
} else if hostname != "" {
|
||||
subDomain = hostname
|
||||
} else {
|
||||
subDomain = "localhost"
|
||||
}
|
||||
}
|
||||
|
||||
host := subDomain
|
||||
if (subPort == 443 && tls) || (subPort == 80 && !tls) {
|
||||
// standard ports: no port in host
|
||||
} else {
|
||||
host = fmt.Sprintf("%s:%d", subDomain, subPort)
|
||||
}
|
||||
|
||||
// Ensure paths
|
||||
if !strings.HasPrefix(subPath, "/") {
|
||||
subPath = "/" + subPath
|
||||
}
|
||||
if !strings.HasSuffix(subPath, "/") {
|
||||
subPath = subPath + "/"
|
||||
}
|
||||
if !strings.HasPrefix(subJsonPath, "/") {
|
||||
subJsonPath = "/" + subJsonPath
|
||||
}
|
||||
if !strings.HasSuffix(subJsonPath, "/") {
|
||||
subJsonPath = subJsonPath + "/"
|
||||
}
|
||||
|
||||
var subURL string
|
||||
var subJsonURL string
|
||||
|
||||
// If pre-configured URIs are available, use them directly
|
||||
if subURI != "" {
|
||||
if !strings.HasSuffix(subURI, "/") {
|
||||
subURI = subURI + "/"
|
||||
}
|
||||
subURL = fmt.Sprintf("%s%s", subURI, client.SubID)
|
||||
} else {
|
||||
subURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||
}
|
||||
|
||||
if subJsonURI != "" {
|
||||
if !strings.HasSuffix(subJsonURI, "/") {
|
||||
subJsonURI = subJsonURI + "/"
|
||||
}
|
||||
subJsonURL = fmt.Sprintf("%s%s", subJsonURI, client.SubID)
|
||||
} else {
|
||||
|
||||
subJsonURL = fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||
}
|
||||
|
||||
if !subJsonEnable {
|
||||
subJsonURL = ""
|
||||
}
|
||||
return subURL, subJsonURL, nil
|
||||
}
|
||||
|
||||
// sendClientSubLinks sends the subscription links for the client to the chat.
|
||||
func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
|
||||
if subJsonURL != "" {
|
||||
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||
}
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links "+email)),
|
||||
),
|
||||
)
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
|
||||
// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
|
||||
func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
|
||||
// Build the HTML sub page URL; we'll call it with header Accept to get raw content
|
||||
subURL, _, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Try to fetch raw subscription links. Prefer plain text response.
|
||||
req, err := http.NewRequest("GET", subURL, nil)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
// Force plain text to avoid HTML page; controller respects Accept header
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
|
||||
// Use optimized client with connection pooling
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := optimizedHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// If service is configured to encode (Base64), decode it
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
|
||||
if err != nil {
|
||||
// fallback to raw text
|
||||
content = string(bodyBytes)
|
||||
} else {
|
||||
content = string(decoded)
|
||||
}
|
||||
} else {
|
||||
content = string(bodyBytes)
|
||||
}
|
||||
|
||||
// Normalize line endings and trim
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
|
||||
return
|
||||
}
|
||||
|
||||
// Send in chunks to respect message length; use monospace formatting
|
||||
const maxPerMessage = 50
|
||||
for i := 0; i < len(cleaned); i += maxPerMessage {
|
||||
j := min(i+maxPerMessage, len(cleaned))
|
||||
chunk := cleaned[i:j]
|
||||
var msg strings.Builder
|
||||
msg.WriteString(t.I18nBot("subscription.individualLinks"))
|
||||
msg.WriteString(":\r\n")
|
||||
for _, link := range chunk {
|
||||
// wrap each link in <code>
|
||||
msg.WriteString("<code>")
|
||||
msg.WriteString(link)
|
||||
msg.WriteString("</code>\r\n")
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg.String())
|
||||
}
|
||||
}
|
||||
|
||||
// sendClientQRLinks generates QR images for subscription URL, JSON URL, and a few individual links, then sends them
|
||||
func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
||||
subURL, subJsonURL, err := t.buildSubscriptionURLs(email)
|
||||
if err != nil {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Helper to create QR PNG bytes from content
|
||||
createQR := func(content string, size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
size = 256
|
||||
}
|
||||
return qrcode.Encode(content, qrcode.Medium, size)
|
||||
}
|
||||
|
||||
// Inform user
|
||||
t.SendMsgToTgbot(chatId, "QRCode for client "+email+":")
|
||||
|
||||
// Send sub URL QR (filename: sub.png)
|
||||
if png, err := createQR(subURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "sub.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
|
||||
// Send JSON URL QR (filename: subjson.png) when available
|
||||
if subJsonURL != "" {
|
||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Also generate a few individual links' QRs (first up to 5)
|
||||
subPageURL := subURL
|
||||
req, err := http.NewRequest("GET", subPageURL, nil)
|
||||
if err == nil {
|
||||
req.Header.Set("Accept", "text/plain, */*;q=0.1")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
req = req.WithContext(ctx)
|
||||
if resp, err := optimizedHTTPClient.Do(req); err == nil {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
encoded, _ := t.settingService.GetSubEncrypt()
|
||||
var content string
|
||||
if encoded {
|
||||
if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
|
||||
content = string(dec)
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
} else {
|
||||
content = string(body)
|
||||
}
|
||||
lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
|
||||
var cleaned []string
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l != "" {
|
||||
cleaned = append(cleaned, l)
|
||||
}
|
||||
}
|
||||
if len(cleaned) > 0 {
|
||||
max := min(len(cleaned), 5)
|
||||
for i := range max {
|
||||
if png, err := createQR(cleaned[i], 320); err == nil {
|
||||
// Use the email as filename for individual link QR
|
||||
filename := email + ".png"
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, filename),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
// Reduced delay for better performance
|
||||
if i < max-1 { // Only delay between documents, not after the last one
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clientInfoMsg formats client information message based on traffic and flags.
|
||||
func (t *Tgbot) clientInfoMsg(
|
||||
traffic *xray.ClientTraffic,
|
||||
printEnabled bool,
|
||||
printOnline bool,
|
||||
printActive bool,
|
||||
printDate bool,
|
||||
printTraffic bool,
|
||||
printRefreshed bool,
|
||||
) string {
|
||||
now := time.Now().Unix()
|
||||
expiryTime := ""
|
||||
flag := false
|
||||
diff := traffic.ExpiryTime/1000 - now
|
||||
if traffic.ExpiryTime == 0 {
|
||||
expiryTime = t.I18nBot("tgbot.unlimited")
|
||||
} else if diff > 172800 || !traffic.Enable {
|
||||
expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
|
||||
if diff > 0 {
|
||||
days := diff / 86400
|
||||
hours := (diff % 86400) / 3600
|
||||
minutes := (diff % 3600) / 60
|
||||
remainingTime := ""
|
||||
if days > 0 {
|
||||
remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days"))
|
||||
}
|
||||
if hours > 0 {
|
||||
remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours"))
|
||||
}
|
||||
if minutes > 0 {
|
||||
remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes"))
|
||||
}
|
||||
expiryTime += fmt.Sprintf(" (%s)", remainingTime)
|
||||
}
|
||||
} else if traffic.ExpiryTime < 0 {
|
||||
expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
|
||||
flag = true
|
||||
} else {
|
||||
expiryTime = fmt.Sprintf("%d %s", diff/3600, t.I18nBot("tgbot.hours"))
|
||||
flag = true
|
||||
}
|
||||
|
||||
total := ""
|
||||
if traffic.Total == 0 {
|
||||
total = t.I18nBot("tgbot.unlimited")
|
||||
} else {
|
||||
total = common.FormatTraffic((traffic.Total))
|
||||
}
|
||||
|
||||
enabled := ""
|
||||
isEnabled, err := t.clientService.CheckIsEnabledByEmail(&t.inboundService, traffic.Email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
enabled = t.I18nBot("tgbot.wentWrong")
|
||||
} else if isEnabled {
|
||||
enabled = t.I18nBot("tgbot.messages.yes")
|
||||
} else {
|
||||
enabled = t.I18nBot("tgbot.messages.no")
|
||||
}
|
||||
|
||||
active := ""
|
||||
if traffic.Enable {
|
||||
active = t.I18nBot("tgbot.messages.yes")
|
||||
} else {
|
||||
active = t.I18nBot("tgbot.messages.no")
|
||||
}
|
||||
|
||||
status := t.I18nBot("tgbot.offline")
|
||||
isOnline := false
|
||||
if service.XrayProcess().IsRunning() {
|
||||
if slices.Contains(service.XrayProcess().GetOnlineClients(), traffic.Email) {
|
||||
status = t.I18nBot("tgbot.online")
|
||||
isOnline = true
|
||||
}
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+traffic.Email)
|
||||
if attachIds, err := t.clientService.GetInboundIdsForEmail(nil, traffic.Email); err == nil && len(attachIds) > 0 {
|
||||
output += fmt.Sprintf("🔗 Inbounds: %s\r\n", t.describeAttachedInbounds(attachIds))
|
||||
}
|
||||
if printEnabled {
|
||||
output += t.I18nBot("tgbot.messages.enabled", "Enable=="+enabled)
|
||||
}
|
||||
if printOnline {
|
||||
output += t.I18nBot("tgbot.messages.online", "Status=="+status)
|
||||
if !isOnline && traffic.LastOnline > 0 {
|
||||
output += t.I18nBot("tgbot.messages.lastOnline", "Time=="+time.UnixMilli(traffic.LastOnline).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
}
|
||||
if printActive {
|
||||
output += t.I18nBot("tgbot.messages.active", "Enable=="+active)
|
||||
}
|
||||
if printDate {
|
||||
if flag {
|
||||
output += t.I18nBot("tgbot.messages.expireIn", "Time=="+expiryTime)
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+expiryTime)
|
||||
}
|
||||
}
|
||||
if printTraffic {
|
||||
output += t.I18nBot("tgbot.messages.upload", "Upload=="+common.FormatTraffic(traffic.Up))
|
||||
output += t.I18nBot("tgbot.messages.download", "Download=="+common.FormatTraffic(traffic.Down))
|
||||
output += t.I18nBot("tgbot.messages.total", "UpDown=="+common.FormatTraffic((traffic.Up+traffic.Down)), "Total=="+total)
|
||||
}
|
||||
if printRefreshed {
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// getClientUsage retrieves and sends client usage information to the chat.
|
||||
func (t *Tgbot) getClientUsage(chatId int64, tgUserID int64, email ...string) {
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(tgUserID)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
if len(traffics) == 0 {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.askToAddUserId", "TgUserID=="+strconv.FormatInt(tgUserID, 10)))
|
||||
return
|
||||
}
|
||||
|
||||
output := ""
|
||||
|
||||
if len(traffics) > 0 {
|
||||
if len(email) > 0 {
|
||||
for _, traffic := range traffics {
|
||||
if traffic.Email == email[0] {
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
return
|
||||
}
|
||||
}
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
} else {
|
||||
for _, traffic := range traffics {
|
||||
output += t.clientInfoMsg(traffic, true, true, true, true, true, false)
|
||||
output += "\r\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
output = t.I18nBot("tgbot.commands.pleaseChoose")
|
||||
t.SendAnswer(chatId, output, false)
|
||||
}
|
||||
|
||||
// searchClientIps searches and sends client IP addresses for the given email.
|
||||
func (t *Tgbot) searchClientIps(chatId int64, email string, messageID ...int) {
|
||||
ips, err := t.inboundService.GetInboundClientIps(email)
|
||||
if err != nil || len(ips) == 0 {
|
||||
ips = t.I18nBot("tgbot.noIpRecord")
|
||||
}
|
||||
|
||||
formattedIps := ips
|
||||
if err == nil && len(ips) > 0 {
|
||||
type ipWithTimestamp struct {
|
||||
IP string `json:"ip"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var ipsWithTime []ipWithTimestamp
|
||||
if json.Unmarshal([]byte(ips), &ipsWithTime) == nil && len(ipsWithTime) > 0 {
|
||||
lines := make([]string, 0, len(ipsWithTime))
|
||||
for _, item := range ipsWithTime {
|
||||
if item.IP == "" {
|
||||
continue
|
||||
}
|
||||
if item.Timestamp > 0 {
|
||||
ts := time.Unix(item.Timestamp, 0).Format("2006-01-02 15:04:05")
|
||||
lines = append(lines, fmt.Sprintf("%s (%s)", item.IP, ts))
|
||||
continue
|
||||
}
|
||||
lines = append(lines, item.IP)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
formattedIps = strings.Join(lines, "\n")
|
||||
}
|
||||
} else {
|
||||
var oldIps []string
|
||||
if json.Unmarshal([]byte(ips), &oldIps) == nil && len(oldIps) > 0 {
|
||||
formattedIps = strings.Join(oldIps, "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||
output += t.I18nBot("tgbot.messages.ips", "IPs=="+formattedIps)
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("ips_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clearIPs")).WithCallbackData(t.encodeQuery("clear_ips "+email)),
|
||||
),
|
||||
)
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// clientTelegramUserInfo retrieves and sends Telegram user info for the client.
|
||||
func (t *Tgbot) clientTelegramUserInfo(chatId int64, email string, messageID ...int) {
|
||||
traffic, client, err := t.inboundService.GetClientByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if client == nil {
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
tgId := "None"
|
||||
if client.TgID != 0 {
|
||||
tgId = strconv.FormatInt(client.TgID, 10)
|
||||
}
|
||||
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.email", "Email=="+email)
|
||||
output += t.I18nBot("tgbot.messages.TGUser", "TelegramID=="+tgId)
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("tgid_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.removeTGUser")).WithCallbackData(t.encodeQuery("tgid_remove "+email)),
|
||||
),
|
||||
)
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
requestUser := telego.KeyboardButtonRequestUsers{
|
||||
RequestID: int32(traffic.Id),
|
||||
UserIsBot: new(bool),
|
||||
}
|
||||
keyboard := tu.Keyboard(
|
||||
tu.KeyboardRow(
|
||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.selectTGUser")).WithRequestUsers(&requestUser),
|
||||
),
|
||||
tu.KeyboardRow(
|
||||
tu.KeyboardButton(t.I18nBot("tgbot.buttons.closeKeyboard")),
|
||||
),
|
||||
).WithIsPersistent().WithResizeKeyboard()
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.buttons.selectOneTGUser"), keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// searchClient searches for a client by email and sends the information.
|
||||
func (t *Tgbot) searchClient(chatId int64, email string, messageID ...int) {
|
||||
traffic, err := t.inboundService.GetClientTrafficByEmail(email)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if traffic == nil {
|
||||
msg := t.I18nBot("tgbot.noResult")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
output := t.clientInfoMsg(traffic, true, true, true, true, true, true)
|
||||
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("client_refresh "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetTraffic")).WithCallbackData(t.encodeQuery("reset_traffic "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData(t.encodeQuery("limit_traffic "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData(t.encodeQuery("reset_exp "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLog")).WithCallbackData(t.encodeQuery("ip_log "+email)),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData(t.encodeQuery("ip_limit "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData(t.encodeQuery("tg_user "+email)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.toggle")).WithCallbackData(t.encodeQuery("toggle_enable "+email)),
|
||||
),
|
||||
)
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// getCommonClientButtons returns the shared inline keyboard rows for the
|
||||
// client-first multi-inbound add flow. Per-protocol secrets (UUID, password,
|
||||
// flow, method) are generated by fillProtocolDefaults on submit, so the bot
|
||||
// only exposes the universal client fields here.
|
||||
func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
|
||||
attachLabel := fmt.Sprintf("➕ Attach inbound (%d)", len(receiver_inbound_IDs))
|
||||
return [][]telego.InlineKeyboardButton{
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_comment")).WithCallbackData("add_client_ch_default_comment"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.limitTraffic")).WithCallbackData("add_client_ch_default_traffic"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.resetExpire")).WithCallbackData("add_client_ch_default_exp"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ipLimit")).WithCallbackData("add_client_ch_default_ip_limit"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.setTGUser")).WithCallbackData("add_client_ch_default_tg_id"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(attachLabel).WithCallbackData("add_client_attach_more"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitDisable")).WithCallbackData("add_client_submit_disable"),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.submitEnable")).WithCallbackData("add_client_submit_enable"),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData("add_client_cancel"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// addClient renders the draft message + shared client-first keyboard.
|
||||
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
|
||||
inlineKeyboard := tu.InlineKeyboard(t.getCommonClientButtons()...)
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], msg, inlineKeyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// getInboundUsages retrieves and formats inbound usage information.
|
||||
func (t *Tgbot) getInboundUsages() string {
|
||||
var info strings.Builder
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
info.WriteString(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
return info.String()
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
info.WriteString(t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark))
|
||||
info.WriteString(t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port)))
|
||||
info.WriteString(t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down)))
|
||||
|
||||
clients, listErr := t.clientService.ListForInbound(nil, inbound.Id)
|
||||
if listErr == nil {
|
||||
info.WriteString(fmt.Sprintf("👥 Clients: %d\r\n", len(clients)))
|
||||
}
|
||||
|
||||
if inbound.ExpiryTime == 0 {
|
||||
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited")))
|
||||
} else {
|
||||
info.WriteString(t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
info.WriteString("\r\n")
|
||||
}
|
||||
return info.String()
|
||||
}
|
||||
|
||||
// getInbounds creates an inline keyboard with all inbounds.
|
||||
func (t *Tgbot) getInbounds() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "get_clients", inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsFor builds an inline keyboard of inbounds for a custom next action.
|
||||
func (t *Tgbot) getInboundsFor(nextAction string) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", nextAction, inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundClientsFor lists clients of an inbound with a specific action prefix to be appended with email
|
||||
func (t *Tgbot) getInboundClientsFor(inboundID int, action string) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbound, err := t.inboundService.GetInbound(inboundID)
|
||||
if err != nil {
|
||||
logger.Warning("getInboundClientsFor run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("GetInboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
} else {
|
||||
if len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery(action+" "+client.Email)))
|
||||
}
|
||||
|
||||
} else {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
|
||||
}
|
||||
cols := 0
|
||||
if len(buttons) < 6 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsAddClient creates an inline keyboard for adding clients to inbounds.
|
||||
func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
logger.Warning("No inbounds found")
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
|
||||
excludedProtocols := map[model.Protocol]bool{
|
||||
model.Tunnel: true,
|
||||
model.Mixed: true,
|
||||
model.WireGuard: true,
|
||||
model.HTTP: true,
|
||||
}
|
||||
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, inbound := range inbounds {
|
||||
if excludedProtocols[inbound.Protocol] {
|
||||
continue
|
||||
}
|
||||
|
||||
status := "❌"
|
||||
if inbound.Enable {
|
||||
status = "✅"
|
||||
}
|
||||
callbackData := t.encodeQuery(fmt.Sprintf("%s %d", "add_client_to", inbound.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(fmt.Sprintf("%v - %v", inbound.Remark, status)).WithCallbackData(callbackData))
|
||||
}
|
||||
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// getInboundsAttachPicker builds a toggle picker over multi-client inbounds
|
||||
// for the "attach more inbounds to the new client" step. Each row shows the
|
||||
// current selection state for the inbound; tapping fires
|
||||
// add_client_toggle_attach <id> which flips it and re-renders. A final
|
||||
// "Done" button (add_client_attach_done) returns to the field-edit screen.
|
||||
func (t *Tgbot) getInboundsAttachPicker() (*telego.InlineKeyboardMarkup, error) {
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("GetAllInbounds run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
excludedProtocols := map[model.Protocol]bool{
|
||||
model.Tunnel: true,
|
||||
model.Mixed: true,
|
||||
model.WireGuard: true,
|
||||
model.HTTP: true,
|
||||
}
|
||||
selected := make(map[int]bool, len(receiver_inbound_IDs))
|
||||
for _, id := range receiver_inbound_IDs {
|
||||
selected[id] = true
|
||||
}
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, ib := range inbounds {
|
||||
if excludedProtocols[ib.Protocol] {
|
||||
continue
|
||||
}
|
||||
mark := "☐"
|
||||
if selected[ib.Id] {
|
||||
mark = "✅"
|
||||
}
|
||||
label := fmt.Sprintf("%s %s (%s)", mark, ib.Remark, ib.Protocol)
|
||||
callback := t.encodeQuery(fmt.Sprintf("add_client_toggle_attach %d", ib.Id))
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(label).WithCallbackData(callback))
|
||||
}
|
||||
cols := 1
|
||||
if len(buttons) >= 6 {
|
||||
cols = 2
|
||||
}
|
||||
rows := tu.InlineKeyboardCols(cols, buttons...)
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton("✅ Done").WithCallbackData(t.encodeQuery("add_client_attach_done")),
|
||||
))
|
||||
return tu.InlineKeyboardGrid(rows), nil
|
||||
}
|
||||
|
||||
// getInboundClients creates an inline keyboard with clients of a specific inbound.
|
||||
func (t *Tgbot) getInboundClients(id int) (*telego.InlineKeyboardMarkup, error) {
|
||||
inbound, err := t.inboundService.GetInbound(id)
|
||||
if err != nil {
|
||||
logger.Warning("getIboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
}
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
|
||||
if err != nil {
|
||||
logger.Warning("GetInboundClients run failed:", err)
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed"))
|
||||
} else {
|
||||
if len(clients) > 0 {
|
||||
for _, client := range clients {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(client.Email).WithCallbackData(t.encodeQuery("client_get_usage "+client.Email)))
|
||||
}
|
||||
|
||||
} else {
|
||||
return nil, errors.New(t.I18nBot("tgbot.answers.getClientsFailed"))
|
||||
}
|
||||
|
||||
}
|
||||
cols := 0
|
||||
if len(buttons) < 6 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
|
||||
return keyboard, nil
|
||||
}
|
||||
|
||||
// searchInbound searches for inbounds by remark and sends the results.
|
||||
func (t *Tgbot) searchInbound(chatId int64, remark string) {
|
||||
inbounds, err := t.inboundService.SearchInbounds(remark)
|
||||
if err != nil {
|
||||
logger.Warning(err)
|
||||
msg := t.I18nBot("tgbot.wentWrong")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
if len(inbounds) == 0 {
|
||||
msg := t.I18nBot("tgbot.noInbounds")
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
return
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
info := ""
|
||||
info += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
||||
info += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
||||
|
||||
if inbound.ExpiryTime == 0 {
|
||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
||||
} else {
|
||||
info += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, info)
|
||||
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
var output strings.Builder
|
||||
for _, traffic := range inbound.ClientStats {
|
||||
output.WriteString(t.clientInfoMsg(&traffic, true, true, true, true, true, true))
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, output.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// SendReport sends a periodic report to admin chats.
|
||||
func (t *Tgbot) SendReport() {
|
||||
runTime, err := t.settingService.GetTgbotRuntime()
|
||||
if err == nil && len(runTime) > 0 {
|
||||
msg := ""
|
||||
msg += t.I18nBot("tgbot.messages.report", "RunTime=="+runTime)
|
||||
msg += t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
|
||||
info := t.sendServerUsage()
|
||||
t.SendMsgToTgbotAdmins(info)
|
||||
|
||||
t.sendExhaustedToAdmins()
|
||||
t.notifyExhausted()
|
||||
|
||||
backupEnable, err := t.settingService.GetTgBotBackup()
|
||||
if err == nil && backupEnable {
|
||||
t.SendBackupToAdmins()
|
||||
}
|
||||
}
|
||||
|
||||
// SendBackupToAdmins sends a database backup to admin chats.
|
||||
func (t *Tgbot) SendBackupToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
for i, adminId := range adminIds {
|
||||
t.sendBackup(int64(adminId))
|
||||
// Add delay between sends to avoid Telegram rate limits
|
||||
if i < len(adminIds)-1 {
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendExhaustedToAdmins sends notifications about exhausted clients to admins.
|
||||
func (t *Tgbot) sendExhaustedToAdmins() {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
for _, adminId := range adminIds {
|
||||
t.getExhausted(int64(adminId))
|
||||
}
|
||||
}
|
||||
|
||||
// getServerUsage retrieves and formats server usage information.
|
||||
func (t *Tgbot) getServerUsage(chatId int64, messageID ...int) string {
|
||||
info := t.prepareServerUsageInfo()
|
||||
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("usage_refresh"))))
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], info, keyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, info, keyboard)
|
||||
}
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// Send server usage without an inline keyboard
|
||||
func (t *Tgbot) sendServerUsage() string {
|
||||
info := t.prepareServerUsageInfo()
|
||||
return info
|
||||
}
|
||||
|
||||
// prepareServerUsageInfo prepares the server usage information string.
|
||||
func (t *Tgbot) prepareServerUsageInfo() string {
|
||||
// Check if we have cached data first
|
||||
if cachedStats, found := t.getCachedServerStats(); found {
|
||||
return cachedStats
|
||||
}
|
||||
|
||||
info, ipv4, ipv6 := "", "", ""
|
||||
|
||||
// get latest status of server with caching
|
||||
if cachedStatus, found := t.getCachedStatus(); found {
|
||||
t.lastStatus = cachedStatus
|
||||
} else {
|
||||
t.lastStatus = t.serverService.GetStatus(t.lastStatus)
|
||||
t.setCachedStatus(t.lastStatus)
|
||||
}
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
|
||||
info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
info += t.I18nBot("tgbot.messages.version", "Version=="+config.GetVersion())
|
||||
info += t.I18nBot("tgbot.messages.xrayVersion", "XrayVersion=="+fmt.Sprint(t.lastStatus.Xray.Version))
|
||||
|
||||
// get ip address
|
||||
netInterfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
logger.Error("net.Interfaces failed, err: ", err.Error())
|
||||
info += t.I18nBot("tgbot.messages.ip", "IP=="+t.I18nBot("tgbot.unknown"))
|
||||
info += "\r\n"
|
||||
} else {
|
||||
for i := range netInterfaces {
|
||||
if (netInterfaces[i].Flags & net.FlagUp) != 0 {
|
||||
addrs, _ := netInterfaces[i].Addrs()
|
||||
|
||||
for _, address := range addrs {
|
||||
if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
if ipnet.IP.To4() != nil {
|
||||
ipv4 += ipnet.IP.String() + " "
|
||||
} else if ipnet.IP.To16() != nil && !ipnet.IP.IsLinkLocalUnicast() {
|
||||
ipv6 += ipnet.IP.String() + " "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.ipv4", "IPv4=="+ipv4)
|
||||
info += t.I18nBot("tgbot.messages.ipv6", "IPv6=="+ipv6)
|
||||
}
|
||||
|
||||
info += t.I18nBot("tgbot.messages.serverUpTime", "UpTime=="+strconv.FormatUint(t.lastStatus.Uptime/86400, 10), "Unit=="+t.I18nBot("tgbot.days"))
|
||||
info += t.I18nBot("tgbot.messages.serverLoad", "Load1=="+strconv.FormatFloat(t.lastStatus.Loads[0], 'f', 2, 64), "Load2=="+strconv.FormatFloat(t.lastStatus.Loads[1], 'f', 2, 64), "Load3=="+strconv.FormatFloat(t.lastStatus.Loads[2], 'f', 2, 64))
|
||||
info += t.I18nBot("tgbot.messages.serverMemory", "Current=="+common.FormatTraffic(int64(t.lastStatus.Mem.Current)), "Total=="+common.FormatTraffic(int64(t.lastStatus.Mem.Total)))
|
||||
info += t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(len(onlines)))
|
||||
info += t.I18nBot("tgbot.messages.tcpCount", "Count=="+strconv.Itoa(t.lastStatus.TcpCount))
|
||||
info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount))
|
||||
info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv)))
|
||||
info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State))
|
||||
|
||||
// Cache the complete server stats
|
||||
t.setCachedServerStats(info)
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// UserLoginNotify sends a notification about user login attempts to admins.
|
||||
func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
|
||||
if !t.IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
|
||||
logger.Warning("UserLoginNotify failed, invalid info!")
|
||||
return
|
||||
}
|
||||
|
||||
loginNotifyEnabled, err := t.settingService.GetTgBotLoginNotify()
|
||||
if err != nil || !loginNotifyEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
msg := ""
|
||||
switch attempt.Status {
|
||||
case LoginSuccess:
|
||||
msg += t.I18nBot("tgbot.messages.loginSuccess")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
case LoginFail:
|
||||
msg += t.I18nBot("tgbot.messages.loginFailed")
|
||||
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
|
||||
if attempt.Reason != "" {
|
||||
msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
|
||||
}
|
||||
}
|
||||
msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
|
||||
msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
|
||||
msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
|
||||
go t.SendMsgToTgbotAdmins(msg)
|
||||
}
|
||||
|
||||
// getExhausted retrieves and sends information about exhausted clients.
|
||||
func (t *Tgbot) getExhausted(chatId int64) {
|
||||
trDiff := int64(0)
|
||||
exDiff := int64(0)
|
||||
now := time.Now().Unix() * 1000
|
||||
var exhaustedInbounds []model.Inbound
|
||||
var exhaustedClients []xray.ClientTraffic
|
||||
var disabledInbounds []model.Inbound
|
||||
var disabledClients []xray.ClientTraffic
|
||||
|
||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
||||
if err == nil && TrafficThreshold > 0 {
|
||||
trDiff = int64(TrafficThreshold) * 1073741824
|
||||
}
|
||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
||||
if err == nil && ExpireThreshold > 0 {
|
||||
exDiff = int64(ExpireThreshold) * 86400000
|
||||
}
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load Inbounds", err)
|
||||
}
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Enable {
|
||||
if (inbound.ExpiryTime > 0 && (inbound.ExpiryTime-now < exDiff)) ||
|
||||
(inbound.Total > 0 && (inbound.Total-(inbound.Up+inbound.Down) < trDiff)) {
|
||||
exhaustedInbounds = append(exhaustedInbounds, *inbound)
|
||||
}
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
for _, client := range inbound.ClientStats {
|
||||
if client.Enable {
|
||||
if (client.ExpiryTime > 0 && (client.ExpiryTime-now < exDiff)) ||
|
||||
(client.Total > 0 && (client.Total-(client.Up+client.Down) < trDiff)) {
|
||||
exhaustedClients = append(exhaustedClients, client)
|
||||
}
|
||||
} else {
|
||||
disabledClients = append(disabledClients, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
disabledInbounds = append(disabledInbounds, *inbound)
|
||||
}
|
||||
}
|
||||
|
||||
// Inbounds
|
||||
output := ""
|
||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.inbounds"))
|
||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledInbounds)))
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedInbounds)))
|
||||
|
||||
if len(exhaustedInbounds) > 0 {
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.inbounds"))
|
||||
|
||||
for _, inbound := range exhaustedInbounds {
|
||||
output += t.I18nBot("tgbot.messages.inbound", "Remark=="+inbound.Remark)
|
||||
output += t.I18nBot("tgbot.messages.port", "Port=="+strconv.Itoa(inbound.Port))
|
||||
output += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic((inbound.Up+inbound.Down)), "Upload=="+common.FormatTraffic(inbound.Up), "Download=="+common.FormatTraffic(inbound.Down))
|
||||
if inbound.ExpiryTime == 0 {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+t.I18nBot("tgbot.unlimited"))
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.expire", "Time=="+time.Unix((inbound.ExpiryTime/1000), 0).Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
output += "\r\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Clients
|
||||
exhaustedCC := len(exhaustedClients)
|
||||
output += t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients"))
|
||||
output += t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients)))
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(exhaustedCC))
|
||||
|
||||
if exhaustedCC > 0 {
|
||||
output += t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+t.I18nBot("tgbot.clients"))
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, traffic := range exhaustedClients {
|
||||
output += t.clientInfoMsg(&traffic, true, false, false, true, true, false)
|
||||
output += "\r\n"
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(traffic.Email).WithCallbackData(t.encodeQuery("client_get_usage "+traffic.Email)))
|
||||
}
|
||||
cols := 0
|
||||
if exhaustedCC < 11 {
|
||||
cols = 1
|
||||
} else {
|
||||
cols = 2
|
||||
}
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
keyboard := tu.InlineKeyboardGrid(tu.InlineKeyboardCols(cols, buttons...))
|
||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
||||
} else {
|
||||
output += t.I18nBot("tgbot.messages.refreshedOn", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyExhausted sends notifications for exhausted clients.
|
||||
func (t *Tgbot) notifyExhausted() {
|
||||
trDiff := int64(0)
|
||||
exDiff := int64(0)
|
||||
now := time.Now().Unix() * 1000
|
||||
|
||||
TrafficThreshold, err := t.settingService.GetTrafficDiff()
|
||||
if err == nil && TrafficThreshold > 0 {
|
||||
trDiff = int64(TrafficThreshold) * 1073741824
|
||||
}
|
||||
ExpireThreshold, err := t.settingService.GetExpireDiff()
|
||||
if err == nil && ExpireThreshold > 0 {
|
||||
exDiff = int64(ExpireThreshold) * 86400000
|
||||
}
|
||||
inbounds, err := t.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
logger.Warning("Unable to load Inbounds", err)
|
||||
}
|
||||
|
||||
var chatIDsDone []int64
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Enable {
|
||||
if len(inbound.ClientStats) > 0 {
|
||||
clients, err := t.inboundService.GetClients(inbound)
|
||||
if err == nil {
|
||||
for _, client := range clients {
|
||||
if client.TgID != 0 {
|
||||
chatID := client.TgID
|
||||
if !int64Contains(chatIDsDone, chatID) && !checkAdmin(chatID) {
|
||||
var disabledClients []xray.ClientTraffic
|
||||
var exhaustedClients []xray.ClientTraffic
|
||||
traffics, err := t.inboundService.GetClientTrafficTgBot(client.TgID)
|
||||
if err == nil && len(traffics) > 0 {
|
||||
var output strings.Builder
|
||||
output.WriteString(t.I18nBot("tgbot.messages.exhaustedCount", "Type=="+t.I18nBot("tgbot.clients")))
|
||||
for _, traffic := range traffics {
|
||||
if traffic.Enable {
|
||||
if (traffic.ExpiryTime > 0 && (traffic.ExpiryTime-now < exDiff)) ||
|
||||
(traffic.Total > 0 && (traffic.Total-(traffic.Up+traffic.Down) < trDiff)) {
|
||||
exhaustedClients = append(exhaustedClients, *traffic)
|
||||
}
|
||||
} else {
|
||||
disabledClients = append(disabledClients, *traffic)
|
||||
}
|
||||
}
|
||||
if len(exhaustedClients) > 0 {
|
||||
output.WriteString(t.I18nBot("tgbot.messages.disabled", "Disabled=="+strconv.Itoa(len(disabledClients))))
|
||||
if len(disabledClients) > 0 {
|
||||
output.WriteString(t.I18nBot("tgbot.clients"))
|
||||
output.WriteString(":\r\n")
|
||||
for _, traffic := range disabledClients {
|
||||
output.WriteString(" ")
|
||||
output.WriteString(traffic.Email)
|
||||
}
|
||||
output.WriteString("\r\n")
|
||||
}
|
||||
output.WriteString("\r\n")
|
||||
output.WriteString(t.I18nBot("tgbot.messages.depleteSoon", "Deplete=="+strconv.Itoa(len(exhaustedClients))))
|
||||
for _, traffic := range exhaustedClients {
|
||||
output.WriteString(t.clientInfoMsg(&traffic, true, false, false, true, true, false))
|
||||
output.WriteString("\r\n")
|
||||
}
|
||||
t.SendMsgToTgbot(chatID, output.String())
|
||||
}
|
||||
chatIDsDone = append(chatIDsDone, chatID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// onlineClients retrieves and sends information about online clients.
|
||||
func (t *Tgbot) onlineClients(chatId int64, messageID ...int) {
|
||||
if !service.XrayProcess().IsRunning() {
|
||||
return
|
||||
}
|
||||
|
||||
onlines := service.XrayProcess().GetOnlineClients()
|
||||
onlinesCount := len(onlines)
|
||||
output := t.I18nBot("tgbot.messages.onlinesCount", "Count=="+fmt.Sprint(onlinesCount))
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.refresh")).WithCallbackData(t.encodeQuery("onlines_refresh"))))
|
||||
|
||||
if onlinesCount > 0 {
|
||||
var buttons []telego.InlineKeyboardButton
|
||||
for _, online := range onlines {
|
||||
buttons = append(buttons, tu.InlineKeyboardButton(online).WithCallbackData(t.encodeQuery("client_get_usage "+online)))
|
||||
}
|
||||
cols := 0
|
||||
if onlinesCount < 21 {
|
||||
cols = 2
|
||||
} else if onlinesCount < 61 {
|
||||
cols = 3
|
||||
} else {
|
||||
cols = 4
|
||||
}
|
||||
keyboard.InlineKeyboard = append(keyboard.InlineKeyboard, tu.InlineKeyboardCols(cols, buttons...)...)
|
||||
}
|
||||
|
||||
if len(messageID) > 0 {
|
||||
t.editMessageTgBot(chatId, messageID[0], output, keyboard)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, output, keyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// sendBackup sends a backup of the database and configuration files.
|
||||
func (t *Tgbot) sendBackup(chatId int64) {
|
||||
output := t.I18nBot("tgbot.messages.backupTime", "Time=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
|
||||
// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
|
||||
dbData, err := t.serverService.GetDb()
|
||||
if err == nil {
|
||||
dbFilename := "x-ui.db"
|
||||
if database.IsPostgres() {
|
||||
dbFilename = "x-ui.dump"
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(dbData, dbFilename),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
cancel()
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading backup: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in getting db backup: ", err)
|
||||
}
|
||||
|
||||
// Small delay between file sends
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// Send config.json backup
|
||||
file, err := os.Open(xray.GetConfigPath())
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(ctx, document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading config.json: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Error("Error in opening config.json file for backup: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendBanLogs sends the ban logs to the specified chat.
|
||||
func (t *Tgbot) sendBanLogs(chatId int64, dt bool) {
|
||||
if dt {
|
||||
output := t.I18nBot("tgbot.messages.datetime", "DateTime=="+time.Now().Format("2006-01-02 15:04:05"))
|
||||
t.SendMsgToTgbot(chatId, output)
|
||||
}
|
||||
|
||||
file, err := os.Open(xray.GetIPLimitBannedPrevLogPath())
|
||||
if err == nil {
|
||||
// Check if the file is non-empty before attempting to upload
|
||||
fileInfo, _ := file.Stat()
|
||||
if fileInfo.Size() > 0 {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedPrevLog: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("IPLimitBannedPrevLog file is empty, not uploading.")
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
logger.Error("Error in opening IPLimitBannedPrevLog file for backup: ", err)
|
||||
}
|
||||
|
||||
file, err = os.Open(xray.GetIPLimitBannedLogPath())
|
||||
if err == nil {
|
||||
// Check if the file is non-empty before attempting to upload
|
||||
fileInfo, _ := file.Stat()
|
||||
if fileInfo.Size() > 0 {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.File(file),
|
||||
)
|
||||
_, err = bot.SendDocument(context.Background(), document)
|
||||
if err != nil {
|
||||
logger.Error("Error in uploading IPLimitBannedLog: ", err)
|
||||
}
|
||||
} else {
|
||||
logger.Warning("IPLimitBannedLog file is empty, not uploading.")
|
||||
}
|
||||
file.Close()
|
||||
} else {
|
||||
logger.Error("Error in opening IPLimitBannedLog file for backup: ", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,249 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// sendResponse sends the response message based on the onlyMessage flag.
|
||||
func (t *Tgbot) sendResponse(chatId int64, msg string, onlyMessage, isAdmin bool) {
|
||||
if onlyMessage {
|
||||
t.SendMsgToTgbot(chatId, msg)
|
||||
} else {
|
||||
t.SendAnswer(chatId, msg, isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
// SendAnswer sends a response message with an inline keyboard to the specified chat.
|
||||
func (t *Tgbot) SendAnswer(chatId int64, msg string, isAdmin bool) {
|
||||
numericKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.SortedTrafficUsageReport")).WithCallbackData(t.encodeQuery("get_sorted_traffic_usage_report")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.serverUsage")).WithCallbackData(t.encodeQuery("get_usage")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.ResetAllTraffics")).WithCallbackData(t.encodeQuery("reset_all_traffics")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.dbBackup")).WithCallbackData(t.encodeQuery("get_backup")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getBanLogs")).WithCallbackData(t.encodeQuery("get_banlogs")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.getInbounds")).WithCallbackData(t.encodeQuery("inbounds")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.depleteSoon")).WithCallbackData(t.encodeQuery("deplete_soon")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("commands")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.onlines")).WithCallbackData(t.encodeQuery("onlines")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.allClients")).WithCallbackData(t.encodeQuery("get_inbounds")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.addClient")).WithCallbackData(t.encodeQuery("add_client")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("admin_client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("admin_client_individual_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("admin_client_qr_links")),
|
||||
),
|
||||
// TODOOOOOOOOOOOOOO: Add restart button here.
|
||||
)
|
||||
numericKeyboardClient := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.clientUsage")).WithCallbackData(t.encodeQuery("client_traffic")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.commands")).WithCallbackData(t.encodeQuery("client_commands")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("pages.settings.subSettings")).WithCallbackData(t.encodeQuery("client_sub_links")),
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links")),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("qrCode")).WithCallbackData(t.encodeQuery("client_qr_links")),
|
||||
),
|
||||
)
|
||||
|
||||
var ReplyMarkup telego.ReplyMarkup
|
||||
if isAdmin {
|
||||
ReplyMarkup = numericKeyboard
|
||||
} else {
|
||||
ReplyMarkup = numericKeyboardClient
|
||||
}
|
||||
t.SendMsgToTgbot(chatId, msg, ReplyMarkup)
|
||||
}
|
||||
|
||||
// SendMsgToTgbot sends a message to the Telegram bot with optional reply markup.
|
||||
func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||
if !isRunning {
|
||||
return
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
logger.Info("[tgbot] message is empty!")
|
||||
return
|
||||
}
|
||||
|
||||
var allMessages []string
|
||||
limit := 2000
|
||||
|
||||
// paging message if it is big
|
||||
if len(msg) > limit {
|
||||
messages := strings.Split(msg, "\r\n\r\n")
|
||||
lastIndex := -1
|
||||
|
||||
for _, message := range messages {
|
||||
if (len(allMessages) == 0) || (len(allMessages[lastIndex])+len(message) > limit) {
|
||||
allMessages = append(allMessages, message)
|
||||
lastIndex++
|
||||
} else {
|
||||
allMessages[lastIndex] += "\r\n\r\n" + message
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(allMessages[len(allMessages)-1]) == "" {
|
||||
allMessages = allMessages[:len(allMessages)-1]
|
||||
}
|
||||
} else {
|
||||
allMessages = append(allMessages, msg)
|
||||
}
|
||||
for n, message := range allMessages {
|
||||
params := telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
Text: message,
|
||||
ParseMode: "HTML",
|
||||
}
|
||||
// only add replyMarkup to last message
|
||||
if len(replyMarkup) > 0 && n == (len(allMessages)-1) {
|
||||
params.ReplyMarkup = replyMarkup[0]
|
||||
}
|
||||
|
||||
// Retry logic with exponential backoff for connection errors
|
||||
maxRetries := 3
|
||||
for attempt := range maxRetries {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
_, err := bot.SendMessage(ctx, ¶ms)
|
||||
cancel()
|
||||
|
||||
if err == nil {
|
||||
break // Success
|
||||
}
|
||||
|
||||
// Check if error is a connection error
|
||||
errStr := err.Error()
|
||||
isConnectionError := strings.Contains(errStr, "connection") ||
|
||||
strings.Contains(errStr, "timeout") ||
|
||||
strings.Contains(errStr, "closed")
|
||||
|
||||
if isConnectionError && attempt < maxRetries-1 {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
backoff := time.Duration(1<<uint(attempt)) * time.Second
|
||||
logger.Warningf("Connection error sending telegram message (attempt %d/%d), retrying in %v: %v",
|
||||
attempt+1, maxRetries, backoff, err)
|
||||
time.Sleep(backoff)
|
||||
} else {
|
||||
logger.Warning("Error sending telegram message:", err)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Reduced delay to improve performance (only needed for rate limiting)
|
||||
if n < len(allMessages)-1 { // Only delay between messages, not after the last one
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendMsgToTgbotAdmins sends a message to all admin Telegram chats.
|
||||
func (t *Tgbot) SendMsgToTgbotAdmins(msg string, replyMarkup ...telego.ReplyMarkup) {
|
||||
if len(replyMarkup) > 0 {
|
||||
for _, adminId := range adminIds {
|
||||
t.SendMsgToTgbot(adminId, msg, replyMarkup[0])
|
||||
}
|
||||
} else {
|
||||
for _, adminId := range adminIds {
|
||||
t.SendMsgToTgbot(adminId, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendCallbackAnswerTgBot answers a callback query with a message.
|
||||
func (t *Tgbot) sendCallbackAnswerTgBot(id string, message string) {
|
||||
params := telego.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: id,
|
||||
Text: message,
|
||||
}
|
||||
if err := bot.AnswerCallbackQuery(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
// editMessageCallbackTgBot edits the reply markup of a message.
|
||||
func (t *Tgbot) editMessageCallbackTgBot(chatId int64, messageID int, inlineKeyboard *telego.InlineKeyboardMarkup) {
|
||||
params := telego.EditMessageReplyMarkupParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
ReplyMarkup: inlineKeyboard,
|
||||
}
|
||||
if _, err := bot.EditMessageReplyMarkup(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
// editMessageTgBot edits the text and reply markup of a message.
|
||||
func (t *Tgbot) editMessageTgBot(chatId int64, messageID int, text string, inlineKeyboard ...*telego.InlineKeyboardMarkup) {
|
||||
params := telego.EditMessageTextParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
Text: text,
|
||||
ParseMode: "HTML",
|
||||
}
|
||||
if len(inlineKeyboard) > 0 {
|
||||
params.ReplyMarkup = inlineKeyboard[0]
|
||||
}
|
||||
if _, err := bot.EditMessageText(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning(err)
|
||||
}
|
||||
}
|
||||
|
||||
// SendMsgToTgbotDeleteAfter sends a message and deletes it after a specified delay.
|
||||
func (t *Tgbot) SendMsgToTgbotDeleteAfter(chatId int64, msg string, delayInSeconds int, replyMarkup ...telego.ReplyMarkup) {
|
||||
// Determine if replyMarkup was passed; otherwise, set it to nil
|
||||
var replyMarkupParam telego.ReplyMarkup
|
||||
if len(replyMarkup) > 0 {
|
||||
replyMarkupParam = replyMarkup[0] // Use the first element
|
||||
}
|
||||
|
||||
// Send the message
|
||||
sentMsg, err := bot.SendMessage(context.Background(), &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
Text: msg,
|
||||
ReplyMarkup: replyMarkupParam, // Use the correct replyMarkup value
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warning("Failed to send message:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete the sent message after the specified number of seconds
|
||||
go func() {
|
||||
time.Sleep(time.Duration(delayInSeconds) * time.Second) // Wait for the specified delay
|
||||
t.deleteMessageTgBot(chatId, sentMsg.MessageID) // Delete the message
|
||||
delete(userStates, chatId)
|
||||
}()
|
||||
}
|
||||
|
||||
// deleteMessageTgBot deletes a message from the chat.
|
||||
func (t *Tgbot) deleteMessageTgBot(chatId int64, messageID int) {
|
||||
params := telego.DeleteMessageParams{
|
||||
ChatID: tu.ID(chatId),
|
||||
MessageID: messageID,
|
||||
}
|
||||
if err := bot.DeleteMessage(context.Background(), ¶ms); err != nil {
|
||||
logger.Warning("Failed to delete message:", err)
|
||||
} else {
|
||||
logger.Info("Message deleted successfully")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package tgbot
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
|
||||
typ := reflect.TypeFor[LoginAttempt]()
|
||||
if _, ok := typ.FieldByName("Password"); ok {
|
||||
t.Fatal("LoginAttempt must not carry attempted passwords")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSupportedBotProxyScheme(t *testing.T) {
|
||||
supported := []string{
|
||||
"socks5://127.0.0.1:1080",
|
||||
"http://127.0.0.1:8080",
|
||||
"https://127.0.0.1:8080",
|
||||
}
|
||||
for _, p := range supported {
|
||||
if !isSupportedBotProxyScheme(p) {
|
||||
t.Errorf("expected %q to be supported", p)
|
||||
}
|
||||
}
|
||||
unsupported := []string{"", "ftp://x", "127.0.0.1:1080", "socks4://1.2.3.4:1080"}
|
||||
for _, p := range unsupported {
|
||||
if isSupportedBotProxyScheme(p) {
|
||||
t.Errorf("expected %q to be unsupported", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recordingDialTarget(t *testing.T, n int) (addr string, got chan []byte) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = make(chan []byte, 1)
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
go func() {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
buf := make([]byte, n)
|
||||
m, _ := io.ReadFull(conn, buf)
|
||||
got <- buf[:m]
|
||||
}()
|
||||
return ln.Addr().String(), got
|
||||
}
|
||||
|
||||
func TestTgbotProxyDialerSelectsHTTPForHTTPScheme(t *testing.T) {
|
||||
addr, got := recordingDialTarget(t, len("CONNECT "))
|
||||
tg := &Tgbot{}
|
||||
client := tg.createRobustFastHTTPClient("http://" + addr)
|
||||
if client.Dial == nil {
|
||||
t.Fatal("Dial must be set for an http:// proxy")
|
||||
}
|
||||
go func() { _, _ = client.Dial("example.com:443") }()
|
||||
select {
|
||||
case b := <-got:
|
||||
if string(b) != "CONNECT " {
|
||||
t.Fatalf("expected HTTP CONNECT to the proxy, got %q", b)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("proxy never received a connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgbotProxyDialerSelectsSOCKSForSocks5Scheme(t *testing.T) {
|
||||
addr, got := recordingDialTarget(t, 1)
|
||||
tg := &Tgbot{}
|
||||
client := tg.createRobustFastHTTPClient("socks5://" + addr)
|
||||
if client.Dial == nil {
|
||||
t.Fatal("Dial must be set for a socks5:// proxy")
|
||||
}
|
||||
go func() { _, _ = client.Dial("example.com:443") }()
|
||||
select {
|
||||
case b := <-got:
|
||||
if len(b) != 1 || b[0] != 0x05 {
|
||||
t.Fatalf("expected SOCKS5 greeting (0x05), got %v", b)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("proxy never received a connection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTgbotProxyDialerNoneWhenEmpty(t *testing.T) {
|
||||
tg := &Tgbot{}
|
||||
client := tg.createRobustFastHTTPClient("")
|
||||
if client.Dial != nil {
|
||||
t.Fatal("Dial must be nil when no proxy is configured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsUntargetedCommand(t *testing.T) {
|
||||
if !isCommandForBot("/status", "panel_bot") {
|
||||
t.Fatal("untargeted commands must remain accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotAllowsMatchingUsername(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "Panel_Bot") {
|
||||
t.Fatal("commands targeted to this bot must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotRejectsOtherUsername(t *testing.T) {
|
||||
if isCommandForBot("/status@other_bot", "panel_bot") {
|
||||
t.Fatal("commands targeted to another bot must be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCommandForBotKeepsLegacyBehaviorWhenUsernameUnavailable(t *testing.T) {
|
||||
if !isCommandForBot("/status@panel_bot", "") {
|
||||
t.Fatal("commands must remain accepted when the current bot username is unavailable")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
trafficWriterQueueSize = 256
|
||||
trafficWriterSubmitTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
type trafficWriteRequest struct {
|
||||
apply func() error
|
||||
done chan error
|
||||
}
|
||||
|
||||
var (
|
||||
twMu sync.Mutex
|
||||
twQueue chan *trafficWriteRequest
|
||||
twCtx context.Context
|
||||
twCancel context.CancelFunc
|
||||
twDone chan struct{}
|
||||
)
|
||||
|
||||
// StartTrafficWriter spins up the serial writer goroutine. Safe to call again
|
||||
// after StopTrafficWriter — each Start/Stop cycle gets fresh channels. The
|
||||
// previous sync.Once-based implementation deadlocked after a SIGHUP-driven
|
||||
// panel restart: Stop killed the consumer goroutine but Once prevented Start
|
||||
// from spawning a new one, so every later submitTrafficWrite blocked forever
|
||||
// on <-req.done with no consumer (including the AddTraffic call inside
|
||||
// XrayService.GetXrayConfig that runs from startTask).
|
||||
func StartTrafficWriter() {
|
||||
twMu.Lock()
|
||||
defer twMu.Unlock()
|
||||
|
||||
if twCancel != nil && twDone != nil {
|
||||
select {
|
||||
case <-twDone:
|
||||
clearTrafficWriterState()
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
queue := make(chan *trafficWriteRequest, trafficWriterQueueSize)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
|
||||
twQueue = queue
|
||||
twCtx = ctx
|
||||
twCancel = cancel
|
||||
twDone = done
|
||||
|
||||
go runTrafficWriter(ctx, queue, done)
|
||||
}
|
||||
|
||||
// StopTrafficWriter cancels the writer context and waits for the goroutine to
|
||||
// drain any pending requests before returning. Resets the package state so a
|
||||
// subsequent StartTrafficWriter can spawn a fresh consumer.
|
||||
func StopTrafficWriter() {
|
||||
twMu.Lock()
|
||||
cancel := twCancel
|
||||
done := twDone
|
||||
if cancel == nil || done == nil {
|
||||
twMu.Unlock()
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
twMu.Unlock()
|
||||
|
||||
<-done
|
||||
|
||||
twMu.Lock()
|
||||
if twDone == done {
|
||||
clearTrafficWriterState()
|
||||
}
|
||||
twMu.Unlock()
|
||||
}
|
||||
|
||||
func clearTrafficWriterState() {
|
||||
twQueue = nil
|
||||
twCtx = nil
|
||||
twCancel = nil
|
||||
twDone = nil
|
||||
}
|
||||
|
||||
func runTrafficWriter(ctx context.Context, queue chan *trafficWriteRequest, done chan struct{}) {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case req := <-queue:
|
||||
req.done <- safeApply(req.apply)
|
||||
case <-ctx.Done():
|
||||
for {
|
||||
select {
|
||||
case req := <-queue:
|
||||
req.done <- safeApply(req.apply)
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func safeApply(fn func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("traffic writer panic: %v", r)
|
||||
logger.Error(err.Error())
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func submitTrafficWrite(fn func() error) error {
|
||||
req := &trafficWriteRequest{apply: fn, done: make(chan error, 1)}
|
||||
|
||||
twMu.Lock()
|
||||
queue := twQueue
|
||||
ctx := twCtx
|
||||
done := twDone
|
||||
if queue == nil || ctx == nil || done == nil {
|
||||
twMu.Unlock()
|
||||
return safeApply(fn)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
twMu.Unlock()
|
||||
return safeApply(fn)
|
||||
default:
|
||||
}
|
||||
|
||||
timer := time.NewTimer(trafficWriterSubmitTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case queue <- req:
|
||||
twMu.Unlock()
|
||||
case <-timer.C:
|
||||
twMu.Unlock()
|
||||
return errors.New("traffic writer queue full")
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-req.done:
|
||||
return err
|
||||
case <-done:
|
||||
select {
|
||||
case err := <-req.done:
|
||||
return err
|
||||
default:
|
||||
return errors.New("traffic writer stopped before write completed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTrafficWriterStartStopStartAcceptsWrites(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
var writes atomic.Int32
|
||||
if err := submitTrafficWrite(func() error {
|
||||
writes.Add(1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("first submitTrafficWrite: %v", err)
|
||||
}
|
||||
|
||||
StopTrafficWriter()
|
||||
StartTrafficWriter()
|
||||
if err := submitTrafficWrite(func() error {
|
||||
writes.Add(1)
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("second submitTrafficWrite: %v", err)
|
||||
}
|
||||
|
||||
if got := writes.Load(); got != 2 {
|
||||
t.Fatalf("writes = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficWriterSubmitAfterStopRunsInline(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
StopTrafficWriter()
|
||||
|
||||
ran := make(chan struct{})
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- submitTrafficWrite(func() error {
|
||||
close(ran)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ran:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("submitTrafficWrite did not run after traffic writer stopped")
|
||||
}
|
||||
if err := waitTrafficWriterErr(t, errCh); err != nil {
|
||||
t.Fatalf("submitTrafficWrite after stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficWriterStopDrainsQueuedWrite(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
firstStarted := make(chan struct{})
|
||||
releaseFirst := make(chan struct{})
|
||||
firstErr := make(chan error, 1)
|
||||
go func() {
|
||||
firstErr <- submitTrafficWrite(func() error {
|
||||
close(firstStarted)
|
||||
<-releaseFirst
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterSignal(t, firstStarted, "first write did not start")
|
||||
|
||||
secondRan := make(chan struct{})
|
||||
secondErr := make(chan error, 1)
|
||||
go func() {
|
||||
secondErr <- submitTrafficWrite(func() error {
|
||||
close(secondRan)
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterQueued(t)
|
||||
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
StopTrafficWriter()
|
||||
close(stopDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stopDone:
|
||||
t.Fatal("StopTrafficWriter returned before in-flight write was released")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
close(releaseFirst)
|
||||
waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter did not return")
|
||||
waitTrafficWriterSignal(t, secondRan, "queued write was not drained")
|
||||
|
||||
if err := waitTrafficWriterErr(t, firstErr); err != nil {
|
||||
t.Fatalf("first submitTrafficWrite: %v", err)
|
||||
}
|
||||
if err := waitTrafficWriterErr(t, secondErr); err != nil {
|
||||
t.Fatalf("second submitTrafficWrite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrafficWriterConcurrentStopDuringSubmitDoesNotHang(t *testing.T) {
|
||||
resetTrafficWriterForTest(t)
|
||||
|
||||
StartTrafficWriter()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- submitTrafficWrite(func() error {
|
||||
close(started)
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
waitTrafficWriterSignal(t, started, "write did not start")
|
||||
|
||||
stopDone := make(chan struct{})
|
||||
go func() {
|
||||
StopTrafficWriter()
|
||||
close(stopDone)
|
||||
}()
|
||||
|
||||
close(release)
|
||||
waitTrafficWriterSignal(t, stopDone, "StopTrafficWriter hung during submit")
|
||||
if err := waitTrafficWriterErr(t, errCh); err != nil {
|
||||
t.Fatalf("submitTrafficWrite during stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func resetTrafficWriterForTest(t *testing.T) {
|
||||
t.Helper()
|
||||
StopTrafficWriter()
|
||||
twMu.Lock()
|
||||
clearTrafficWriterState()
|
||||
twMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
StopTrafficWriter()
|
||||
twMu.Lock()
|
||||
clearTrafficWriterState()
|
||||
twMu.Unlock()
|
||||
})
|
||||
}
|
||||
|
||||
func waitTrafficWriterQueued(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
twMu.Lock()
|
||||
queued := 0
|
||||
if twQueue != nil {
|
||||
queued = len(twQueue)
|
||||
}
|
||||
twMu.Unlock()
|
||||
if queued > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("write was not queued")
|
||||
}
|
||||
|
||||
func waitTrafficWriterSignal(t *testing.T, ch <-chan struct{}, msg string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func waitTrafficWriterErr(t *testing.T, ch <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-ch:
|
||||
return err
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for traffic writer result")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
|
||||
)
|
||||
|
||||
// SanitizeHTTPURL validates and normalizes an http(s) URL without resolving
|
||||
// DNS. Use SanitizePublicHTTPURL at the point of an outbound request.
|
||||
func SanitizeHTTPURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("unsupported URL scheme %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" || u.Hostname() == "" {
|
||||
return "", fmt.Errorf("URL host is required")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// SanitizePublicHTTPURL validates and normalizes an http(s) URL, then blocks
|
||||
// private/internal targets unless the caller explicitly allows them.
|
||||
func SanitizePublicHTTPURL(raw string, allowPrivate bool) (string, error) {
|
||||
clean, err := SanitizeHTTPURL(raw)
|
||||
if err != nil || clean == "" {
|
||||
return clean, err
|
||||
}
|
||||
if allowPrivate {
|
||||
return clean, nil
|
||||
}
|
||||
u, err := url.Parse(clean)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := rejectPrivateHost(ctx, u.Hostname()); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return clean, nil
|
||||
}
|
||||
|
||||
func rejectPrivateHost(ctx context.Context, hostname string) error {
|
||||
if ip := net.ParseIP(hostname); ip != nil {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf("blocked private/internal address %s", ip.String())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot resolve host %s: %w", hostname, err)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
return fmt.Errorf("host %s has no IP addresses", hostname)
|
||||
}
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
return fmt.Errorf("host %s resolves to blocked private/internal address %s", hostname, ipAddr.IP.String())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
return netsafe.IsBlockedIP(ip)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"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/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
)
|
||||
|
||||
var (
|
||||
p *xray.Process
|
||||
lock sync.Mutex
|
||||
isNeedXrayRestart atomic.Bool // Indicates that restart was requested for Xray
|
||||
isManuallyStopped atomic.Bool // Indicates that Xray was stopped manually from the panel
|
||||
result string
|
||||
)
|
||||
|
||||
// XrayService provides business logic for Xray process management.
|
||||
// It handles starting, stopping, restarting Xray, and managing its configuration.
|
||||
type XrayService struct {
|
||||
inboundService InboundService
|
||||
settingService SettingService
|
||||
xrayAPI xray.XrayAPI
|
||||
}
|
||||
|
||||
// IsXrayRunning checks if the Xray process is currently running.
|
||||
func (s *XrayService) IsXrayRunning() bool {
|
||||
return p != nil && p.IsRunning()
|
||||
}
|
||||
|
||||
// XrayProcess returns the current Xray process instance (may be nil when Xray
|
||||
// is not running). It exposes the package-level process to callers outside this
|
||||
// package (e.g. the tgbot subpackage) without changing access semantics.
|
||||
func XrayProcess() *xray.Process {
|
||||
return p
|
||||
}
|
||||
|
||||
// GetXrayErr returns the error from the Xray process, if any.
|
||||
func (s *XrayService) GetXrayErr() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := p.GetErr()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if runtime.GOOS == "windows" && err.Error() == "exit status 1" {
|
||||
// exit status 1 on Windows means that Xray process was killed
|
||||
// as we kill process to stop in on Windows, this is not an error
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetXrayResult returns the result string from the Xray process.
|
||||
func (s *XrayService) GetXrayResult() string {
|
||||
if result != "" {
|
||||
return result
|
||||
}
|
||||
if s.IsXrayRunning() {
|
||||
return ""
|
||||
}
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
result = p.GetResult()
|
||||
|
||||
if runtime.GOOS == "windows" && result == "exit status 1" {
|
||||
// exit status 1 on Windows means that Xray process was killed
|
||||
// as we kill process to stop in on Windows, this is not an error
|
||||
return ""
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetXrayVersion returns the version of the running Xray process.
|
||||
func (s *XrayService) GetXrayVersion() string {
|
||||
if p == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
return p.GetVersion()
|
||||
}
|
||||
|
||||
// RemoveIndex removes an element at the specified index from a slice.
|
||||
// Returns a new slice with the element removed.
|
||||
func RemoveIndex(s []any, index int) []any {
|
||||
return append(s[:index], s[index+1:]...)
|
||||
}
|
||||
|
||||
// GetXrayConfig retrieves and builds the Xray configuration from settings and inbounds.
|
||||
func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
templateConfig, err := s.settingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xrayConfig := &xray.Config{}
|
||||
err = json.Unmarshal([]byte(templateConfig), xrayConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
|
||||
|
||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||
|
||||
inbounds, err := s.inboundService.GetAllInbounds()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
if !inbound.Enable {
|
||||
continue
|
||||
}
|
||||
if inbound.NodeID != nil {
|
||||
continue
|
||||
}
|
||||
if inbound.Protocol == model.MTProto {
|
||||
continue
|
||||
}
|
||||
settings := map[string]any{}
|
||||
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||
|
||||
dbClients, listErr := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
|
||||
if listErr != nil {
|
||||
return nil, listErr
|
||||
}
|
||||
|
||||
clientStats := inbound.ClientStats
|
||||
enableMap := make(map[string]bool, len(clientStats))
|
||||
for _, clientTraffic := range clientStats {
|
||||
enableMap[clientTraffic.Email] = clientTraffic.Enable
|
||||
}
|
||||
|
||||
var finalClients []any
|
||||
for i := range dbClients {
|
||||
c := dbClients[i]
|
||||
if enable, exists := enableMap[c.Email]; exists && !enable {
|
||||
logger.Infof("Remove Inbound User %s due to expiration or traffic limit", c.Email)
|
||||
continue
|
||||
}
|
||||
if !c.Enable {
|
||||
continue
|
||||
}
|
||||
flow := c.Flow
|
||||
if flow == "xtls-rprx-vision-udp443" {
|
||||
flow = "xtls-rprx-vision"
|
||||
}
|
||||
entry := map[string]any{"email": c.Email}
|
||||
switch inbound.Protocol {
|
||||
case model.VLESS:
|
||||
if c.ID != "" {
|
||||
entry["id"] = c.ID
|
||||
}
|
||||
if flow != "" {
|
||||
entry["flow"] = flow
|
||||
}
|
||||
if c.Reverse != nil {
|
||||
entry["reverse"] = c.Reverse
|
||||
}
|
||||
case model.VMESS:
|
||||
if c.ID != "" {
|
||||
entry["id"] = c.ID
|
||||
}
|
||||
if c.Security != "" {
|
||||
entry["security"] = c.Security
|
||||
}
|
||||
case model.Trojan:
|
||||
if c.Password != "" {
|
||||
entry["password"] = c.Password
|
||||
}
|
||||
if flow != "" {
|
||||
entry["flow"] = flow
|
||||
}
|
||||
case model.Shadowsocks:
|
||||
if c.Password != "" {
|
||||
entry["password"] = c.Password
|
||||
}
|
||||
case model.Hysteria:
|
||||
if c.Auth != "" {
|
||||
entry["auth"] = c.Auth
|
||||
}
|
||||
}
|
||||
finalClients = append(finalClients, entry)
|
||||
}
|
||||
|
||||
_, hadClients := settings["clients"]
|
||||
mutated := hadClients || len(finalClients) > 0
|
||||
if mutated {
|
||||
settings["clients"] = finalClients
|
||||
}
|
||||
|
||||
if inboundCanHostFallbacks(inbound) {
|
||||
fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
|
||||
if fbErr != nil {
|
||||
return nil, fbErr
|
||||
}
|
||||
if len(fallbacks) > 0 {
|
||||
generic := make([]any, 0, len(fallbacks))
|
||||
for _, f := range fallbacks {
|
||||
generic = append(generic, f)
|
||||
}
|
||||
settings["fallbacks"] = generic
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
if mutated {
|
||||
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inbound.Settings = string(modifiedSettings)
|
||||
}
|
||||
|
||||
if len(inbound.StreamSettings) > 0 {
|
||||
// Unmarshal stream JSON
|
||||
var stream map[string]any
|
||||
json.Unmarshal([]byte(inbound.StreamSettings), &stream)
|
||||
|
||||
// Remove the "settings" field under "tlsSettings" and "realitySettings"
|
||||
tlsSettings, ok1 := stream["tlsSettings"].(map[string]any)
|
||||
realitySettings, ok2 := stream["realitySettings"].(map[string]any)
|
||||
if ok1 || ok2 {
|
||||
if ok1 {
|
||||
delete(tlsSettings, "settings")
|
||||
} else if ok2 {
|
||||
delete(realitySettings, "settings")
|
||||
}
|
||||
}
|
||||
|
||||
delete(stream, "externalProxy")
|
||||
|
||||
newStream, err := json.MarshalIndent(stream, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inbound.StreamSettings = string(newStream)
|
||||
}
|
||||
|
||||
if inbound.Protocol == model.Shadowsocks {
|
||||
if healed, ok := model.HealShadowsocksClientMethods(inbound.Settings); ok {
|
||||
inbound.Settings = healed
|
||||
}
|
||||
}
|
||||
|
||||
inboundConfig := inbound.GenXrayInboundConfig()
|
||||
xrayConfig.InboundConfigs = append(xrayConfig.InboundConfigs, *inboundConfig)
|
||||
}
|
||||
|
||||
// Merge subscription-derived outbounds (if any) into the final outbounds array.
|
||||
// These are additive: each subscription is placed before or after the template
|
||||
// outbounds based on its Prepend flag, ordered by Priority. Tags assigned by the
|
||||
// subscription service are kept stable across refreshes so that balancers and
|
||||
// routing rules continue to work.
|
||||
subSvc := &OutboundSubscriptionService{}
|
||||
if prepend, appendList, err := subSvc.activeOutboundsSplit(); err == nil && (len(prepend) > 0 || len(appendList) > 0) {
|
||||
mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
|
||||
}
|
||||
|
||||
return xrayConfig, nil
|
||||
}
|
||||
|
||||
// mergeSubscriptionOutbounds appends the subscription outbounds to the
|
||||
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
|
||||
// template so that manually configured outbounds are never overwritten.
|
||||
//
|
||||
// Safety: if we cannot parse the template's outbounds array, we leave
|
||||
// OutboundConfigs exactly as it came from the template (we do not inject
|
||||
// subscription outbounds). This prevents us from accidentally dropping the
|
||||
// user's manually configured outbounds when the template is in a weird state.
|
||||
func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
|
||||
if len(prepend) == 0 && len(appendList) == 0 {
|
||||
return
|
||||
}
|
||||
var templateOutbounds []any
|
||||
if len(cfg.OutboundConfigs) > 0 {
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &templateOutbounds); err != nil {
|
||||
// Corrupt template outbounds — do not touch the field at all.
|
||||
// The user will see problems on Xray start / next save.
|
||||
return
|
||||
}
|
||||
}
|
||||
merged := make([]any, 0, len(prepend)+len(templateOutbounds)+len(appendList))
|
||||
merged = append(merged, prepend...)
|
||||
merged = append(merged, templateOutbounds...)
|
||||
merged = append(merged, appendList...)
|
||||
combined, err := json.MarshalIndent(merged, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cfg.OutboundConfigs = json_util.RawMessage(combined)
|
||||
}
|
||||
|
||||
// resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
|
||||
// absolute paths under config.GetLogFolder(), so Xray writes those files
|
||||
// alongside the panel's other logs regardless of the working directory the
|
||||
// panel was launched from. Values that are empty, "none", or already absolute
|
||||
// are left untouched, as are unparseable log blocks.
|
||||
func resolveXrayLogPaths(logCfg json_util.RawMessage) json_util.RawMessage {
|
||||
if len(logCfg) == 0 {
|
||||
return logCfg
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal(logCfg, &parsed); err != nil {
|
||||
return logCfg
|
||||
}
|
||||
changed := false
|
||||
for _, key := range []string{"access", "error"} {
|
||||
v, ok := parsed[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" || strings.EqualFold(trimmed, "none") {
|
||||
continue
|
||||
}
|
||||
if filepath.IsAbs(trimmed) {
|
||||
continue
|
||||
}
|
||||
cleaned := filepath.ToSlash(filepath.Clean(trimmed))
|
||||
base := filepath.Base(cleaned)
|
||||
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||
continue
|
||||
}
|
||||
// Only rewrite bare names ("./access.log", "access.log").
|
||||
// A nested relative path like "./logs/foo.log" is treated as
|
||||
// a deliberate user choice and left alone.
|
||||
if cleaned != base {
|
||||
continue
|
||||
}
|
||||
parsed[key] = filepath.Join(config.GetLogFolder(), base)
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return logCfg
|
||||
}
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return logCfg
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetXrayTraffic fetches the current traffic statistics from the running Xray process.
|
||||
func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic, error) {
|
||||
if !s.IsXrayRunning() {
|
||||
err := errors.New("xray is not running")
|
||||
logger.Debug("Attempted to fetch Xray traffic, but Xray is not running:", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
apiPort := p.GetAPIPort()
|
||||
if err := s.xrayAPI.Init(apiPort); err != nil {
|
||||
logger.Debug("Failed to initialize Xray API:", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
defer s.xrayAPI.Close()
|
||||
|
||||
traffic, clientTraffic, err := s.xrayAPI.GetTraffic()
|
||||
if err != nil {
|
||||
logger.Debug("Failed to fetch Xray traffic:", err)
|
||||
return nil, nil, err
|
||||
}
|
||||
return traffic, clientTraffic, nil
|
||||
}
|
||||
|
||||
// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
|
||||
func (s *XrayService) RestartXray(isForce bool) error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
logger.Debug("restart Xray, force:", isForce)
|
||||
isManuallyStopped.Store(false)
|
||||
|
||||
xrayConfig, err := s.GetXrayConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.IsXrayRunning() {
|
||||
if !isForce && p.GetConfig().Equals(xrayConfig) && !isNeedXrayRestart.Load() {
|
||||
logger.Debug("It does not need to restart Xray")
|
||||
return nil
|
||||
}
|
||||
p.Stop()
|
||||
}
|
||||
|
||||
p = xray.NewProcess(xrayConfig)
|
||||
result = ""
|
||||
s.xrayAPI.StatsLastValues = nil
|
||||
err = p.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopXray stops the running Xray process.
|
||||
func (s *XrayService) StopXray() error {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
isManuallyStopped.Store(true)
|
||||
logger.Debug("Attempting to stop Xray...")
|
||||
if s.IsXrayRunning() {
|
||||
return p.Stop()
|
||||
}
|
||||
return errors.New("xray is not running")
|
||||
}
|
||||
|
||||
// SetToNeedRestart marks that Xray needs to be restarted.
|
||||
func (s *XrayService) SetToNeedRestart() {
|
||||
isNeedXrayRestart.Store(true)
|
||||
}
|
||||
|
||||
// GetXrayAPIPort returns the port the local xray process is listening on
|
||||
// for its gRPC HandlerService, or 0 when xray isn't currently running.
|
||||
// Exposed for the runtime package's LocalRuntime adapter — runtime can't
|
||||
// reach into the package-level `p` directly without a service-package
|
||||
// import cycle.
|
||||
func (s *XrayService) GetXrayAPIPort() int {
|
||||
if p == nil || !p.IsRunning() {
|
||||
return 0
|
||||
}
|
||||
return p.GetAPIPort()
|
||||
}
|
||||
|
||||
// IsNeedRestartAndSetFalse checks if restart is needed and resets the flag to false.
|
||||
func (s *XrayService) IsNeedRestartAndSetFalse() bool {
|
||||
return isNeedXrayRestart.CompareAndSwap(true, false)
|
||||
}
|
||||
|
||||
// DidXrayCrash checks if Xray crashed by verifying it's not running and wasn't manually stopped.
|
||||
func (s *XrayService) DidXrayCrash() bool {
|
||||
return !s.IsXrayRunning() && !isManuallyStopped.Load()
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
type xrayMetricsState struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Listen string `json:"listen"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
type ObsTagSnapshot struct {
|
||||
Tag string `json:"tag"`
|
||||
Alive bool `json:"alive"`
|
||||
Delay int64 `json:"delay"`
|
||||
LastSeenTime int64 `json:"lastSeenTime"`
|
||||
LastTryTime int64 `json:"lastTryTime"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type XrayMetricsService struct {
|
||||
settingService SettingService
|
||||
|
||||
mu sync.RWMutex
|
||||
state xrayMetricsState
|
||||
client *http.Client
|
||||
obsByTag map[string]ObsTagSnapshot
|
||||
}
|
||||
|
||||
var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
|
||||
|
||||
func obsHistoryKey(tag string) string {
|
||||
return "xrObs." + tag + ".delay"
|
||||
}
|
||||
|
||||
func newXrayMetricsClient() *http.Client {
|
||||
return &http.Client{Timeout: 1500 * time.Millisecond}
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) getClient() *http.Client {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.client == nil {
|
||||
s.client = newXrayMetricsClient()
|
||||
}
|
||||
return s.client
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) State() xrayMetricsState {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.state
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) AggregateMetric(metric string, bucketSeconds, maxPoints int) []map[string]any {
|
||||
return xrayMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]ObsTagSnapshot, 0, len(s.obsByTag))
|
||||
for _, v := range s.obsByTag {
|
||||
out = append(out, v)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Tag < out[j].Tag })
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
|
||||
if !validObsTag.MatchString(tag) {
|
||||
return false
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
_, ok := s.obsByTag[tag]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
|
||||
if !validObsTag.MatchString(tag) {
|
||||
return []map[string]any{}
|
||||
}
|
||||
return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) discoverListen() (string, error) {
|
||||
tmpl, err := s.settingService.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var parsed struct {
|
||||
Metrics *struct {
|
||||
Listen string `json:"listen"`
|
||||
} `json:"metrics"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(tmpl), &parsed); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if parsed.Metrics == nil || strings.TrimSpace(parsed.Metrics.Listen) == "" {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(parsed.Metrics.Listen), nil
|
||||
}
|
||||
|
||||
type rawObsEntry struct {
|
||||
Alive bool `json:"alive"`
|
||||
Delay int64 `json:"delay"`
|
||||
LastSeenTime int64 `json:"last_seen_time"`
|
||||
LastTryTime int64 `json:"last_try_time"`
|
||||
OutboundTag string `json:"outbound_tag"`
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) Sample(t time.Time) {
|
||||
listen, err := s.discoverListen()
|
||||
if err != nil {
|
||||
s.setState(xrayMetricsState{Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
if listen == "" {
|
||||
s.setState(xrayMetricsState{Reason: "metrics block not configured in xray template"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
||||
defer cancel()
|
||||
url := fmt.Sprintf("http://%s/debug/vars", listen)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := s.getClient().Do(req)
|
||||
if err != nil {
|
||||
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
s.setState(xrayMetricsState{Listen: listen, Reason: fmt.Sprintf("HTTP %d", resp.StatusCode)})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
MemStats struct {
|
||||
HeapAlloc uint64 `json:"HeapAlloc"`
|
||||
Sys uint64 `json:"Sys"`
|
||||
HeapObjects uint64 `json:"HeapObjects"`
|
||||
NumGC uint32 `json:"NumGC"`
|
||||
PauseNs [256]uint64 `json:"PauseNs"`
|
||||
} `json:"memstats"`
|
||||
Observatory map[string]rawObsEntry `json:"observatory"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
xrayMetrics.append("xrAlloc", t, float64(payload.MemStats.HeapAlloc))
|
||||
xrayMetrics.append("xrSys", t, float64(payload.MemStats.Sys))
|
||||
xrayMetrics.append("xrHeapObjects", t, float64(payload.MemStats.HeapObjects))
|
||||
xrayMetrics.append("xrNumGC", t, float64(payload.MemStats.NumGC))
|
||||
var lastPause uint64
|
||||
if payload.MemStats.NumGC > 0 {
|
||||
idx := (payload.MemStats.NumGC + 255) % 256
|
||||
lastPause = payload.MemStats.PauseNs[idx]
|
||||
}
|
||||
xrayMetrics.append("xrPauseNs", t, float64(lastPause))
|
||||
|
||||
s.applyObservatory(t, payload.Observatory)
|
||||
s.setState(xrayMetricsState{Enabled: true, Listen: listen})
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]rawObsEntry) {
|
||||
next := make(map[string]ObsTagSnapshot, len(entries))
|
||||
for key, e := range entries {
|
||||
tag := e.OutboundTag
|
||||
if tag == "" {
|
||||
tag = key
|
||||
}
|
||||
if !validObsTag.MatchString(tag) {
|
||||
continue
|
||||
}
|
||||
snap := ObsTagSnapshot{
|
||||
Tag: tag,
|
||||
Alive: e.Alive,
|
||||
Delay: e.Delay,
|
||||
LastSeenTime: e.LastSeenTime,
|
||||
LastTryTime: e.LastTryTime,
|
||||
UpdatedAt: t.Unix(),
|
||||
}
|
||||
next[tag] = snap
|
||||
xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
for tag := range s.obsByTag {
|
||||
if _, kept := next[tag]; !kept {
|
||||
xrayMetrics.drop(obsHistoryKey(tag))
|
||||
}
|
||||
}
|
||||
s.obsByTag = next
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *XrayMetricsService) setState(st xrayMetricsState) {
|
||||
s.mu.Lock()
|
||||
s.state = st
|
||||
s.mu.Unlock()
|
||||
if !st.Enabled && st.Reason != "" {
|
||||
logger.Debugf("xray metrics unavailable: %s", st.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// XraySettingService provides business logic for Xray configuration management.
|
||||
// It handles validation and storage of Xray template configurations.
|
||||
type XraySettingService struct {
|
||||
SettingService
|
||||
}
|
||||
|
||||
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
|
||||
// The frontend round-trips the whole getXraySetting response back
|
||||
// through the textarea, so if it has ever received a wrapped
|
||||
// payload (see UnwrapXrayTemplateConfig) it sends that same wrapper
|
||||
// back here. Strip it before validation/storage, otherwise we save
|
||||
// garbage the next read can't recover from without this same call.
|
||||
newXraySettings = UnwrapXrayTemplateConfig(newXraySettings)
|
||||
if err := s.CheckXrayConfig(newXraySettings); err != nil {
|
||||
return err
|
||||
}
|
||||
if hoisted, err := EnsureStatsRouting(newXraySettings); err == nil {
|
||||
newXraySettings = hoisted
|
||||
}
|
||||
return s.SettingService.saveSetting("xrayTemplateConfig", newXraySettings)
|
||||
}
|
||||
|
||||
func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
xrayConfig := &xray.Config{}
|
||||
err := json.Unmarshal([]byte(XrayTemplateConfig), xrayConfig)
|
||||
if err != nil {
|
||||
return common.NewError("xray template config invalid:", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]interface{}) error {
|
||||
template, err := s.GetXrayConfigTemplate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outbounds, ok := cfg["outbounds"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
updated := false
|
||||
for _, outIface := range outbounds {
|
||||
out, ok := outIface.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tag, ok := out["tag"].(string); ok && tag == "warp" {
|
||||
settings, ok := out["settings"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
settings["secretKey"] = warpData["private_key"]
|
||||
|
||||
if conf, ok := warpConfig["config"].(map[string]interface{}); ok {
|
||||
if iface, ok := conf["interface"].(map[string]interface{}); ok {
|
||||
if addrs, ok := iface["addresses"].(map[string]interface{}); ok {
|
||||
var addrList []string
|
||||
if v4, ok := addrs["v4"].(string); ok && v4 != "" {
|
||||
addrList = append(addrList, v4+"/32")
|
||||
}
|
||||
if v6, ok := addrs["v6"].(string); ok && v6 != "" {
|
||||
addrList = append(addrList, v6+"/128")
|
||||
}
|
||||
settings["address"] = addrList
|
||||
}
|
||||
}
|
||||
|
||||
var clientId string
|
||||
if id, ok := conf["client_id"].(string); ok {
|
||||
clientId = id
|
||||
} else if id, ok := warpData["client_id"]; ok {
|
||||
clientId = id
|
||||
}
|
||||
if clientId != "" {
|
||||
decoded, _ := base64.StdEncoding.DecodeString(clientId)
|
||||
var res []int
|
||||
for _, b := range decoded {
|
||||
res = append(res, int(b))
|
||||
}
|
||||
settings["reserved"] = res
|
||||
}
|
||||
|
||||
if peers, ok := conf["peers"].([]interface{}); ok && len(peers) > 0 {
|
||||
if peer, ok := peers[0].(map[string]interface{}); ok {
|
||||
if pSettings, ok := settings["peers"].([]interface{}); ok && len(pSettings) > 0 {
|
||||
if pSet, ok := pSettings[0].(map[string]interface{}); ok {
|
||||
pSet["publicKey"] = peer["public_key"]
|
||||
if endpoint, ok := peer["endpoint"].(map[string]interface{}); ok {
|
||||
pSet["endpoint"] = endpoint["host"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
outJSON, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.SaveXraySetting(string(outJSON))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
|
||||
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
|
||||
// "xraySetting": <real config> }` response-shaped wrappers that may have
|
||||
// ended up in the database.
|
||||
//
|
||||
// How it got there: getXraySetting used to embed the raw DB value as
|
||||
// `xraySetting` in its response without checking whether the stored
|
||||
// value was already that exact response shape. If the frontend then
|
||||
// saved it verbatim (the textarea is a round-trip of the JSON it was
|
||||
// handed), the wrapper got persisted — and each subsequent save nested
|
||||
// another layer, producing the blank Xray Settings page reported in
|
||||
// issue #4059.
|
||||
//
|
||||
// If `raw` does not look like a wrapper, it is returned unchanged.
|
||||
func UnwrapXrayTemplateConfig(raw string) string {
|
||||
const maxDepth = 8 // defensive cap against pathological multi-nest values
|
||||
for range maxDepth {
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &top); err != nil {
|
||||
return raw
|
||||
}
|
||||
inner, ok := top["xraySetting"]
|
||||
if !ok {
|
||||
return raw
|
||||
}
|
||||
// Real xray configs never contain a top-level "xraySetting" key,
|
||||
// but they do contain things like "inbounds"/"outbounds"/"api".
|
||||
// If any of those are present, we're already at the real config
|
||||
// and the "xraySetting" field is either user data or coincidence
|
||||
// — don't touch it.
|
||||
for _, k := range []string{"inbounds", "outbounds", "routing", "api", "dns", "log", "policy", "stats"} {
|
||||
if _, hit := top[k]; hit {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
// Peel off one layer.
|
||||
unwrapped := string(inner)
|
||||
// `xraySetting` may be stored either as a JSON object or as a
|
||||
// JSON-encoded string of an object. Handle both.
|
||||
var asStr string
|
||||
if err := json.Unmarshal(inner, &asStr); err == nil {
|
||||
unwrapped = asStr
|
||||
}
|
||||
raw = unwrapped
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// EnsureStatsRouting hoists the `api -> api` routing rule to the front
|
||||
// of routing.rules so the stats query path is never starved by a
|
||||
// catch-all rule the admin may have added or reordered above it.
|
||||
//
|
||||
// Why this matters (#4113, #2818): an admin who adds a cascade outbound
|
||||
// (e.g. vless to another server) and a routing rule sending all inbound
|
||||
// traffic to it ends up sending the internal stats inbound's traffic to
|
||||
// that cascade too, since rules are evaluated top-to-bottom and the
|
||||
// catch-all matches first. The panel's gRPC stats query then can't reach
|
||||
// the running xray instance, GetTraffic returns nothing, and every
|
||||
// client appears offline with zero traffic even though the actual proxy
|
||||
// path works fine.
|
||||
//
|
||||
// The api inbound is special-cased internal infrastructure for the
|
||||
// panel, not something the admin should ever route to a real outbound.
|
||||
// Keeping its rule pinned at index 0 is the only correct configuration.
|
||||
//
|
||||
// If the api rule is already at index 0 the input is returned unchanged.
|
||||
// If it exists somewhere else it is moved. If it is missing entirely a
|
||||
// default rule (`type=field, inboundTag=[api], outboundTag=api`) is
|
||||
// inserted at the front. Other routing entries keep their relative order.
|
||||
func EnsureStatsRouting(raw string) (string, error) {
|
||||
var cfg map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
|
||||
var routing map[string]json.RawMessage
|
||||
if r, ok := cfg["routing"]; ok && len(r) > 0 {
|
||||
if err := json.Unmarshal(r, &routing); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
}
|
||||
if routing == nil {
|
||||
routing = make(map[string]json.RawMessage)
|
||||
}
|
||||
|
||||
var rules []map[string]any
|
||||
if r, ok := routing["rules"]; ok && len(r) > 0 {
|
||||
if err := json.Unmarshal(r, &rules); err != nil {
|
||||
return raw, err
|
||||
}
|
||||
}
|
||||
|
||||
apiIdx := findApiRule(rules)
|
||||
if apiIdx == 0 {
|
||||
return raw, nil // already correct, don't churn the JSON
|
||||
}
|
||||
|
||||
var apiRule map[string]any
|
||||
if apiIdx > 0 {
|
||||
apiRule = rules[apiIdx]
|
||||
rules = append(rules[:apiIdx], rules[apiIdx+1:]...)
|
||||
} else {
|
||||
apiRule = map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []string{"api"},
|
||||
"outboundTag": "api",
|
||||
}
|
||||
}
|
||||
rules = append([]map[string]any{apiRule}, rules...)
|
||||
|
||||
rulesJSON, err := json.Marshal(rules)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
routing["rules"] = rulesJSON
|
||||
|
||||
routingJSON, err := json.Marshal(routing)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
cfg["routing"] = routingJSON
|
||||
|
||||
out, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return raw, err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// findApiRule returns the index of the routing rule that targets the
|
||||
// internal api inbound (inboundTag contains "api" and outboundTag is
|
||||
// "api"), or -1 if no such rule exists.
|
||||
func findApiRule(rules []map[string]any) int {
|
||||
for i, rule := range rules {
|
||||
if outTag, _ := rule["outboundTag"].(string); outTag != "api" {
|
||||
continue
|
||||
}
|
||||
raw, ok := rule["inboundTag"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// inboundTag is usually []string but can come as []any from a
|
||||
// roundtrip through map[string]any. Accept both shapes.
|
||||
switch tags := raw.(type) {
|
||||
case []any:
|
||||
for _, t := range tags {
|
||||
if s, ok := t.(string); ok && s == "api" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
case []string:
|
||||
if slices.Contains(tags, "api") {
|
||||
return i
|
||||
}
|
||||
case string:
|
||||
if tags == "api" {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUnwrapXrayTemplateConfig(t *testing.T) {
|
||||
real := `{"log":{},"inbounds":[],"outbounds":[],"routing":{}}`
|
||||
|
||||
t.Run("passes through a clean config", func(t *testing.T) {
|
||||
if got := UnwrapXrayTemplateConfig(real); got != real {
|
||||
t.Fatalf("clean config was modified: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("passes through invalid JSON unchanged", func(t *testing.T) {
|
||||
in := "not json at all"
|
||||
if got := UnwrapXrayTemplateConfig(in); got != in {
|
||||
t.Fatalf("invalid input was modified: %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwraps one layer of response-shaped wrapper", func(t *testing.T) {
|
||||
wrapper := `{"inboundTags":["tag"],"outboundTestUrl":"x","xraySetting":` + real + `}`
|
||||
got := UnwrapXrayTemplateConfig(wrapper)
|
||||
if !equalJSON(t, got, real) {
|
||||
t.Fatalf("want %s, got %s", real, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwraps multiple stacked layers", func(t *testing.T) {
|
||||
lvl1 := `{"xraySetting":` + real + `}`
|
||||
lvl2 := `{"xraySetting":` + lvl1 + `}`
|
||||
lvl3 := `{"xraySetting":` + lvl2 + `}`
|
||||
got := UnwrapXrayTemplateConfig(lvl3)
|
||||
if !equalJSON(t, got, real) {
|
||||
t.Fatalf("want %s, got %s", real, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles an xraySetting stored as a JSON-encoded string", func(t *testing.T) {
|
||||
encoded, _ := json.Marshal(real) // becomes a quoted string
|
||||
wrapper := `{"xraySetting":` + string(encoded) + `}`
|
||||
got := UnwrapXrayTemplateConfig(wrapper)
|
||||
if !equalJSON(t, got, real) {
|
||||
t.Fatalf("want %s, got %s", real, got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not unwrap when top level already has real xray keys", func(t *testing.T) {
|
||||
// Pathological but defensible: if a user's actual config somehow
|
||||
// has both the real keys and an unrelated `xraySetting` key, we
|
||||
// must not strip it.
|
||||
in := `{"inbounds":[],"xraySetting":{"some":"thing"}}`
|
||||
got := UnwrapXrayTemplateConfig(in)
|
||||
if got != in {
|
||||
t.Fatalf("should have left real config alone, got %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stops at a reasonable depth", func(t *testing.T) {
|
||||
// Build a deeper-than-maxDepth chain that ends at something
|
||||
// non-wrapped, and confirm we end up at some valid JSON (we
|
||||
// don't loop forever and we don't blow the stack).
|
||||
s := real
|
||||
for range 16 {
|
||||
s = `{"xraySetting":` + s + `}`
|
||||
}
|
||||
got := UnwrapXrayTemplateConfig(s)
|
||||
if !strings.Contains(got, `"inbounds"`) && !strings.Contains(got, `"xraySetting"`) {
|
||||
t.Fatalf("unexpected tail: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func equalJSON(t *testing.T, a, b string) bool {
|
||||
t.Helper()
|
||||
var va, vb any
|
||||
if err := json.Unmarshal([]byte(a), &va); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &vb); err != nil {
|
||||
return false
|
||||
}
|
||||
ja, _ := json.Marshal(va)
|
||||
jb, _ := json.Marshal(vb)
|
||||
return string(ja) == string(jb)
|
||||
}
|
||||
|
||||
// firstRuleOutbound parses the (post-hoisted) config and returns
|
||||
// routing.rules[0].outboundTag, or "" if anything is missing.
|
||||
func firstRuleOutbound(t *testing.T, raw string) string {
|
||||
t.Helper()
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||
t.Fatalf("unmarshal cfg: %v", err)
|
||||
}
|
||||
routing, _ := cfg["routing"].(map[string]any)
|
||||
rules, _ := routing["rules"].([]any)
|
||||
if len(rules) == 0 {
|
||||
return ""
|
||||
}
|
||||
first, _ := rules[0].(map[string]any)
|
||||
tag, _ := first["outboundTag"].(string)
|
||||
return tag
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_HoistsApiRuleFromMiddle(t *testing.T) {
|
||||
// #4113 repro shape: admin added a cascade outbound and put a
|
||||
// catch-all routing rule above the api rule. stats query path
|
||||
// gets starved by the catch-all unless we hoist the api rule.
|
||||
in := `{
|
||||
"routing": {
|
||||
"rules": [
|
||||
{"type":"field","inboundTag":["inbound-vless"],"outboundTag":"vless-cascade"},
|
||||
{"type":"field","inboundTag":["api"],"outboundTag":"api"},
|
||||
{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
|
||||
]
|
||||
}
|
||||
}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("api rule should be at index 0 after hoist, got first outboundTag = %q\nfull: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_NoOpWhenAlreadyFirst(t *testing.T) {
|
||||
// Don't churn the JSON when nothing needs fixing — same string in,
|
||||
// same string out. Lets the diff in the panel UI stay quiet for
|
||||
// well-formed configs.
|
||||
in := `{"routing":{"rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"},{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}]}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("expected unchanged input, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_InsertsDefaultWhenMissing(t *testing.T) {
|
||||
// Some admins delete the api rule by accident. Re-add a default
|
||||
// at the front so stats keep working after the next save.
|
||||
in := `{"routing":{"rules":[{"type":"field","outboundTag":"vless-cascade","inboundTag":["inbound-vless"]}]}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("default api rule should be inserted at index 0, got %q\nfull: %s", got, out)
|
||||
}
|
||||
// The original rule should still be there, just shifted.
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(out), &cfg)
|
||||
rules := cfg["routing"].(map[string]any)["rules"].([]any)
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("expected 2 rules after insert, got %d: %v", len(rules), rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_NoRoutingBlock(t *testing.T) {
|
||||
// Pathological but possible: empty config or one without a routing
|
||||
// section. Don't crash, and create the section with the api rule.
|
||||
in := `{"log":{}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("api rule should be created when routing was missing, got %q\nfull: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_InvalidJsonReturnsAsIs(t *testing.T) {
|
||||
// SaveXraySetting calls CheckXrayConfig before this helper, so
|
||||
// invalid JSON shouldn't reach us in practice — but be defensive
|
||||
// about garbage in (return same garbage out plus an error) so the
|
||||
// caller can choose to skip the hoist instead of corrupting input.
|
||||
in := "definitely not json"
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for invalid json, got none")
|
||||
}
|
||||
if out != in {
|
||||
t.Fatalf("expected raw passthrough on error, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsRouting_AcceptsInboundTagAsString(t *testing.T) {
|
||||
// Some manually-edited configs use a single string instead of an
|
||||
// array for inboundTag. Make sure we still recognize the api rule.
|
||||
in := `{"routing":{"rules":[{"type":"field","inboundTag":["other"],"outboundTag":"vless-cascade"},{"type":"field","inboundTag":"api","outboundTag":"api"}]}}`
|
||||
out, err := EnsureStatsRouting(in)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got := firstRuleOutbound(t, out); got != "api" {
|
||||
t.Fatalf("api rule with string-form inboundTag should hoist to front, got %q\nfull: %s", got, out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user