Compare commits

...

13 Commits

Author SHA1 Message Date
n0ctal ad32144c42 fix(sub): use a fullwidth percent in USAGE_PERCENTAGE (#6174)
* fix(sub): use a fullwidth percent in USAGE_PERCENTAGE

A remark is placed in the share link fragment, so an ASCII percent is
percent-encoded to %25. Happ treats such a fragment as malformed, discards the
whole remark and falls back to showing the server hostname, which defeats the
point of a remark template and leaks the host into the client's server list.

Emit U+FF05 FULLWIDTH PERCENT SIGN instead. It renders the same to a reader,
never produces %25, and round-trips through url.Parse unchanged.

* test(sub): exercise production fragment encoding

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:13:15 +02:00
n0ctal 34c248bb79 fix(cli): stop -getApiToken accumulating admin tokens (#6175)
* fix(cli): stop -getApiToken accumulating admin tokens

`x-ui setting -getApiToken` reads like a getter, but when tokens already exist
it minted a brand-new one named `cli-fallback-<unix>` on every invocation. The
plaintext is printed once and the row stays enabled forever, so an operator who
runs the command a few times while debugging silently leaves several
admin-equivalent credentials behind that nobody can tell apart or revoke
knowingly.

Keep the convenience the fallback was added for, but rotate a single
`cli-fallback` token instead: RecreateByName drops any existing row with that
name before issuing a new one, so at most one CLI-issued token exists at a time
and the previous plaintext stops working.

* fix(api-token): preserve token on failed replacement

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:12:43 +02:00
n0ctal 17fea2f656 fix(database): keep IP limits when the fail2ban probe is inconclusive (#6176)
* fix(database): keep IP limits when the fail2ban probe is inconclusive

ResetIpLimitNoFail2ban clears limitIp on every client — inbound settings JSON
and the clients table — whenever fail2banCanEnforce() returns false, then
records itself in the seeder history so it never re-evaluates. The probe was a
single `fail2ban-client -h` run, so it answered false both when fail2ban is
genuinely absent and when the command merely failed that once: a panel that
starts before fail2ban is up, or in a container where it is installed a moment
later, permanently loses every configured limit with no log line and no way
back.

Separate the two. A missing binary still means "absent" and the cleanup runs as
before; a binary that exists but will not run is reported as unknown, leaves the
configured values untouched, logs why, and does not record the seeder, so the
next start decides again.

* test(database): cover fail2ban reset safeguards

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:12:07 +02:00
n0ctal c5dec64d36 fix(clients): push bulk client changes to nodes only after the commit lands (#6181)
* fix(clients): apply bulk mutations after durable commit

* test(clients): guard bulk pushes behind commit

* fix(clients): fully delete remote bulk clients

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:11:19 +02:00
n0ctal b56b087254 fix(migration): stop a half-applied startup migration from committing silently (#6182)
* fix(traffic): check maintenance commits and IP-limit errors

* fix(migrations): propagate transactional failures

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:10:39 +02:00
n0ctal 3bb87e80aa fix(db): harden unrestricted freedom outbounds (#6184) 2026-08-14 20:07:30 +02:00
n0ctal 60453bf523 fix(nodes): persist the master mTLS credential atomically and stop silent reissue (#6195)
* fix: harden master mTLS credential persistence

* fix(mtls): validate and serialize credential persistence

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:03:54 +02:00
n0ctal bb29b6afec fix(node): adopt a matching deployed inbound instead of recreating it (#6197)
* fix(node): adopt compatible origin inbounds without mutation

* fix(nodes): preserve ambiguous and adopted aliases

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:01:35 +02:00
n0ctal 3b19091547 fix(sub): render the full remark once per subscription, not once per credential (#6198)
* fix(sub): scope full remarks to subscription identity

* fix(sub): preserve configured remark whitespace

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 20:00:51 +02:00
n0ctal 0496c23a26 fix(groups): report changed bulk moves without restarting xray (#6199) 2026-08-14 19:50:06 +02:00
n0ctal 1396005082 fix(traffic): apply maintenance side effects only after the commit lands (#6200)
* fix(traffic): apply maintenance after durable commit

* fix(traffic): apply all runtime maintenance after commit

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 19:42:09 +02:00
n0ctal b70c5abce8 fix(outbounds): propagate allocation query failures (#6208)
* fix(outbounds): propagate allocation query failures

* test(outbounds): cover update allocation failure

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 19:41:24 +02:00
n0ctal 20b3f84f77 fix(web): report unexpected HTTP serve failures (#6210)
* fix(web): report unexpected HTTP serve failures

* test(web): cover normal close and all HTTP servers

---------

Co-authored-by: n0ctal <293235942+n0ctal@users.noreply.github.com>
2026-08-14 19:40:53 +02:00
46 changed files with 1948 additions and 522 deletions
+1
View File
@@ -4,6 +4,7 @@ export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type staticEgressResolver = string;
export type trafficLocalApplyAction = number;
export type transportBits = number;
export interface AllSetting {
+3
View File
@@ -15,6 +15,9 @@ export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const staticEgressResolverSchema = z.string();
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
export const trafficLocalApplyActionSchema = z.number().int();
export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
export const transportBitsSchema = z.number().int();
export type transportBits = z.infer<typeof transportBitsSchema>;
+1 -1
View File
@@ -39,7 +39,7 @@ export const REMARK_VARIABLES: RemarkVar[] = [
{ token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
{ token: 'DAYS_LEFT', group: 'time', sample: '12' },
{ token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
{ token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3%' },
{ token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3' },
{ token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
{ token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
{ token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
+38 -6
View File
@@ -1278,9 +1278,14 @@ func resetIpLimitsWithoutFail2ban() error {
return nil
}
if fail2banCanEnforce() {
state, probeErr := fail2banEnforcementState()
if state == fail2banEnforcing {
return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
}
if state == fail2banUnknown {
log.Printf("ResetIpLimitNoFail2ban: fail2ban-client present but not runnable (%v); keeping configured IP limits, will retry next start", probeErr)
return nil
}
var inbounds []model.Inbound
if err := db.Find(&inbounds).Error; err != nil {
@@ -1340,14 +1345,30 @@ func resetIpLimitsWithoutFail2ban() error {
})
}
func fail2banCanEnforce() bool {
type fail2banState int
const (
fail2banEnforcing fail2banState = iota
fail2banAbsent
fail2banUnknown
)
// fail2banEnforcementState separates "fail2ban is not installed" from "the probe
// itself failed", so a transient failure never drives an irreversible cleanup.
func fail2banEnforcementState() (fail2banState, error) {
if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
return false
return fail2banAbsent, nil
}
if runtime.GOOS == "windows" {
return false
return fail2banAbsent, nil
}
return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil
if _, err := exec.LookPath("fail2ban-client"); err != nil {
return fail2banAbsent, nil
}
if err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run(); err != nil {
return fail2banUnknown, err
}
return fail2banEnforcing, nil
}
func clearLegacyProxySettings() error {
@@ -1628,6 +1649,14 @@ func isLegacyPrivateOnlyFinalRules(v any) bool {
return true
}
func isUnrestrictedFreedomFinalRules(v any, present bool) bool {
if !present || v == nil {
return true
}
rules, ok := v.([]any)
return ok && len(rules) == 0
}
func hardenFreedomFinalRules() error {
var setting model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
@@ -1680,7 +1709,10 @@ func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) {
if !ok {
continue
}
if !isAllowOnlyFinalRules(settings["finalRules"]) && !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) {
finalRules, present := settings["finalRules"]
if !isUnrestrictedFreedomFinalRules(finalRules, present) &&
!isAllowOnlyFinalRules(finalRules) &&
!isLegacyPrivateOnlyFinalRules(finalRules) {
continue
}
settings["finalRules"] = []any{
+109
View File
@@ -0,0 +1,109 @@
package database
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// stubFail2banClient puts a fail2ban-client on PATH whose exit code the test picks.
func stubFail2banClient(t *testing.T, exitCode int) {
t.Helper()
dir := t.TempDir()
script := filepath.Join(dir, "fail2ban-client")
body := "#!/bin/sh\nexit " + string(rune('0'+exitCode)) + "\n"
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
t.Fatalf("write stub: %v", err)
}
t.Setenv("PATH", dir)
}
func TestFail2banEnforcementStateSeparatesAbsentFromUnrunnable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fail2ban shell fixtures are Unix-only")
}
t.Run("absent", func(t *testing.T) {
t.Setenv("PATH", t.TempDir())
if got, _ := fail2banEnforcementState(); got != fail2banAbsent {
t.Fatalf("state = %v, want fail2banAbsent", got)
}
})
t.Run("present and runnable", func(t *testing.T) {
stubFail2banClient(t, 0)
if got, _ := fail2banEnforcementState(); got != fail2banEnforcing {
t.Fatalf("state = %v, want fail2banEnforcing", got)
}
})
t.Run("present but failing", func(t *testing.T) {
stubFail2banClient(t, 1)
got, err := fail2banEnforcementState()
if got != fail2banUnknown {
t.Fatalf("state = %v, want fail2banUnknown", got)
}
if err == nil {
t.Fatal("want the probe error, got nil")
}
})
}
func TestResetIpLimitsKeepsConfiguredLimitsWhenProbeFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fail2ban shell fixtures are Unix-only")
}
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := InitDB(config.GetDBPath()); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if err := db.Where("seeder_name = ?", "ResetIpLimitNoFail2ban").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
settings, err := json.Marshal(map[string]any{"clients": []any{map[string]any{"email": "kept@example.test", "limitIp": 6}}})
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
inbound := model.Inbound{Remark: "kept", Settings: string(settings)}
if err := db.Create(&inbound).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
record := model.ClientRecord{Email: "kept@example.test", LimitIP: 2}
if err := db.Create(&record).Error; err != nil {
t.Fatalf("create client record: %v", err)
}
stubFail2banClient(t, 1)
if err := resetIpLimitsWithoutFail2ban(); err != nil {
t.Fatalf("reset: %v", err)
}
var gotInbound model.Inbound
if err := db.First(&gotInbound, inbound.Id).Error; err != nil {
t.Fatalf("reload inbound: %v", err)
}
var got map[string]any
if err := json.Unmarshal([]byte(gotInbound.Settings), &got); err != nil {
t.Fatalf("decode settings: %v", err)
}
clients := got["clients"].([]any)
if limit := clients[0].(map[string]any)["limitIp"]; limit != float64(6) {
t.Fatalf("inbound limitIp = %v, want 6", limit)
}
if err := db.First(&record, record.Id).Error; err != nil {
t.Fatalf("reload client record: %v", err)
}
if record.LimitIP != 2 {
t.Fatalf("client record limitIp = %d, want 2", record.LimitIP)
}
var count int64
if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "ResetIpLimitNoFail2ban").Count(&count).Error; err != nil {
t.Fatalf("count seeder history: %v", err)
}
if count != 0 {
t.Fatalf("seeder history rows = %d, want 0", count)
}
}
@@ -23,6 +23,24 @@ func TestRewriteFreedomFinalRulesPrivateEgress(t *testing.T) {
wantChanged: true,
wantRules: hardened,
},
{
name: "missing finalRules is hardened",
raw: `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"}]}`,
wantChanged: true,
wantRules: hardened,
},
{
name: "null finalRules is hardened",
raw: `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":null},"tag":"direct"}]}`,
wantChanged: true,
wantRules: hardened,
},
{
name: "empty finalRules is hardened",
raw: `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":[]},"tag":"direct"}]}`,
wantChanged: true,
wantRules: hardened,
},
{
name: "legacy private-only allow is hardened",
raw: `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]},"tag":"direct"}]}`,
@@ -76,6 +94,40 @@ func TestRewriteFreedomFinalRulesPrivateEgress(t *testing.T) {
}
}
func TestRewriteFreedomFinalRulesPreservesSplitRouting(t *testing.T) {
const raw = `{
"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"}],
"routing":{"domainStrategy":"AsIs","rules":[
{"type":"field","domain":["regexp:.*\\.ru$"],"outboundTag":"direct"},
{"type":"field","network":"tcp,udp","outboundTag":"proxy"}
]}
}`
updated, changed, err := rewriteFreedomFinalRulesPrivateEgress(raw)
if err != nil {
t.Fatalf("rewrite: %v", err)
}
if !changed {
t.Fatal("missing finalRules must be hardened")
}
var before, after map[string]any
if err := json.Unmarshal([]byte(raw), &before); err != nil {
t.Fatalf("decode before: %v", err)
}
if err := json.Unmarshal([]byte(updated), &after); err != nil {
t.Fatalf("decode after: %v", err)
}
beforeRouting, _ := json.Marshal(before["routing"])
afterRouting, _ := json.Marshal(after["routing"])
if string(afterRouting) != string(beforeRouting) {
t.Fatalf("split routing changed:\n got %s\nwant %s", afterRouting, beforeRouting)
}
outbound := after["outbounds"].([]any)[0].(map[string]any)
settings := outbound["settings"].(map[string]any)
if settings["domainStrategy"] != "AsIs" {
t.Fatalf("freedom domainStrategy=%v want AsIs", settings["domainStrategy"])
}
}
func TestRewriteFreedomFinalRulesPrivateEgressInvalidJSON(t *testing.T) {
_, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json")
if err == nil {
+15 -7
View File
@@ -350,8 +350,8 @@ func statusEmoji(st xray.ClientTraffic) string {
}
}
// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
// Returns "" when the client has no traffic limit.
// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3").
// Uses U+FF05: an ASCII percent encodes to %25, which Happ rejects, dropping the remark.
func usagePercentage(st xray.ClientTraffic) string {
if st.Total <= 0 {
return ""
@@ -361,7 +361,7 @@ func usagePercentage(st xray.ClientTraffic) string {
if pct > 100 {
pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
}
return fmt.Sprintf("%.1f%%", pct)
return fmt.Sprintf("%.1f", pct)
}
// timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
@@ -607,19 +607,27 @@ func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []
return runs
}
func (s *SubService) effectiveTemplate(email string) string {
func templateInfoKey(client model.Client) string {
if client.SubID != "" {
return "sub:" + client.SubID
}
return "email:" + client.Email
}
func (s *SubService) effectiveTemplate(client model.Client) string {
translated := translateUISingleBrackets(s.remarkTemplate)
if s.usageShown == nil {
s.usageShown = map[string]bool{}
}
if s.usageShown[email] {
key := templateInfoKey(client)
if s.usageShown[key] {
remove := firstLinkOnlyBodyTokens
if s.showIdentityOnAllLinks {
remove = usageInfoTokens
}
return filterRemarkTemplate(translated, remove)
}
s.usageShown[email] = true
s.usageShown[key] = true
return translated
}
@@ -646,7 +654,7 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
}
var tmpl string
if s.subscriptionBody {
tmpl = s.effectiveTemplate(client.Email)
tmpl = s.effectiveTemplate(client)
} else {
tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
}
+47 -8
View File
@@ -1,6 +1,7 @@
package sub
import (
"net/url"
"strings"
"testing"
@@ -480,18 +481,33 @@ func TestStatusEmoji(t *testing.T) {
}
func TestUsagePercentage(t *testing.T) {
if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0" {
t.Errorf("usagePercentage 50%% = %q", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
t.Errorf("usagePercentage unlimited = %q, want empty", got)
}
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0" {
t.Errorf("usagePercentage 100%% = %q", got)
}
// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0" {
t.Errorf("usagePercentage over-quota = %q, want 100.0", got)
}
}
func TestUsagePercentageSurvivesFragmentEncoding(t *testing.T) {
remark := "node " + usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 50 * gb})
link := buildLinkWithParams("vless://id@example.test:443", nil, remark)
if strings.Contains(link, "%25") {
t.Fatalf("encoded remark contains %%25, Happ drops such remarks: %s", link)
}
u, err := url.Parse(link)
if err != nil {
t.Fatalf("parse: %v", err)
}
if u.Fragment != remark {
t.Fatalf("fragment = %q, want %q", u.Fragment, remark)
}
}
@@ -546,7 +562,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
cases := []struct{ tmpl, want string }{
{"{{STATUS_EMOJI}}", "✅"},
{"{{USAGE_PERCENTAGE}}", "50.0%"},
{"{{USAGE_PERCENTAGE}}", "50.0"},
{"{{PROTOCOL}}", "VLESS"},
{"{{TRANSPORT}}", "ws"},
{"{{SECURITY}}", "REALITY"},
@@ -619,7 +635,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
{"{DATA_USAGE}", "50.00GB"},
{"{DATA_LIMIT}", "100.00GB"},
{"{STATUS_EMOJI}", "✅"},
{"{USAGE_PERCENTAGE}", "50.0%"},
{"{USAGE_PERCENTAGE}", "50.0"},
{"{PROTOCOL}", "VLESS"},
{"{TRANSPORT}", "ws"},
{"{SECURITY}", "TLS"},
@@ -649,7 +665,6 @@ func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
}
client := model.Client{Email: "alice@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
s.usageShown["alice@x"] = true
second := s.genTemplatedRemark(inbound, client, "", "ws")
if !strings.Contains(first, "📊") {
t.Fatalf("first link should carry usage: %q", first)
@@ -675,7 +690,6 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
}
client := model.Client{Email: "alice@x"}
first := s.genTemplatedRemark(inbound, client, "", "ws")
s.usageShown["alice@x"] = true
second := s.genTemplatedRemark(inbound, client, "", "ws")
if !strings.Contains(first, "alice@x") {
t.Fatalf("first link should carry email: %q", first)
@@ -724,3 +738,28 @@ func TestIdentityOnAllLinks(t *testing.T) {
})
}
}
func TestSharedSubIDRemark_FullInfoOncePerSubscription(t *testing.T) {
const tmpl = "{{INBOUND}}-{{EMAIL}}"
s := &SubService{
remarkTemplate: tmpl,
subscriptionBody: true,
usageShown: map[string]bool{},
}
first := model.Client{Email: "first@example", SubID: "shared-sub"}
second := model.Client{Email: "second@example", SubID: "shared-sub"}
if got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, first, "", "tcp"); got != "DE-first@example" {
t.Fatalf("first credential remark = %q", got)
}
if got := s.genTemplatedRemark(&model.Inbound{Remark: "FI"}, second, "", "tcp"); got != "FI" {
t.Fatalf("second credential with shared subId remark = %q, want identity suppressed", got)
}
}
func TestGenTemplatedRemarkPreservesConfiguredOuterWhitespace(t *testing.T) {
s := &SubService{remarkTemplate: " {{INBOUND}} ", subscriptionBody: true, usageShown: map[string]bool{}}
got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, model.Client{Email: "user@example.test"}, "", "tcp")
if got != " DE " {
t.Fatalf("remark = %q, want configured outer whitespace preserved", got)
}
}
+2 -3
View File
@@ -37,9 +37,8 @@ type SubService struct {
// other context — the sub info page, the panel's link/QR displays — renders
// the name-only template, like Remnawave.
subscriptionBody bool
// usageShown tracks, per client email, whether the info part of the template
// has already been emitted this request, so it appears on the first body
// link only. Per-request state; reset in PrepareForRequest.
// usageShown emits info once per subscription identity, including twins.
// PrepareForRequest resets this per-request state.
usageShown map[string]bool
showIdentityOnAllLinks bool
inboundService service.InboundService
+1 -3
View File
@@ -371,9 +371,7 @@ func (s *Server) Start() (err error) {
IdleTimeout: 120 * time.Second,
}
go func() {
_ = s.httpServer.Serve(listener)
}()
go network.ServeHTTP(s.httpServer, listener, "Subscription server")
return nil
}
-1
View File
@@ -148,7 +148,6 @@ func (a *GroupController) bulkAdd(c *gin.Context) {
return
}
jsonObj(c, gin.H{"affected": affected}, nil)
a.xrayService.SetToNeedRestart()
notifyClientsChanged()
}
@@ -389,6 +389,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
j.inboundService.ClearNodeOnlineClients(n.Id)
return nil
}
snap.ManagedAliases = rt.AdoptedInboundAliases()
service.FilterNodeSnapshot(n, snap)
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
if !dirty {
+17
View File
@@ -0,0 +1,17 @@
package network
import (
"errors"
"net"
"net/http"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// ServeHTTP runs a panel HTTP server and records unexpected listener failures.
// A normal Shutdown returns http.ErrServerClosed and is intentionally silent.
func ServeHTTP(server *http.Server, listener net.Listener, name string) {
if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error(name, " stopped unexpectedly: ", err)
}
}
+111
View File
@@ -0,0 +1,111 @@
package network
import (
"errors"
"go/ast"
"go/parser"
"go/token"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
type failingListener struct{ err error }
func (l failingListener) Accept() (net.Conn, error) { return nil, l.err }
func (failingListener) Close() error { return nil }
func (failingListener) Addr() net.Addr { return testAddr("failing") }
type testAddr string
func (a testAddr) Network() string { return string(a) }
func (a testAddr) String() string { return string(a) }
func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
errInjected := errors.New("injected listener failure")
ServeHTTP(&http.Server{}, failingListener{err: errInjected}, "Test server")
for _, line := range logger.GetLogs(100, "error") {
if strings.Contains(line, errInjected.Error()) {
return
}
}
t.Fatal("unexpected listener failure was not recorded in the panel log")
}
func TestServeHTTPSuppressesNormalServerClose(t *testing.T) {
const marker = "normal-close-must-stay-silent"
ServeHTTP(&http.Server{}, failingListener{err: http.ErrServerClosed}, marker)
for _, line := range logger.GetLogs(100, "error") {
if strings.Contains(line, marker) {
t.Fatalf("normal http.ErrServerClosed was recorded as an error: %s", line)
}
}
}
func TestProductionHTTPServersUseServeHTTPWrapper(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("locate test source")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../../.."))
fset := token.NewFileSet()
err := filepath.WalkDir(repoRoot, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" || entry.Name() == "vendor" || entry.Name() == "node_modules" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || path == currentFile || path == filepath.Join(filepath.Dir(currentFile), "serve.go") {
return nil
}
parsed, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return err
}
usesHTTP := false
for _, imp := range parsed.Imports {
if imp.Path.Value == `"net/http"` {
usesHTTP = true
break
}
}
if !usesHTTP {
return nil
}
parsed, err = parser.ParseFile(fset, path, nil, 0)
if err != nil {
return err
}
ast.Inspect(parsed, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
selector, ok := call.Fun.(*ast.SelectorExpr)
if ok && selector.Sel.Name == "Serve" {
position := fset.Position(call.Pos())
t.Errorf("direct Serve call at %s; production HTTP servers must use network.ServeHTTP", position)
}
return true
})
return nil
})
if err != nil {
t.Fatalf("scan production Go files: %v", err)
}
}
+32 -7
View File
@@ -78,8 +78,9 @@ func (e *remoteAPIError) Error() string { return "remote: " + e.msg }
type Remote struct {
node *model.Node
mu sync.RWMutex
remoteIDByTag map[string]int
mu sync.RWMutex
remoteIDByTag map[string]int
adoptedAliases map[string]string
// pushedFP holds the fingerprint of the last inbound wire payload successfully
// pushed, keyed by panel-side tag, so reconcile can skip re-sending an
// unchanged inbound. Guarded by mu; dropped with the Remote on node config change.
@@ -99,8 +100,10 @@ type Remote struct {
}
type RemoteInboundOption struct {
Id int `json:"id"`
Tag string `json:"tag"`
Remark string `json:"remark"`
Listen string `json:"listen"`
Protocol model.Protocol `json:"protocol"`
Port int `json:"port"`
}
@@ -109,6 +112,7 @@ func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
return &Remote{
node: n,
remoteIDByTag: make(map[string]int),
adoptedAliases: make(map[string]string),
pushedFP: make(map[string]string),
egressResolver: r,
}
@@ -479,13 +483,33 @@ func (r *Remote) recordPushedInbound(ib *model.Inbound) {
r.mu.Unlock()
}
// RecordAdoptedInbound stamps the fingerprint when the master adopts the
// node's own settings serialization into its DB — direct knowledge of the
// exact payload the node holds.
// RecordAdoptedInbound stamps the exact payload fingerprint after the master
// adopts a node's settings serialization.
func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
r.recordPushedInbound(ib)
}
// AdoptInboundAlias records a deployed alias without mutating either panel.
// The runtime association is rediscovered after a master restart.
func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {
r.mu.Lock()
r.remoteIDByTag[remote.Tag] = remote.Id
r.remoteIDByTag[ib.Tag] = remote.Id
r.adoptedAliases[ib.Tag] = remote.Tag
r.pushedFP[ib.Tag] = wireFingerprint(wireInbound(ib, r.node.Id))
r.mu.Unlock()
}
func (r *Remote) AdoptedInboundAliases() []string {
r.mu.RLock()
defer r.mu.RUnlock()
aliases := make([]string, 0, len(r.adoptedAliases))
for _, alias := range r.adoptedAliases {
aliases = append(aliases, alias)
}
return aliases
}
// AdvancePushedInbound moves the reconcile-skip fingerprint from an inbound's
// pre-edit payload to its post-edit payload once every per-client push for the
// edit succeeded. It advances only when the recorded fingerprint proves the
@@ -661,8 +685,9 @@ func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) err
}
type TrafficSnapshot struct {
Inbounds []*model.Inbound
OnlineEmails []string
Inbounds []*model.Inbound
OnlineEmails []string
ManagedAliases []string
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
// its panelGuid plus every descendant under theirs). Preferred over the flat
// OnlineEmails so the master can attribute deeply nested clients to the real
+51 -91
View File
@@ -652,7 +652,6 @@ func (s *ClientService) bulkAdjustInboundClients(
}
return res
}
prevSettings := oldInbound.Settings
oldInbound.Settings = string(newSettings)
// A flow change rewrites the user's xray config, which the lightweight
@@ -662,45 +661,6 @@ func (s *ClientService) bulkAdjustInboundClients(
res.needRestart = true
}
if oldInbound.NodeID != nil {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
for email := range foundEmails {
res.perEmailSkipped[email] = perr.Error()
delete(foundEmails, email)
}
} else {
if flowChanged {
push = false
}
// Large batches collapse into one reconcile push rather than M updates.
if push && len(foundEmails) > nodeBulkPushThreshold {
push = false
}
if push {
pushFailed := false
for email := range foundEmails {
entry := plan[email]
updated := *entry.record.ToClient()
if entry.applyExpiry {
updated.ExpiryTime = entry.newExpiry
}
if entry.applyTotal {
updated.TotalGB = entry.newTotal
}
updated.UpdatedAt = nowMs
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
pushFailed = true
}
}
if !pushFailed {
advancePushedInbound(rt, prevSettings, oldInbound)
}
}
}
}
// Serialize against the traffic poll to avoid the cross-transaction
// lock-order deadlock on inbounds/client_records (runSerializedTx).
txErr := runSerializedTx(func(tx *gorm.DB) error {
@@ -725,6 +685,26 @@ func (s *ClientService) bulkAdjustInboundClients(
res.perEmailSkipped[email] = txErr.Error()
}
}
} else if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
} else if push {
for email := range foundEmails {
entry := plan[email]
updated := *entry.record.ToClient()
if entry.applyExpiry {
updated.ExpiryTime = entry.newExpiry
}
if entry.applyTotal {
updated.TotalGB = entry.newTotal
}
updated.UpdatedAt = nowMs
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
}
}
}
}
return res
@@ -980,7 +960,6 @@ func (s *ClientService) bulkDelInboundClients(
}
return res
}
prevSettings := oldInbound.Settings
oldInbound.Settings = string(newSettings)
foundList := make([]string, 0, len(foundEmails))
@@ -1048,56 +1027,6 @@ func (s *ClientService) bulkDelInboundClients(
}
}
if oldInbound.NodeID == nil {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
res.needRestart = true
} else {
for email := range foundEmails {
if !enableByEmail[email] || !notDepletedByEmail[email] {
continue
}
err1 := rt.RemoveUser(context.Background(), oldInbound, email)
if err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
res.needRestart = true
}
}
}
} else {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
for email := range foundEmails {
res.perEmailSkipped[email] = perr.Error()
delete(foundEmails, email)
}
} else {
// Large batches collapse into one reconcile push rather than M deletes.
if push && len(foundEmails) > nodeBulkPushThreshold {
push = false
}
if push {
// bulkDelInboundClients only runs for full client deletion
// (BulkDelete), so the node must drop its client record too,
// not just detach from this inbound (#5797).
pushFailed := false
for email := range foundEmails {
if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
pushFailed = true
}
}
if !pushFailed {
advancePushedInbound(rt, prevSettings, oldInbound)
}
}
}
}
// Serialize against the traffic poll to avoid the cross-transaction
// lock-order deadlock on inbounds/client_records (runSerializedTx).
txErr := runSerializedTx(func(tx *gorm.DB) error {
@@ -1122,6 +1051,37 @@ func (s *ClientService) bulkDelInboundClients(
res.perEmailSkipped[email] = txErr.Error()
}
}
} else if oldInbound.NodeID == nil {
rt, rterr := inboundSvc.runtimeFor(oldInbound)
if rterr != nil {
res.needRestart = true
} else {
for email := range foundEmails {
if !enableByEmail[email] || !notDepletedByEmail[email] {
continue
}
err1 := rt.RemoveUser(context.Background(), oldInbound, email)
if err1 == nil {
logger.Debug("Client deleted on", rt.Name(), ":", email)
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
logger.Debug("User is already deleted. Nothing to do more...")
} else {
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
res.needRestart = true
}
}
}
} else if len(foundEmails) <= nodeBulkPushThreshold {
rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
if perr != nil {
logger.Warning("BulkDelete: node runtime lookup after commit failed:", perr)
} else if push {
for email := range foundEmails {
if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
}
}
}
}
return res
@@ -0,0 +1,42 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func TestAddToGroupReportsOnlyChangedRecordsIncludingNull(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
if err := db.Create(&model.ClientGroup{Name: "paid"}).Error; err != nil {
t.Fatalf("create group: %v", err)
}
rows := []model.ClientRecord{
{Email: "same@example", UUID: "same", Group: "paid"},
{Email: "other@example", UUID: "other", Group: "free"},
{Email: "null@example", UUID: "null"},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("create clients: %v", err)
}
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "null@example").UpdateColumn("group_name", nil).Error; err != nil {
t.Fatalf("set NULL group: %v", err)
}
got, err := (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example", "missing@example"}, "paid")
if err != nil {
t.Fatalf("AddToGroup: %v", err)
}
if got != 2 {
t.Fatalf("affected = %d, want 2 changed records", got)
}
got, err = (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example"}, "paid")
if err != nil {
t.Fatalf("second AddToGroup: %v", err)
}
if got != 0 {
t.Fatalf("second affected = %d, want 0", got)
}
}
+11 -5
View File
@@ -234,7 +234,9 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
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 {
if err := db.Where("email IN ?", batch).
Where("group_name IS NULL OR group_name <> ?", group).
Find(&rows).Error; err != nil {
return 0, err
}
records = append(records, rows...)
@@ -248,13 +250,17 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
}
tx := db.Begin()
var affected int64
for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
if err := tx.Model(&model.ClientRecord{}).
result := tx.Model(&model.ClientRecord{}).
Where("email IN ?", batch).
UpdateColumn("group_name", group).Error; err != nil {
Where("group_name IS NULL OR group_name <> ?", group).
UpdateColumn("group_name", group)
if result.Error != nil {
tx.Rollback()
return 0, err
return 0, result.Error
}
affected += result.RowsAffected
}
var inboundIDs []int
@@ -331,7 +337,7 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
if err := tx.Commit().Error; err != nil {
return 0, err
}
return len(records), nil
return int(affected), nil
}
func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) {
+5 -5
View File
@@ -75,7 +75,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
t.Fatalf("empty globals must use the local-only predicate")
}
seedClientRow(t, "local-cap", 1, 600, 600, 1000)
if _, count, err := svc.disableInvalidClients(db); err != nil {
if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 {
t.Fatalf("local over-quota client must be disabled, disabled %d", count)
@@ -115,7 +115,7 @@ func TestStaleGlobalTraffic_Ignored(t *testing.T) {
if got, _ := depletedCond(db); got != depletedClientsCondLocal {
t.Fatalf("only stale globals must fall back to the local-only predicate")
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 0 {
t.Fatalf("stale global usage must not disable a client, disabled %d", count)
@@ -140,7 +140,7 @@ func TestStaleGlobalTraffic_Ignored(t *testing.T) {
if got, _ := depletedCond(db); got != depletedClientsCond {
t.Fatalf("a fresh global row must select the cross-panel predicate")
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 0 {
t.Fatalf("the live master reports usage well under quota, disabled %d", count)
@@ -149,7 +149,7 @@ func TestStaleGlobalTraffic_Ignored(t *testing.T) {
if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 {
t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
@@ -167,7 +167,7 @@ func TestGlobalUsage_DisablesClient(t *testing.T) {
t.Fatalf("AcceptGlobalTraffic: %v", err)
}
if _, count, err := svc.disableInvalidClients(db); err != nil {
if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("disableInvalidClients: %v", err)
} else if count != 1 {
t.Fatalf("expected 1 client disabled, got %d", count)
-1
View File
@@ -29,7 +29,6 @@ import (
)
type InboundService struct {
xrayApi xray.XrayAPI
clientService ClientService
fallbackService FallbackService
}
@@ -77,7 +77,7 @@ func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db); err != nil {
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 1 {
t.Fatalf("renewed count = %d, want 1", count)
@@ -52,7 +52,7 @@ func TestAutoRenewClients_MultiInbound(t *testing.T) {
t.Fatalf("seed client_traffics: %v", err)
}
if _, count, err := svc.autoRenewClients(db); err != nil {
if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
t.Fatalf("autoRenewClients: %v", err)
} else if count != 3 {
t.Fatalf("renewed count = %d, want 3", count)
+46 -97
View File
@@ -1,44 +1,27 @@
package service
import (
"context"
"encoding/json"
"fmt"
"slices"
"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) {
func (s *InboundService) disableInvalidInbounds(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, error) {
now := time.Now().Unix() * 1000
needRestart := false
if process := currentXrayProcess(); process != 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(process.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()
var inbounds []model.Inbound
if err := tx.Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
Find(&inbounds).Error; err != nil {
return false, 0, err
}
for i := range inbounds {
mutationBatch.localPlans = append(mutationBatch.localPlans, trafficLocalApplyPlan{
action: trafficDisableInbound, inbound: inbounds[i],
})
}
result := tx.Model(model.Inbound{}).
@@ -46,7 +29,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
Update("enable", false)
err := result.Error
count := result.RowsAffected
return needRestart, count, err
return false, count, err
}
const globalTrafficFreshWindow = 24 * time.Hour
@@ -94,8 +77,8 @@ func depletedCond(tx *gorm.DB) (string, []any) {
return depletedClientsCondLocal, []any{now}
}
func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
needRestart := false
func (s *InboundService) disableInvalidClients(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, []int, error) {
now := time.Now().UnixMilli()
cond, condArgs := depletedCond(tx)
var depletedRows []xray.ClientTraffic
@@ -103,10 +86,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
Where(cond+" AND enable = ?", append(condArgs, true)...).
Find(&depletedRows).Error
if err != nil {
return false, 0, err
return false, 0, nil, err
}
if len(depletedRows) == 0 {
return false, 0, nil
return false, 0, nil, nil
}
depletedEmails := make([]string, 0, len(depletedRows))
@@ -134,47 +117,39 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
WHERE clients.email IN ?
`, depletedEmails).Scan(&targets).Error
if err != nil {
return false, 0, err
return false, 0, nil, err
}
}
var localTargets []target
localByInbound := make(map[int]map[string]struct{})
remoteByInbound := make(map[int][]target)
byInbound := 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)
}
byInbound[t.InboundID] = append(byInbound[t.InboundID], t)
}
if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
_ = s.xrayApi.Init(process.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
}
disabledNodeIDs := make(map[int]struct{})
for inboundID, group := range byInbound {
emails := make(map[string]struct{}, len(group))
for _, t := range group {
emails[t.Email] = struct{}{}
}
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)
oldInbound, inbound, mErr := s.markClientsDisabledInSettings(tx, inboundID, emails)
if mErr != nil {
return false, 0, nil, mErr
}
if inbound.NodeID != nil {
mutationBatch.remotePlans = append(mutationBatch.remotePlans, trafficInboundUpdatePlan{
oldInbound: *oldInbound, newInbound: *inbound,
})
mutationBatch.addNode(*inbound.NodeID)
disabledNodeIDs[*inbound.NodeID] = struct{}{}
continue
}
for email := range emails {
mutationBatch.localPlans = append(mutationBatch.localPlans, trafficLocalApplyPlan{
action: trafficRemoveUser, inbound: *inbound, email: email,
})
}
}
// Flip the rows already collected above by primary key instead of
// re-evaluating the depleted predicate, which was a second full scan of
// client_traffics on every poll. Sorted ids keep the lock order stable.
@@ -189,7 +164,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
Where("id IN ? AND enable = ?", batch, true).
Update("enable", false)
if result.Error != nil {
return needRestart, count, result.Error
return false, count, nil, result.Error
}
count += result.RowsAffected
}
@@ -197,23 +172,17 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
if len(depletedEmails) > 0 {
if err := tx.Model(&model.ClientRecord{}).
Where("email IN ?", depletedEmails).
Updates(map[string]any{"enable": false, "updated_at": time.Now().UnixMilli()}).Error; err != nil {
logger.Warning("disableInvalidClients update clients.enable:", err)
Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
return false, count, nil, err
}
}
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
}
nodeIDs := make([]int, 0, len(disabledNodeIDs))
for nodeID := range disabledNodeIDs {
nodeIDs = append(nodeIDs, nodeID)
}
return needRestart, count, nil
return false, count, nodeIDs, nil
}
// markClientsDisabledInSettings flips client.enable=false in the inbound's
@@ -265,23 +234,3 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
}
return &snapshot, &ib, nil
}
// disableRemoteClients flips the clients off in the inbound's stored settings
// and pushes the updated inbound to its node, which applies it to its own
// running Xray. That push is the whole reconcile — restarting the node's Xray
// afterwards would drop every live connection on the node for nothing (#5740).
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
}
+43 -23
View File
@@ -33,13 +33,15 @@ func (s *InboundService) MigrationRemoveOrphanedTraffics() {
}
}
func (s *InboundService) MigrationRequirements() {
func (s *InboundService) MigrationRequirements() (err error) {
db := database.GetDB()
tx := db.Begin()
var err error
defer func() {
if err == nil {
tx.Commit()
if commitErr := tx.Commit().Error; commitErr != nil {
err = commitErr
return
}
if !database.IsPostgres() {
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
logger.Warningf("VACUUM failed: %v", dbErr)
@@ -76,8 +78,8 @@ func (s *InboundService) MigrationRequirements() {
// 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(`
normalizeBool := func(table, col string) error {
return tx.Exec(fmt.Sprintf(`
DO $$
BEGIN
IF EXISTS (
@@ -88,14 +90,13 @@ func (s *InboundService) MigrationRequirements() {
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))
END $$;`, table, col, table, col, col)).Error
}
for _, column := range [][2]string{{"inbounds", "enable"}, {"client_traffics", "enable"}, {"nodes", "enable"}, {"clients", "enable"}, {"api_tokens", "enabled"}, {"outbound_subscriptions", "enabled"}} {
if err = normalizeBool(column[0], column[1]); err != nil {
return
}
}
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
@@ -160,7 +161,8 @@ func (s *InboundService) MigrationRequirements() {
delete(settings, "testseed")
}
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
var modifiedSettings []byte
modifiedSettings, err = json.MarshalIndent(settings, "", " ")
if err != nil {
return
}
@@ -169,30 +171,39 @@ func (s *InboundService) MigrationRequirements() {
}
// Add client traffic row for all clients which has email
modelClients, err := s.GetClients(inbounds[inbound_index])
var modelClients []model.Client
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 err = tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count).Error; err != nil {
return
}
if count == 0 {
_ = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
if err = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient); err != nil {
return
}
}
}
}
// 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)
if err = s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); err != nil {
return
}
}
tx.Save(inbounds)
if err = tx.Save(inbounds).Error; err != nil {
return
}
// Remove orphaned traffics
tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
if err = tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}).Error; err != nil {
return
}
// Migrate old MultiDomain to External Proxy
var externalProxy []struct {
@@ -238,8 +249,14 @@ func (s *InboundService) MigrationRequirements() {
}
}
stream["externalProxy"] = reverses
newStream, _ := json.MarshalIndent(stream, " ", " ")
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
newStream, marshalErr := json.MarshalIndent(stream, " ", " ")
if marshalErr != nil {
err = marshalErr
return
}
if err = tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream).Error; err != nil {
return
}
}
// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
@@ -256,10 +273,13 @@ func (s *InboundService) MigrationRequirements() {
if err != nil {
return
}
return err
}
func (s *InboundService) MigrateDB() {
s.MigrationRequirements()
if err := s.MigrationRequirements(); err != nil {
logger.Errorf("MigrationRequirements failed: %v", err)
}
s.MigrationRemoveOrphanedTraffics()
s.MigrationRestoreVisionFlow()
}
@@ -1,10 +1,13 @@
package service
import (
"errors"
"path/filepath"
"strings"
"testing"
"gorm.io/gorm"
"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"
@@ -90,6 +93,41 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *
}
}
func TestMigrationRequirementsReturnsAddClientStatFailure(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()
first := &model.Inbound{UserId: 1, Tag: "first", Port: 31001, Protocol: model.VLESS, Settings: `{"clients":[{"email":"first@example.test","id":"id-1"}]}`, StreamSettings: `{}`}
if err := db.Create(first).Error; err != nil {
t.Fatalf("create first: %v", err)
}
const injected = "injected AddClientStat failure"
failSave := func(tx *gorm.DB) {
tx.AddError(errors.New(injected))
}
if err := db.Callback().Update().Before("gorm:update").Register("test:fail-migration-inbound-save", failSave); err != nil {
t.Fatalf("register update callback: %v", err)
}
if err := db.Callback().Create().Before("gorm:create").Register("test:fail-migration-inbound-save", failSave); err != nil {
t.Fatalf("register create callback: %v", err)
}
err := (&InboundService{}).MigrationRequirements()
if err == nil || err.Error() != injected {
t.Fatalf("MigrationRequirements error = %v, want %q", err, injected)
}
var count int64
if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "first@example.test").Count(&count).Error; err != nil {
t.Fatalf("count rolled-back traffic: %v", err)
}
if count != 0 {
t.Fatalf("earlier traffic write committed after save failure: count=%d", count)
}
}
// TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
// strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
// externalProxy detection query returns rows and the cleanup is reached (it early-returns
+77 -3
View File
@@ -96,13 +96,15 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
return err
}
remoteTags, err := rt.ListRemoteTags(ctx)
remoteInbounds, err := rt.ListInboundOptions(ctx)
if err != nil {
return err
}
remoteTags := make([]string, 0, len(remoteInbounds))
remoteTagSet := make(map[string]struct{}, len(remoteTags))
for _, tag := range remoteTags {
remoteTagSet[tag] = struct{}{}
for _, remoteIb := range remoteInbounds {
remoteTags = append(remoteTags, remoteIb.Tag)
remoteTagSet[remoteIb.Tag] = struct{}{}
}
prefix := nodeTagPrefix(&nodeID)
desiredTags := make(map[string]struct{}, len(inbounds)*2)
@@ -129,6 +131,33 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
runtimeIb = built
}
if !existsOnNode && n.Guid != "" && ib.OriginNodeGuid == n.Guid {
var compatible []runtime.RemoteInboundOption
for _, remoteIb := range remoteInbounds {
if remoteIb.Port == runtimeIb.Port &&
remoteIb.Protocol == runtimeIb.Protocol &&
strings.TrimSpace(remoteIb.Listen) == strings.TrimSpace(runtimeIb.Listen) {
compatible = append(compatible, remoteIb)
}
}
switch len(compatible) {
case 1:
alias := compatible[0]
desiredTags[alias.Tag] = struct{}{}
rt.AdoptInboundAlias(runtimeIb, alias)
existsOnNode = true
logger.Infof("adopted compatible inbound %q on node %s as %q", alias.Tag, n.Name, ib.Tag)
case 0:
// No compatible occupant: keep the normal create path, which
// leaves a real port/protocol drift loud.
default:
for _, candidate := range compatible {
desiredTags[candidate.Tag] = struct{}{}
}
errs = append(errs, fmt.Errorf("reconcile inbound %q: ambiguous compatible remote inbounds", ib.Tag))
continue
}
}
if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
}
@@ -514,6 +543,29 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
}
c, ok := tagToCentral[snapIb.Tag]
if !ok {
origin := originGuidFor(snapIb)
var compatible []*model.Inbound
for i := range central {
candidate := &central[i]
if candidate.OriginNodeGuid == origin &&
candidate.Port == snapIb.Port &&
candidate.Protocol == snapIb.Protocol &&
strings.TrimSpace(candidate.Listen) == strings.TrimSpace(snapIb.Listen) {
compatible = append(compatible, candidate)
}
}
switch len(compatible) {
case 1:
c, ok = compatible[0], true
tagToCentral[snapIb.Tag] = c
snapTags[c.Tag] = struct{}{}
case 0:
// A genuinely new inbound follows the normal adoption path.
default:
return false, fmt.Errorf("setRemoteTraffic: inbound %q has ambiguous compatible central aliases", snapIb.Tag)
}
}
if !ok {
if dirty {
continue
@@ -1079,6 +1131,28 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
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 {
process := currentXrayProcess()
if process == nil {
@@ -251,6 +251,107 @@ func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
}
}
func TestReconcileNode_AdoptsCompatibleOriginInboundWithoutRemoteMutation(t *testing.T) {
setupConflictDB(t)
var mu sync.Mutex
mutations := 0
writeOK := func(w http.ResponseWriter, obj any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
}
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
writeOK(w, []map[string]any{{"id": 41, "tag": "already-deployed", "listen": "", "port": 8443, "protocol": "vless"}})
})
mux.HandleFunc("/panel/api/inbounds/", func(w http.ResponseWriter, _ *http.Request) {
mu.Lock()
mutations++
mu.Unlock()
writeOK(w, nil)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
node := reconcileTestNode(t, ts, "adopt-node", "all", nil)
node.Guid = "origin-guid"
if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
t.Fatalf("update node guid: %v", err)
}
seedInboundConflictNode(t, "desired-name", "", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
t.Fatalf("set origin guid: %v", err)
}
svc := InboundService{}
rt := runtime.NewRemote(node, nil)
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
t.Fatalf("first ReconcileNode: %v", err)
}
if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
t.Fatalf("second ReconcileNode: %v", err)
}
mu.Lock()
got := mutations
mu.Unlock()
if got != 0 {
t.Fatalf("remote mutations = %d, want 0 while adopting compatible deployed inbound", got)
}
}
func TestReconcileNode_AmbiguousCompatibleInboundsAreNotSwept(t *testing.T) {
setupConflictDB(t)
ts, deletedIDs := fakeNodePanel(t, map[string]int{"alias-a": 51, "alias-b": 52})
node := reconcileTestNode(t, ts, "ambiguous-node", "all", nil)
node.Guid = "origin-guid"
if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
t.Fatalf("update node guid: %v", err)
}
seedInboundConflictNode(t, "desired-name", "", 0, model.Protocol(""), `{}`, `{"clients":[]}`, &node.Id)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
t.Fatalf("set origin guid: %v", err)
}
err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
if err == nil || !strings.Contains(err.Error(), "ambiguous compatible remote inbounds") {
t.Fatalf("ReconcileNode error = %v, want ambiguity error", err)
}
if got := deletedIDs(); len(got) != 0 {
t.Fatalf("deleted ambiguous candidates = %v, want none", got)
}
}
func TestReconcileNode_IncompatiblePortOccupantRemainsLoud(t *testing.T) {
setupConflictDB(t)
writeOK := func(w http.ResponseWriter, obj any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
}
mux := http.NewServeMux()
mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
writeOK(w, []map[string]any{{"id": 42, "tag": "port-owner", "listen": "", "port": 9443, "protocol": "trojan"}})
})
mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "msg": "port already occupied", "obj": nil})
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
node := reconcileTestNode(t, ts, "drift-node", "all", nil)
node.Guid = "origin-guid"
seedInboundConflictNode(t, "desired-name", "", 9443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
t.Fatalf("set origin guid: %v", err)
}
err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
if err == nil || !strings.Contains(err.Error(), "port already occupied") {
t.Fatalf("ReconcileNode error = %v, want loud incompatible-port error", err)
}
}
func TestEnsureInboundTagAllowed(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
+273 -224
View File
@@ -22,60 +22,77 @@ import (
)
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, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
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, error) {
var err error
func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
db := database.GetDB()
tx := db.Begin()
defer func() {
if err != nil {
if rbErr := tx.Rollback().Error; rbErr != nil {
logger.Warning("Error rolling back traffic tx:", rbErr)
}
} else if cErr := tx.Commit().Error; cErr != nil {
logger.Warning("Error committing traffic tx:", cErr)
// Commit durable traffic before best-effort lifecycle maintenance so helper
// failures cannot discard usage already reported by Xray.
if err := db.Transaction(func(tx *gorm.DB) error {
if err := s.addInboundTraffic(tx, inboundTraffics); err != nil {
return err
}
}()
err = s.addInboundTraffic(tx, inboundTraffics)
return s.addClientTraffic(tx, clientTraffics)
}); err != nil {
return false, false, nil, err
}
var (
needRestart bool
clientsDisabled bool
disabledNodeIDs []int
disabledClientsCount int64
)
batch := newTrafficMutationBatch()
err := db.Transaction(func(tx *gorm.DB) error {
needRestart0, count, err := s.autoRenewClients(tx, batch)
if err != nil {
return fmt.Errorf("renew clients: %w", err)
}
if count > 0 {
logger.Debugf("%v clients renewed", count)
}
needRestart1, count, nodeIDs, err := s.disableInvalidClients(tx, batch)
if err != nil {
return fmt.Errorf("disable invalid clients: %w", err)
}
if count > 0 {
logger.Debugf("%v clients disabled", count)
disabledClientsCount = count
}
needRestart2, count, err := s.disableInvalidInbounds(tx, batch)
if err != nil {
return fmt.Errorf("disable invalid inbounds: %w", err)
}
if count > 0 {
logger.Debugf("%v inbounds disabled", count)
}
if err := batch.markNodesTx(tx); err != nil {
return err
}
needRestart = needRestart0 || needRestart1 || needRestart2
clientsDisabled = disabledClientsCount > 0
disabledNodeIDs = nodeIDs
return nil
})
if err != nil {
return false, false, err
logger.Warning("traffic lifecycle maintenance failed after traffic commit:", err)
return false, false, nil, nil
}
err = s.addClientTraffic(tx, clientTraffics)
if err != nil {
return false, false, err
}
needRestart0, count, renewErr := s.autoRenewClients(tx)
if renewErr != nil {
logger.Warning("Error in renew clients:", renewErr)
} else if count > 0 {
logger.Debugf("%v clients renewed", count)
}
disabledClientsCount := int64(0)
needRestart1, count, disableClientsErr := s.disableInvalidClients(tx)
if disableClientsErr != nil {
logger.Warning("Error in disabling invalid clients:", disableClientsErr)
} else if count > 0 {
logger.Debugf("%v clients disabled", count)
disabledClientsCount = count
}
needRestart2, count, disableInboundsErr := s.disableInvalidInbounds(tx)
if disableInboundsErr != nil {
logger.Warning("Error in disabling invalid inbounds:", disableInboundsErr)
} else if count > 0 {
logger.Debugf("%v inbounds disabled", count)
}
return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
needRestart = needRestart || s.applyTrafficMutationBatch(batch)
return needRestart, clientsDisabled, disabledNodeIDs, nil
}
func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -304,11 +321,11 @@ func apiUserFromClient(client map[string]any, cipher string) map[string]any {
return user
}
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, error) {
// check for time expired
var traffics []*xray.ClientTraffic
now := time.Now().Unix() * 1000
var err, err1 error
var err error
// Filter to clients that have at least one local inbound. Using
// client_traffics.inbound_id is wrong: it goes stale after an inbound is
@@ -335,9 +352,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
var inbounds []*model.Inbound
needRestart := false
var clientsToAdd []struct {
protocol string
tag string
client map[string]any
inbound model.Inbound
client map[string]any
}
// Resolve the inbounds to renew through the client_inbounds link rather than
@@ -408,13 +424,11 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
c["enable"] = true
clientsToAdd = append(clientsToAdd,
struct {
protocol string
tag string
client map[string]any
inbound model.Inbound
client map[string]any
}{
protocol: string(inbounds[inbound_index].Protocol),
tag: inbounds[inbound_index].Tag,
client: apiUserFromClient(c, cipher),
inbound: *inbounds[inbound_index],
client: apiUserFromClient(c, cipher),
})
}
clients[client_index] = any(c)
@@ -452,18 +466,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
return false, 0, err
}
if process := currentXrayProcess(); process != nil {
err1 = s.xrayApi.Init(process.GetAPIPort())
if err1 != nil {
return true, int64(len(traffics)), nil
for _, clientToAdd := range clientsToAdd {
if clientToAdd.inbound.NodeID != nil {
mutationBatch.addNode(*clientToAdd.inbound.NodeID)
continue
}
for _, clientToAdd := range clientsToAdd {
err1 = s.xrayApi.AddUser(clientToAdd.protocol, clientToAdd.tag, clientToAdd.client)
if err1 != nil {
needRestart = true
}
}
s.xrayApi.Close()
mutationBatch.localPlans = append(mutationBatch.localPlans, trafficLocalApplyPlan{
action: trafficAddUser, inbound: clientToAdd.inbound, client: clientToAdd.client,
})
}
return needRestart, int64(len(traffics)), nil
}
@@ -577,56 +587,58 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
}
func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRestart bool, err error) {
var resetInbound *model.Inbound
err = submitTrafficWrite(func() error {
var inner error
needRestart, inner = s.resetClientTrafficLocked(id, clientEmail)
needRestart, resetInbound, inner = s.resetClientTrafficLocked(id, clientEmail)
return inner
})
if err == nil {
s.resetMtprotoClientQuota(clientEmail)
if resetInbound != nil && resetInbound.NodeID != nil {
if rt, rterr := s.runtimeFor(resetInbound); rterr == nil {
if e := rt.ResetClientTraffic(context.Background(), resetInbound, clientEmail); e != nil {
logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)
}
} else {
logger.Warning("ResetClientTraffic: runtime lookup failed:", rterr)
}
}
}
return
}
func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, error) {
func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, *model.Inbound, error) {
needRestart := false
var reenablePlan *trafficLocalApplyPlan
var reenableNodeID *int
traffic, err := s.GetClientTrafficByEmail(clientEmail)
if err != nil {
return false, err
return false, nil, err
}
if !traffic.Enable {
inbound, err := s.GetInbound(id)
if err != nil {
return false, err
return false, nil, err
}
clients, err := s.GetClients(inbound)
if err != nil {
return false, err
return false, nil, err
}
for _, client := range clients {
if client.Email == clientEmail && client.Enable {
rt, push, _, perr := s.nodePushPlan(inbound)
if perr != nil {
return false, perr
}
if !push {
if inbound.NodeID == nil {
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
return false, nil, err
}
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
clientMap := map[string]any{
"email": client.Email,
"id": client.ID,
"auth": client.Auth,
@@ -634,14 +646,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
"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 inbound.NodeID != nil {
reenableNodeID = inbound.NodeID
} else {
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
needRestart = true
reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
}
break
}
@@ -656,7 +665,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
now := time.Now().UnixMilli()
inbound, err := s.GetInbound(id)
if err != nil {
return false, err
return false, nil, err
}
if err := db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
@@ -676,25 +685,30 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
Update("last_traffic_reset_time", now).Error; err != nil {
return err
}
if reenableNodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID)
}
if inbound != nil && inbound.NodeID != nil {
return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
}
return nil
}); err != nil {
return false, err
return false, nil, err
}
if 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)
}
if reenablePlan != nil {
rt, err := s.runtimeFor(&reenablePlan.inbound)
if err != nil {
needRestart = true
} else if err := rt.AddUser(context.Background(), &reenablePlan.inbound, reenablePlan.client); err != nil {
logger.Debug("Error in enabling client on", rt.Name(), ":", err)
needRestart = true
} else {
logger.Warning("ResetClientTraffic: runtime lookup failed:", rterr)
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
}
}
return needRestart, nil
return needRestart, inbound, nil
}
func (s *InboundService) ResetAllTraffics() error {
@@ -740,16 +754,24 @@ func (s *InboundService) propagateResetAllTrafficsToNodes() {
}
func (s *InboundService) ResetInboundTraffic(id int) error {
var inbound *model.Inbound
if err := submitTrafficWrite(func() error {
return database.GetDB().Model(model.Inbound{}).
db := database.GetDB()
if err := db.Model(model.Inbound{}).
Where("id = ?", id).
Updates(map[string]any{"up": 0, "down": 0}).Error
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
return err
}
var err error
inbound, err = s.GetInbound(id)
if err != nil {
return err
}
return nil
}); err != nil {
return err
}
inbound, err := s.GetInbound(id)
if err == nil && inbound != nil && inbound.NodeID != nil {
if 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)
@@ -763,134 +785,161 @@ func (s *InboundService) ResetInboundTraffic(id int) error {
func (s *InboundService) DelDepletedClients(id int) (err error) {
db := database.GetDB()
tx := db.Begin()
defer func() {
if err == nil {
tx.Commit()
} else {
tx.Rollback()
var deletedInbounds []model.Inbound
err = db.Transaction(func(tx *gorm.DB) error {
// 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
if err := tx.Model(xray.ClientTraffic{}).
Where(depletedClause, now).
Find(&depletedRows).Error; err != nil {
return err
}
if len(depletedRows) == 0 {
return nil
}
}()
// 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
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 := tx.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 {
deletedInbounds = append(deletedInbounds, *inbound)
if err := s.clientService.DetachInbound(tx, inbound.Id); err != nil {
return err
}
if err := tx.Where("inbound_id = ?", inbound.Id).Delete(&model.Host{}).Error; err != nil {
return err
}
if err := tx.Delete(model.Inbound{}, inbound.Id).Error; err != nil {
return err
}
if inbound.NodeID != nil {
if err := (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID); err != nil {
return err
}
}
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 {
return gcErr
}
if err := s.clientService.SyncInbound(tx, inbound.Id, survivingClients); err != nil {
return err
}
if inbound.NodeID != nil {
if err := (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID); 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 {
return tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error
}
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
})
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
for i := range deletedInbounds {
inbound := &deletedInbounds[i]
if rt, rtErr := s.runtimeFor(inbound); rtErr != nil {
logger.Warning("DelDepletedClients: runtime lookup failed after commit:", rtErr)
} else if rtErr = rt.DelInbound(context.Background(), inbound); rtErr != nil && !xray.IsMissingHandlerErr(rtErr) {
logger.Warning("DelDepletedClients: runtime cleanup failed after commit:", rtErr)
}
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
if inbound.Tag != "" {
if _, syncErr := (&XraySettingService{}).RemoveInboundTagReferences(inbound.Tag); syncErr != nil {
logger.Warning("DelDepletedClients: routing cleanup failed after commit:", syncErr)
}
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
@@ -0,0 +1,105 @@
package service
import (
"context"
"strings"
"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"
)
type trafficLocalApplyAction uint8
const (
trafficAddUser trafficLocalApplyAction = iota + 1
trafficRemoveUser
trafficDisableInbound
)
type trafficLocalApplyPlan struct {
action trafficLocalApplyAction
inbound model.Inbound
client map[string]any
email string
}
type trafficMutationBatch struct {
localPlans []trafficLocalApplyPlan
remotePlans []trafficInboundUpdatePlan
nodeIDs map[int]struct{}
}
type trafficInboundUpdatePlan struct{ oldInbound, newInbound model.Inbound }
func newTrafficMutationBatch() *trafficMutationBatch {
return &trafficMutationBatch{nodeIDs: make(map[int]struct{})}
}
func (b *trafficMutationBatch) addNode(nodeID int) {
if nodeID > 0 {
b.nodeIDs[nodeID] = struct{}{}
}
}
func (b *trafficMutationBatch) markNodesTx(tx *gorm.DB) error {
if b == nil {
return nil
}
nodeSvc := NodeService{}
for nodeID := range b.nodeIDs {
if err := nodeSvc.MarkNodeDirtyTx(tx, nodeID); err != nil {
return err
}
}
return nil
}
func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool {
if b == nil {
return false
}
needRestart := false
for i := range b.remotePlans {
plan := &b.remotePlans[i]
rt, err := s.runtimeFor(&plan.newInbound)
if err == nil {
err = rt.UpdateInbound(context.Background(), &plan.oldInbound, &plan.newInbound)
}
if err != nil {
logger.Debug("traffic post-commit remote apply failed:", err)
needRestart = true
}
}
for i := range b.localPlans {
plan := &b.localPlans[i]
if plan.inbound.Protocol == model.MTProto {
s.applyLocalMtproto(plan.inbound.Id)
continue
}
rt, err := s.runtimeFor(&plan.inbound)
if err == nil {
switch plan.action {
case trafficAddUser:
err = rt.AddUser(context.Background(), &plan.inbound, plan.client)
case trafficRemoveUser:
err = rt.RemoveUser(context.Background(), &plan.inbound, plan.email)
if err != nil && strings.Contains(err.Error(), "not found") {
err = nil
}
case trafficDisableInbound:
err = rt.DelInbound(context.Background(), &plan.inbound)
if xray.IsMissingHandlerErr(err) {
err = nil
}
}
}
if err != nil {
logger.Debug("traffic post-commit runtime apply failed:", err)
needRestart = true
}
}
return needRestart
}
+3
View File
@@ -689,6 +689,9 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
return
}
allowed := nodeSelectedTagSet(n)
for _, tag := range snap.ManagedAliases {
allowed[tag] = struct{}{}
}
filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
for _, inbound := range snap.Inbounds {
if inbound == nil {
@@ -2,11 +2,13 @@ package service
import (
"context"
"errors"
"fmt"
"sync/atomic"
"testing"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -165,6 +167,90 @@ func TestNodeBulk_SmallAddPushesLive(t *testing.T) {
}
}
func TestNodeBulkAdjustDoesNotPushBeforeFailedCommit(t *testing.T) {
setupBulkDB(t)
nodeID, fake := setupNodeRuntime(t)
client := model.Client{
ID: uuid.NewString(),
Email: "txfail-adjust@x",
Enable: true,
ExpiryTime: 1_900_000_000_000,
}
nodeInbound(t, nodeID, 30022, []model.Client{client})
db := database.GetDB()
const callbackName = "bulk-adjust:fail-inbound-update"
if err := db.Callback().Update().After("gorm:update").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "inbounds" {
tx.AddError(errors.New("injected bulk-adjust transaction failure"))
}
}); err != nil {
t.Fatalf("register callback: %v", err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "")
if err != nil {
t.Fatalf("BulkAdjust: %v", err)
}
if result.Adjusted != 0 || len(result.Skipped) != 1 {
t.Fatalf("BulkAdjust result = %+v, want one skipped client after injected failure", result)
}
if got := fake.updateUser.Load(); got != 0 {
t.Fatalf("failed transaction pushed %d UpdateUser call(s) to the node, want 0", got)
}
}
func TestNodeBulkDeleteDoesNotPushBeforeFailedCommit(t *testing.T) {
setupBulkDB(t)
nodeID, fake := setupNodeRuntime(t)
client := model.Client{ID: uuid.NewString(), Email: "txfail-delete@x", Enable: true}
nodeInbound(t, nodeID, 30023, []model.Client{client})
db := database.GetDB()
const callbackName = "bulk-delete:fail-inbound-update"
if err := db.Callback().Update().After("gorm:update").Register(callbackName, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "inbounds" {
tx.AddError(errors.New("injected bulk-delete transaction failure"))
}
}); err != nil {
t.Fatalf("register callback: %v", err)
}
t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
result, _, err := (&ClientService{}).BulkDelete(&InboundService{}, []string{client.Email}, true)
if err != nil {
t.Fatalf("BulkDelete: %v", err)
}
if result.Deleted != 0 || len(result.Skipped) != 1 {
t.Fatalf("BulkDelete result = %+v, want one skipped client after injected failure", result)
}
if got := fake.deleteClient.Load() + fake.deleteUser.Load(); got != 0 {
t.Fatalf("failed transaction pushed %d delete call(s) to the node, want 0", got)
}
}
func TestNodeBulkSmallDeleteRemovesWholeRemoteClient(t *testing.T) {
setupBulkDB(t)
nodeID, fake := setupNodeRuntime(t)
client := model.Client{ID: uuid.NewString(), Email: "full-delete@x", Enable: true}
nodeInbound(t, nodeID, 30024, []model.Client{client})
result, _, err := (&ClientService{}).BulkDelete(&InboundService{}, []string{client.Email}, true)
if err != nil {
t.Fatalf("BulkDelete: %v", err)
}
if result.Deleted != 1 || len(result.Skipped) != 0 {
t.Fatalf("BulkDelete result = %+v, want one deleted client", result)
}
if got := fake.deleteClient.Load(); got != 1 {
t.Fatalf("remote DeleteClient calls = %d, want 1", got)
}
if got := fake.deleteUser.Load(); got != 0 {
t.Fatalf("remote DeleteUser detach calls = %d, want 0 for full deletion", got)
}
}
func TestNodeUpdateInboundClientNoopSkipsRuntimeAndDirty(t *testing.T) {
setupBulkDB(t)
nodeID, fake := setupNodeRuntime(t)
@@ -64,3 +64,37 @@ func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down)
}
}
func TestSetRemoteTraffic_AdoptsCompatibleOriginAliasWithoutDuplicate(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 1
if err := db.Create(&model.Node{Id: nodeID, Name: "node", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-guid"}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
id := nodeID
central := &model.Inbound{UserId: 1, NodeID: &id, OriginNodeGuid: "node-guid", Tag: "desired-name", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(central).Error; err != nil {
t.Fatalf("create central inbound: %v", err)
}
snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
Tag: "already-deployed", Enable: true, Port: 8443, Protocol: model.VLESS,
Settings: `{"clients":[]}`, Up: 11, Down: 22,
}}}
if _, err := (&InboundService{}).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 || rows[0].Id != central.Id || rows[0].Tag != "desired-name" {
t.Fatalf("alias adoption rows = %#v, want original central inbound only", rows)
}
if rows[0].Up != 11 || rows[0].Down != 22 {
t.Fatalf("alias traffic = %d/%d, want 11/22", rows[0].Up, rows[0].Down)
}
}
+15
View File
@@ -235,3 +235,18 @@ func TestFilterNodeSnapshotMatchesPrefixedSelectedTag(t *testing.T) {
t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept)
}
}
func TestFilterNodeSnapshotKeepsAdoptedAlias(t *testing.T) {
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{Tag: "deployed-alias"}, {Tag: "unmanaged"}},
ManagedAliases: []string{"deployed-alias"},
}
FilterNodeSnapshot(&model.Node{
InboundSyncMode: "selected",
InboundTags: []string{"desired-name"},
}, snap)
if len(snap.Inbounds) != 1 || snap.Inbounds[0].Tag != "deployed-alias" {
t.Fatalf("filtered snapshot = %#v, want adopted alias only", snap.Inbounds)
}
}
+4 -18
View File
@@ -22,24 +22,10 @@ import (
type OutboundService struct{}
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
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
return s.addOutboundTraffic(tx, traffics)
})
return err, false
}
// saturatingAdd caps counters at database.TrafficMax: unlike the SQL paths,
@@ -0,0 +1,54 @@
package outbound
import (
"os"
"strings"
"testing"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestAddTrafficReturnsDeferredCommitFailure(t *testing.T) {
if os.Getenv("XUI_DB_TYPE") != "postgres" || strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" {
t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run commit-failure injection")
}
if err := database.InitDB(""); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
const parent = "outbound_commit_parent"
const child = "outbound_commit_child"
_ = db.Exec("DROP TABLE IF EXISTS " + child).Error
_ = db.Exec("DROP TABLE IF EXISTS " + parent).Error
if err := db.Exec("CREATE TABLE " + parent + " (id bigint PRIMARY KEY)").Error; err != nil {
t.Fatal(err)
}
if err := db.Exec("CREATE TABLE " + child + " (id bigint PRIMARY KEY, parent_id bigint REFERENCES " + parent + "(id) DEFERRABLE INITIALLY DEFERRED)").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = db.Exec("DROP TABLE IF EXISTS " + child).Error
_ = db.Exec("DROP TABLE IF EXISTS " + parent).Error
})
const callback = "test:outbound-deferred-commit"
if err := db.Callback().Create().After("gorm:create").Register(callback, func(tx *gorm.DB) {
if tx.Statement == nil || tx.Statement.Table != "outbound_traffics" {
return
}
if result := tx.Session(&gorm.Session{NewDB: true}).Exec("INSERT INTO " + child + " (id, parent_id) VALUES (1, 999999)"); result.Error != nil {
tx.AddError(result.Error)
}
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
err, _ := (&OutboundService{}).AddTraffic([]*xray.Traffic{{Tag: "commit-test", IsOutbound: true, Up: 1}}, nil)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "foreign key") {
t.Fatalf("AddTraffic error = %v, want deferred foreign-key commit failure", err)
}
}
+16 -6
View File
@@ -156,10 +156,12 @@ func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int
// 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 {
func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (string, error) {
var subs []*model.OutboundSubscription
_ = database.GetDB().Find(&subs).Error
return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
if err := database.GetDB().Find(&subs).Error; err != nil {
return "", err
}
return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId)), nil
}
func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
@@ -175,11 +177,16 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
}
prefix := strings.TrimSpace(tagPrefix)
if prefix == "" {
prefix = s.nextDefaultSubPrefix(0)
prefix, err = s.nextDefaultSubPrefix(0)
if err != nil {
return nil, err
}
}
// New subscriptions go to the end of the priority order.
var count int64
database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
if err := database.GetDB().Model(&model.OutboundSubscription{}).Count(&count).Error; err != nil {
return nil, err
}
sub := &model.OutboundSubscription{
Remark: strings.TrimSpace(remark),
Url: cleanURL,
@@ -215,7 +222,10 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
}
prefix := strings.TrimSpace(tagPrefix)
if prefix == "" {
prefix = s.nextDefaultSubPrefix(sub.Id)
prefix, err = s.nextDefaultSubPrefix(sub.Id)
if err != nil {
return err
}
}
sub.Remark = strings.TrimSpace(remark)
sub.Url = cleanURL
@@ -5,10 +5,102 @@ import (
"errors"
"testing"
"gorm.io/gorm"
"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/link"
)
func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testing.T) {
setupSettingTestDB(t)
db := database.GetDB()
const callback = "test:fail_outbound_subscription_query"
errInjected := errors.New("injected outbound subscription query failure")
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement != nil && tx.Statement.Table == "outbound_subscriptions" {
tx.AddError(errInjected)
}
}); err != nil {
t.Fatalf("register query callback: %v", err)
}
t.Cleanup(func() {
if err := db.Callback().Query().Remove(callback); err != nil {
t.Errorf("remove query callback: %v", err)
}
})
for _, tc := range []struct {
name string
tagPrefix string
operation string
}{
{name: "default prefix query", tagPrefix: "", operation: "prefix allocation"},
{name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
} {
t.Run(tc.name, func(t *testing.T) {
created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, true, 600, false, false, false)
if !errors.Is(err, errInjected) {
t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
}
if created != nil {
t.Fatalf("Create returned row %+v after %s query failure", created, tc.operation)
}
})
}
}
func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t *testing.T) {
setupSettingTestDB(t)
db := database.GetDB()
original := &model.OutboundSubscription{
Remark: "before", Url: "https://1.1.1.1/original", TagPrefix: "custom-",
Enabled: true, UpdateInterval: 600,
}
if err := db.Create(original).Error; err != nil {
t.Fatalf("seed subscription: %v", err)
}
errInjected := errors.New("injected update prefix query failure")
queryCount := 0
const callback = "test:fail_update_prefix_query"
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
if tx.Statement == nil || tx.Statement.Table != "outbound_subscriptions" {
return
}
queryCount++
if queryCount == 2 {
tx.AddError(errInjected)
}
}); err != nil {
t.Fatalf("register query callback: %v", err)
}
t.Cleanup(func() {
if err := db.Callback().Query().Remove(callback); err != nil {
t.Errorf("remove query callback: %v", err)
}
})
err := (&OutboundSubscriptionService{}).Update(
original.Id, "after", "https://1.1.1.1/changed", "", false, 1200, false, false, false,
)
if !errors.Is(err, errInjected) {
t.Fatalf("Update error = %v, want injected prefix query failure", err)
}
if queryCount != 2 {
t.Fatalf("outbound subscription queries = %d, want Get plus prefix allocation", queryCount)
}
var got model.OutboundSubscription
if err := db.First(&got, original.Id).Error; err != nil {
t.Fatalf("reload subscription: %v", err)
}
if got.Remark != original.Remark || got.Url != original.Url || got.TagPrefix != original.TagPrefix ||
got.Enabled != original.Enabled || got.UpdateInterval != original.UpdateInterval {
t.Fatalf("subscription changed after failed allocation: got %+v, want %+v", got, *original)
}
}
func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
t.Run("accepts body at the limit", func(t *testing.T) {
want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
+24
View File
@@ -5,6 +5,8 @@ import (
"errors"
"strings"
"gorm.io/gorm"
"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"
@@ -86,6 +88,28 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
return view, nil
}
// RecreateByName replaces any token with this name, keeping exactly one so a
// repeatedly-run caller cannot accumulate credentials it can never revoke.
func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, common.NewError("token name is required")
}
plaintext := random.Seq(apiTokenLength)
row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
return err
}
return tx.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")
+76 -1
View File
@@ -1,6 +1,17 @@
package panel
import "testing"
import (
"errors"
"testing"
"gorm.io/gorm"
"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"
)
var errInjectedTokenCreate = errors.New("injected token create failure")
func TestApiTokenCreatedAtSeconds(t *testing.T) {
tests := []struct {
@@ -21,3 +32,67 @@ func TestApiTokenCreatedAtSeconds(t *testing.T) {
})
}
}
func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(config.GetDBPath()); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := ApiTokenService{}
first, err := svc.RecreateByName("cli-fallback")
if err != nil {
t.Fatalf("first recreate: %v", err)
}
db := database.GetDB()
const callback = "test:fail-token-replacement"
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
if token, ok := tx.Statement.Dest.(*model.ApiToken); ok && token.Name == "cli-fallback" {
tx.AddError(errInjectedTokenCreate)
}
}); err != nil {
t.Fatalf("register callback: %v", err)
}
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
if _, err := svc.RecreateByName("cli-fallback"); !errors.Is(err, errInjectedTokenCreate) {
t.Fatalf("recreate error = %v, want %v", err, errInjectedTokenCreate)
}
var row model.ApiToken
if err := db.Where("name = ?", "cli-fallback").First(&row).Error; err != nil {
t.Fatalf("load preserved token: %v", err)
}
if !svc.Match(first.Token) {
t.Fatal("original token was revoked after replacement failure")
}
}
func TestRecreateByNameKeepsOneToken(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := database.InitDB(config.GetDBPath()); err != nil {
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
svc := ApiTokenService{}
first, err := svc.RecreateByName("cli-fallback")
if err != nil {
t.Fatalf("first recreate: %v", err)
}
second, err := svc.RecreateByName("cli-fallback")
if err != nil {
t.Fatalf("second recreate: %v", err)
}
if first.Token == second.Token {
t.Fatal("second call returned the same plaintext, want a rotated token")
}
var count int64
if err := database.GetDB().Model(model.ApiToken{}).Where("name = ?", "cli-fallback").Count(&count).Error; err != nil {
t.Fatalf("count: %v", err)
}
if count != 1 {
t.Fatalf("token rows = %d, want 1", count)
}
}
+1
View File
@@ -59,6 +59,7 @@ var defaultValueMap = map[string]string{
"nodeMtlsCaKeyPem": "",
"nodeMtlsClientCertPem": "",
"nodeMtlsClientKeyPem": "",
"nodeMtlsClientCertSha256": "",
"nodeMtlsClientCAPem": "",
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
"sessionMaxAge": "360",
@@ -52,6 +52,7 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
"nodeMtlsCaKeyPem",
"nodeMtlsClientCertPem",
"nodeMtlsClientKeyPem",
"nodeMtlsClientCertSha256",
"xrayTemplateConfig",
"tgBotToken",
"twoFactorToken",
+74 -2
View File
@@ -1,17 +1,30 @@
package service
import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"strings"
"sync"
"gorm.io/gorm"
"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"
)
var masterClientCredentialMu sync.Mutex
const (
settingNodeMtlsCaCert = "nodeMtlsCaCertPem"
settingNodeMtlsCaKey = "nodeMtlsCaKeyPem"
settingNodeMtlsClientCert = "nodeMtlsClientCertPem"
settingNodeMtlsClientKey = "nodeMtlsClientKeyPem"
settingNodeMtlsClientPin = "nodeMtlsClientCertSha256"
settingNodeMtlsClientCA = "nodeMtlsClientCAPem"
)
@@ -49,10 +62,26 @@ func (s *SettingService) EnsureNodeMtlsCA() (crypto.CertKeyPEM, error) {
return ca, nil
}
func clientCertSHA256FromPEM(certPEM []byte) (string, error) {
block, rest := pem.Decode(certPEM)
if block == nil || block.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) != 0 {
return "", common.NewError("client certificate is not valid PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", err
}
sum := sha256.Sum256(cert.Raw)
return hex.EncodeToString(sum[:]), nil
}
// EnsureMasterClientCert returns the client certificate this panel presents when
// calling its nodes over mTLS, issuing it from the node CA on first use and
// reusing the stored pair thereafter.
func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
masterClientCredentialMu.Lock()
defer masterClientCredentialMu.Unlock()
certPem, err := s.getString(settingNodeMtlsClientCert)
if err != nil {
return crypto.CertKeyPEM{}, err
@@ -61,7 +90,27 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
if err != nil {
return crypto.CertKeyPEM{}, err
}
storedPin, err := s.getString(settingNodeMtlsClientPin)
if err != nil {
return crypto.CertKeyPEM{}, err
}
storedPin = strings.ToLower(strings.TrimSpace(storedPin))
if certPem != "" && keyPem != "" {
if _, err := tls.X509KeyPair([]byte(certPem), []byte(keyPem)); err != nil {
return crypto.CertKeyPEM{}, common.NewError("stored master client certificate/key pair is invalid: ", err)
}
actualPin, err := clientCertSHA256FromPEM([]byte(certPem))
if err != nil {
return crypto.CertKeyPEM{}, err
}
if storedPin != "" && storedPin != actualPin {
return crypto.CertKeyPEM{}, common.NewError("stored master client certificate does not match nodeMtlsClientCertSha256; refusing to rotate")
}
if storedPin == "" {
if err := s.saveSetting(settingNodeMtlsClientPin, actualPin); err != nil {
return crypto.CertKeyPEM{}, err
}
}
return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
}
// Half a stored pair signals corrupted settings; reissuing would rotate the
@@ -77,15 +126,38 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
if err != nil {
return crypto.CertKeyPEM{}, err
}
if err := s.saveSetting(settingNodeMtlsClientCert, string(client.CertPEM)); err != nil {
pin, err := clientCertSHA256FromPEM(client.CertPEM)
if err != nil {
return crypto.CertKeyPEM{}, err
}
if err := s.saveSetting(settingNodeMtlsClientKey, string(client.KeyPEM)); err != nil {
if err := saveMasterClientCredential(client, pin); err != nil {
return crypto.CertKeyPEM{}, err
}
return client, nil
}
func saveMasterClientCredential(client crypto.CertKeyPEM, pin string) error {
values := map[string]string{
settingNodeMtlsClientCert: string(client.CertPEM),
settingNodeMtlsClientKey: string(client.KeyPEM),
settingNodeMtlsClientPin: pin,
}
return database.GetDB().Transaction(func(tx *gorm.DB) error {
for key, value := range values {
result := tx.Model(&model.Setting{}).Where("key = ?", key).Update("value", value)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
return err
}
}
}
return nil
})
}
// NodeMtlsClientCAPool builds the trust pool used as the panel listener's
// ClientCAs for incoming node-API client certificates. It returns (nil, nil)
// when no trust CA is configured, so mTLS stays off and the listener behaves
+149
View File
@@ -6,8 +6,10 @@ import (
"encoding/pem"
"path/filepath"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
)
func setupSettingMtlsDB(t *testing.T) *SettingService {
@@ -88,6 +90,153 @@ func TestEnsureMasterClientCert_VerifiesAndIdempotent(t *testing.T) {
}
}
func TestEnsureMasterClientCertRejectsMismatchedStoredKey(t *testing.T) {
s := setupSettingMtlsDB(t)
first, err := s.EnsureMasterClientCert()
if err != nil {
t.Fatal(err)
}
ca, err := s.EnsureNodeMtlsCA()
if err != nil {
t.Fatal(err)
}
other, err := crypto.IssueClientCert(ca, "other master")
if err != nil {
t.Fatal(err)
}
if err := s.setString(settingNodeMtlsClientCert, string(first.CertPEM)); err != nil {
t.Fatal(err)
}
if err := s.setString(settingNodeMtlsClientKey, string(other.KeyPEM)); err != nil {
t.Fatal(err)
}
if _, err := s.EnsureMasterClientCert(); err == nil {
t.Fatal("mismatched stored certificate and key were accepted")
}
}
func TestEnsureMasterClientCert_ReissuesLeafWhenCAStillExists(t *testing.T) {
s := setupSettingMtlsDB(t)
client, err := s.EnsureMasterClientCert()
if err != nil {
t.Fatalf("EnsureMasterClientCert: %v", err)
}
pin, err := clientCertSHA256FromPEM(client.CertPEM)
if err != nil {
t.Fatalf("clientCertSHA256FromPEM: %v", err)
}
if err := s.setString(settingNodeMtlsClientPin, pin); err != nil {
t.Fatalf("persist client pin: %v", err)
}
if err := s.setString(settingNodeMtlsClientCert, ""); err != nil {
t.Fatalf("clear client cert: %v", err)
}
if err := s.setString(settingNodeMtlsClientKey, ""); err != nil {
t.Fatalf("clear client key: %v", err)
}
reissued, err := s.EnsureMasterClientCert()
if err != nil {
t.Fatalf("reissue with surviving CA: %v", err)
}
newPin, err := clientCertSHA256FromPEM(reissued.CertPEM)
if err != nil {
t.Fatalf("new pin: %v", err)
}
if newPin == pin {
t.Fatal("reissued credential kept the lost leaf identity")
}
stored, err := s.getString(settingNodeMtlsClientPin)
if err != nil || stored != newPin {
t.Fatalf("stored pin = %q, error = %v, want %q", stored, err, newPin)
}
}
func TestEnsureMasterClientCertConcurrentFirstUseMintsOneCredential(t *testing.T) {
s := setupSettingMtlsDB(t)
blocked := make(chan struct{})
masterClientCredentialMu.Lock()
go func() {
_, _ = s.EnsureMasterClientCert()
close(blocked)
}()
select {
case <-blocked:
masterClientCredentialMu.Unlock()
t.Fatal("EnsureMasterClientCert returned while its serialization lock was held")
case <-time.After(50 * time.Millisecond):
}
masterClientCredentialMu.Unlock()
select {
case <-blocked:
case <-time.After(time.Second):
t.Fatal("EnsureMasterClientCert remained blocked after serialization lock release")
}
const callers = 8
start := make(chan struct{})
results := make(chan crypto.CertKeyPEM, callers)
errs := make(chan error, callers)
for range callers {
go func() {
<-start
credential, err := s.EnsureMasterClientCert()
results <- credential
errs <- err
}()
}
close(start)
var first crypto.CertKeyPEM
for i := 0; i < callers; i++ {
credential := <-results
if err := <-errs; err != nil {
t.Fatalf("caller %d: %v", i, err)
}
if i == 0 {
first = credential
} else if !bytes.Equal(first.CertPEM, credential.CertPEM) || !bytes.Equal(first.KeyPEM, credential.KeyPEM) {
t.Fatalf("caller %d received a different credential", i)
}
}
storedCert, _ := s.getString(settingNodeMtlsClientCert)
storedKey, _ := s.getString(settingNodeMtlsClientKey)
if storedCert != string(first.CertPEM) || storedKey != string(first.KeyPEM) {
t.Fatal("persisted credential differs from concurrent callers")
}
}
func TestEnsureMasterClientCert_PersistsCredentialAtomically(t *testing.T) {
s := setupSettingMtlsDB(t)
db := database.GetDB()
trigger := `CREATE TRIGGER fail_master_pin_insert
BEFORE INSERT ON settings
WHEN NEW.key = 'nodeMtlsClientCertSha256'
BEGIN SELECT RAISE(ABORT, 'injected pin failure'); END`
if err := db.Exec(trigger).Error; err != nil {
t.Fatalf("create failure trigger: %v", err)
}
if _, err := s.EnsureMasterClientCert(); err == nil {
t.Fatal("injected persistence failure unexpectedly succeeded")
}
for _, key := range []string{settingNodeMtlsClientCert, settingNodeMtlsClientKey, settingNodeMtlsClientPin} {
got, err := s.getString(key)
if err != nil {
t.Fatalf("get %s after rollback: %v", key, err)
}
if got != "" {
t.Fatalf("%s persisted despite transaction rollback", key)
}
}
if err := db.Exec("DROP TRIGGER fail_master_pin_insert").Error; err != nil {
t.Fatalf("drop failure trigger: %v", err)
}
if _, err := s.EnsureMasterClientCert(); err != nil {
t.Fatalf("retry after rollback: %v", err)
}
}
func TestNodeMtlsClientCAPool(t *testing.T) {
s := setupSettingMtlsDB(t)
@@ -0,0 +1,86 @@
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"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestTrafficDisableImmediatelyUpdatesNodeRuntime(t *testing.T) {
setupConflictDB(t)
nodeID, fake := setupNodeRuntime(t)
client := model.Client{Email: "spent-node", Enable: true}
ib := nodeInbound(t, nodeID, 46301, []model.Client{client})
if err := database.GetDB().Create(&xray.ClientTraffic{
InboundId: ib.Id, Email: client.Email, Enable: true, Up: 100, Total: 100,
}).Error; err != nil {
t.Fatalf("seed traffic: %v", err)
}
if _, _, _, err := (&InboundService{}).addTrafficLocked(nil, nil); err != nil {
t.Fatalf("addTrafficLocked: %v", err)
}
if got := fake.updateInbound.Load(); got != 1 {
t.Fatalf("remote UpdateInbound calls = %d, want 1 after commit", got)
}
}
func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
seedInboundConflict(t, "mt-spent", "", 46302, model.MTProto, "",
`{"clients":[{"email":"spent-mt","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
ib := loadInboundByTag(t, "mt-spent")
clients, err := (&InboundService{}).GetClients(ib)
if err != nil {
t.Fatalf("GetClients: %v", err)
}
if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
seedClientTraffic(t, ib.Id, "spent-mt", true)
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "spent-mt").
Updates(map[string]any{"up": 100, "total": 100}).Error; err != nil {
t.Fatalf("deplete traffic: %v", err)
}
if _, _, _, err := (&InboundService{}).addTrafficLocked(nil, nil); err != nil {
t.Fatalf("addTrafficLocked: %v", err)
}
if got := fake.updateInbound.Load(); got != 1 {
t.Fatalf("MTProto sidecar UpdateInbound calls = %d, want 1 after commit", got)
}
}
func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) {
setupConflictDB(t)
mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
fake := &fakeNodeRuntime{}
mgr.SetLocalRuntimeOverride(fake)
runtime.SetManager(mgr)
t.Cleanup(func() { runtime.SetManager(nil) })
seedInboundConflict(t, "depleted-only", "", 46303, model.VLESS, `{"network":"tcp"}`,
`{"clients":[{"email":"gone","enable":true}]}`)
ib := loadInboundByTag(t, "depleted-only")
seedClientTraffic(t, ib.Id, "gone", true)
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "gone").
Updates(map[string]any{"up": 100, "total": 100, "reset": 0}).Error; err != nil {
t.Fatalf("deplete traffic: %v", err)
}
if err := (&InboundService{}).DelDepletedClients(-1); err != nil {
t.Fatalf("DelDepletedClients: %v", err)
}
if got := fake.delInbound.Load(); got != 1 {
t.Fatalf("runtime DelInbound calls = %d, want 1 after commit", got)
}
}
+1 -3
View File
@@ -591,9 +591,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
IdleTimeout: 120 * time.Second,
}
go func() {
_ = s.httpServer.Serve(listener)
}()
go network.ServeHTTP(s.httpServer, listener, "Web server")
// Create event bus before startTask so jobs can use it
s.bus = eventbus.New(eventbus.DefaultBufferSize)
+8 -5
View File
@@ -12,7 +12,6 @@ import (
"os"
"os/signal"
"syscall"
"time"
_ "unsafe"
"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -32,6 +31,10 @@ import (
"github.com/op/go-logging"
)
// cliFallbackTokenName is the single token the CLI regenerates, so `-getApiToken`
// cannot accumulate admin-equivalent credentials that are never revoked.
const cliFallbackTokenName = "cli-fallback"
// runWebServer initializes and starts the web server for the 3x-ui panel.
func runWebServer() {
log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
@@ -455,14 +458,14 @@ func GetApiToken(getApiToken bool) {
fmt.Printf("There are %d API token(s) configured. Existing tokens cannot be retrieved in plaintext because only hashes are stored.\n", len(tokens))
fmt.Println("If you have lost your token, you can manage and generate new tokens through the Panel UI (Settings -> API Tokens).")
// Create a new fallback token so the CLI is still useful without the UI
fallbackName := fmt.Sprintf("cli-fallback-%d", time.Now().Unix())
created, err := apiTokenService.Create(fallbackName)
// Rotate one reusable fallback so repeated calls cannot pile up
// indefinitely many admin-equivalent tokens that never expire.
created, err := apiTokenService.RecreateByName(cliFallbackTokenName)
if err != nil {
fmt.Println("Failed to create a fallback API token:", err)
return
}
fmt.Println("\nA new fallback token has been generated for your convenience:")
fmt.Println("\nThe CLI fallback token has been regenerated (any previous one is now invalid):")
fmt.Println("apiToken:", created.Token)
return
}