mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-29 06:31:58 +00:00
perf(ip-sync): avoid redundant client IP reads and writes (#6930)
The access-log scanner and the cross-node merge stored the same IP list in opposite orders, so neither saw a row as unchanged, and the merge re-read every preloaded row and re-upserted every attribution row each sync cycle. All writers now share one canonical encoder (timestamp, then IP ascending), the first CAS is seeded from the preload, and identical attribution rows are skipped. A create race between the scanner and node sync now merges into the winning row instead of dropping the loser's IPs, which on PostgreSQL also aborted the scanner's whole scan transaction. Closes #6927
This commit is contained in:
1 parent
952c1c751a
commit
8eb58a4a14
22 files changed
+1273
-247
No files matched your search
@@ -90,6 +90,16 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
- name: PostgreSQL client IP sync regressions
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
go test -p=1 ./internal/web/service ./internal/web/job -run '^TestClientIpSyncPostgres(Scanner)?$' -count=1 -v | tee /tmp/postgres-client-ip-sync.log
|
||||||
|
passed=$(grep -c -- '^--- PASS' /tmp/postgres-client-ip-sync.log || true)
|
||||||
|
if [ "$passed" -ne 2 ]; then
|
||||||
|
echo "expected 2 passing PostgreSQL client IP sync tests, got $passed" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
- name: PostgreSQL global-traffic heartbeat regressions
|
- name: PostgreSQL global-traffic heartbeat regressions
|
||||||
run: |
|
run: |
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|||||||
@@ -86,6 +86,12 @@ fields) is pushed to child nodes verbatim, so the node's Xray records the real I
|
|||||||
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
|
parent panel pulls each node's per-client IPs roughly every 10 seconds. The real visitor IP
|
||||||
shows up on the parent automatically.
|
shows up on the parent automatically.
|
||||||
|
|
||||||
|
Reports retain each address's newest timestamp within the 30-minute retention window.
|
||||||
|
Flat and per-node IP blobs use a stable timestamp-ascending order, with IP text breaking
|
||||||
|
ties, so equivalent reports preserve the same bytes and skip redundant writes. Flat-row
|
||||||
|
merges use compare-and-set retries to preserve concurrent observations; advancing a
|
||||||
|
timestamp remains a real change. GUID attribution stays separate from the flat union.
|
||||||
|
|
||||||
## Security note
|
## Security note
|
||||||
|
|
||||||
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
|
Both `acceptProxyProtocol` and `trustedXForwardedFor` are **server-side only** — they are
|
||||||
|
|||||||
@@ -1,14 +1,35 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
// ClientIpEntry is the wire/JSON shape of a single observed client IP with the
|
import (
|
||||||
// last time it was seen (unix seconds). It mirrors job.IPWithTimestamp and the
|
"cmp"
|
||||||
// service-internal clientIpEntry so the per-node attribution blob round-trips
|
"encoding/json"
|
||||||
// with the existing inbound_client_ips storage.
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientIpEntry records an observed client IP and its last-seen Unix timestamp.
|
||||||
type ClientIpEntry struct {
|
type ClientIpEntry struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SortClientIpEntries(entries []ClientIpEntry) {
|
||||||
|
slices.SortFunc(entries, func(a, b ClientIpEntry) int {
|
||||||
|
if order := cmp.Compare(a.Timestamp, b.Timestamp); order != 0 {
|
||||||
|
return order
|
||||||
|
}
|
||||||
|
return cmp.Compare(a.IP, b.IP)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarshalClientIpEntries(entries []ClientIpEntry) (string, error) {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return "[]", nil
|
||||||
|
}
|
||||||
|
SortClientIpEntries(entries)
|
||||||
|
encoded, err := json.Marshal(entries)
|
||||||
|
return string(encoded), err
|
||||||
|
}
|
||||||
|
|
||||||
// NodeClientIp records which panel (identified by its stable panelGuid) observed
|
// NodeClientIp records which panel (identified by its stable panelGuid) observed
|
||||||
// a client's IPs on its own Xray. Unlike InboundClientIps (a flattened,
|
// a client's IPs on its own Xray. Unlike InboundClientIps (a flattened,
|
||||||
// cluster-wide union used for IP-limit counting and that is pushed back to every
|
// cluster-wide union used for IP-limit counting and that is pushed back to every
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
const settleTimeout = 30 * time.Second
|
const settleTimeout = 30 * time.Second
|
||||||
@@ -196,6 +197,32 @@ func (p *panel) deleteInboundRow(id int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *panel) seedClientIpAttribution(email, ips string) string {
|
||||||
|
p.t.Helper()
|
||||||
|
if p.cmd != nil {
|
||||||
|
p.t.Fatalf("%s: seedClientIpAttribution on a running panel", p.name)
|
||||||
|
}
|
||||||
|
sharedDBMu.Lock()
|
||||||
|
defer sharedDBMu.Unlock()
|
||||||
|
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
|
||||||
|
p.t.Fatalf("%s: open db: %v", p.name, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = database.CloseDB() }()
|
||||||
|
db := database.GetDB()
|
||||||
|
var setting model.Setting
|
||||||
|
if err := db.Where("key = ?", "panelGuid").First(&setting).Error; err != nil {
|
||||||
|
p.t.Fatalf("%s: read panelGuid: %v", p.name, err)
|
||||||
|
}
|
||||||
|
if setting.Value == "" {
|
||||||
|
p.t.Fatalf("%s: empty panelGuid", p.name)
|
||||||
|
}
|
||||||
|
row := model.NodeClientIp{NodeGuid: setting.Value, Email: email, Ips: ips}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
p.t.Fatalf("%s: seed attribution: %v", p.name, err)
|
||||||
|
}
|
||||||
|
return setting.Value
|
||||||
|
}
|
||||||
|
|
||||||
func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
|
func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
|
||||||
|
|
||||||
type envelope struct {
|
type envelope struct {
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package nodee2e
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"maps"
|
||||||
"net/http"
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNodeSync walks one master/node pair per enrollment scope through every
|
// TestNodeSync walks one master/node pair per enrollment scope through every
|
||||||
@@ -91,6 +94,35 @@ func hasAll(have []string, want ...string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clientIpSnapshot(t *testing.T, p *panel, email string) (string, map[string]int64) {
|
||||||
|
t.Helper()
|
||||||
|
var rows []struct {
|
||||||
|
ClientEmail string `json:"clientEmail"`
|
||||||
|
Ips json.RawMessage `json:"ips"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(p.call(http.MethodGet, "/panel/api/server/clientIps", nil), &rows); err != nil {
|
||||||
|
t.Fatalf("decode %s client IP rows: %v", p.name, err)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.ClientEmail != email {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var entries []model.ClientIpEntry
|
||||||
|
if err := json.Unmarshal(row.Ips, &entries); err != nil {
|
||||||
|
t.Fatalf("decode %s IP array: %v (%s)", p.name, err, row.Ips)
|
||||||
|
}
|
||||||
|
got := make(map[string]int64, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
if _, duplicate := got[entry.IP]; duplicate {
|
||||||
|
t.Fatalf("%s contains duplicate IP %s: %s", p.name, entry.IP, row.Ips)
|
||||||
|
}
|
||||||
|
got[entry.IP] = entry.Timestamp
|
||||||
|
}
|
||||||
|
return string(row.Ips), got
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
||||||
master := newPanel(t, bin, "master")
|
master := newPanel(t, bin, "master")
|
||||||
node := newPanel(t, bin, "node")
|
node := newPanel(t, bin, "node")
|
||||||
@@ -243,6 +275,86 @@ func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("client IP union keeps maximum timestamps stable across sync and an offline node", func(t *testing.T) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
post := func(p *panel, ips any) {
|
||||||
|
p.call(http.MethodPost, "/panel/api/server/clientIps", []map[string]any{{"clientEmail": "p5", "ips": ips}})
|
||||||
|
}
|
||||||
|
post(master, []model.ClientIpEntry{
|
||||||
|
{IP: "198.51.100.11", Timestamp: now - 30},
|
||||||
|
{IP: "198.51.100.12", Timestamp: now - 20},
|
||||||
|
})
|
||||||
|
post(node, fmt.Sprintf(`[{"ip":"198.51.100.13","timestamp":%d},{"ip":"198.51.100.12","timestamp":%d}]`, now-10, now-5))
|
||||||
|
want := map[string]int64{"198.51.100.11": now - 30, "198.51.100.12": now - 5, "198.51.100.13": now - 10}
|
||||||
|
waitUnion := func() {
|
||||||
|
t.Helper()
|
||||||
|
eventually(t, settleTimeout, "both panels retain the exact IP union and maximum timestamps", func() (bool, string) {
|
||||||
|
masterRaw, masterIPs := clientIpSnapshot(t, master, "p5")
|
||||||
|
nodeRaw, nodeIPs := clientIpSnapshot(t, node, "p5")
|
||||||
|
return maps.Equal(masterIPs, want) && maps.Equal(nodeIPs, want) && masterRaw == nodeRaw,
|
||||||
|
fmt.Sprintf("master=%s node=%s want=%v", masterRaw, nodeRaw, want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
waitUnion()
|
||||||
|
stableRaw, _ := clientIpSnapshot(t, master, "p5")
|
||||||
|
time.Sleep(12 * time.Second)
|
||||||
|
for _, p := range []*panel{master, node} {
|
||||||
|
raw, got := clientIpSnapshot(t, p, "p5")
|
||||||
|
if raw != stableRaw || !maps.Equal(got, want) {
|
||||||
|
t.Fatalf("%s IPs drifted after another sync cycle: %s, want %s", p.name, raw, stableRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
node.stop()
|
||||||
|
post(master, []model.ClientIpEntry{{IP: "198.51.100.14", Timestamp: now - 1}})
|
||||||
|
node.start()
|
||||||
|
want["198.51.100.14"] = now - 1
|
||||||
|
waitUnion()
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stopped database observations retain separate GUID attribution buckets", func(t *testing.T) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
master.stop()
|
||||||
|
node.stop()
|
||||||
|
masterIPs := fmt.Sprintf(`[{"ip":"203.0.113.21","timestamp":%d}]`, now-20)
|
||||||
|
nodeIPs := fmt.Sprintf(`[{"ip":"203.0.113.22","timestamp":%d}]`, now-10)
|
||||||
|
masterGuid := master.seedClientIpAttribution("p5", masterIPs)
|
||||||
|
nodeGuid := node.seedClientIpAttribution("p5", nodeIPs)
|
||||||
|
if masterGuid == nodeGuid {
|
||||||
|
t.Fatalf("independent panels share GUID %q", masterGuid)
|
||||||
|
}
|
||||||
|
master.start()
|
||||||
|
node.start()
|
||||||
|
want := map[string]map[string][]model.ClientIpEntry{
|
||||||
|
masterGuid: {"p5": {{IP: "203.0.113.21", Timestamp: now - 20}}},
|
||||||
|
nodeGuid: {"p5": {{IP: "203.0.113.22", Timestamp: now - 10}}},
|
||||||
|
}
|
||||||
|
eventually(t, settleTimeout, "master retains the exact independent GUID observation buckets", func() (bool, string) {
|
||||||
|
var got map[string]map[string][]model.ClientIpEntry
|
||||||
|
raw := master.call(http.MethodPost, "/panel/api/clients/clientIpsByGuid", nil)
|
||||||
|
if err := json.Unmarshal(raw, &got); err != nil {
|
||||||
|
t.Fatalf("decode attribution: %v", err)
|
||||||
|
}
|
||||||
|
expected, err := json.Marshal(want)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
actual, err := json.Marshal(got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(actual) == string(expected), fmt.Sprintf("attribution=%s want=%s", actual, expected)
|
||||||
|
})
|
||||||
|
var nodeTree map[string]map[string][]model.ClientIpEntry
|
||||||
|
raw := node.call(http.MethodPost, "/panel/api/clients/clientIpsByGuid", nil)
|
||||||
|
if err := json.Unmarshal(raw, &nodeTree); err != nil {
|
||||||
|
t.Fatalf("decode node attribution: %v", err)
|
||||||
|
}
|
||||||
|
if len(nodeTree) != 1 || len(nodeTree[nodeGuid]) != 1 ||
|
||||||
|
!slices.Equal(nodeTree[nodeGuid]["p5"], want[nodeGuid]["p5"]) {
|
||||||
|
t.Fatalf("node attribution mixed parent observations into its own bucket: %s", raw)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("an inbound deleted on the node is removed from the master too", func(t *testing.T) {
|
t.Run("an inbound deleted on the node is removed from the master too", func(t *testing.T) {
|
||||||
pr.t = t
|
pr.t = t
|
||||||
master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-node", lostPort, &pr.nodeID, vlessClient("l1")))
|
master.call(http.MethodPost, "/panel/api/inbounds/add", vlessInbound("deleted-on-node", lostPort, &pr.nodeID, vlessClient("l1")))
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -86,7 +87,7 @@ func TestProcessObserved_CommitFailureDoesNotPublishBan(t *testing.T) {
|
|||||||
const email = "rollback-must-not-ban@x"
|
const email = "rollback-must-not-ban@x"
|
||||||
seedLinkedInboundWithClient(t, "rollback-must-not-ban", email, 1)
|
seedLinkedInboundWithClient(t, "rollback-must-not-ban", email, 1)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.10", Timestamp: now - 2}})
|
seedClientIps(t, email, []model.ClientIpEntry{{IP: "198.51.100.10", Timestamp: now - 2}})
|
||||||
|
|
||||||
installClientIpCommitFailure(t)
|
installClientIpCommitFailure(t)
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ func TestProcessObserved_ForgetsBannedSeenWhenClientReturnsUnderLimit(t *testing
|
|||||||
const email = "prune-banned-seen@x"
|
const email = "prune-banned-seen@x"
|
||||||
seedLinkedInboundWithClient(t, "prune-banned-seen", email, 1)
|
seedLinkedInboundWithClient(t, "prune-banned-seen", email, 1)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "203.0.113.1", Timestamp: now - 500}})
|
seedClientIps(t, email, []model.ClientIpEntry{{IP: "203.0.113.1", Timestamp: now - 500}})
|
||||||
j := NewCheckClientIpJob()
|
j := NewCheckClientIpJob()
|
||||||
|
|
||||||
j.processObserved(map[string]map[string]int64{
|
j.processObserved(map[string]map[string]int64{
|
||||||
@@ -180,7 +181,7 @@ func (f failingWriter) Write([]byte) (int, error) { return 0, f.err }
|
|||||||
// sees, so it has to reach the caller.
|
// sees, so it has to reach the caller.
|
||||||
func TestWriteBanLinesSurfacesWriteFailure(t *testing.T) {
|
func TestWriteBanLinesSurfacesWriteFailure(t *testing.T) {
|
||||||
want := errors.New("no space left on device")
|
want := errors.New("no space left on device")
|
||||||
err := writeBanLines(failingWriter{err: want}, "write-failure@x", []IPWithTimestamp{
|
err := writeBanLines(failingWriter{err: want}, "write-failure@x", []model.ClientIpEntry{
|
||||||
{IP: "203.0.113.9", Timestamp: time.Now().Unix()},
|
{IP: "203.0.113.9", Timestamp: time.Now().Unix()},
|
||||||
})
|
})
|
||||||
if !errors.Is(err, want) {
|
if !errors.Is(err, want) {
|
||||||
@@ -195,7 +196,7 @@ func TestProcessObserved_PublishesBanForCommittedScan(t *testing.T) {
|
|||||||
const email = "published-ban@x"
|
const email = "published-ban@x"
|
||||||
seedLinkedInboundWithClient(t, "published-ban", email, 1)
|
seedLinkedInboundWithClient(t, "published-ban", email, 1)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "203.0.113.50", Timestamp: now - 500}})
|
seedClientIps(t, email, []model.ClientIpEntry{{IP: "203.0.113.50", Timestamp: now - 500}})
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
j := NewCheckClientIpJob()
|
||||||
if cleaned := j.processObserved(map[string]map[string]int64{
|
if cleaned := j.processObserved(map[string]map[string]int64{
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"maps"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -9,20 +9,26 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A node sync landing between the scan's read and its write must keep its remote
|
// A node sync landing between the scan's read and its write must keep its remote
|
||||||
// IP (#6587); the hook injects that write, since SQLite serializes real writers.
|
// IP (#6587); the hook injects that write, since SQLite serializes real writers.
|
||||||
func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
||||||
setupIntegrationDB(t)
|
setupIntegrationDB(t)
|
||||||
|
processObservedKeepsNodeSyncWrite(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func processObservedKeepsNodeSyncWrite(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
const email = "cas-scan@x"
|
const email = "cas-scan@x"
|
||||||
seedLinkedInboundWithClient(t, "cas-scan", email, 3)
|
seedLinkedInboundWithClient(t, "cas-scan", email, 3)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.1", Timestamp: now - 60}})
|
seed := seedClientIps(t, email, []model.ClientIpEntry{{IP: "198.51.100.1", Timestamp: now - 60}})
|
||||||
|
|
||||||
nodeBlob, err := json.Marshal([]IPWithTimestamp{
|
nodeBlob, err := model.MarshalClientIpEntries([]model.ClientIpEntry{
|
||||||
{IP: "198.51.100.1", Timestamp: now - 60},
|
{IP: "198.51.100.1", Timestamp: now - 60},
|
||||||
{IP: "203.0.113.77", Timestamp: now - 5},
|
{IP: "203.0.113.77", Timestamp: now - 5},
|
||||||
})
|
})
|
||||||
@@ -35,11 +41,19 @@ func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
|||||||
if injected || tx.Statement.Schema == nil || tx.Statement.Schema.Table != "inbound_client_ips" {
|
if injected || tx.Statement.Schema == nil || tx.Statement.Schema.Table != "inbound_client_ips" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
where, ok := tx.Statement.Clauses["WHERE"].Expression.(clause.Where)
|
||||||
|
if !ok || len(where.Exprs) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
predicate, ok := where.Exprs[0].(clause.Expr)
|
||||||
|
if !ok || len(predicate.Vars) != 2 || predicate.Vars[0] != seed.Id {
|
||||||
|
return
|
||||||
|
}
|
||||||
injected = true
|
injected = true
|
||||||
if err := tx.Session(&gorm.Session{SkipHooks: true, NewDB: true}).
|
if err := tx.Session(&gorm.Session{SkipHooks: true, NewDB: true}).
|
||||||
Model(&model.InboundClientIps{}).
|
Model(&model.InboundClientIps{}).
|
||||||
Where("client_email = ?", email).
|
Where("client_email = ?", email).
|
||||||
Update("ips", string(nodeBlob)).Error; err != nil {
|
Update("ips", nodeBlob).Error; err != nil {
|
||||||
_ = tx.AddError(err)
|
_ = tx.AddError(err)
|
||||||
}
|
}
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -55,10 +69,8 @@ func TestProcessObserved_KeepsNodeSyncWriteThatLandsMidScan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := ipSet(readClientIps(t, email))
|
got := ipSet(readClientIps(t, email))
|
||||||
if _, ok := got["203.0.113.77"]; !ok {
|
want := map[string]int64{"203.0.113.77": now - 5, "198.51.100.1": now}
|
||||||
t.Fatalf("scan overwrote the node's remote IP (the #6587 lost update): %v", got)
|
if !maps.Equal(got, want) {
|
||||||
}
|
t.Fatalf("scan/node union = %v, want %v", got, want)
|
||||||
if got["198.51.100.1"] != now {
|
|
||||||
t.Fatalf("scan's own observation missing or stale: %v", got)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func banLineCount(t *testing.T, email string) int {
|
func banLineCount(t *testing.T, email string) int {
|
||||||
@@ -27,7 +29,7 @@ func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
|
|||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
deadStart := now - 300
|
deadStart := now - 300
|
||||||
live := []IPWithTimestamp{
|
live := []model.ClientIpEntry{
|
||||||
{IP: "10.2.0.1", Timestamp: deadStart},
|
{IP: "10.2.0.1", Timestamp: deadStart},
|
||||||
{IP: "192.0.2.7", Timestamp: now},
|
{IP: "192.0.2.7", Timestamp: now},
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func TestUpdateInboundClientIps_FrozenLastSeenBannedOnce(t *testing.T) {
|
|||||||
t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
|
t.Fatalf("ban lines after frozen rescan = %d, want still 1", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnected := []IPWithTimestamp{
|
reconnected := []model.ClientIpEntry{
|
||||||
{IP: "10.2.0.1", Timestamp: now + 30},
|
{IP: "10.2.0.1", Timestamp: now + 30},
|
||||||
{IP: "192.0.2.7", Timestamp: now + 60},
|
{IP: "192.0.2.7", Timestamp: now + 60},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,14 +21,9 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IPWithTimestamp tracks an IP address with its last seen timestamp
|
|
||||||
type IPWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// onlineUsersSource is the one service.XrayService method the scan needs, so
|
// onlineUsersSource is the one service.XrayService method the scan needs, so
|
||||||
// tests can drive Run end to end without a live xray core.
|
// tests can drive Run end to end without a live xray core.
|
||||||
type onlineUsersSource interface {
|
type onlineUsersSource interface {
|
||||||
@@ -352,14 +347,12 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to IPWithTimestamp slice
|
ipsWithTime := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
||||||
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
|
|
||||||
attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
attrEntries := make([]model.ClientIpEntry, 0, len(ipTimestamps))
|
||||||
for ip, timestamp := range ipTimestamps {
|
for ip, timestamp := range ipTimestamps {
|
||||||
ipsWithTime = append(ipsWithTime, IPWithTimestamp{IP: ip, Timestamp: timestamp})
|
ipsWithTime = append(ipsWithTime, model.ClientIpEntry{IP: ip, Timestamp: timestamp})
|
||||||
// Live API observations may carry an old lastSeen (connection start),
|
// A live connection's lastSeen may be its old start time.
|
||||||
// so stamp attribution with now; otherwise the stale cutoff would evict
|
// Stamp attribution now so retention cannot evict a connected IP.
|
||||||
// an IP that is connected right now.
|
|
||||||
attrTs := timestamp
|
attrTs := timestamp
|
||||||
if observedAreLive {
|
if observedAreLive {
|
||||||
attrTs = now
|
attrTs = now
|
||||||
@@ -372,15 +365,27 @@ func (j *CheckClientIpJob) processObserved(observed map[string]map[string]int64,
|
|||||||
|
|
||||||
clientIpsRecord, ok := ipRowByEmail[email]
|
clientIpsRecord, ok := ipRowByEmail[email]
|
||||||
if !ok {
|
if !ok {
|
||||||
jsonIps, err := json.Marshal(ipsWithTime)
|
jsonIps, err := model.MarshalClientIpEntries(ipsWithTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := tx.Save(&model.InboundClientIps{ClientEmail: email, Ips: string(jsonIps)}).Error; err != nil {
|
clientIpsRecord = &model.InboundClientIps{ClientEmail: email, Ips: jsonIps}
|
||||||
|
result := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "client_email"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(clientIpsRecord)
|
||||||
|
if result.Error != nil {
|
||||||
|
j.checkError(result.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := tx.Where("client_email = ?", email).First(clientIpsRecord).Error; err != nil {
|
||||||
j.checkError(err)
|
j.checkError(err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates, keptLive := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
|
candidates, keptLive := j.updateInboundClientIps(tx, clientIpsRecord, inbound, email, limitByEmail[email], ipsWithTime, enforce, observedAreLive)
|
||||||
@@ -429,11 +434,9 @@ func (j *CheckClientIpJob) recordLocalAttribution(attribution map[string][]model
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeClientIps folds this scan's observations into the persisted set,
|
// mergeClientIps retains fresh entries and exempts currently observed live
|
||||||
// dropping entries older than staleCutoff. newAlwaysLive exempts the new
|
// connections from expiry even when their dispatch timestamps are old.
|
||||||
// entries from that cutoff: an API-observed IP is a live connection by
|
func mergeClientIps(old, new []model.ClientIpEntry, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
|
||||||
// definition, even when its lastSeen (set at dispatch time) is hours old.
|
|
||||||
func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive bool) map[string]int64 {
|
|
||||||
ipMap := make(map[string]int64, len(old)+len(new))
|
ipMap := make(map[string]int64, len(old)+len(new))
|
||||||
for _, ipTime := range old {
|
for _, ipTime := range old {
|
||||||
if ipTime.Timestamp < staleCutoff {
|
if ipTime.Timestamp < staleCutoff {
|
||||||
@@ -454,7 +457,7 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64, newAlwaysLive
|
|||||||
|
|
||||||
// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
|
// selectIpsToBan splits the live IPs (sorted oldest-first by partitionLiveIps)
|
||||||
// into the newest `limit` entries to keep and the older remainder to ban.
|
// into the newest `limit` entries to keep and the older remainder to ban.
|
||||||
func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTimestamp) {
|
func selectIpsToBan(live []model.ClientIpEntry, limit int) (kept, banned []model.ClientIpEntry) {
|
||||||
if limit <= 0 || len(live) <= limit {
|
if limit <= 0 || len(live) <= limit {
|
||||||
return live, nil
|
return live, nil
|
||||||
}
|
}
|
||||||
@@ -462,33 +465,21 @@ func selectIpsToBan(live []IPWithTimestamp, limit int) (kept, banned []IPWithTim
|
|||||||
return live[cutoff:], live[:cutoff]
|
return live[cutoff:], live[:cutoff]
|
||||||
}
|
}
|
||||||
|
|
||||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []model.ClientIpEntry) {
|
||||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
live = make([]model.ClientIpEntry, 0, len(observedThisScan))
|
||||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
historical = make([]model.ClientIpEntry, 0, len(ipMap))
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
for ip, ts := range ipMap {
|
for ip, ts := range ipMap {
|
||||||
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
entry := model.ClientIpEntry{IP: ip, Timestamp: ts}
|
||||||
// Consider an IP "live" if it was seen locally in this scan, OR if its
|
// Recent remote observations count toward the cluster-wide limit.
|
||||||
// timestamp from the synced database is very recent (e.g. within 2 minutes).
|
|
||||||
// This ensures cluster-wide limits work even if the IP was seen on another node.
|
|
||||||
if observedThisScan[ip] || now-ts < 120 {
|
if observedThisScan[ip] || now-ts < 120 {
|
||||||
live = append(live, entry)
|
live = append(live, entry)
|
||||||
} else {
|
} else {
|
||||||
historical = append(historical, entry)
|
historical = append(historical, entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sort.Slice(live, func(i, j int) bool {
|
model.SortClientIpEntries(live)
|
||||||
if live[i].Timestamp != live[j].Timestamp {
|
model.SortClientIpEntries(historical)
|
||||||
return live[i].Timestamp < live[j].Timestamp
|
|
||||||
}
|
|
||||||
return live[i].IP < live[j].IP
|
|
||||||
})
|
|
||||||
sort.Slice(historical, func(i, j int) bool {
|
|
||||||
if historical[i].Timestamp != historical[j].Timestamp {
|
|
||||||
return historical[i].Timestamp < historical[j].Timestamp
|
|
||||||
}
|
|
||||||
return historical[i].IP < historical[j].IP
|
|
||||||
})
|
|
||||||
return live, historical
|
return live, historical
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,10 +514,8 @@ func (j *CheckClientIpJob) delInboundClientIps(tx *gorm.DB, clientEmail string)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateInboundClientIps merges one email's observed IPs into its tracking row
|
// updateInboundClientIps returns ban candidates; publication must wait for commit.
|
||||||
// and applies the IP limit. Ban candidates are returned, not written: the
|
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []model.ClientIpEntry, enforce, observedAreLive bool) (banCandidates []model.ClientIpEntry, keptLiveCount int) {
|
||||||
// fail2ban log is the point of no return and must wait for the commit.
|
|
||||||
func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, limitIp int, newIpsWithTime []IPWithTimestamp, enforce, observedAreLive bool) (banCandidates []IPWithTimestamp, keptLiveCount int) {
|
|
||||||
if inbound.Settings == "" {
|
if inbound.Settings == "" {
|
||||||
logger.Debug("wrong data:", inbound)
|
logger.Debug("wrong data:", inbound)
|
||||||
return nil, 0
|
return nil, 0
|
||||||
@@ -545,14 +534,14 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
|||||||
// Node sync merges into the same blob (#6587): compare-and-set it, and on a
|
// Node sync merges into the same blob (#6587): compare-and-set it, and on a
|
||||||
// miss re-read and re-merge so neither writer drops the other's IPs.
|
// miss re-read and re-merge so neither writer drops the other's IPs.
|
||||||
for range service.ClientIpCasRetries {
|
for range service.ClientIpCasRetries {
|
||||||
var oldIpsWithTime []IPWithTimestamp
|
var oldIpsWithTime []model.ClientIpEntry
|
||||||
if inboundClientIps.Ips != "" {
|
if inboundClientIps.Ips != "" {
|
||||||
_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
_ = json.Unmarshal([]byte(inboundClientIps.Ips), &oldIpsWithTime)
|
||||||
}
|
}
|
||||||
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, staleCutoff, observedAreLive)
|
ipMap := mergeClientIps(oldIpsWithTime, newIpsWithTime, staleCutoff, observedAreLive)
|
||||||
|
|
||||||
dbIps := make([]IPWithTimestamp, 0, len(ipMap))
|
dbIps := make([]model.ClientIpEntry, 0, len(ipMap))
|
||||||
var bannedLive []IPWithTimestamp
|
var bannedLive []model.ClientIpEntry
|
||||||
keptLive := 0
|
keptLive := 0
|
||||||
if enforceLimit {
|
if enforceLimit {
|
||||||
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
liveIps, historicalIps := partitionLiveIps(ipMap, observedThisScan)
|
||||||
@@ -569,12 +558,16 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
|||||||
dbIps = append(dbIps, kept...)
|
dbIps = append(dbIps, kept...)
|
||||||
dbIps = append(dbIps, historicalIps...)
|
dbIps = append(dbIps, historicalIps...)
|
||||||
} else {
|
} else {
|
||||||
// Collection-only run (#6722): no ban selection; keep every IP in
|
// Collection-only run (#6722): no ban selection, keep every IP in the window.
|
||||||
// the 30-minute window, sorted so unchanged scans skip the write.
|
for ip, timestamp := range ipMap {
|
||||||
dbIps = sortedIpsFromMap(ipMap)
|
dbIps = append(dbIps, model.ClientIpEntry{IP: ip, Timestamp: timestamp})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newIps, err := model.MarshalClientIpEntries(dbIps)
|
||||||
|
if err != nil {
|
||||||
|
j.checkError(err)
|
||||||
|
return nil, 0
|
||||||
}
|
}
|
||||||
jsonIps, _ := json.Marshal(dbIps)
|
|
||||||
newIps := string(jsonIps)
|
|
||||||
if newIps == inboundClientIps.Ips {
|
if newIps == inboundClientIps.Ips {
|
||||||
return bannedLive, keptLive
|
return bannedLive, keptLive
|
||||||
}
|
}
|
||||||
@@ -597,28 +590,12 @@ func (j *CheckClientIpJob) updateInboundClientIps(tx *gorm.DB, inboundClientIps
|
|||||||
return nil, 0
|
return nil, 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// sortedIpsFromMap orders the merged IP set by timestamp with the IP as
|
|
||||||
// tie-breaker, giving a stable blob for the skip-when-unchanged compare.
|
|
||||||
func sortedIpsFromMap(ipMap map[string]int64) []IPWithTimestamp {
|
|
||||||
out := make([]IPWithTimestamp, 0, len(ipMap))
|
|
||||||
for ip, ts := range ipMap {
|
|
||||||
out = append(out, IPWithTimestamp{IP: ip, Timestamp: ts})
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool {
|
|
||||||
if out[i].Timestamp != out[j].Timestamp {
|
|
||||||
return out[i].Timestamp < out[j].Timestamp
|
|
||||||
}
|
|
||||||
return out[i].IP < out[j].IP
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// pendingBan carries one client's enforcement outcome from inside the scan's
|
// pendingBan carries one client's enforcement outcome from inside the scan's
|
||||||
// transaction to the publication that may only follow a successful commit.
|
// transaction to the publication that may only follow a successful commit.
|
||||||
type pendingBan struct {
|
type pendingBan struct {
|
||||||
inbound *model.Inbound
|
inbound *model.Inbound
|
||||||
email string
|
email string
|
||||||
candidates []IPWithTimestamp
|
candidates []model.ClientIpEntry
|
||||||
keptLive int
|
keptLive int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -662,7 +639,7 @@ func (j *CheckClientIpJob) publishBans(bans []pendingBan) []pendingBan {
|
|||||||
|
|
||||||
// writeBanLines emits one line per address; the wording is load-bearing, since
|
// writeBanLines emits one line per address; the wording is load-bearing, since
|
||||||
// x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it.
|
// x-ui.sh create_iplimit_jails builds filter.d/3x-ipl.conf failregex from it.
|
||||||
func writeBanLines(w io.Writer, clientEmail string, actionable []IPWithTimestamp) error {
|
func writeBanLines(w io.Writer, clientEmail string, actionable []model.ClientIpEntry) error {
|
||||||
stamp := time.Now().Format("2006/01/02 15:04:05")
|
stamp := time.Now().Format("2006/01/02 15:04:05")
|
||||||
for _, ipTime := range actionable {
|
for _, ipTime := range actionable {
|
||||||
if _, err := fmt.Fprintf(w, "%s [LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d\n",
|
if _, err := fmt.Fprintf(w, "%s [LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d\n",
|
||||||
@@ -675,8 +652,8 @@ func writeBanLines(w io.Writer, clientEmail string, actionable []IPWithTimestamp
|
|||||||
|
|
||||||
// selectAdvancedSinceLastBan drops pairs with a frozen lastSeen: the core
|
// selectAdvancedSinceLastBan drops pairs with a frozen lastSeen: the core
|
||||||
// refreshes it only on a new dispatch, so those are unreaped dead connections.
|
// refreshes it only on a new dispatch, so those are unreaped dead connections.
|
||||||
func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []IPWithTimestamp) []IPWithTimestamp {
|
func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []model.ClientIpEntry) []model.ClientIpEntry {
|
||||||
actionable := make([]IPWithTimestamp, 0, len(banned))
|
actionable := make([]model.ClientIpEntry, 0, len(banned))
|
||||||
for _, ipTime := range banned {
|
for _, ipTime := range banned {
|
||||||
if last, ok := j.bannedSeen[email+"|"+ipTime.IP]; ok && ipTime.Timestamp <= last {
|
if last, ok := j.bannedSeen[email+"|"+ipTime.IP]; ok && ipTime.Timestamp <= last {
|
||||||
continue
|
continue
|
||||||
@@ -688,7 +665,7 @@ func (j *CheckClientIpJob) selectAdvancedSinceLastBan(email string, banned []IPW
|
|||||||
|
|
||||||
// recordBannedSeen marks published pairs and forgets addresses this scan no
|
// recordBannedSeen marks published pairs and forgets addresses this scan no
|
||||||
// longer bans; it runs for every enforced client, which is what prunes the map.
|
// longer bans; it runs for every enforced client, which is what prunes the map.
|
||||||
func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []IPWithTimestamp) {
|
func (j *CheckClientIpJob) recordBannedSeen(email string, banned, published []model.ClientIpEntry) {
|
||||||
if j.bannedSeen == nil {
|
if j.bannedSeen == nil {
|
||||||
j.bannedSeen = make(map[string]int64)
|
j.bannedSeen = make(map[string]int64)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ import (
|
|||||||
// log a warning. otherwise log.Warningf panics on a nil logger.
|
// log a warning. otherwise log.Warningf panics on a nil logger.
|
||||||
var loggerInitOnce sync.Once
|
var loggerInitOnce sync.Once
|
||||||
|
|
||||||
// setupIntegrationDB wires a temp sqlite db and log folder so
|
// setupIntegrationDB closes its temporary SQLite database before directory cleanup.
|
||||||
// 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) {
|
func setupIntegrationDB(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -38,6 +35,8 @@ func setupIntegrationDB(t *testing.T) {
|
|||||||
logDir := t.TempDir()
|
logDir := t.TempDir()
|
||||||
|
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
t.Setenv("XUI_DB_TYPE", "sqlite")
|
||||||
|
t.Setenv("XUI_DB_DSN", "")
|
||||||
t.Setenv("XUI_LOG_FOLDER", logDir)
|
t.Setenv("XUI_LOG_FOLDER", logDir)
|
||||||
|
|
||||||
// updateInboundClientIps calls log.SetOutput on the package global,
|
// updateInboundClientIps calls log.SetOutput on the package global,
|
||||||
@@ -54,7 +53,7 @@ func setupIntegrationDB(t *testing.T) {
|
|||||||
|
|
||||||
// enforceIpLimitForTest runs the same two steps processObserved does: select
|
// enforceIpLimitForTest runs the same two steps processObserved does: select
|
||||||
// inside the transaction, publish only once it would have committed.
|
// inside the transaction, publish only once it would have committed.
|
||||||
func (j *CheckClientIpJob) enforceIpLimitForTest(t *testing.T, row *model.InboundClientIps, inbound *model.Inbound, email string, limit int, live []IPWithTimestamp, observedAreLive bool) (banned bool, published []IPWithTimestamp) {
|
func (j *CheckClientIpJob) enforceIpLimitForTest(t *testing.T, row *model.InboundClientIps, inbound *model.Inbound, email string, limit int, live []model.ClientIpEntry, observedAreLive bool) (banned bool, published []model.ClientIpEntry) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
candidates, keptLive := j.updateInboundClientIps(database.GetDB(), row, inbound, email, limit, live, true, observedAreLive)
|
candidates, keptLive := j.updateInboundClientIps(database.GetDB(), row, inbound, email, limit, live, true, observedAreLive)
|
||||||
actionable := j.selectAdvancedSinceLastBan(email, candidates)
|
actionable := j.selectAdvancedSinceLastBan(email, candidates)
|
||||||
@@ -115,15 +114,15 @@ func seedLinkedInboundWithClient(t *testing.T, tag, email string, limitIp int) *
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seed an InboundClientIps row with the given blob.
|
// seed an InboundClientIps row with the given blob.
|
||||||
func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
|
func seedClientIps(t *testing.T, email string, ips []model.ClientIpEntry) *model.InboundClientIps {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
blob, err := json.Marshal(ips)
|
blob, err := model.MarshalClientIpEntries(ips)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("marshal ips: %v", err)
|
t.Fatalf("marshal ips: %v", err)
|
||||||
}
|
}
|
||||||
row := &model.InboundClientIps{
|
row := &model.InboundClientIps{
|
||||||
ClientEmail: email,
|
ClientEmail: email,
|
||||||
Ips: string(blob),
|
Ips: blob,
|
||||||
}
|
}
|
||||||
if err := database.GetDB().Create(row).Error; err != nil {
|
if err := database.GetDB().Create(row).Error; err != nil {
|
||||||
t.Fatalf("seed InboundClientIps: %v", err)
|
t.Fatalf("seed InboundClientIps: %v", err)
|
||||||
@@ -132,7 +131,7 @@ func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.Inb
|
|||||||
}
|
}
|
||||||
|
|
||||||
// read the persisted blob and parse it back.
|
// read the persisted blob and parse it back.
|
||||||
func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
func readClientIps(t *testing.T, email string) []model.ClientIpEntry {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
row := &model.InboundClientIps{}
|
row := &model.InboundClientIps{}
|
||||||
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
|
||||||
@@ -141,7 +140,7 @@ func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
|||||||
if row.Ips == "" {
|
if row.Ips == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var out []IPWithTimestamp
|
var out []model.ClientIpEntry
|
||||||
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
|
||||||
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,7 @@ func readClientIps(t *testing.T, email string) []IPWithTimestamp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// make a lookup map so asserts don't depend on slice order.
|
// make a lookup map so asserts don't depend on slice order.
|
||||||
func ipSet(entries []IPWithTimestamp) map[string]int64 {
|
func ipSet(entries []model.ClientIpEntry) map[string]int64 {
|
||||||
out := make(map[string]int64, len(entries))
|
out := make(map[string]int64, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
out[e.IP] = e.Timestamp
|
out[e.IP] = e.Timestamp
|
||||||
@@ -198,7 +197,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
|||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
// idle but still within the 30min staleness window.
|
// idle but still within the 30min staleness window.
|
||||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
row := seedClientIps(t, email, []model.ClientIpEntry{
|
||||||
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
{IP: "10.0.0.1", Timestamp: now - 20*60},
|
||||||
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
{IP: "10.0.0.2", Timestamp: now - 15*60},
|
||||||
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
{IP: "10.0.0.3", Timestamp: now - 10*60},
|
||||||
@@ -206,7 +205,7 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
|
|||||||
|
|
||||||
j := NewCheckClientIpJob()
|
j := NewCheckClientIpJob()
|
||||||
// the one that's actually connecting (user's 128.71.x.x).
|
// the one that's actually connecting (user's 128.71.x.x).
|
||||||
live := []IPWithTimestamp{
|
live := []model.ClientIpEntry{
|
||||||
{IP: "128.71.1.1", Timestamp: now},
|
{IP: "128.71.1.1", Timestamp: now},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +249,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|||||||
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
row := seedClientIps(t, email, []model.ClientIpEntry{
|
||||||
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
{IP: "10.1.0.1", Timestamp: now - 60}, // original connection
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
|
|||||||
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
// both live, limit=1. use distinct timestamps so sort-by-timestamp
|
||||||
// is deterministic: 10.1.0.1 is the original (older) and must get
|
// 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).
|
// banned; 192.0.2.9 joined later and keeps the slot (last IP wins).
|
||||||
live := []IPWithTimestamp{
|
live := []model.ClientIpEntry{
|
||||||
{IP: "10.1.0.1", Timestamp: now - 5},
|
{IP: "10.1.0.1", Timestamp: now - 5},
|
||||||
{IP: "192.0.2.9", Timestamp: now},
|
{IP: "192.0.2.9", Timestamp: now},
|
||||||
}
|
}
|
||||||
@@ -329,7 +328,7 @@ func TestProcessObserved_CollectionOnlyKeepsRecentWindow(t *testing.T) {
|
|||||||
const email = "window-user"
|
const email = "window-user"
|
||||||
seedInboundWithClient(t, "inbound-window", email, 0)
|
seedInboundWithClient(t, "inbound-window", email, 0)
|
||||||
fiveMinAgo := time.Now().Unix() - 5*60
|
fiveMinAgo := time.Now().Unix() - 5*60
|
||||||
seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.1", Timestamp: fiveMinAgo}})
|
seedClientIps(t, email, []model.ClientIpEntry{{IP: "198.51.100.1", Timestamp: fiveMinAgo}})
|
||||||
|
|
||||||
observed := map[string]map[string]int64{
|
observed := map[string]map[string]int64{
|
||||||
email: {"203.0.113.30": time.Now().Unix()},
|
email: {"203.0.113.30": time.Now().Unix()},
|
||||||
@@ -391,7 +390,7 @@ func TestProcessObserved_StaleEmailIsSkippedAndOrphanDropped(t *testing.T) {
|
|||||||
const staleEmail = "renamed-away"
|
const staleEmail = "renamed-away"
|
||||||
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
// No inbound references staleEmail. Pre-seed an orphan tracking row to
|
||||||
// confirm the job removes it rather than leaving it to error forever.
|
// 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()}})
|
seedClientIps(t, staleEmail, []model.ClientIpEntry{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
|
||||||
|
|
||||||
observed := map[string]map[string]int64{
|
observed := map[string]map[string]int64{
|
||||||
staleEmail: {"203.0.113.5": time.Now().Unix()},
|
staleEmail: {"203.0.113.5": time.Now().Unix()},
|
||||||
@@ -493,14 +492,14 @@ func TestUpdateInboundClientIps_AllowlistedIpIsNeitherCountedNorBanned(t *testin
|
|||||||
seedInboundWithClient(t, "inbound-issue5378", email, 1)
|
seedInboundWithClient(t, "inbound-issue5378", email, 1)
|
||||||
|
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
row := seedClientIps(t, email, []IPWithTimestamp{
|
row := seedClientIps(t, email, []model.ClientIpEntry{
|
||||||
{IP: "203.0.113.10", Timestamp: now - 60},
|
{IP: "203.0.113.10", Timestamp: now - 60},
|
||||||
})
|
})
|
||||||
|
|
||||||
j := NewCheckClientIpJob()
|
j := NewCheckClientIpJob()
|
||||||
j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
|
j.allowlist = parseIpLimitAllowlist("203.0.113.0/24")
|
||||||
|
|
||||||
live := []IPWithTimestamp{
|
live := []model.ClientIpEntry{
|
||||||
{IP: "203.0.113.10", Timestamp: now - 5},
|
{IP: "203.0.113.10", Timestamp: now - 5},
|
||||||
{IP: "192.0.2.9", Timestamp: now},
|
{IP: "192.0.2.9", Timestamp: now},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
|||||||
// banned again immediately because a long-disconnected IP stayed in the
|
// banned again immediately because a long-disconnected IP stayed in the
|
||||||
// DB with an ancient timestamp and kept "protecting" itself against
|
// DB with an ancient timestamp and kept "protecting" itself against
|
||||||
// eviction. Guard against that regression here.
|
// eviction. Guard against that regression here.
|
||||||
old := []IPWithTimestamp{
|
old := []model.ClientIpEntry{
|
||||||
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
|
{IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
|
||||||
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
|
{IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
|
||||||
}
|
}
|
||||||
new := []IPWithTimestamp{
|
new := []model.ClientIpEntry{
|
||||||
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
{IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
|||||||
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
||||||
// Backwards-compat: entries that aren't stale are still carried forward,
|
// Backwards-compat: entries that aren't stale are still carried forward,
|
||||||
// so enforcement survives access-log rotation.
|
// so enforcement survives access-log rotation.
|
||||||
old := []IPWithTimestamp{
|
old := []model.ClientIpEntry{
|
||||||
{IP: "1.1.1.1", Timestamp: 1500},
|
{IP: "1.1.1.1", Timestamp: 1500},
|
||||||
}
|
}
|
||||||
got := mergeClientIps(old, nil, 1000, false)
|
got := mergeClientIps(old, nil, 1000, false)
|
||||||
@@ -49,8 +49,8 @@ func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
||||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
|
old := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 1500}}
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
|
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 1700}}
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 1000, false)
|
got := mergeClientIps(old, new, 1000, false)
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
|
|||||||
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
||||||
// A log line with a clock-skewed old timestamp must not resurrect a
|
// A log line with a clock-skewed old timestamp must not resurrect a
|
||||||
// stale IP past the cutoff.
|
// stale IP past the cutoff.
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
|
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 500}}
|
||||||
got := mergeClientIps(nil, new, 1000, false)
|
got := mergeClientIps(nil, new, 1000, false)
|
||||||
|
|
||||||
if len(got) != 0 {
|
if len(got) != 0 {
|
||||||
@@ -73,8 +73,8 @@ func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
|
|||||||
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
|
||||||
// Defensive: a zero cutoff (e.g. during very first run on a fresh
|
// Defensive: a zero cutoff (e.g. during very first run on a fresh
|
||||||
// install) must not over-evict.
|
// install) must not over-evict.
|
||||||
old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
|
old := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 100}}
|
||||||
new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
|
new := []model.ClientIpEntry{{IP: "2.2.2.2", Timestamp: 200}}
|
||||||
|
|
||||||
got := mergeClientIps(old, new, 0, false)
|
got := mergeClientIps(old, new, 0, false)
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
|
|||||||
// online-API mode: lastSeen is set when the connection was dispatched, so
|
// online-API mode: lastSeen is set when the connection was dispatched, so
|
||||||
// a connection held open for hours has an "old" timestamp while being live
|
// a connection held open for hours has an "old" timestamp while being live
|
||||||
// by definition. It must survive the stale cutoff.
|
// by definition. It must survive the stale cutoff.
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
|
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
|
||||||
got := mergeClientIps(nil, new, 1000, true)
|
got := mergeClientIps(nil, new, 1000, true)
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 500}
|
want := map[string]int64{"1.1.1.1": 500}
|
||||||
@@ -100,8 +100,8 @@ func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
|
|||||||
func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
||||||
// the bypass applies only to this scan's observations — persisted entries
|
// the bypass applies only to this scan's observations — persisted entries
|
||||||
// from past scans still age out as before.
|
// from past scans still age out as before.
|
||||||
old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
|
old := []model.ClientIpEntry{{IP: "2.2.2.2", Timestamp: 100}}
|
||||||
new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
|
new := []model.ClientIpEntry{{IP: "1.1.1.1", Timestamp: 2000}}
|
||||||
got := mergeClientIps(old, new, 1000, true)
|
got := mergeClientIps(old, new, 1000, true)
|
||||||
|
|
||||||
want := map[string]int64{"1.1.1.1": 2000}
|
want := map[string]int64{"1.1.1.1": 2000}
|
||||||
@@ -111,7 +111,7 @@ func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectIpsToBan(t *testing.T) {
|
func TestSelectIpsToBan(t *testing.T) {
|
||||||
live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
|
live := []model.ClientIpEntry{ // sorted oldest-first, as partitionLiveIps returns
|
||||||
{IP: "A", Timestamp: 100},
|
{IP: "A", Timestamp: 100},
|
||||||
{IP: "B", Timestamp: 200},
|
{IP: "B", Timestamp: 200},
|
||||||
{IP: "C", Timestamp: 300},
|
{IP: "C", Timestamp: 300},
|
||||||
@@ -144,7 +144,7 @@ func TestSelectIpsToBan(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectIps(entries []IPWithTimestamp) []string {
|
func collectIps(entries []model.ClientIpEntry) []string {
|
||||||
out := make([]string, 0, len(entries))
|
out := make([]string, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
out = append(out, e.IP)
|
out = append(out, e.IP)
|
||||||
@@ -294,7 +294,7 @@ func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
|
|||||||
func TestPartitionLiveIps_TiedTimestampsKeepStableIpOrder(t *testing.T) {
|
func TestPartitionLiveIps_TiedTimestampsKeepStableIpOrder(t *testing.T) {
|
||||||
ipMap := map[string]int64{"203.0.113.3": 100, "203.0.113.1": 100, "203.0.113.2": 100}
|
ipMap := map[string]int64{"203.0.113.3": 100, "203.0.113.1": 100, "203.0.113.2": 100}
|
||||||
observed := map[string]bool{"203.0.113.1": true, "203.0.113.2": true, "203.0.113.3": true}
|
observed := map[string]bool{"203.0.113.1": true, "203.0.113.2": true, "203.0.113.3": true}
|
||||||
var first []IPWithTimestamp
|
var first []model.ClientIpEntry
|
||||||
for i := range 50 {
|
for i := range 50 {
|
||||||
live, _ := partitionLiveIps(ipMap, observed)
|
live, _ := partitionLiveIps(ipMap, observed)
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +21,7 @@ import (
|
|||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
|
// setupScaleJobDB mirrors the service package's scale gating: Postgres via
|
||||||
@@ -28,10 +32,15 @@ func setupScaleJobDB(t *testing.T) {
|
|||||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||||
|
|
||||||
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
if os.Getenv("XUI_DB_TYPE") == "postgres" && strings.TrimSpace(os.Getenv("XUI_DB_DSN")) != "" {
|
||||||
|
cleanup, err := testpg.IsolatePackage("job_ip_scale")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
if err := database.InitDB(""); err != nil {
|
if err := database.InitDB(""); err != nil {
|
||||||
t.Fatalf("InitDB(postgres): %v", err)
|
t.Fatalf("InitDB(postgres): %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
switch strings.ToLower(strings.TrimSpace(os.Getenv("XUI_SCALE_TEST"))) {
|
||||||
@@ -164,11 +173,7 @@ func seedScaleIPDataset(t *testing.T, n, numInbounds int) []string {
|
|||||||
return limitedEmails
|
return limitedEmails
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckClientIpScale measures the @every 10s ip-limit job pieces: the
|
// TestCheckClientIpScale measures the gate and two scans over an identical fixture.
|
||||||
// hasLimitIp gate (settings LIKE scan + full JSON parse of every matching
|
|
||||||
// inbound) and processObserved with M online users (per-email inbound lookup,
|
|
||||||
// settings parse and autocommit save). Run twice: first scan half add / half
|
|
||||||
// update, second scan all update path.
|
|
||||||
func TestCheckClientIpScale(t *testing.T) {
|
func TestCheckClientIpScale(t *testing.T) {
|
||||||
shapes := []struct {
|
shapes := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -201,15 +206,27 @@ func TestCheckClientIpScale(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for i := range m / 2 {
|
for i := range m / 2 {
|
||||||
seedClientIps(t, limited[i], []IPWithTimestamp{{IP: "10.99.0.1", Timestamp: now - 60}})
|
seedClientIps(t, limited[i], []model.ClientIpEntry{{IP: "10.99.0.1", Timestamp: now - 60}})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
counts := countScannerScaleSQL(t)
|
||||||
|
before := captureScannerScaleStats(t)
|
||||||
|
var memoryBefore, memoryAfter runtime.MemStats
|
||||||
|
runtime.ReadMemStats(&memoryBefore)
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
j.processObserved(observed, true, true)
|
j.processObserved(observed, true, true)
|
||||||
firstScan := time.Since(start)
|
firstScan := time.Since(start)
|
||||||
|
firstCounts := counts.snapshot()
|
||||||
start = time.Now()
|
start = time.Now()
|
||||||
j.processObserved(observed, true, true)
|
j.processObserved(observed, true, true)
|
||||||
secondScan := time.Since(start)
|
secondScan := time.Since(start)
|
||||||
|
runtime.ReadMemStats(&memoryAfter)
|
||||||
|
finalCounts := counts.snapshot()
|
||||||
|
t.Logf("first scan SQL flat_SELECTs=%d per_ID_SELECTs=%d CAS_attempts=%d CAS_misses=%d flat_creates=%d flat_affected=%d attribution_SELECTs=%d upsert_attempts=%d attribution_affected=%d", firstCounts[0], firstCounts[1], firstCounts[2], firstCounts[2]-firstCounts[3], firstCounts[4], firstCounts[3]+firstCounts[5], firstCounts[6], firstCounts[7], firstCounts[8])
|
||||||
|
t.Logf("second scan SQL flat_SELECTs=%d per_ID_SELECTs=%d CAS_attempts=%d CAS_misses=%d flat_creates=%d flat_affected=%d attribution_SELECTs=%d upsert_attempts=%d attribution_affected=%d", finalCounts[0]-firstCounts[0], finalCounts[1]-firstCounts[1], finalCounts[2]-firstCounts[2], finalCounts[2]-firstCounts[2]-(finalCounts[3]-firstCounts[3]), finalCounts[4]-firstCounts[4], finalCounts[3]-firstCounts[3]+finalCounts[5]-firstCounts[5], finalCounts[6]-firstCounts[6], finalCounts[7]-firstCounts[7], finalCounts[8]-firstCounts[8])
|
||||||
|
after := captureScannerScaleStats(t)
|
||||||
|
t.Logf("scan allocations=%d bytes=%d", memoryAfter.Mallocs-memoryBefore.Mallocs, memoryAfter.TotalAlloc-memoryBefore.TotalAlloc)
|
||||||
|
logScannerScaleStats(t, before, after)
|
||||||
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
|
t.Logf("N=%-7d shape=%-8s processObserved M=%-5d first=%-10v second=%-10v (%.1fms/email)",
|
||||||
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
|
n, shape.name, m, firstScan.Round(time.Millisecond), secondScan.Round(time.Millisecond),
|
||||||
float64(secondScan.Milliseconds())/float64(m))
|
float64(secondScan.Milliseconds())/float64(m))
|
||||||
@@ -221,7 +238,120 @@ func TestCheckClientIpScale(t *testing.T) {
|
|||||||
if rows != int64(m) {
|
if rows != int64(m) {
|
||||||
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
|
t.Fatalf("inbound_client_ips rows = %d, want %d", rows, m)
|
||||||
}
|
}
|
||||||
|
for i := range m {
|
||||||
|
want := map[string]int64{fmt.Sprintf("10.0.%d.%d", i/250, i%250+1): now}
|
||||||
|
if i < m/2 {
|
||||||
|
want["10.99.0.1"] = now - 60
|
||||||
|
}
|
||||||
|
if got := ipSet(readClientIps(t, limited[i])); !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("retained %s = %v, want %v", limited[i], got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type scannerScaleStats struct {
|
||||||
|
Calls, Rows, WALRecords, WALFpi, WALBytes, SharedBlksHit, SharedBlksRead, SharedBlksDirtied int64
|
||||||
|
TotalExecTime float64
|
||||||
|
StatsReset string
|
||||||
|
Dealloc int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureScannerScaleStats(t *testing.T) scannerScaleStats {
|
||||||
|
t.Helper()
|
||||||
|
var result scannerScaleStats
|
||||||
|
if config.GetDBKind() != "postgres" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
if err := db.Raw(`SELECT COALESCE(sum(calls),0) AS calls, COALESCE(sum(rows),0) AS rows,
|
||||||
|
COALESCE(sum(total_exec_time),0) AS total_exec_time,
|
||||||
|
COALESCE(sum(wal_records),0) AS wal_records, COALESCE(sum(wal_fpi),0) AS wal_fpi, COALESCE(sum(wal_bytes),0) AS wal_bytes,
|
||||||
|
COALESCE(sum(shared_blks_hit),0) AS shared_blks_hit, COALESCE(sum(shared_blks_read),0) AS shared_blks_read,
|
||||||
|
COALESCE(sum(shared_blks_dirtied),0) AS shared_blks_dirtied
|
||||||
|
FROM public.pg_stat_statements
|
||||||
|
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||||
|
AND (query ILIKE '%inbound_client_ips%' OR query ILIKE '%node_client_ips%')
|
||||||
|
AND query NOT ILIKE '%pg_stat_statements%'`).Scan(&result).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var info struct {
|
||||||
|
StatsReset string
|
||||||
|
Dealloc int64
|
||||||
|
}
|
||||||
|
if err := db.Raw("SELECT stats_reset::text AS stats_reset, dealloc FROM public.pg_stat_statements_info").Scan(&info).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result.StatsReset, result.Dealloc = info.StatsReset, info.Dealloc
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func logScannerScaleStats(t *testing.T, before, after scannerScaleStats) {
|
||||||
|
t.Helper()
|
||||||
|
if config.GetDBKind() != "postgres" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("PG own-db IP statements delta calls=%d rows=%d total_exec_time_ms=%.3f wal_records=%d wal_fpi=%d wal_bytes=%d shared_blks_hit=%d shared_blks_read=%d shared_blks_dirtied=%d stats_reset=%q->%q dealloc=%d->%d",
|
||||||
|
after.Calls-before.Calls, after.Rows-before.Rows, after.TotalExecTime-before.TotalExecTime,
|
||||||
|
after.WALRecords-before.WALRecords, after.WALFpi-before.WALFpi, after.WALBytes-before.WALBytes,
|
||||||
|
after.SharedBlksHit-before.SharedBlksHit, after.SharedBlksRead-before.SharedBlksRead, after.SharedBlksDirtied-before.SharedBlksDirtied,
|
||||||
|
before.StatsReset, after.StatsReset, before.Dealloc, after.Dealloc)
|
||||||
|
}
|
||||||
|
|
||||||
|
type scannerScaleCounts [9]atomic.Int64
|
||||||
|
|
||||||
|
func (c *scannerScaleCounts) snapshot() [9]int64 {
|
||||||
|
var out [9]int64
|
||||||
|
for i := range out {
|
||||||
|
out[i] = c[i].Load()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func countScannerScaleSQL(t *testing.T) *scannerScaleCounts {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
counts := &scannerScaleCounts{}
|
||||||
|
const name = "test:scanner_scale_sql"
|
||||||
|
if err := db.Callback().Query().After("gorm:query").Register(name, func(tx *gorm.DB) {
|
||||||
|
switch tx.Statement.Table {
|
||||||
|
case "inbound_client_ips":
|
||||||
|
counts[0].Add(1)
|
||||||
|
if strings.Contains(tx.Statement.SQL.String(), "WHERE id =") {
|
||||||
|
counts[1].Add(1)
|
||||||
|
}
|
||||||
|
case "node_client_ips":
|
||||||
|
counts[6].Add(1)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Callback().Update().After("gorm:update").Register(name, func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == "inbound_client_ips" {
|
||||||
|
counts[2].Add(1)
|
||||||
|
counts[3].Add(tx.RowsAffected)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Callback().Create().After("gorm:create").Register(name, func(tx *gorm.DB) {
|
||||||
|
switch tx.Statement.Table {
|
||||||
|
case "inbound_client_ips":
|
||||||
|
counts[4].Add(1)
|
||||||
|
counts[5].Add(tx.RowsAffected)
|
||||||
|
case "node_client_ips":
|
||||||
|
counts[7].Add(1)
|
||||||
|
counts[8].Add(tx.RowsAffected)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = db.Callback().Query().Remove(name)
|
||||||
|
_ = db.Callback().Update().Remove(name)
|
||||||
|
_ = db.Callback().Create().Remove(name)
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/op/go-logging"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientIpWritersPreserveCanonicalBytes(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
clientIpWritersPreserveCanonicalBytes(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIpWritersPreserveCanonicalBytes(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
const email = "canonical-writers@x"
|
||||||
|
seedLinkedInboundWithClient(t, "canonical-writers", email, 3)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
entries := []model.ClientIpEntry{
|
||||||
|
{IP: "192.0.2.9", Timestamp: now},
|
||||||
|
{IP: "10.0.0.1", Timestamp: now},
|
||||||
|
{IP: "203.0.113.8", Timestamp: now - 300},
|
||||||
|
{IP: "192.0.2.1", Timestamp: now},
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf(`[{"ip":"203.0.113.8","timestamp":%d},{"ip":"10.0.0.1","timestamp":%d},{"ip":"192.0.2.1","timestamp":%d},{"ip":"192.0.2.9","timestamp":%d}]`, now-300, now, now, now)
|
||||||
|
row := model.InboundClientIps{ClientEmail: email, Ips: want}
|
||||||
|
db := database.GetDB()
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
flatWrites, attributionWrites := 0, 0
|
||||||
|
const callback = "test:canonical_writer_updates"
|
||||||
|
if err := db.Callback().Update().After("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Schema == nil || tx.RowsAffected == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tx.Statement.Schema.Table == "inbound_client_ips" {
|
||||||
|
flatWrites++
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
|
||||||
|
if err := db.Callback().Create().After("gorm:create").Register(callback, func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Schema != nil && tx.Statement.Schema.Table == "node_client_ips" && tx.RowsAffected > 0 {
|
||||||
|
attributionWrites++
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
||||||
|
j := NewCheckClientIpJob()
|
||||||
|
j.allowlist = parseIpLimitAllowlist("10.0.0.1")
|
||||||
|
observed := map[string]map[string]int64{email: {"192.0.2.9": now, "10.0.0.1": now, "192.0.2.1": now}}
|
||||||
|
for iteration := range 4 {
|
||||||
|
blob, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := (&service.InboundService{}).MergeInboundClientIps([]model.InboundClientIps{{ClientEmail: email, Ips: string(blob)}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
j.processObserved(observed, true, false)
|
||||||
|
var got model.InboundClientIps
|
||||||
|
if err := db.Where("client_email = ?", email).First(&got).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Ips != want {
|
||||||
|
t.Fatalf("iteration %d canonical bytes = %s, want %s", iteration, got.Ips, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if flatWrites != 0 {
|
||||||
|
t.Fatalf("unchanged alternating writers performed %d flat updates, want 0", flatWrites)
|
||||||
|
}
|
||||||
|
beforeAttribution := attributionWrites
|
||||||
|
j.processObserved(map[string]map[string]int64{email: {"192.0.2.9": now + 1}}, true, false)
|
||||||
|
if flatWrites != 1 {
|
||||||
|
t.Fatalf("advancing observation flat updates = %d, want 1", flatWrites)
|
||||||
|
}
|
||||||
|
advancedWant := fmt.Sprintf(`[{"ip":"203.0.113.8","timestamp":%d},{"ip":"10.0.0.1","timestamp":%d},{"ip":"192.0.2.1","timestamp":%d},{"ip":"192.0.2.9","timestamp":%d}]`, now-300, now, now, now+1)
|
||||||
|
var advanced model.InboundClientIps
|
||||||
|
if err := db.Where("client_email = ?", email).First(&advanced).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if advanced.Ips != advancedWant {
|
||||||
|
t.Fatalf("advancing flat bytes = %s, want %s", advanced.Ips, advancedWant)
|
||||||
|
}
|
||||||
|
if attributionWrites != beforeAttribution+1 {
|
||||||
|
t.Fatalf("advancing attribution writes = %d, want %d", attributionWrites, beforeAttribution+1)
|
||||||
|
}
|
||||||
|
var attribution model.NodeClientIp
|
||||||
|
if err := db.Where("email = ?", email).First(&attribution).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var attributed []model.ClientIpEntry
|
||||||
|
if err := json.Unmarshal([]byte(attribution.Ips), &attributed); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, entry := range attributed {
|
||||||
|
if entry.IP == "192.0.2.9" {
|
||||||
|
found = entry.Timestamp == now+1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("advancing attribution = %v, want 192.0.2.9 at %d", attributed, now+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessObserved_CreateConflictPreservesUnion(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
processObservedCreateConflictPreservesUnion(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func processObservedCreateConflictPreservesUnion(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
const email = "create-conflict@x"
|
||||||
|
seedLinkedInboundWithClient(t, "create-conflict", email, 3)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
winning := fmt.Sprintf(`[{"ip":"192.0.2.1","timestamp":%d},{"ip":"203.0.113.8","timestamp":%d}]`, now-30, now)
|
||||||
|
db := database.GetDB()
|
||||||
|
injected := false
|
||||||
|
const callback = "test:scanner_create_conflict"
|
||||||
|
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
|
||||||
|
if injected || tx.Statement.Schema == nil || tx.Statement.Schema.Table != "inbound_client_ips" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
injected = true
|
||||||
|
if err := tx.Session(&gorm.Session{NewDB: true, SkipHooks: true}).Exec("INSERT INTO inbound_client_ips (client_email, ips) VALUES (?, ?)", email, winning).Error; err != nil {
|
||||||
|
_ = tx.AddError(err)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
||||||
|
NewCheckClientIpJob().processObserved(map[string]map[string]int64{email: {"192.0.2.1": now, "198.51.100.2": now - 5}}, true, true)
|
||||||
|
if !injected {
|
||||||
|
t.Fatal("missing-row insert was not exercised")
|
||||||
|
}
|
||||||
|
var row model.InboundClientIps
|
||||||
|
if err := db.Where("client_email = ?", email).First(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := fmt.Sprintf(`[{"ip":"198.51.100.2","timestamp":%d},{"ip":"192.0.2.1","timestamp":%d},{"ip":"203.0.113.8","timestamp":%d}]`, now-5, now, now)
|
||||||
|
if row.Ips != want {
|
||||||
|
t.Fatalf("conflict union bytes = %s, want %s", row.Ips, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncPostgresScanner(t *testing.T) {
|
||||||
|
if os.Getenv("XUI_DB_TYPE") != "postgres" || os.Getenv("XUI_DB_DSN") == "" {
|
||||||
|
t.Skip("requires PostgreSQL")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
run func(*testing.T)
|
||||||
|
}{
|
||||||
|
{"CanonicalWriters", clientIpWritersPreserveCanonicalBytes},
|
||||||
|
{"CreateConflict", processObservedCreateConflictPreservesUnion},
|
||||||
|
{"ConcurrentWriter", processObservedKeepsNodeSyncWrite},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cleanup, err := testpg.IsolatePackage("job_ip_sync")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||||
|
loggerInitOnce.Do(func() { xuilogger.InitLogger(logging.ERROR) })
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
if err := database.InitDB(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tc.run(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// An address that matches is neither counted towards a client's IP limit nor
|
// An address that matches is neither counted towards a client's IP limit nor
|
||||||
@@ -66,11 +68,11 @@ func (l ipLimitAllowlist) contains(ip string) bool {
|
|||||||
|
|
||||||
// split separates the entries an allowlist protects from the ones the limit
|
// split separates the entries an allowlist protects from the ones the limit
|
||||||
// still applies to, preserving the caller's ordering in both.
|
// still applies to, preserving the caller's ordering in both.
|
||||||
func (l ipLimitAllowlist) split(entries []IPWithTimestamp) (limited, allowed []IPWithTimestamp) {
|
func (l ipLimitAllowlist) split(entries []model.ClientIpEntry) (limited, allowed []model.ClientIpEntry) {
|
||||||
if l.empty() {
|
if l.empty() {
|
||||||
return entries, nil
|
return entries, nil
|
||||||
}
|
}
|
||||||
limited = make([]IPWithTimestamp, 0, len(entries))
|
limited = make([]model.ClientIpEntry, 0, len(entries))
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if l.contains(entry.IP) {
|
if l.contains(entry.IP) {
|
||||||
allowed = append(allowed, entry)
|
allowed = append(allowed, entry)
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
// Addresses in the examples below come from the documentation ranges reserved
|
// Addresses in the examples below come from the documentation ranges reserved
|
||||||
// by RFC 5737 and RFC 3849.
|
// by RFC 5737 and RFC 3849.
|
||||||
@@ -37,7 +41,7 @@ func TestIpLimitAllowlistIgnoresUnparsableEntries(t *testing.T) {
|
|||||||
// The point of the setting: a shared address is neither banned nor counted, so
|
// The point of the setting: a shared address is neither banned nor counted, so
|
||||||
// the office NAT it protects does not consume the client's limit either.
|
// the office NAT it protects does not consume the client's limit either.
|
||||||
func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
|
func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
|
||||||
live := []IPWithTimestamp{
|
live := []model.ClientIpEntry{
|
||||||
{IP: "203.0.113.10", Timestamp: 1},
|
{IP: "203.0.113.10", Timestamp: 1},
|
||||||
{IP: "192.0.2.1", Timestamp: 2},
|
{IP: "192.0.2.1", Timestamp: 2},
|
||||||
{IP: "192.0.2.2", Timestamp: 3},
|
{IP: "192.0.2.2", Timestamp: 3},
|
||||||
@@ -62,7 +66,7 @@ func TestIpLimitAllowlistSplitKeepsAllowedOutOfTheCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestIpLimitAllowlistEmptyListChangesNothing(t *testing.T) {
|
func TestIpLimitAllowlistEmptyListChangesNothing(t *testing.T) {
|
||||||
live := []IPWithTimestamp{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
|
live := []model.ClientIpEntry{{IP: "192.0.2.1", Timestamp: 1}, {IP: "192.0.2.2", Timestamp: 2}}
|
||||||
limited, allowed := parseIpLimitAllowlist("").split(live)
|
limited, allowed := parseIpLimitAllowlist("").split(live)
|
||||||
if allowed != nil || len(limited) != 2 {
|
if allowed != nil || len(limited) != 2 {
|
||||||
t.Fatalf("empty allowlist changed the input: limited=%v allowed=%v", limited, allowed)
|
t.Fatalf("empty allowlist changed the input: limited=%v allowed=%v", limited, allowed)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClientIpSyncPostgres(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 PostgreSQL IP sync regressions")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
run func(*testing.T)
|
||||||
|
}{
|
||||||
|
{"seeded snapshot", TestClientIpSyncSeededSnapshot},
|
||||||
|
{"duplicates", TestClientIpSyncDuplicatePayloadsKeepUnion},
|
||||||
|
{"raw snapshot retry", TestClientIpSyncRawSnapshotAndSingleRetry},
|
||||||
|
{"create conflict", TestClientIpSyncCreateConflictMergesWinner},
|
||||||
|
{"exhaustion rollback", TestClientIpSyncCASExhaustionRollsBack},
|
||||||
|
{"concurrent writer", TestMergeInboundClientIps_RetriesAfterConcurrentWriter},
|
||||||
|
{"attribution unchanged", TestNodeClientIpCanonicalUnchangedSkipsUpsert},
|
||||||
|
{"remote ids", TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId},
|
||||||
|
{"attribution round trip", TestRecordLocalClientIps_RoundTripByGuid},
|
||||||
|
{"attribution expiry", TestRecordLocalClientIps_MergesAndDropsStale},
|
||||||
|
{"attribution empty", TestUpsertNodeClientIps_EmptyMergeDeletesRow},
|
||||||
|
{"retention", TestPruneStaleClientIpsExpiresUnobservedRows},
|
||||||
|
{"cloned GUID", TestMergeClientIpsByGuid_RemapsClonedNodeSubtree},
|
||||||
|
{"maximum timestamps", TestMergeInboundClientIps_DedupKeepsMaxTimestamp},
|
||||||
|
{"stale merge", TestMergeInboundClientIps_DropsStaleIps},
|
||||||
|
{"stale insert", TestMergeInboundClientIps_SkipsAllStaleCreate},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, tc.run)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type clientIpPGStats struct {
|
||||||
|
Calls float64
|
||||||
|
Rows float64
|
||||||
|
TotalExecTime float64
|
||||||
|
WalRecords float64
|
||||||
|
WalFpi float64
|
||||||
|
WalBytes float64
|
||||||
|
SharedBlksHit float64
|
||||||
|
SharedBlksRead float64
|
||||||
|
SharedBlksDirtied float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientIpPGSnapshot(t *testing.T) clientIpPGStats {
|
||||||
|
t.Helper()
|
||||||
|
var out clientIpPGStats
|
||||||
|
db := database.GetDB()
|
||||||
|
if db.Name() != "postgres" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
err := db.Raw(`SELECT COALESCE(sum(calls),0) AS calls, COALESCE(sum(rows),0) AS rows,
|
||||||
|
COALESCE(sum(total_exec_time),0) AS total_exec_time, COALESCE(sum(wal_records),0) AS wal_records,
|
||||||
|
COALESCE(sum(wal_fpi),0) AS wal_fpi, COALESCE(sum(wal_bytes),0) AS wal_bytes,
|
||||||
|
COALESCE(sum(shared_blks_hit),0) AS shared_blks_hit, COALESCE(sum(shared_blks_read),0) AS shared_blks_read,
|
||||||
|
COALESCE(sum(shared_blks_dirtied),0) AS shared_blks_dirtied
|
||||||
|
FROM public.pg_stat_statements WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
|
||||||
|
AND (query ILIKE '%inbound_client_ips%' OR query ILIKE '%node_client_ips%')
|
||||||
|
AND query NOT ILIKE '%pg_stat_statements%'`).Scan(&out).Error
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func logClientIpPGDelta(t *testing.T, before, after clientIpPGStats) {
|
||||||
|
t.Helper()
|
||||||
|
if database.GetDB().Name() != "postgres" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t.Logf("PG delta calls=%.0f rows=%.0f total_exec_time_ms=%.3f wal_records=%.0f wal_fpi=%.0f wal_bytes=%.0f shared_blks_hit=%.0f read=%.0f dirtied=%.0f",
|
||||||
|
after.Calls-before.Calls, after.Rows-before.Rows, after.TotalExecTime-before.TotalExecTime,
|
||||||
|
after.WalRecords-before.WalRecords, after.WalFpi-before.WalFpi, after.WalBytes-before.WalBytes,
|
||||||
|
after.SharedBlksHit-before.SharedBlksHit, after.SharedBlksRead-before.SharedBlksRead, after.SharedBlksDirtied-before.SharedBlksDirtied)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logClientIpPGContext(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
if db.Name() != "postgres" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var info struct {
|
||||||
|
StatsReset string
|
||||||
|
Dealloc int64
|
||||||
|
}
|
||||||
|
if err := db.Raw("SELECT stats_reset::text, dealloc FROM public.pg_stat_statements_info").Scan(&info).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var version string
|
||||||
|
if err := db.Raw("SELECT version()").Scan(&version).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var settings struct {
|
||||||
|
FullPageWrites string
|
||||||
|
CheckpointTimeout string
|
||||||
|
}
|
||||||
|
if err := db.Raw("SELECT current_setting('full_page_writes') AS full_page_writes, current_setting('checkpoint_timeout') AS checkpoint_timeout").Scan(&settings).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Logf("PG %s stats_reset=%s dealloc=%d full_page_writes=%s checkpoint_timeout=%s", version, info.StatsReset, info.Dealloc, settings.FullPageWrites, settings.CheckpointTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncScale(t *testing.T) {
|
||||||
|
for _, n := range scaleSizes(t, 1000, 10000, 20000) {
|
||||||
|
t.Run(fmt.Sprintf("N=%d", n), func(t *testing.T) {
|
||||||
|
cleanup, err := testpg.IsolatePackage("client_ip_sync_scale")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(cleanup)
|
||||||
|
setupScaleDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
resetScaleTables(t, db, "inbound_client_ips", "node_client_ips")
|
||||||
|
logClientIpPGContext(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
makeReport := func(node int, advance int64) ([]model.InboundClientIps, map[string][]model.ClientIpEntry) {
|
||||||
|
rows := make([]model.InboundClientIps, n)
|
||||||
|
attribution := make(map[string][]model.ClientIpEntry, n)
|
||||||
|
for i := range n {
|
||||||
|
email := fmt.Sprintf("sync-%07d@scale", i)
|
||||||
|
entries := []model.ClientIpEntry{{IP: fmt.Sprintf("2001:db8:%x::%x", node+1, i+1), Timestamp: now + int64(node) + advance}}
|
||||||
|
b, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows[i] = model.InboundClientIps{ClientEmail: email, Ips: string(b)}
|
||||||
|
attribution[email] = entries
|
||||||
|
}
|
||||||
|
return rows, attribution
|
||||||
|
}
|
||||||
|
apply := func(node int, rows []model.InboundClientIps, attrs map[string][]model.ClientIpEntry) error {
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(rows); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return (&InboundService{}).RecordLocalClientIps(fmt.Sprintf("bench-node-%d", node), attrs)
|
||||||
|
}
|
||||||
|
for node := range 4 {
|
||||||
|
rows, attrs := makeReport(node, 0)
|
||||||
|
if err := apply(node, rows, attrs); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flat := countClientIpSQL(t, "inbound_client_ips")
|
||||||
|
attr := countClientIpSQL(t, "node_client_ips")
|
||||||
|
for phase, advance := range []int64{0, 10, 20} {
|
||||||
|
t.Run([]string{"unchanged", "advancing", "contention"}[phase], func(t *testing.T) {
|
||||||
|
reports := make([][]model.InboundClientIps, 4)
|
||||||
|
attrs := make([]map[string][]model.ClientIpEntry, 4)
|
||||||
|
for node := range 4 {
|
||||||
|
reports[node], attrs[node] = makeReport(node, advance)
|
||||||
|
}
|
||||||
|
before := clientIpPGSnapshot(t)
|
||||||
|
fs, fu, fa := flat.selects.Load(), flat.updates.Load(), flat.affected.Load()
|
||||||
|
as, ac, aa := attr.selects.Load(), attr.creates.Load(), attr.affected.Load()
|
||||||
|
start := time.Now()
|
||||||
|
if phase == 2 {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan error, 4)
|
||||||
|
for node := range 4 {
|
||||||
|
wg.Go(func() { errs <- apply(node, reports[node], attrs[node]) })
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
for err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for node := range 4 {
|
||||||
|
if err := apply(node, reports[node], attrs[node]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
after := clientIpPGSnapshot(t)
|
||||||
|
t.Logf("N=%d reports=4 phase=%s elapsed=%v flat_SELECTs=%d CAS_attempts=%d CAS_misses=%d flat_affected=%d attribution_SELECTs=%d upsert_attempts=%d attribution_affected=%d", n, t.Name(), elapsed,
|
||||||
|
flat.selects.Load()-fs, flat.updates.Load()-fu, flat.updates.Load()-fu-(flat.affected.Load()-fa), flat.affected.Load()-fa,
|
||||||
|
attr.selects.Load()-as, attr.creates.Load()-ac, attr.affected.Load()-aa)
|
||||||
|
logClientIpPGDelta(t, before, after)
|
||||||
|
var actual []model.InboundClientIps
|
||||||
|
if err := db.Find(&actual).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(actual) != n {
|
||||||
|
t.Fatalf("flat rows=%d, want %d", len(actual), n)
|
||||||
|
}
|
||||||
|
for _, row := range actual {
|
||||||
|
var entries []model.ClientIpEntry
|
||||||
|
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := make(map[string]int64, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
got[entry.IP] = entry.Timestamp
|
||||||
|
}
|
||||||
|
want := make(map[string]int64, 4)
|
||||||
|
for node := range 4 {
|
||||||
|
for _, entry := range attrs[node][row.ClientEmail] {
|
||||||
|
want[entry.IP] = entry.Timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(entries) != 4 || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("%s entries=%v, want %v", row.ClientEmail, entries, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trees, err := (&InboundService{}).GetClientIpsByGuid()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for node := range 4 {
|
||||||
|
if !reflect.DeepEqual(trees[fmt.Sprintf("bench-node-%d", node)], attrs[node]) {
|
||||||
|
t.Fatalf("node %d attribution does not match exact report", node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
logClientIpPGContext(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,10 +29,10 @@ func TestPruneStaleClientIpsExpiresUnobservedRows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seed := []any{
|
seed := []any{
|
||||||
&model.InboundClientIps{ClientEmail: "offline", Ips: marshalIps(t, clientIpEntry{IP: "198.51.100.7", Timestamp: stale})},
|
&model.InboundClientIps{ClientEmail: "offline", Ips: marshalIps(t, model.ClientIpEntry{IP: "198.51.100.7", Timestamp: stale})},
|
||||||
&model.InboundClientIps{ClientEmail: "mixed", Ips: marshalIps(t,
|
&model.InboundClientIps{ClientEmail: "mixed", Ips: marshalIps(t,
|
||||||
clientIpEntry{IP: "198.51.100.8", Timestamp: stale},
|
model.ClientIpEntry{IP: "198.51.100.8", Timestamp: stale},
|
||||||
clientIpEntry{IP: "203.0.113.9", Timestamp: fresh})},
|
model.ClientIpEntry{IP: "203.0.113.9", Timestamp: fresh})},
|
||||||
&model.NodeClientIp{NodeGuid: "g1", Email: "node-offline", Ips: mkNode(model.ClientIpEntry{IP: "198.51.100.9", Timestamp: stale})},
|
&model.NodeClientIp{NodeGuid: "g1", Email: "node-offline", Ips: mkNode(model.ClientIpEntry{IP: "198.51.100.9", Timestamp: stale})},
|
||||||
&model.NodeClientIp{NodeGuid: "g1", Email: "node-fresh", Ips: mkNode(model.ClientIpEntry{IP: "203.0.113.10", Timestamp: fresh})},
|
&model.NodeClientIp{NodeGuid: "g1", Email: "node-fresh", Ips: mkNode(model.ClientIpEntry{IP: "203.0.113.10", Timestamp: fresh})},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,23 +47,11 @@ func (s *InboundService) GetNodeInboundClientIps(nodeID int) ([]model.InboundCli
|
|||||||
return ips, nil
|
return ips, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
|
// Match the scanner's 30-minute retention so sync cannot resurrect pruned IPs.
|
||||||
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
|
|
||||||
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
|
|
||||||
// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
|
|
||||||
const clientIpStaleAfterSeconds = int64(30 * 60)
|
const clientIpStaleAfterSeconds = int64(30 * 60)
|
||||||
|
|
||||||
// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
|
// mergeClientIpEntries keeps each fresh IP's maximum timestamp.
|
||||||
// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
|
func mergeClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
|
||||||
type clientIpEntry struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeClientIpEntries unions old and incoming IP observations, dropping anything
|
|
||||||
// older than cutoff, keeping the most recent timestamp per IP, and returning the
|
|
||||||
// result sorted newest-first.
|
|
||||||
func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
|
|
||||||
ipMap := make(map[string]int64, len(old)+len(incoming))
|
ipMap := make(map[string]int64, len(old)+len(incoming))
|
||||||
for _, e := range old {
|
for _, e := range old {
|
||||||
if e.Timestamp < cutoff {
|
if e.Timestamp < cutoff {
|
||||||
@@ -79,19 +67,14 @@ func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientI
|
|||||||
ipMap[e.IP] = e.Timestamp
|
ipMap[e.IP] = e.Timestamp
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out := make([]clientIpEntry, 0, len(ipMap))
|
out := make([]model.ClientIpEntry, 0, len(ipMap))
|
||||||
for ip, ts := range ipMap {
|
for ip, ts := range ipMap {
|
||||||
out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
|
out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeInboundClientIps folds client IPs synced from another node into the local
|
// MergeInboundClientIps unions remote observations without losing concurrent scanner writes.
|
||||||
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
|
|
||||||
// without resurrecting stale entries. Existing rows are updated in place; brand-new
|
|
||||||
// clients (typically node-only clients with no local row) are created with a fresh
|
|
||||||
// local id.
|
|
||||||
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
|
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var currentIps []model.InboundClientIps
|
var currentIps []model.InboundClientIps
|
||||||
@@ -107,10 +90,7 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
cutoff := now - clientIpStaleAfterSeconds
|
cutoff := now - clientIpStaleAfterSeconds
|
||||||
|
|
||||||
// Node syncs run concurrently (one goroutine per node) and shared clients
|
// All sync writers take row locks in email order to avoid PostgreSQL deadlocks.
|
||||||
// appear in several nodes' reports. Locking rows in each node's arbitrary
|
|
||||||
// report order lets two merges grab the same rows in opposite order, which
|
|
||||||
// Postgres aborts as a deadlock (40P01) — take them in one global order.
|
|
||||||
sort.Slice(incomingIps, func(i, j int) bool {
|
sort.Slice(incomingIps, func(i, j int) bool {
|
||||||
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
|
return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail
|
||||||
})
|
})
|
||||||
@@ -127,39 +107,42 @@ func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClient
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var incomingEntries []clientIpEntry
|
var incomingEntries []model.ClientIpEntry
|
||||||
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
|
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
|
||||||
|
|
||||||
current, exists := currentMap[incoming.ClientEmail]
|
current, exists := currentMap[incoming.ClientEmail]
|
||||||
if !exists {
|
if !exists {
|
||||||
// New client we've never seen locally. Drop stale entries up front and
|
// A new row must contain at least one fresh observation.
|
||||||
// skip the row entirely if nothing is fresh, so we don't persist a row
|
|
||||||
// that is dead on arrival.
|
|
||||||
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
|
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
|
||||||
if len(fresh) == 0 {
|
if len(fresh) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(fresh)
|
incoming.Ips, _ = model.MarshalClientIpEntries(fresh)
|
||||||
incoming.Ips = string(b)
|
// Remote primary keys belong to an independent database.
|
||||||
// Never carry the remote node's primary key into the local table: id
|
|
||||||
// spaces are independent across nodes and the remote id would collide
|
|
||||||
// with an unrelated local row. OnConflict guards the race where
|
|
||||||
// check_client_ip_job creates the same brand-new email between the
|
|
||||||
// snapshot above and this insert.
|
|
||||||
incoming.Id = 0
|
incoming.Id = 0
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
result := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "client_email"}},
|
Columns: []clause.Column{{Name: "client_email"}},
|
||||||
DoNothing: true,
|
DoNothing: true,
|
||||||
}).Create(&incoming).Error; err != nil {
|
}).Create(&incoming)
|
||||||
|
if result.Error != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 1 {
|
||||||
|
currentMap[incoming.ClientEmail] = &incoming
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
current = &model.InboundClientIps{}
|
||||||
|
if err := tx.Where("client_email = ?", incoming.ClientEmail).First(current).Error; err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
continue
|
currentMap[incoming.ClientEmail] = current
|
||||||
}
|
}
|
||||||
|
|
||||||
// check_client_ip_job rewrites this blob too; an unconditional Update loses
|
// check_client_ip_job rewrites this blob too; an unconditional Update loses
|
||||||
// whichever writer commits second and its remote IPs with it (#6587).
|
// whichever writer commits second and its remote IPs with it (#6587).
|
||||||
if err := mergeExistingClientIps(tx, current.Id, incomingEntries, cutoff); err != nil {
|
if err := mergeExistingClientIps(tx, current, incomingEntries, cutoff); err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -183,33 +166,33 @@ func CasUpdateInboundClientIps(tx *gorm.DB, id int, expectedIps, newIps string)
|
|||||||
return res.RowsAffected == 1, nil
|
return res.RowsAffected == 1, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeExistingClientIps folds incoming into the row at id under a CAS loop so
|
// Seed the first CAS from the preload; only a competing write requires a re-read.
|
||||||
// a concurrent check_client_ip_job write cannot erase the merge (#6587).
|
func mergeExistingClientIps(tx *gorm.DB, current *model.InboundClientIps, incoming []model.ClientIpEntry, cutoff int64) error {
|
||||||
func mergeExistingClientIps(tx *gorm.DB, id int, incoming []clientIpEntry, cutoff int64) error {
|
for attempt := range ClientIpCasRetries {
|
||||||
for attempt := 0; attempt < ClientIpCasRetries; attempt++ {
|
var oldEntries []model.ClientIpEntry
|
||||||
var row model.InboundClientIps
|
if current.Ips != "" {
|
||||||
if err := tx.Where("id = ?", id).First(&row).Error; err != nil {
|
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
|
||||||
return err
|
|
||||||
}
|
|
||||||
var oldEntries []clientIpEntry
|
|
||||||
if row.Ips != "" {
|
|
||||||
_ = json.Unmarshal([]byte(row.Ips), &oldEntries)
|
|
||||||
}
|
}
|
||||||
merged := mergeClientIpEntries(oldEntries, incoming, cutoff)
|
merged := mergeClientIpEntries(oldEntries, incoming, cutoff)
|
||||||
b, _ := json.Marshal(merged)
|
mergedStr, _ := model.MarshalClientIpEntries(merged)
|
||||||
mergedStr := string(b)
|
if current.Ips == mergedStr {
|
||||||
if row.Ips == mergedStr {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
ok, err := CasUpdateInboundClientIps(tx, id, row.Ips, mergedStr)
|
ok, err := CasUpdateInboundClientIps(tx, current.Id, current.Ips, mergedStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
|
current.Ips = mergedStr
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if attempt+1 < ClientIpCasRetries {
|
||||||
|
if err := tx.Where("id = ?", current.Id).First(current).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", id)
|
return fmt.Errorf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", current.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
|
func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error {
|
||||||
@@ -250,13 +233,7 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse as new format (with timestamps)
|
var ipsWithTime []model.ClientIpEntry
|
||||||
type IPWithTimestamp struct {
|
|
||||||
IP string `json:"ip"`
|
|
||||||
Timestamp int64 `json:"timestamp"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var ipsWithTime []IPWithTimestamp
|
|
||||||
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
|
err = json.Unmarshal([]byte(InboundClientIps.Ips), &ipsWithTime)
|
||||||
|
|
||||||
// If successfully parsed as new format, return with timestamps
|
// If successfully parsed as new format, return with timestamps
|
||||||
@@ -270,12 +247,9 @@ func (s *InboundService) GetInboundClientIps(clientEmail string) (string, error)
|
|||||||
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
|
err = json.Unmarshal([]byte(InboundClientIps.Ips), &oldIps)
|
||||||
if err == nil && len(oldIps) > 0 {
|
if err == nil && len(oldIps) > 0 {
|
||||||
// Convert old format to new format with current timestamp
|
// Convert old format to new format with current timestamp
|
||||||
newIpsWithTime := make([]IPWithTimestamp, len(oldIps))
|
newIpsWithTime := make([]model.ClientIpEntry, len(oldIps))
|
||||||
for i, ip := range oldIps {
|
for i, ip := range oldIps {
|
||||||
newIpsWithTime[i] = IPWithTimestamp{
|
newIpsWithTime[i] = model.ClientIpEntry{IP: ip, Timestamp: time.Now().Unix()}
|
||||||
IP: ip,
|
|
||||||
Timestamp: time.Now().Unix(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result, _ := json.Marshal(newIpsWithTime)
|
result, _ := json.Marshal(newIpsWithTime)
|
||||||
return string(result), nil
|
return string(result), nil
|
||||||
@@ -309,7 +283,7 @@ func (s *InboundService) PruneStaleClientIps() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
var entries []clientIpEntry
|
var entries []model.ClientIpEntry
|
||||||
if row.Ips != "" {
|
if row.Ips != "" {
|
||||||
// Legacy blobs without timestamps stay untouched; the next scan rewrites them.
|
// Legacy blobs without timestamps stay untouched; the next scan rewrites them.
|
||||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||||
@@ -326,8 +300,8 @@ func (s *InboundService) PruneStaleClientIps() error {
|
|||||||
if len(kept) == len(entries) {
|
if len(kept) == len(entries) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(kept)
|
encoded, _ := model.MarshalClientIpEntries(kept)
|
||||||
if err := db.Model(&model.InboundClientIps{}).Where("id = ?", row.Id).Update("ips", string(b)).Error; err != nil {
|
if err := db.Model(&model.InboundClientIps{}).Where("id = ?", row.Id).Update("ips", encoded).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,27 +2,37 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
|
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
|
||||||
// for a single test, mirroring the harness used by the other service tests.
|
// for a single test, mirroring the harness used by the other service tests.
|
||||||
func setupClientIpTestDB(t *testing.T) {
|
func setupClientIpTestDB(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
cleanup, err := testpg.IsolatePackage("client_ip_sync")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(cleanup)
|
||||||
dbDir := t.TempDir()
|
dbDir := t.TempDir()
|
||||||
t.Setenv("XUI_DB_FOLDER", dbDir)
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func marshalIps(t *testing.T, entries ...clientIpEntry) string {
|
func marshalIps(t *testing.T, entries ...model.ClientIpEntry) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := json.Marshal(entries)
|
b, err := json.Marshal(entries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,7 +53,7 @@ func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read client ips for %s: %v", email, err)
|
t.Fatalf("read client ips for %s: %v", email, err)
|
||||||
}
|
}
|
||||||
var entries []clientIpEntry
|
var entries []model.ClientIpEntry
|
||||||
if row.Ips != "" {
|
if row.Ips != "" {
|
||||||
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||||
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
|
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
|
||||||
@@ -62,7 +72,7 @@ func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T)
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
// Local client occupies id 1.
|
// Local client occupies id 1.
|
||||||
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
|
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now})}
|
||||||
if err := db.Create(local).Error; err != nil {
|
if err := db.Create(local).Error; err != nil {
|
||||||
t.Fatalf("seed local row: %v", err)
|
t.Fatalf("seed local row: %v", err)
|
||||||
}
|
}
|
||||||
@@ -72,7 +82,7 @@ func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T)
|
|||||||
incoming := []model.InboundClientIps{{
|
incoming := []model.InboundClientIps{{
|
||||||
Id: 1,
|
Id: 1,
|
||||||
ClientEmail: "node@x",
|
ClientEmail: "node@x",
|
||||||
Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
|
Ips: marshalIps(t, model.ClientIpEntry{IP: "2.2.2.2", Timestamp: now}),
|
||||||
}}
|
}}
|
||||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
t.Fatalf("merge: %v", err)
|
t.Fatalf("merge: %v", err)
|
||||||
@@ -103,7 +113,7 @@ func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
|
|||||||
|
|
||||||
if err := db.Create(&model.InboundClientIps{
|
if err := db.Create(&model.InboundClientIps{
|
||||||
ClientEmail: "a@x",
|
ClientEmail: "a@x",
|
||||||
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
|
Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -111,8 +121,8 @@ func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
|
|||||||
incoming := []model.InboundClientIps{{
|
incoming := []model.InboundClientIps{{
|
||||||
ClientEmail: "a@x",
|
ClientEmail: "a@x",
|
||||||
Ips: marshalIps(t,
|
Ips: marshalIps(t,
|
||||||
clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
|
model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
|
||||||
clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
|
model.ClientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
@@ -139,8 +149,8 @@ func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
|
|||||||
if err := db.Create(&model.InboundClientIps{
|
if err := db.Create(&model.InboundClientIps{
|
||||||
ClientEmail: "a@x",
|
ClientEmail: "a@x",
|
||||||
Ips: marshalIps(t,
|
Ips: marshalIps(t,
|
||||||
clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
|
model.ClientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
|
||||||
clientIpEntry{IP: "fresh", Timestamp: now - 60},
|
model.ClientIpEntry{IP: "fresh", Timestamp: now - 60},
|
||||||
),
|
),
|
||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
@@ -149,8 +159,8 @@ func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
|
|||||||
incoming := []model.InboundClientIps{{
|
incoming := []model.InboundClientIps{{
|
||||||
ClientEmail: "a@x",
|
ClientEmail: "a@x",
|
||||||
Ips: marshalIps(t,
|
Ips: marshalIps(t,
|
||||||
clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
|
model.ClientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
|
||||||
clientIpEntry{IP: "incFresh", Timestamp: now - 10},
|
model.ClientIpEntry{IP: "incFresh", Timestamp: now - 10},
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
@@ -178,7 +188,7 @@ func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
|
|||||||
|
|
||||||
incoming := []model.InboundClientIps{{
|
incoming := []model.InboundClientIps{{
|
||||||
ClientEmail: "b@x",
|
ClientEmail: "b@x",
|
||||||
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
|
Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
|
||||||
}}
|
}}
|
||||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
t.Fatalf("merge: %v", err)
|
t.Fatalf("merge: %v", err)
|
||||||
@@ -194,7 +204,7 @@ func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
|
|||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
|
|
||||||
incoming := []model.InboundClientIps{
|
incoming := []model.InboundClientIps{
|
||||||
{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
|
{ClientEmail: "", Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now})},
|
||||||
{ClientEmail: "c@x", Ips: ""},
|
{ClientEmail: "c@x", Ips: ""},
|
||||||
}
|
}
|
||||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
@@ -217,13 +227,13 @@ func TestCasUpdateInboundClientIps_MatchAndMismatch(t *testing.T) {
|
|||||||
|
|
||||||
seed := &model.InboundClientIps{
|
seed := &model.InboundClientIps{
|
||||||
ClientEmail: "cas@x",
|
ClientEmail: "cas@x",
|
||||||
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now}),
|
Ips: marshalIps(t, model.ClientIpEntry{IP: "1.1.1.1", Timestamp: now}),
|
||||||
}
|
}
|
||||||
if err := db.Create(seed).Error; err != nil {
|
if err := db.Create(seed).Error; err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
next := marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now})
|
next := marshalIps(t, model.ClientIpEntry{IP: "2.2.2.2", Timestamp: now})
|
||||||
ok, err := CasUpdateInboundClientIps(db, seed.Id, "not-the-blob", next)
|
ok, err := CasUpdateInboundClientIps(db, seed.Id, "not-the-blob", next)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("stale CAS: %v", err)
|
t.Fatalf("stale CAS: %v", err)
|
||||||
@@ -258,13 +268,13 @@ func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
|
|||||||
|
|
||||||
seed := &model.InboundClientIps{
|
seed := &model.InboundClientIps{
|
||||||
ClientEmail: "race@x",
|
ClientEmail: "race@x",
|
||||||
Ips: marshalIps(t, clientIpEntry{IP: "10.0.0.1", Timestamp: now - 30}),
|
Ips: marshalIps(t, model.ClientIpEntry{IP: "10.0.0.1", Timestamp: now - 30}),
|
||||||
}
|
}
|
||||||
if err := db.Create(seed).Error; err != nil {
|
if err := db.Create(seed).Error; err != nil {
|
||||||
t.Fatalf("seed: %v", err)
|
t.Fatalf("seed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
jobBlob := marshalIps(t, clientIpEntry{IP: "10.0.0.2", Timestamp: now - 10})
|
jobBlob := marshalIps(t, model.ClientIpEntry{IP: "10.0.0.2", Timestamp: now - 10})
|
||||||
const callback = "test:inbound_client_ips_cas_inject"
|
const callback = "test:inbound_client_ips_cas_inject"
|
||||||
injected := false
|
injected := false
|
||||||
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||||
@@ -294,7 +304,7 @@ func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
|
|||||||
|
|
||||||
incoming := []model.InboundClientIps{{
|
incoming := []model.InboundClientIps{{
|
||||||
ClientEmail: "race@x",
|
ClientEmail: "race@x",
|
||||||
Ips: marshalIps(t, clientIpEntry{IP: "10.0.0.3", Timestamp: now}),
|
Ips: marshalIps(t, model.ClientIpEntry{IP: "10.0.0.3", Timestamp: now}),
|
||||||
}}
|
}}
|
||||||
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
t.Fatalf("merge: %v", err)
|
t.Fatalf("merge: %v", err)
|
||||||
@@ -316,3 +326,314 @@ func TestMergeInboundClientIps_RetriesAfterConcurrentWriter(t *testing.T) {
|
|||||||
t.Fatalf("pre-job IP should not resurrect after job replaced the blob: %v", ips)
|
t.Fatalf("pre-job IP should not resurrect after job replaced the blob: %v", ips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type clientIpSQLCounts struct {
|
||||||
|
selects atomic.Int64
|
||||||
|
updates atomic.Int64
|
||||||
|
creates atomic.Int64
|
||||||
|
affected atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func countClientIpSQL(t *testing.T, table string) *clientIpSQLCounts {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
counts := &clientIpSQLCounts{}
|
||||||
|
name := "test:ip_sql_" + table
|
||||||
|
query := func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == table {
|
||||||
|
counts.selects.Add(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
update := func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == table {
|
||||||
|
counts.updates.Add(1)
|
||||||
|
counts.affected.Add(tx.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
create := func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table == table {
|
||||||
|
counts.creates.Add(1)
|
||||||
|
counts.affected.Add(tx.RowsAffected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := db.Callback().Query().After("gorm:query").Register(name, query); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Callback().Update().After("gorm:update").Register(name, update); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.Callback().Create().After("gorm:create").Register(name, create); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = db.Callback().Query().Remove(name)
|
||||||
|
_ = db.Callback().Update().Remove(name)
|
||||||
|
_ = db.Callback().Create().Remove(name)
|
||||||
|
})
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawClientIpBlob(t *testing.T, entries ...model.ClientIpEntry) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireClientIpSet(t *testing.T, email string, want map[string]int64) {
|
||||||
|
t.Helper()
|
||||||
|
got, exists := readClientIps(t, email)
|
||||||
|
if !exists || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("%s IPs = %v (exists=%v), want %v", email, got, exists, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncSeededSnapshot(t *testing.T) {
|
||||||
|
for _, changed := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("changed=%v", changed), func(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
rows := make([]model.InboundClientIps, 3)
|
||||||
|
incoming := make([]model.InboundClientIps, 3)
|
||||||
|
for i := range rows {
|
||||||
|
rows[i] = model.InboundClientIps{ClientEmail: fmt.Sprintf("snapshot-%d@x", i), Ips: rawClientIpBlob(t,
|
||||||
|
model.ClientIpEntry{IP: "192.0.2.1", Timestamp: now - 10}, model.ClientIpEntry{IP: "192.0.2.2", Timestamp: now})}
|
||||||
|
incoming[i] = rows[i]
|
||||||
|
if changed {
|
||||||
|
incoming[i].Ips = rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.2", Timestamp: now + 1})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := database.GetDB().Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counts := countClientIpSQL(t, "inbound_client_ips")
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantUpdates := int64(0)
|
||||||
|
if changed {
|
||||||
|
wantUpdates = 3
|
||||||
|
}
|
||||||
|
if counts.selects.Load() != 1 || counts.updates.Load() != wantUpdates {
|
||||||
|
t.Fatalf("SELECTs/CAS attempts = %d/%d, want 1/%d", counts.selects.Load(), counts.updates.Load(), wantUpdates)
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
ts := now
|
||||||
|
if changed {
|
||||||
|
ts++
|
||||||
|
}
|
||||||
|
requireClientIpSet(t, row.ClientEmail, map[string]int64{"192.0.2.1": now - 10, "192.0.2.2": ts})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncDuplicatePayloadsKeepUnion(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
seed := model.InboundClientIps{ClientEmail: "existing@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.1", Timestamp: now})}
|
||||||
|
if err := database.GetDB().Create(&seed).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var incoming []model.InboundClientIps
|
||||||
|
for _, email := range []string{"new@x", "existing@x"} {
|
||||||
|
for i := range 3 {
|
||||||
|
incoming = append(incoming, model.InboundClientIps{Id: 99, ClientEmail: email, Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: fmt.Sprintf("192.0.2.%d", i+1), Timestamp: now + int64(i)})})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, email := range []string{"new@x", "existing@x"} {
|
||||||
|
requireClientIpSet(t, email, map[string]int64{"192.0.2.1": now, "192.0.2.2": now + 1, "192.0.2.3": now + 2})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncCreateConflictMergesWinner(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
winner := rawClientIpBlob(t, model.ClientIpEntry{IP: "198.51.100.1", Timestamp: now})
|
||||||
|
const callback = "test:ip_create_winner"
|
||||||
|
injected := false
|
||||||
|
if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
|
||||||
|
row, ok := tx.Statement.Dest.(*model.InboundClientIps)
|
||||||
|
if injected || tx.Statement.Table != "inbound_client_ips" || !ok || row.ClientEmail != "winner@x" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
injected = true
|
||||||
|
if err := tx.Exec("INSERT INTO inbound_client_ips (client_email, ips) VALUES (?, ?)", row.ClientEmail, winner).Error; err != nil {
|
||||||
|
tx.AddError(err)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps([]model.InboundClientIps{{ClientEmail: "winner@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.1", Timestamp: now + 1})}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !injected {
|
||||||
|
t.Fatal("create conflict was not injected")
|
||||||
|
}
|
||||||
|
requireClientIpSet(t, "winner@x", map[string]int64{"198.51.100.1": now, "203.0.113.1": now + 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncCASExhaustionRollsBack(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
rows := []model.InboundClientIps{
|
||||||
|
{ClientEmail: "a@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.1", Timestamp: now})},
|
||||||
|
{ClientEmail: "z@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "192.0.2.2", Timestamp: now})},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counts := countClientIpSQL(t, "inbound_client_ips")
|
||||||
|
const callback = "test:ip_exhaustion"
|
||||||
|
attempts := 0
|
||||||
|
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||||
|
if tx.Statement.Table != "inbound_client_ips" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where, ok := tx.Statement.Clauses["WHERE"].Expression.(clause.Where)
|
||||||
|
if !ok || len(where.Exprs) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
predicate, ok := where.Exprs[0].(clause.Expr)
|
||||||
|
if !ok || len(predicate.Vars) != 2 || predicate.Vars[0] != rows[1].Id {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
attempts++
|
||||||
|
blob := rawClientIpBlob(t, model.ClientIpEntry{IP: "198.51.100.9", Timestamp: now + int64(attempts)})
|
||||||
|
if err := tx.Exec("UPDATE inbound_client_ips SET ips = ? WHERE id = ?", blob, rows[1].Id).Error; err != nil {
|
||||||
|
tx.AddError(err)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
|
||||||
|
err := (&InboundService{}).MergeInboundClientIps([]model.InboundClientIps{
|
||||||
|
{ClientEmail: "z@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.8", Timestamp: now})},
|
||||||
|
{ClientEmail: "a@x", Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.7", Timestamp: now})},
|
||||||
|
})
|
||||||
|
want := fmt.Sprintf("inbound_client_ips id=%d: exhausted CAS retries merging client IPs", rows[1].Id)
|
||||||
|
if err == nil || err.Error() != want {
|
||||||
|
t.Fatalf("error = %v, want %q", err, want)
|
||||||
|
}
|
||||||
|
if attempts != 8 || counts.selects.Load() != 8 || counts.updates.Load() != 9 {
|
||||||
|
t.Fatalf("attempts/selects/updates = %d/%d/%d, want 8/8/9", attempts, counts.selects.Load(), counts.updates.Load())
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
var got model.InboundClientIps
|
||||||
|
if err := db.First(&got, row.Id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got.Ips != row.Ips {
|
||||||
|
t.Fatalf("rollback changed %s: %s, want %s", row.ClientEmail, got.Ips, row.Ips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNodeClientIpCanonicalUnchangedSkipsUpsert(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
report := map[string][]model.ClientIpEntry{"u@x": {{IP: "192.0.2.1", Timestamp: now - 1}, {IP: "192.0.2.2", Timestamp: now}}}
|
||||||
|
row := model.NodeClientIp{NodeGuid: "guid", Email: "u@x", Ips: rawClientIpBlob(t, report["u@x"]...)}
|
||||||
|
if err := database.GetDB().Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counts := countClientIpSQL(t, "node_client_ips")
|
||||||
|
if err := (&InboundService{}).RecordLocalClientIps("guid", report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if counts.selects.Load() != 1 || counts.creates.Load() != 0 || counts.updates.Load() != 0 {
|
||||||
|
t.Fatalf("unchanged attribution SELECT/create/update = %d/%d/%d, want 1/0/0", counts.selects.Load(), counts.creates.Load(), counts.updates.Load())
|
||||||
|
}
|
||||||
|
report["u@x"][1].Timestamp++
|
||||||
|
if err := (&InboundService{}).RecordLocalClientIps("guid", report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if counts.creates.Load() != 1 || counts.affected.Load() != 1 {
|
||||||
|
t.Fatalf("advancing attribution attempts/affected = %d/%d, want 1/1", counts.creates.Load(), counts.affected.Load())
|
||||||
|
}
|
||||||
|
trees, err := (&InboundService{}).GetClientIpsByGuid()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(trees["guid"]["u@x"], report["u@x"]) {
|
||||||
|
t.Fatalf("attribution = %v, want %v", trees, report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientIpSyncRawSnapshotAndSingleRetry(t *testing.T) {
|
||||||
|
for _, contend := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("contention=%v", contend), func(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
raw := fmt.Sprintf("[ {\"ip\":\"192.0.2.2\",\"timestamp\":%d}, {\"ip\":\"192.0.2.1\",\"timestamp\":%d} ]", now, now-1)
|
||||||
|
row := model.InboundClientIps{ClientEmail: "raw@x", Ips: raw}
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
counts := countClientIpSQL(t, "inbound_client_ips")
|
||||||
|
injected := false
|
||||||
|
if contend {
|
||||||
|
const callback = "test:ip_one_miss"
|
||||||
|
if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) {
|
||||||
|
if injected || tx.Statement.Table != "inbound_client_ips" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
where, ok := tx.Statement.Clauses["WHERE"].Expression.(clause.Where)
|
||||||
|
if !ok || len(where.Exprs) != 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
predicate, ok := where.Exprs[0].(clause.Expr)
|
||||||
|
if !ok || len(predicate.Vars) != 2 || predicate.Vars[0] != row.Id {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
injected = true
|
||||||
|
winner := rawClientIpBlob(t, model.ClientIpEntry{IP: "198.51.100.1", Timestamp: now + 2})
|
||||||
|
if err := tx.Exec("UPDATE inbound_client_ips SET ips = ? WHERE id = ?", winner, row.Id).Error; err != nil {
|
||||||
|
tx.AddError(err)
|
||||||
|
}
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Callback().Update().Remove(callback) })
|
||||||
|
}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps([]model.InboundClientIps{{ClientEmail: row.ClientEmail, Ips: rawClientIpBlob(t, model.ClientIpEntry{IP: "203.0.113.1", Timestamp: now + 1})}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wantCount := int64(1)
|
||||||
|
want := map[string]int64{"192.0.2.1": now - 1, "192.0.2.2": now, "203.0.113.1": now + 1}
|
||||||
|
if contend {
|
||||||
|
wantCount = 2
|
||||||
|
want = map[string]int64{"198.51.100.1": now + 2, "203.0.113.1": now + 1}
|
||||||
|
if !injected {
|
||||||
|
t.Fatal("competing SQL update was not injected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if counts.selects.Load() != wantCount || counts.updates.Load() != wantCount {
|
||||||
|
t.Fatalf("SELECTs/CAS=%d/%d, want %d/%d", counts.selects.Load(), counts.updates.Load(), wantCount, wantCount)
|
||||||
|
}
|
||||||
|
requireClientIpSet(t, row.ClientEmail, want)
|
||||||
|
var got model.InboundClientIps
|
||||||
|
if err := db.First(&got, row.Id).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var entries []model.ClientIpEntry
|
||||||
|
if err := json.Unmarshal([]byte(got.Ips), &entries); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(entries); i++ {
|
||||||
|
if entries[i-1].Timestamp > entries[i].Timestamp || (entries[i-1].Timestamp == entries[i].Timestamp && entries[i-1].IP > entries[i].IP) {
|
||||||
|
t.Fatalf("noncanonical persisted blob %s", got.Ips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,9 +18,7 @@ import (
|
|||||||
// observations under its panelGuid, and the master merges every node's
|
// observations under its panelGuid, and the master merges every node's
|
||||||
// guid-keyed report — never mixing in IPs a parent pushed down.
|
// guid-keyed report — never mixing in IPs a parent pushed down.
|
||||||
|
|
||||||
// mergeModelClientIpEntries unions old and incoming observations, drops anything
|
// Attribution excludes empty addresses and keeps each fresh IP's maximum timestamp.
|
||||||
// older than cutoff, keeps the newest timestamp per IP, and sorts newest-first.
|
|
||||||
// It mirrors mergeClientIpEntries but operates on the exported wire type.
|
|
||||||
func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
|
func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64) []model.ClientIpEntry {
|
||||||
ipMap := make(map[string]int64, len(old)+len(incoming))
|
ipMap := make(map[string]int64, len(old)+len(incoming))
|
||||||
for _, e := range old {
|
for _, e := range old {
|
||||||
@@ -41,13 +39,11 @@ func mergeModelClientIpEntries(old, incoming []model.ClientIpEntry, cutoff int64
|
|||||||
for ip, ts := range ipMap {
|
for ip, ts := range ipMap {
|
||||||
out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
|
out = append(out, model.ClientIpEntry{IP: ip, Timestamp: ts})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
|
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsertNodeClientIps folds a guid's per-email observations into NodeClientIp,
|
// upsertNodeClientIps merges fresh observations per (guid, email).
|
||||||
// merging with whatever is already stored for that (guid, email) and dropping
|
// Empty results delete attribution instead of retaining expired rows.
|
||||||
// stale entries. Empty merged results delete the row so the table stays bounded.
|
|
||||||
func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry) error {
|
func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry) error {
|
||||||
if guid == "" || len(perEmail) == 0 {
|
if guid == "" || len(perEmail) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -99,8 +95,11 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(merged)
|
encoded, _ := model.MarshalClientIpEntries(merged)
|
||||||
row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: string(b)}
|
if current := existingByEmail[email]; current != nil && current.Ips == encoded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
row := model.NodeClientIp{NodeGuid: guid, Email: email, Ips: encoded}
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "node_guid"}, {Name: "email"}},
|
Columns: []clause.Column{{Name: "node_guid"}, {Name: "email"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"ips"}),
|
DoUpdates: clause.AssignmentColumns([]string{"ips"}),
|
||||||
@@ -112,9 +111,7 @@ func upsertNodeClientIps(guid string, perEmail map[string][]model.ClientIpEntry)
|
|||||||
return tx.Commit().Error
|
return tx.Commit().Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecordLocalClientIps stores this panel's own Xray observations under its
|
// RecordLocalClientIps stores the scanner's local observations under this panel's GUID.
|
||||||
// panelGuid. Called by check_client_ip_job each scan with the live per-email IPs
|
|
||||||
// the local core reported.
|
|
||||||
func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[string][]model.ClientIpEntry) error {
|
func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[string][]model.ClientIpEntry) error {
|
||||||
return upsertNodeClientIps(panelGuid, observed)
|
return upsertNodeClientIps(panelGuid, observed)
|
||||||
}
|
}
|
||||||
@@ -172,6 +169,7 @@ func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.Cli
|
|||||||
if len(fresh) == 0 {
|
if len(fresh) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
model.SortClientIpEntries(fresh)
|
||||||
if out[row.NodeGuid] == nil {
|
if out[row.NodeGuid] == nil {
|
||||||
out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
|
out[row.NodeGuid] = make(map[string][]model.ClientIpEntry)
|
||||||
}
|
}
|
||||||
@@ -325,8 +323,8 @@ func pruneStaleNodeClientIps(cutoff int64) error {
|
|||||||
if len(kept) == len(entries) {
|
if len(kept) == len(entries) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(kept)
|
encoded, _ := model.MarshalClientIpEntries(kept)
|
||||||
if err := db.Model(&model.NodeClientIp{}).Where("id = ?", row.Id).Update("ips", string(b)).Error; err != nil {
|
if err := db.Model(&model.NodeClientIp{}).Where("id = ?", row.Id).Update("ips", encoded).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) {
|
|||||||
if len(got) != 2 {
|
if len(got) != 2 {
|
||||||
t.Fatalf("want 2 entries, got %v", got)
|
t.Fatalf("want 2 entries, got %v", got)
|
||||||
}
|
}
|
||||||
if got[0].IP != "1.1.1.1" { // newest-first ordering
|
if got[0].IP != "2.2.2.2" || got[0].Timestamp != now-10 || got[1].IP != "1.1.1.1" || got[1].Timestamp != now {
|
||||||
t.Fatalf("want newest first, got %v", got)
|
t.Fatalf("want ascending timestamps, got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user