mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-20 18:11:00 +00:00
d6472740dc
* feat(limitip): let operators exempt trusted addresses from the IP limit Behind a shared address — an office gateway, a campus NAT, a residential carrier — every user looks like the same client. One of them trips the IP limit and the address is disconnected and handed to fail2ban, taking the others with it. Today the only way out is editing jail.d by hand, which an update overwrites. Add an allowlist setting of addresses and networks. A matching address is neither banned nor counted towards the limit: counting it would still cut the shared network the entry exists to protect. Entries are validated on save rather than skipped at scan time — a typo would otherwise leave the address unprotected until someone noticed the bans. * fix(limitip): keep each doc comment on its function and one grammar for the list Three review follow-ups. loadAllowlist landed between hasLimitIp's doc comment and hasLimitIp itself, so godoc showed one function's rationale above another's body; it now sits after that function with its own comment. The parser advertised semicolons and whitespace as separators while the settings validator accepts commas only, making those forms unreachable through the panel and the API — a promise the software never keeps. Both sides now read the same comma-separated grammar. The dist stub was a build artifact and does not belong in the tree. * chore: drop the accidentally committed dist build stub internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing it changes fresh-clone behaviour for everyone: today a bare go build fails loudly on //go:embed all:dist, which is the documented signal to run the stub target; with the file present the build succeeds and the panel serves an empty dist instead. * chore(i18n): translate the IP limit allowlist strings into the remaining locales Ten locales carried the English source text verbatim; only ru-RU and uk-UA were translated. The i18n dead-key test only checks that a key exists in every file, so an untranslated value passes it silently. Wording follows each locale's existing terms: the ipLimit noun already in the file, and the comma-separated IP/CIDR phrasing from trustedProxyCidrsDesc. * refactor(limitip): share one IP/CIDR list validator and read the allowlist only when enforcing The allowlist check in CheckValid was a line-for-line copy of the trusted-proxy loop directly above it. Both now call one helper, each passing its own message, so the two lists cannot drift apart. Run() read the allowlist on every 10s scan, including the majority of panels where no client carries an IP limit and the value is discarded. It is now read only once enforcement is known to apply. CheckValid had no test for either list. The new one pins that a malformed entry is rejected and that each list still names itself in the error, which is what the shared helper could otherwise break. --------- Co-authored-by: n0ctal <n0ctal@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
471 lines
15 KiB
Go
471 lines
15 KiB
Go
package job
|
|
|
|
import (
|
|
"encoding/json"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/op/go-logging"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
)
|
|
|
|
// 3x-ui logger must be initialised once before any code path that can
|
|
// log a warning. otherwise log.Warningf panics on a nil logger.
|
|
var loggerInitOnce sync.Once
|
|
|
|
// setupIntegrationDB wires a temp sqlite db and log folder so
|
|
// updateInboundClientIps can run end to end. closes the db before
|
|
// TempDir cleanup so windows doesn't complain about the file being in
|
|
// use.
|
|
func setupIntegrationDB(t *testing.T) {
|
|
t.Helper()
|
|
|
|
loggerInitOnce.Do(func() {
|
|
xuilogger.InitLogger(logging.ERROR)
|
|
})
|
|
|
|
dbDir := t.TempDir()
|
|
logDir := t.TempDir()
|
|
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
t.Setenv("XUI_LOG_FOLDER", logDir)
|
|
|
|
// updateInboundClientIps calls log.SetOutput on the package global,
|
|
// which would leak to other tests in the same binary.
|
|
origLogWriter := log.Writer()
|
|
origLogFlags := log.Flags()
|
|
t.Cleanup(func() {
|
|
log.SetOutput(origLogWriter)
|
|
log.SetFlags(origLogFlags)
|
|
})
|
|
|
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
t.Fatalf("database.InitDB failed: %v", err)
|
|
}
|
|
// LIFO cleanup order: this runs before t.TempDir's own cleanup.
|
|
t.Cleanup(func() {
|
|
if err := database.CloseDB(); err != nil {
|
|
t.Logf("database.CloseDB warning: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// seed an inbound whose settings json has a single client with the
|
|
// given email and ip limit.
|
|
func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
|
|
t.Helper()
|
|
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{
|
|
{
|
|
"email": email,
|
|
"limitIp": limitIp,
|
|
"enable": true,
|
|
},
|
|
},
|
|
}
|
|
settingsJSON, err := json.Marshal(settings)
|
|
if err != nil {
|
|
t.Fatalf("marshal settings: %v", err)
|
|
}
|
|
inbound := &model.Inbound{
|
|
Tag: tag,
|
|
Enable: true,
|
|
Protocol: model.VLESS,
|
|
Port: 4321,
|
|
Settings: string(settingsJSON),
|
|
}
|
|
if err := database.GetDB().Create(inbound).Error; err != nil {
|
|
t.Fatalf("seed inbound: %v", err)
|
|
}
|
|
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, LimitIP: limitIp}
|
|
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.
|
|
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
|
|
t.Helper()
|
|
blob, err := json.Marshal(ips)
|
|
if err != nil {
|
|
t.Fatalf("marshal ips: %v", err)
|
|
}
|
|
row := &model.InboundClientIps{
|
|
ClientEmail: email,
|
|
Ips: string(blob),
|
|
}
|
|
if err := database.GetDB().Create(row).Error; err != nil {
|
|
t.Fatalf("seed InboundClientIps: %v", err)
|
|
}
|
|
return row
|
|
}
|
|
|
|
// read the persisted blob and parse it back.
|
|
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
|
t.Helper()
|
|
row := &model.InboundClientIps{}
|
|
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
|
t.Fatalf("read InboundClientIps for %s: %v", email, err)
|
|
}
|
|
if row.Ips == "" {
|
|
return nil
|
|
}
|
|
var out []IPWithTimestamp
|
|
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
|
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// make a lookup map so asserts don't depend on slice order.
|
|
func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
|
out := make(map[string]int64, len(entries))
|
|
for _, e := range entries {
|
|
out[e.IP] = e.Timestamp
|
|
}
|
|
return out
|
|
}
|
|
|
|
// 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", "true")
|
|
marker := fakeFail2BanClient(t)
|
|
|
|
const email = "no-api-user"
|
|
seedInboundWithClient(t, "inbound-no-api", email, 1)
|
|
|
|
NewCheckClientIpJob().Run()
|
|
|
|
if _, err := os.Stat(marker); !os.IsNotExist(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 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("no IP-limit rows should be persisted when Run no-ops, got %d", count)
|
|
}
|
|
}
|
|
|
|
// #4091 repro: client has limit=3, db still holds 3 idle ips from a
|
|
// few minutes ago, only one live ip is actually connecting. pre-fix:
|
|
// live ip got banned every tick and never appeared in the panel.
|
|
// post-fix: no ban, live ip persisted, historical ips still visible.
|
|
func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
|
|
setupIntegrationDB(t)
|
|
|
|
const email = "pr4091-repro"
|
|
seedInboundWithClient(t, "inbound-pr4091", email, 3)
|
|
|
|
now := time.Now().Unix()
|
|
// idle but still within the 30min staleness window.
|
|
row := seedClientIps(t, email, []IPWithTimestamp{
|
|
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
|
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
|
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
|
})
|
|
|
|
j := NewCheckClientIpJob()
|
|
// the one that's actually connecting (user's 128.71.x.x).
|
|
live := []IPWithTimestamp{
|
|
{IP: "128.71.1.1", Timestamp: now},
|
|
}
|
|
|
|
inbound, err := j.getInboundByEmail(email)
|
|
if err != nil {
|
|
t.Fatalf("getInboundByEmail: %v", err)
|
|
}
|
|
shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 3, live, true, false)
|
|
|
|
if shouldCleanLog {
|
|
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
|
|
}
|
|
if banned {
|
|
t.Fatalf("banned must be false with 1 live ip under limit 3")
|
|
}
|
|
if len(j.disAllowedIps) != 0 {
|
|
t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
|
|
}
|
|
|
|
persisted := ipSet(readClientIps(t, email))
|
|
for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
|
|
if _, ok := persisted[want]; !ok {
|
|
t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
|
|
}
|
|
}
|
|
if got := persisted["128.71.1.1"]; got != now {
|
|
t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
|
|
}
|
|
|
|
// 3xipl.log must not contain a ban line.
|
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
body, _ := os.ReadFile(readIpLimitLogPath())
|
|
t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// opposite invariant: when several ips are actually live and exceed
|
|
// the limit, the oldest connection is dropped and the most recent one
|
|
// keeps the slot (last-IP-wins policy from #3735, restored in #4699).
|
|
func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|
setupIntegrationDB(t)
|
|
|
|
const email = "pr4091-abuse"
|
|
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
|
|
|
now := time.Now().Unix()
|
|
row := seedClientIps(t, email, []IPWithTimestamp{
|
|
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
|
})
|
|
|
|
j := NewCheckClientIpJob()
|
|
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
|
// is deterministic: 10.1.0.1 is the original (older) and must get
|
|
// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
|
|
live := []IPWithTimestamp{
|
|
{IP: "10.1.0.1", Timestamp: now - 5},
|
|
{IP: "192.0.2.9", Timestamp: now},
|
|
}
|
|
|
|
inbound, err := j.getInboundByEmail(email)
|
|
if err != nil {
|
|
t.Fatalf("getInboundByEmail: %v", err)
|
|
}
|
|
shouldCleanLog, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
|
|
|
|
if !shouldCleanLog {
|
|
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
|
|
}
|
|
if !banned {
|
|
t.Fatalf("banned must be true when the live set exceeds the limit")
|
|
}
|
|
if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "10.1.0.1" {
|
|
t.Fatalf("expected 10.1.0.1 to be banned; disAllowedIps = %v", j.disAllowedIps)
|
|
}
|
|
|
|
persisted := ipSet(readClientIps(t, email))
|
|
if _, ok := persisted["192.0.2.9"]; !ok {
|
|
t.Errorf("newest IP 192.0.2.9 must still be persisted; got %v", persisted)
|
|
}
|
|
if _, ok := persisted["10.1.0.1"]; ok {
|
|
t.Errorf("banned IP 10.1.0.1 must NOT be persisted; got %v", persisted)
|
|
}
|
|
|
|
// 3xipl.log must contain the ban line in the exact fail2ban format.
|
|
body, err := os.ReadFile(readIpLimitLogPath())
|
|
if err != nil {
|
|
t.Fatalf("read 3xipl.log: %v", err)
|
|
}
|
|
wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 10.1.0.1"
|
|
if !contains(string(body), wantSubstr) {
|
|
t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
|
|
}
|
|
}
|
|
|
|
// #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)
|
|
|
|
const email = "no-limit-user"
|
|
seedInboundWithClient(t, "inbound-no-limit", email, 0) // limitIp = 0
|
|
|
|
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 observed IP to be collected without a limit, got %v", ips)
|
|
}
|
|
|
|
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
|
|
body, _ := os.ReadFile(readIpLimitLogPath())
|
|
t.Fatalf("3xipl.log should be empty with no limit set, got:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// #4963: 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)
|
|
|
|
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()}})
|
|
|
|
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 {
|
|
t.Fatalf("count InboundClientIps: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
|
|
}
|
|
}
|
|
|
|
// readIpLimitLogPath reads the 3xipl.log path the same way the job
|
|
// does via xray.GetIPLimitLogPath but without importing xray here
|
|
// just for the path helper (which would pull a lot more deps into the
|
|
// test binary). The env-derived log folder is deterministic.
|
|
func readIpLimitLogPath() string {
|
|
folder := os.Getenv("XUI_LOG_FOLDER")
|
|
if folder == "" {
|
|
folder = filepath.Join(".", "log")
|
|
}
|
|
return filepath.Join(folder, "3xipl.log")
|
|
}
|
|
|
|
func contains(haystack, needle string) bool {
|
|
for i := 0; i+len(needle) <= len(haystack); i++ {
|
|
if haystack[i:i+len(needle)] == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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", "exact@example.com", 1)
|
|
seedInboundOnlyWithClient(t, "other-inbound", "not-exact@example.com", 1)
|
|
|
|
got, err := (&CheckClientIpJob{}).getInboundByEmail("exact@example.com")
|
|
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
|
|
// "ann@example.com" does not match an inbound holding "joann@example.com".
|
|
func TestGetInboundByEmailRejectsSubstringFallbackMatch(t *testing.T) {
|
|
setupIntegrationDB(t)
|
|
|
|
seedInboundOnlyWithClient(t, "substring-only", "joann@example.com", 1)
|
|
|
|
if got, err := (&CheckClientIpJob{}).getInboundByEmail("ann@example.com"); err == nil {
|
|
t.Fatalf("substring email matched inbound %d; want no exact match", got.Id)
|
|
}
|
|
}
|
|
|
|
// hasLimitIp gates every 10s scan on the normalized clients table: a bare
|
|
// "limitIp":0 in settings JSON (which the old LIKE scan matched and parsed)
|
|
// must not enable enforcement, while a single clients.limit_ip > 0 row must.
|
|
func TestHasLimitIp_ProbesClientRecords(t *testing.T) {
|
|
setupIntegrationDB(t)
|
|
j := &CheckClientIpJob{}
|
|
|
|
if j.hasLimitIp() {
|
|
t.Fatal("hasLimitIp = true on an empty database")
|
|
}
|
|
|
|
seedLinkedInboundWithClient(t, "no-limit", "nolimit@example.com", 0)
|
|
if j.hasLimitIp() {
|
|
t.Fatal("hasLimitIp = true with only limit_ip=0 clients")
|
|
}
|
|
|
|
limited := &model.ClientRecord{Email: "limited@example.com", LimitIP: 2}
|
|
if err := database.GetDB().Create(limited).Error; err != nil {
|
|
t.Fatalf("seed limited client: %v", err)
|
|
}
|
|
if !j.hasLimitIp() {
|
|
t.Fatal("hasLimitIp = false with a limit_ip=2 client present")
|
|
}
|
|
}
|
|
|
|
// The mirror of TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned: with the
|
|
// older address on the operator's allowlist nothing may be banned, it must not
|
|
// consume the limit, and no fail2ban line may be written for it (#5378).
|
|
func TestUpdateInboundClientIps_AllowlistedIpIsNeitherCountedNorBanned(t *testing.T) {
|
|
setupIntegrationDB(t)
|
|
|
|
const email = "issue5378-office"
|
|
seedInboundWithClient(t, "inbound-issue5378", email, 1)
|
|
|
|
now := time.Now().Unix()
|
|
row := seedClientIps(t, email, []IPWithTimestamp{
|
|
{IP: "203.0.113.10", Timestamp: now - 60},
|
|
})
|
|
|
|
j := NewCheckClientIpJob()
|
|
j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
|
|
|
|
live := []IPWithTimestamp{
|
|
{IP: "203.0.113.10", Timestamp: now - 5},
|
|
{IP: "192.0.2.9", Timestamp: now},
|
|
}
|
|
|
|
inbound, err := j.getInboundByEmail(email)
|
|
if err != nil {
|
|
t.Fatalf("getInboundByEmail: %v", err)
|
|
}
|
|
_, banned := j.updateInboundClientIps(database.GetDB(), row, inbound, email, 1, live, true, false)
|
|
|
|
if banned {
|
|
t.Fatal("an allowlisted address pushed the client over its limit and something was banned")
|
|
}
|
|
if len(j.disAllowedIps) != 0 {
|
|
t.Fatalf("disAllowedIps = %v, want none", j.disAllowedIps)
|
|
}
|
|
|
|
persisted := ipSet(readClientIps(t, email))
|
|
for _, ip := range []string{"203.0.113.10", "192.0.2.9"} {
|
|
if _, ok := persisted[ip]; !ok {
|
|
t.Errorf("%s must still be persisted; got %v", ip, persisted)
|
|
}
|
|
}
|
|
|
|
if body, err := os.ReadFile(readIpLimitLogPath()); err == nil {
|
|
if contains(string(body), "203.0.113.10") {
|
|
t.Fatalf("an allowlisted address reached the fail2ban log:\n%s", body)
|
|
}
|
|
}
|
|
}
|