|
|
@@ -59,6 +59,11 @@ func setupIntegrationDB(t *testing.T) {
|
|
|
// seed an inbound whose settings json has a single client with the
|
|
|
// given email and ip limit.
|
|
|
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
|
|
+ t.Helper()
|
|
|
+ seedInboundOnlyWithClient(t, tag, email, limitIp)
|
|
|
+}
|
|
|
+
|
|
|
+func seedInboundOnlyWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
|
|
t.Helper()
|
|
|
settings := map[string]any{
|
|
|
"clients": []map[string]any{
|
|
|
@@ -83,6 +88,21 @@ func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
|
|
if err := database.GetDB().Create(inbound).Error; err != nil {
|
|
|
t.Fatalf("seed inbound: %v", err)
|
|
|
}
|
|
|
+ return inbound
|
|
|
+}
|
|
|
+
|
|
|
+func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *model.Inbound {
|
|
|
+ t.Helper()
|
|
|
+ inbound := seedInboundOnlyWithClient(t, tag, email, limitIp)
|
|
|
+ client := &model.ClientRecord{Email: email}
|
|
|
+ if err := database.GetDB().Create(client).Error; err != nil {
|
|
|
+ t.Fatalf("seed client record: %v", err)
|
|
|
+ }
|
|
|
+ link := &model.ClientInbound{ClientId: client.Id, InboundId: inbound.Id}
|
|
|
+ if err := database.GetDB().Create(link).Error; err != nil {
|
|
|
+ t.Fatalf("seed client inbound link: %v", err)
|
|
|
+ }
|
|
|
+ return inbound
|
|
|
}
|
|
|
|
|
|
// seed an InboundClientIps row with the given blob.
|
|
|
@@ -128,46 +148,32 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
|
|
return out
|
|
|
}
|
|
|
|
|
|
-func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
|
|
|
+// With the access-log fallback removed, an unavailable online-stats API (xray
|
|
|
+// down, as in this unit test) must make Run a clean no-op: no fail2ban probe, no
|
|
|
+// ban log, and no inbound_client_ips rows — never a crash or partial work.
|
|
|
+func TestRun_NoOpWhenOnlineApiUnavailable(t *testing.T) {
|
|
|
setupIntegrationDB(t)
|
|
|
- t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
|
|
+ t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
|
marker := fakeFail2BanClient(t)
|
|
|
|
|
|
- const email = "disabled-fail2ban"
|
|
|
- seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
|
|
|
+ const email = "no-api-user"
|
|
|
+ seedInboundWithClient(t, "inbound-no-api", email, 1)
|
|
|
|
|
|
- binDir := t.TempDir()
|
|
|
- accessLog := filepath.Join(t.TempDir(), "access.log")
|
|
|
- t.Setenv("XUI_BIN_FOLDER", binDir)
|
|
|
- configData, err := json.Marshal(map[string]any{
|
|
|
- "log": map[string]any{"access": accessLog},
|
|
|
- })
|
|
|
- if err != nil {
|
|
|
- t.Fatalf("marshal xray config: %v", err)
|
|
|
- }
|
|
|
- if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
|
|
|
- t.Fatalf("write xray config: %v", err)
|
|
|
- }
|
|
|
- if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
|
|
|
- t.Fatalf("write access log: %v", err)
|
|
|
- }
|
|
|
-
|
|
|
- j := NewCheckClientIpJob()
|
|
|
- j.Run()
|
|
|
+ NewCheckClientIpJob().Run()
|
|
|
|
|
|
if _, err := os.Stat(marker); !os.IsNotExist(err) {
|
|
|
- t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
|
|
|
+ t.Fatalf("fail2ban-client should not have been probed when the online API is unavailable, stat error: %v", err)
|
|
|
}
|
|
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
|
body, _ := os.ReadFile(readIpLimitLogPath())
|
|
|
- t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
|
|
|
+ t.Fatalf("3xipl.log should be empty when Run no-ops, got:\n%s", body)
|
|
|
}
|
|
|
var count int64
|
|
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
|
|
|
t.Fatalf("count InboundClientIps: %v", err)
|
|
|
}
|
|
|
if count != 0 {
|
|
|
- t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
|
|
|
+ t.Fatalf("no IP-limit rows should be persisted when Run no-ops, got %d", count)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -280,47 +286,24 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// writeXrayAccessLog points bin/config.json at a fresh access.log holding a
|
|
|
-// single default-format Xray line (`from tcp:<ip>:<port> accepted … email: <e>`)
|
|
|
-// for the given client, so Run() has something to scrape.
|
|
|
-func writeXrayAccessLog(t *testing.T, email, ip string) {
|
|
|
- t.Helper()
|
|
|
- binDir := t.TempDir()
|
|
|
- accessLog := filepath.Join(t.TempDir(), "access.log")
|
|
|
- t.Setenv("XUI_BIN_FOLDER", binDir)
|
|
|
- configData, err := json.Marshal(map[string]any{
|
|
|
- "log": map[string]any{"access": accessLog},
|
|
|
- })
|
|
|
- if err != nil {
|
|
|
- t.Fatalf("marshal xray config: %v", err)
|
|
|
- }
|
|
|
- if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
|
|
|
- t.Fatalf("write xray config: %v", err)
|
|
|
- }
|
|
|
- line := "2026/06/02 13:35:53 from tcp:" + ip + ":2387 accepted tcp:example.com:443 email: " + email + "\n"
|
|
|
- if err := os.WriteFile(accessLog, []byte(line), 0644); err != nil {
|
|
|
- t.Fatalf("write access log: %v", err)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-// #4800: the per-client IP log must populate even when no client has an IP
|
|
|
-// limit. Before the fix, Run() only scraped the access log when an IP limit
|
|
|
-// was active, so a limit-free install always showed an empty IP log despite
|
|
|
-// valid access-log lines. No ban may be written since there's no limit.
|
|
|
-func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
|
|
|
+// #4800: per-client IP tracking must populate even when no client has an IP
|
|
|
+// limit. processObserved records observed IPs for the panel regardless of any
|
|
|
+// limit; only enforcement is gated, so a limit-free install still shows IPs. No
|
|
|
+// ban may be written since there's no limit.
|
|
|
+func TestProcessObserved_CollectsIpsWithoutLimit(t *testing.T) {
|
|
|
setupIntegrationDB(t)
|
|
|
- t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
|
- fakeFail2BanClient(t)
|
|
|
|
|
|
const email = "no-limit-user"
|
|
|
seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
|
|
|
- writeXrayAccessLog(t, email, "203.0.113.10")
|
|
|
|
|
|
- NewCheckClientIpJob().Run()
|
|
|
+ observed := map[string]map[string]int64{
|
|
|
+ email: {"203.0.113.10": time.Now().Unix()},
|
|
|
+ }
|
|
|
+ NewCheckClientIpJob().processObserved(observed, true, true)
|
|
|
|
|
|
ips := readClientIps(t, email)
|
|
|
if len(ips) != 1 || ips[0].IP != "203.0.113.10" {
|
|
|
- t.Fatalf("expected the access-log IP to be collected without a limit, got %v", ips)
|
|
|
+ t.Fatalf("expected the observed IP to be collected without a limit, got %v", ips)
|
|
|
}
|
|
|
|
|
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
|
@@ -329,22 +312,21 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-// #4963: a stale access-log entry for a renamed/deleted client (its email no
|
|
|
-// longer maps to any inbound) must not create or resurrect an
|
|
|
-// inbound_client_ips row, and must drop any orphan left behind — instead of
|
|
|
-// spamming "failed to fetch inbound settings" every run.
|
|
|
-func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
|
|
+// #4963: an observed IP for a renamed/deleted client (its email no longer maps
|
|
|
+// to any inbound) must not create or resurrect an inbound_client_ips row, and
|
|
|
+// must drop any orphan left behind — instead of erroring every run.
|
|
|
+func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
|
|
setupIntegrationDB(t)
|
|
|
- t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
|
|
|
- fakeFail2BanClient(t)
|
|
|
|
|
|
const staleEmail = "renamed-away"
|
|
|
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
|
|
// confirm the job removes it rather than leaving it to error forever.
|
|
|
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
|
|
- writeXrayAccessLog(t, staleEmail, "203.0.113.5")
|
|
|
|
|
|
- NewCheckClientIpJob().Run()
|
|
|
+ observed := map[string]map[string]int64{
|
|
|
+ staleEmail: {"203.0.113.5": time.Now().Unix()},
|
|
|
+ }
|
|
|
+ NewCheckClientIpJob().processObserved(observed, true, true)
|
|
|
|
|
|
var count int64
|
|
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
|
|
|
@@ -375,3 +357,33 @@ func contains(haystack, needle string) bool {
|
|
|
}
|
|
|
return false
|
|
|
}
|
|
|
+
|
|
|
+// the exact clients/client_inbounds relation must win over the substring scan,
|
|
|
+// so a client is resolved to its own inbound even when another inbound holds a
|
|
|
+// superstring email.
|
|
|
+func TestGetInboundByEmailUsesClientInboundLink(t *testing.T) {
|
|
|
+ setupIntegrationDB(t)
|
|
|
+
|
|
|
+ want := seedLinkedInboundWithClient(t, "linked-inbound", "[email protected]", 1)
|
|
|
+ seedInboundOnlyWithClient(t, "other-inbound", "[email protected]", 1)
|
|
|
+
|
|
|
+ got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]")
|
|
|
+ if err != nil {
|
|
|
+ t.Fatalf("getInboundByEmail returned error: %v", err)
|
|
|
+ }
|
|
|
+ if got.Id != want.Id {
|
|
|
+ t.Fatalf("getInboundByEmail returned inbound %d, want %d", got.Id, want.Id)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// the substring fallback must still verify the exact email inside settings, so
|
|
|
+// "[email protected]" does not match an inbound holding "[email protected]".
|
|
|
+func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
|
|
|
+ setupIntegrationDB(t)
|
|
|
+
|
|
|
+ seedInboundOnlyWithClient(t, "substring-only", "[email protected]", 1)
|
|
|
+
|
|
|
+ if got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]"); err == nil {
|
|
|
+ t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
|
|
|
+ }
|
|
|
+}
|