mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-03 00:47:14 +00:00
Compare commits
13 Commits
d291e1c5ee
...
ad32144c42
| Author | SHA1 | Date | |
|---|---|---|---|
| ad32144c42 | |||
| 34c248bb79 | |||
| 17fea2f656 | |||
| c5dec64d36 | |||
| b56b087254 | |||
| 3bb87e80aa | |||
| 60453bf523 | |||
| bb29b6afec | |||
| 3b19091547 | |||
| 0496c23a26 | |||
| 1396005082 | |||
| b70c5abce8 | |||
| 20b3f84f77 |
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 := ¢ral[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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user