Compare commits

...

9 Commits

Author SHA1 Message Date
MHSanaei 64c306037f feat(wireguard): make client allowedIPs editable with validation
The WireGuard peer address was allocated server-side and shown read-only
in the client editor, so changing it required hand-editing the inbound's
raw settings JSON (#5715). The backend add/update paths already honored a
submitted allowedIPs; only the form withheld it.

Make the field editable (comma-separated, empty still auto-assigns) and
validate submissions server-side: entries must parse as an IP or CIDR,
bare addresses normalize to single-host prefixes, and an address already
used by another peer on the inbound is rejected.

Closes #5715
2026-07-02 09:45:54 +02:00
MHSanaei 8dd3b31ee8 fix(node): show the activated first-use deadline on the Clients page
With "start after first use" on a node inbound, the node activates the
absolute deadline and the master adopts it into client_traffics via the
sync CASE merge — but the client record (what the Clients page reads) was
only refreshed by SyncInbound from the snapshot's settings JSON. A node
whose JSON still carried the negative duration (stale conversion, older
node build, or a mixed local+node attachment) kept rewriting the record
back to "not started" even though the DB held the real deadline (#5714).

Lift the activated deadline from client_traffics onto still-negative
client records at the end of every node sync, after SyncInbound has run.
Intentional resets back to delayed start are unaffected: editing a client
also resets client_traffics to the negative duration, so the lift's
expiry_time > 0 guard never matches.

Closes #5714
2026-07-02 09:36:07 +02:00
MHSanaei e5b56c9444 fix(xray): reconcile client auto-disable through the API instead of a forced restart
When a client expired or hit its traffic limit, XrayTrafficJob called
RestartXray(true), stopping the whole process and dropping every live
connection on every inbound (#5712 reported this as XHTTP on 443 dying) —
even though disableInvalidClients had already removed the user from the
running core over gRPC. The force restart existed only to re-sync the
process's config snapshot.

Switch the job to a non-forced restart and teach ComputeHotDiff to express
a client-only inbound change as per-user AlterInbound operations for
vless/vmess/trojan, so the reconcile is a no-op RemoveUser plus a snapshot
update rather than a handler swap that would still blip that inbound's
listener. Anything beyond the clients list still falls back to handler
replacement or a full restart as before.

Closes #5712
2026-07-02 09:26:53 +02:00
MHSanaei 1153d5db8c fix(groups): keep group traffic totals stable across client resets and deletes
ListGroups displays live_sum(client_traffics) minus the group's stored
reset baseline, but only ResetGroupTraffic ever moved the baseline. Any
client-level operation that zeroed or deleted traffic rows (single/bulk
reset, client delete, removing a client's last inbound) shrank the live
sum and silently subtracted that client's history from the group total.

Shift the baseline down by the removed counters inside the same
transaction, so group totals only change through group reset. Derived
groups without a stored row get one with a negative baseline, which the
existing clamp handles.

Closes #5675
2026-07-02 09:17:47 +02:00
MHSanaei 539bcc897c fix(inbounds): apply the legacy xhttp session-key migration when editing
rawInboundToFormValues injected the stored xhttpSettings blob into the form
store without running it through XHttpStreamSettingsSchema, so the
sessionPlacement/sessionKey -> sessionIDPlacement/sessionIDKey rename from
xray-core v26.6.22 (and the v3.4.0 field defaults) never applied on the
edit path. Inbounds saved before the rename opened with blank session
fields, and the stale keys could ride back on save even though the core no
longer reads them. Parse the sub-object through the schema on load, and
lift any stale legacy keys in normalizeXhttpForWire as a backstop.

Closes #5621
2026-07-01 23:11:58 +02:00
MHSanaei 273f88721e fix(database): stop noisy per-startup errors in the Postgres server log
Two statements failed server-side on every panel start after a SQLite to
Postgres migration, flooding the postgres log even though the Go side
suppressed them:

- resyncPostgresSequences issued SELECT MAX(id) against client_inbounds,
  whose composite primary key has no id column; Postgres validates the
  SELECT list at parse time, so the WHERE pg_get_serial_sequence(...) guard
  never got a chance to no-op it. Skip models whose GORM schema maps no id
  column before issuing the statement.

- AutoMigrate detects existing columns via information_schema filtered by
  table_catalog = CURRENT_DATABASE(), which misdetects on some setups and
  re-issues ALTER TABLE ... ADD for columns that already exist. HasColumn/
  HasIndex query without that filter and are reliable (the existing
  duplicate-column suppressor depends on exactly that), so skip AutoMigrate
  outright when the table, every column, and every index already exist.

Closes #5665
2026-07-01 23:07:05 +02:00
MHSanaei 1f2e3e1447 fix(sub): use configured spiderX instead of always randomizing
applyShareRealityParams and SubJsonService.realityData generated a fresh
random spx on every export, so share links, "export all links", and JSON
subscriptions never matched a spiderX configured on the inbound and two
exports of the same client disagreed with each other. Read the value from
realitySettings.settings like pbk/fp/pqv and keep the random value only as
a fallback when none is configured.

Closes #5718
2026-07-01 23:07:05 +02:00
MHSanaei 49773c18de fix(xray): force full restart for inbounds with a VLESS reverse client
Hot-applying an inbound change swaps it via DelInbound+AddInbound on
the running core. That unregisters any client's reverse.tag handler
on the xray-core side without closing the bridge's already-established
connection, so the reverse tunnel is silently orphaned until someone
manually restarts xray. diffInbounds now bails out of the hot-apply
path whenever the old or new inbound carries a reverse-tagged client,
falling back to a full restart, which actually drops the socket and
lets the bridge redial on its own.

Also scope the .claude ignore rule to its contents (.claude/*) instead
of the whole directory, so individual files under .claude/ can be
tracked selectively.
2026-07-01 14:02:13 +02:00
MHSanaei 427613b308 chore(ci): upgrade claude-bot to Sonnet 5 and set explicit effort levels
Sonnet 5 reaches near-Opus quality on coding/agentic work at lower cost;
pin effort explicitly (xhigh/max) instead of relying on model defaults.
2026-07-01 00:43:27 +02:00
43 changed files with 949 additions and 70 deletions
+6 -3
View File
@@ -30,7 +30,8 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_non_write_users: "*" allowed_non_write_users: "*"
claude_args: | claude_args: |
--model claude-sonnet-4-6 --model claude-sonnet-5
--effort xhigh
--max-turns 300 --max-turns 300
--allowedTools "Bash(gh:*),Read,Glob,Grep" --allowedTools "Bash(gh:*),Read,Glob,Grep"
prompt: | prompt: |
@@ -282,7 +283,8 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_non_write_users: "*" allowed_non_write_users: "*"
claude_args: | claude_args: |
--model claude-opus-4-8 --model claude-sonnet-5
--effort max
--max-turns 250 --max-turns 250
--allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep" --allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep"
prompt: | prompt: |
@@ -500,7 +502,8 @@ jobs:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude_args: | claude_args: |
--model claude-opus-4-8 --model claude-sonnet-5
--effort max
--max-turns 250 --max-turns 250
--allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep,Edit,Write" --allowedTools "Bash(gh:*),Bash(git:*),Read,Glob,Grep,Edit,Write"
--append-system-prompt "You are replying to an @claude mention in the MHSanaei/3x-ui repository, an open-source web panel for managing Xray-core servers. The full repo source is checked out in the working directory; use Read, Glob and Grep to open and verify the relevant files before stating any default, path, flag, option name, or behavior. --append-system-prompt "You are replying to an @claude mention in the MHSanaei/3x-ui repository, an open-source web panel for managing Xray-core servers. The full repo source is checked out in the working directory; use Read, Glob and Grep to open and verify the relevant files before stating any default, path, flag, option name, or behavior.
+1 -1
View File
@@ -2,7 +2,7 @@
.idea/ .idea/
.vscode/ .vscode/
.cursor/ .cursor/
.claude/ .claude/*
.cache/ .cache/
.sync* .sync*
@@ -13,7 +13,7 @@ import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod'; import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { canEnableSniffing } from '@/lib/xray/protocol-capabilities'; import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -164,7 +164,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
const streamRecord = streamSettings as unknown as Record<string, unknown>; const streamRecord = streamSettings as unknown as Record<string, unknown>;
const xh = streamRecord.xhttpSettings; const xh = streamRecord.xhttpSettings;
if (xh && typeof xh === 'object' && !Array.isArray(xh)) { if (xh && typeof xh === 'object' && !Array.isArray(xh)) {
const xhttp = xh as Record<string, unknown>; const parsed = XHttpStreamSettingsSchema.safeParse(xh);
const xhttp = (parsed.success ? parsed.data : xh) as Record<string, unknown>;
streamRecord.xhttpSettings = xhttp;
const xmux = xhttp.xmux; const xmux = xhttp.xmux;
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) { if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
xhttp.enableXmux = true; xhttp.enableXmux = true;
@@ -108,6 +108,18 @@ export function validateRealityTarget(target: string): string | undefined {
return undefined; return undefined;
} }
function liftLegacyXhttpSessionKeys(obj: Record<string, unknown>): void {
const lift = (legacy: string, renamed: string) => {
const v = obj[legacy];
if ((obj[renamed] === undefined || obj[renamed] === '') && typeof v === 'string' && v !== '') {
obj[renamed] = v;
}
delete obj[legacy];
};
lift('sessionPlacement', 'sessionIDPlacement');
lift('sessionKey', 'sessionIDKey');
}
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void { function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) { for (const key of keys) {
const v = obj[key]; const v = obj[key];
@@ -160,6 +172,7 @@ export function normalizeXhttpForWire(
side: StreamWireSide, side: StreamWireSide,
): Record<string, unknown> { ): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw }; const out: Record<string, unknown> = { ...raw };
liftLegacyXhttpSessionKeys(out);
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto'; const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
const enableXmux = out.enableXmux === true; const enableXmux = out.enableXmux === true;
delete out.enableXmux; delete out.enableXmux;
+17 -5
View File
@@ -492,6 +492,13 @@ export default function ClientFormModal({
if (form.wgPreSharedKey) { if (form.wgPreSharedKey) {
clientPayload.preSharedKey = form.wgPreSharedKey; clientPayload.preSharedKey = form.wgPreSharedKey;
} }
const allowedIPs = form.wgAllowedIPs
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
if (allowedIPs.length > 0) {
clientPayload.allowedIPs = allowedIPs;
}
} }
const externalLinks: ExternalLinkInput[] = form.externalLinks const externalLinks: ExternalLinkInput[] = form.externalLinks
@@ -802,11 +809,16 @@ export default function ClientFormModal({
onChange={(e) => update('wgPreSharedKey', e.target.value)} onChange={(e) => update('wgPreSharedKey', e.target.value)}
/> />
</Form.Item> </Form.Item>
{isEdit && form.wgAllowedIPs && ( <Form.Item
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}> label={t('pages.clients.wireguardAllowedIPs')}
<Input value={form.wgAllowedIPs} disabled /> extra={t('pages.clients.wireguardAllowedIPsHint')}
</Form.Item> >
)} <Input
value={form.wgAllowedIPs}
placeholder="10.0.0.2/32"
onChange={(e) => update('wgAllowedIPs', e.target.value)}
/>
</Form.Item>
</> </>
)} )}
</> </>
@@ -7,6 +7,7 @@ import {
type RawInboundRow, type RawInboundRow,
} from '@/lib/xray/inbound-form-adapter'; } from '@/lib/xray/inbound-form-adapter';
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form'; import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting // Round-trip: raw DB row → InboundFormValues → wire payload, asserting
@@ -304,3 +305,51 @@ describe('subSortIndex', () => {
expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1); expect(InboundDbFieldsSchema.parse({}).subSortIndex).toBe(1);
}); });
}); });
describe('legacy xhttp session keys on edit (#5621)', () => {
const legacyXhttpRow: RawInboundRow = {
...vlessRow,
streamSettings: {
network: 'xhttp',
security: 'none',
xhttpSettings: {
path: '/xh',
mode: 'packet-up',
sessionPlacement: 'cookie',
sessionKey: 'x_session',
},
},
};
it('rawInboundToFormValues lifts sessionPlacement/sessionKey onto the renamed keys', () => {
const values = rawInboundToFormValues(legacyXhttpRow);
const xhttp = (values.streamSettings as unknown as Record<string, Record<string, unknown>>).xhttpSettings;
expect(xhttp.sessionIDPlacement).toBe('cookie');
expect(xhttp.sessionIDKey).toBe('x_session');
expect(xhttp.sessionPlacement).toBeUndefined();
expect(xhttp.sessionKey).toBeUndefined();
expect(xhttp.path).toBe('/xh');
expect(xhttp.xPaddingBytes).toBe('100-1000');
});
it('formValuesToWirePayload never emits the legacy key names', () => {
const values = rawInboundToFormValues(legacyXhttpRow);
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, Record<string, unknown>>;
expect(stream.xhttpSettings.sessionPlacement).toBeUndefined();
expect(stream.xhttpSettings.sessionKey).toBeUndefined();
expect(stream.xhttpSettings.sessionIDPlacement).toBe('cookie');
expect(stream.xhttpSettings.sessionIDKey).toBe('x_session');
});
it('normalizeXhttpForWire lifts stale legacy keys that bypassed the schema', () => {
const out = normalizeXhttpForWire(
{ sessionPlacement: 'header', sessionKey: 'x_raw' },
'inbound',
);
expect(out.sessionIDPlacement).toBe('header');
expect(out.sessionIDKey).toBe('x_raw');
expect(out.sessionPlacement).toBeUndefined();
expect(out.sessionKey).toBeUndefined();
});
});
+27
View File
@@ -83,6 +83,9 @@ func initModels() error {
&model.OutboundSubscription{}, &model.OutboundSubscription{},
} }
for _, mdl := range models { for _, mdl := range models {
if IsPostgres() && postgresModelSettled(mdl) {
continue
}
if err := db.AutoMigrate(mdl); err != nil { if err := db.AutoMigrate(mdl); err != nil {
if isIgnorableDuplicateColumnErr(err, mdl) { if isIgnorableDuplicateColumnErr(err, mdl) {
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err) log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
@@ -119,6 +122,30 @@ func initModels() error {
return nil return nil
} }
// postgresModelSettled skips AutoMigrate when table, columns, and indexes all exist:
// its catalog-filtered column probe misdetects on some setups and re-ADDs columns forever (#5665).
func postgresModelSettled(mdl any) bool {
migrator := db.Migrator()
if !migrator.HasTable(mdl) {
return false
}
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(mdl); err != nil || stmt.Schema == nil {
return false
}
for _, dbName := range stmt.Schema.DBNames {
if !migrator.HasColumn(mdl, dbName) {
return false
}
}
for _, idx := range stmt.Schema.ParseIndexes() {
if !migrator.HasIndex(mdl, idx.Name) {
return false
}
}
return true
}
func dropLegacyForeignKeys() error { func dropLegacyForeignKeys() error {
if !IsPostgres() { if !IsPostgres() {
return nil return nil
+57
View File
@@ -0,0 +1,57 @@
package database
import (
"path/filepath"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Locks the #5665 guard: composite-PK client_inbounds has no id column, so the
// sequence-reset SQL must never be issued for it.
func TestTableWithIdColumn_SkipsCompositeKeyModels(t *testing.T) {
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
if table, ok := tableWithIdColumn(db, &model.ClientInbound{}); ok {
t.Errorf("ClientInbound (table %q) has no id column but was not skipped", table)
}
table, ok := tableWithIdColumn(db, &model.Inbound{})
if !ok {
t.Fatal("Inbound has an id column but was reported as skippable")
}
if table != "inbounds" {
t.Errorf("Inbound table = %q, want inbounds", table)
}
}
// Exercises the #5665 AutoMigrate skip on SQLite (the check is dialect-agnostic):
// settled after InitDB, not settled with a missing column or table.
func TestPostgresModelSettled_TracksSchemaPresence(t *testing.T) {
if err := InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
for _, mdl := range []any{&model.ClientRecord{}, &model.ClientGroup{}, &model.ClientInbound{}} {
if !postgresModelSettled(mdl) {
t.Errorf("%T not settled right after InitDB", mdl)
}
}
if err := db.Migrator().DropColumn(&model.ClientGroup{}, "reset_up"); err != nil {
t.Fatalf("drop column: %v", err)
}
if postgresModelSettled(&model.ClientGroup{}) {
t.Error("ClientGroup settled despite missing reset_up column")
}
if err := db.Migrator().DropTable(&model.ClientGroup{}); err != nil {
t.Fatalf("drop table: %v", err)
}
if postgresModelSettled(&model.ClientGroup{}) {
t.Error("ClientGroup settled despite missing table")
}
}
+17 -9
View File
@@ -270,19 +270,14 @@ func resetPostgresSequences(dst *gorm.DB) error {
return resyncPostgresSequences(dst, migrationModels()) return resyncPostgresSequences(dst, migrationModels())
} }
// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next // resyncPostgresSequences sets each model's id sequence to MAX(id); idempotent. Id-less
// auto-increment INSERT won't collide with an existing row. Table names are // composite-PK tables are skipped — Postgres rejects MAX(id) at parse time and logs it (#5665).
// resolved from the models themselves (not hardcoded), so they always match the
// migrated tables. The statement is a no-op for tables without an id sequence
// (e.g. composite-PK tables), and idempotent on a healthy DB, so it is safe to
// run both after migration and on every Postgres startup.
func resyncPostgresSequences(db *gorm.DB, models []any) error { func resyncPostgresSequences(db *gorm.DB, models []any) error {
for _, m := range models { for _, m := range models {
stmt := &gorm.Statement{DB: db} t, ok := tableWithIdColumn(db, m)
if err := stmt.Parse(m); err != nil { if !ok {
continue continue
} }
t := stmt.Table
// t comes from the trusted model set parsed by GORM, not user input, so // t comes from the trusted model set parsed by GORM, not user input, so
// interpolating it as an identifier is safe. We ignore errors per-table. // interpolating it as an identifier is safe. We ignore errors per-table.
_ = db.Exec( _ = db.Exec(
@@ -293,3 +288,16 @@ func resyncPostgresSequences(db *gorm.DB, models []any) error {
} }
return nil return nil
} }
// tableWithIdColumn resolves a model's table name and reports whether its GORM
// schema maps an "id" database column.
func tableWithIdColumn(db *gorm.DB, m any) (string, bool) {
stmt := &gorm.Statement{DB: db}
if err := stmt.Parse(m); err != nil {
return "", false
}
if stmt.Schema == nil || stmt.Schema.LookUpField("id") == nil {
return "", false
}
return stmt.Table, true
}
+3 -1
View File
@@ -331,8 +331,10 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
rltyData["fingerprint"] = rltyClientSettings["fingerprint"] rltyData["fingerprint"] = rltyClientSettings["fingerprint"]
rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"] rltyData["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
// Set random data
rltyData["spiderX"] = "/" + random.Seq(15) rltyData["spiderX"] = "/" + random.Seq(15)
if spx, ok := rltyClientSettings["spiderX"].(string); ok && spx != "" {
rltyData["spiderX"] = spx
}
shortIds, ok := rData["shortIds"].([]any) shortIds, ok := rData["shortIds"].([]any)
if ok && len(shortIds) > 0 { if ok && len(shortIds) > 0 {
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string) rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
+43
View File
@@ -254,3 +254,46 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
t.Fatalf("mux payload wrong: %#v", m) t.Fatalf("mux payload wrong: %#v", m)
} }
} }
func TestSubJsonServiceRealityDataUsesConfiguredSpiderX(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
}
}`)
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
if rlty["spiderX"] != "/mypath" {
t.Fatalf("spiderX = %v, want configured /mypath (#5718)", rlty["spiderX"])
}
}
func TestSubJsonServiceRealityDataSpiderXFallsBackToRandom(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{
"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox"}
}
}`)
rlty, _ := stream["realitySettings"].(map[string]any)
if rlty == nil {
t.Fatal("streamData dropped realitySettings")
}
spx, _ := rlty["spiderX"].(string)
if len(spx) != 16 || spx[0] != '/' {
t.Fatalf("spiderX fallback = %q, want random 16-char /-prefixed value", spx)
}
}
+5
View File
@@ -1357,6 +1357,11 @@ func applyShareRealityParams(stream map[string]any, params map[string]string) {
} }
} }
params["spx"] = "/" + random.Seq(15) params["spx"] = "/" + random.Seq(15)
if spxValue, ok := searchKey(realitySettings, "spiderX"); ok {
if spx, ok := spxValue.(string); ok && len(spx) > 0 {
params["spx"] = spx
}
}
} }
} }
+38 -20
View File
@@ -52,11 +52,43 @@ func TestGenVlessLink_TLSParamsMapped(t *testing.T) {
} }
} }
// TestGenVlessLink_RealityParamsMapped locks the reality field mapping // Locks the reality field mapping of applyShareRealityParams; a configured
// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element // spiderX must round-trip verbatim (#5718), distinct pbk/sid catch a swap mutant.
// so random.Num is deterministic (index 0); spx is random so it is asserted by prefix.
// Distinct pbk/sid values catch a pbk<->sid swap mutant.
func TestGenVlessLink_RealityParamsMapped(t *testing.T) { func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
stream := `{
"network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}},
"realitySettings":{
"serverNames":["reality.example.com"],
"shortIds":["ab12cd"],
"settings":{"publicKey":"PBKvalue","fingerprint":"firefox","spiderX":"/mypath"}
}
}`
s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user")
wants := []string{
"security=reality",
"sni=reality.example.com",
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2Fmypath",
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("reality link missing %q\n got: %s", w, link)
}
}
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
}
}
// Without a configured spiderX, spx must still fall back to a random
// "/"-prefixed value so clients always receive a plausible path.
func TestGenVlessLink_RealitySpiderXFallsBackToRandom(t *testing.T) {
stream := `{ stream := `{
"network":"tcp","security":"reality", "network":"tcp","security":"reality",
"tcpSettings":{"header":{"type":"none"}}, "tcpSettings":{"header":{"type":"none"}},
@@ -69,21 +101,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
s := &SubService{} s := &SubService{}
link := s.genVlessLink(shareLinkInbound(stream), "user") link := s.genVlessLink(shareLinkInbound(stream), "user")
wants := []string{ if !strings.Contains(link, "spx=%2F") {
"security=reality", t.Fatalf("reality link missing random spx fallback\n got: %s", link)
"sni=reality.example.com",
"pbk=PBKvalue",
"sid=ab12cd",
"fp=firefox",
"spx=%2F", // "/" + random.Seq(15), percent-encoded leading slash
}
for _, w := range wants {
if !strings.Contains(link, w) {
t.Fatalf("reality link missing %q\n got: %s", w, link)
}
}
// A pbk<->sid swap must not silently pass: pbk must not carry the shortId.
if strings.Contains(link, "pbk=ab12cd") || strings.Contains(link, "sid=PBKvalue") {
t.Fatalf("reality pbk/sid mapping crossed: %s", link)
} }
} }
+2 -2
View File
@@ -50,8 +50,8 @@ func (j *XrayTrafficJob) Run() {
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr) logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
} }
if restartOnDisable { if restartOnDisable {
if err := j.xrayService.RestartXray(true); err != nil { if err := j.xrayService.RestartXray(false); err != nil {
logger.Warning("restart xray after disabling clients failed:", err) logger.Warning("reconcile xray after disabling clients failed:", err)
j.xrayService.SetToNeedRestart() j.xrayService.SetToNeedRestart()
} }
} }
+3
View File
@@ -834,6 +834,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
// Serialize the row cleanup against the traffic poll to avoid the // Serialize the row cleanup against the traffic poll to avoid the
// cross-transaction lock-order deadlock on client_traffics/inbounds. // cross-transaction lock-order deadlock on client_traffics/inbounds.
if err := runSerializedTx(func(tx *gorm.DB) error { if err := runSerializedTx(func(tx *gorm.DB) error {
if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
return e
}
for _, batch := range chunkInts(successIds, sqlInChunk) { for _, batch := range chunkInts(successIds, sqlInChunk) {
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil { if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
return e return e
+22 -15
View File
@@ -466,24 +466,31 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
} }
db := database.GetDB() db := database.GetDB()
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil { if err := db.Transaction(func(tx *gorm.DB) error {
return needRestart, err if existing.Email != "" {
} if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil { return err
return needRestart, err }
}
if !keepTraffic && existing.Email != "" {
if err := db.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return needRestart, err
} }
if err := clearGlobalTraffic(db, existing.Email); err != nil { if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
return needRestart, err return err
} }
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil { if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
return needRestart, err return err
} }
} if !keepTraffic && existing.Email != "" {
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil { if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
return err
}
if err := clearGlobalTraffic(tx, existing.Email); err != nil {
return err
}
if err := tx.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
return err
}
}
return tx.Delete(&model.ClientRecord{}, id).Error
}); err != nil {
return needRestart, err return needRestart, err
} }
return needRestart, nil return needRestart, nil
@@ -125,3 +125,105 @@ func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
t.Fatal("ResetGroupTraffic(blank) = nil, want error") t.Fatal("ResetGroupTraffic(blank) = nil, want error")
} }
} }
func TestGroupTotalsSurviveSingleClientReset(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "erin", "gold", 100, 200)
seedGroupedClient(t, "frank", "gold", 40, 60)
if err := isvc.ResetClientTrafficByEmail("erin"); err != nil {
t.Fatalf("ResetClientTrafficByEmail: %v", err)
}
g := groupByName(t, csvc, "gold")
if g.Up != 140 || g.Down != 260 || g.TrafficUsed != 400 {
t.Fatalf("group totals changed by client reset: got %+v, want up=140 down=260 used=400", g)
}
var erin xray.ClientTraffic
if err := database.GetDB().Where("email = ?", "erin").First(&erin).Error; err != nil {
t.Fatalf("load erin traffic: %v", err)
}
if erin.Up != 0 || erin.Down != 0 {
t.Fatalf("client traffic not reset: up=%d down=%d", erin.Up, erin.Down)
}
}
func TestGroupTotalsSurviveBulkClientReset(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "gina", "silver", 10, 20)
seedGroupedClient(t, "hank", "silver", 30, 40)
affected, err := csvc.BulkResetTraffic(isvc, []string{"gina", "hank"})
if err != nil {
t.Fatalf("BulkResetTraffic: %v", err)
}
if affected != 2 {
t.Fatalf("BulkResetTraffic affected = %d, want 2", affected)
}
g := groupByName(t, csvc, "silver")
if g.Up != 40 || g.Down != 60 || g.TrafficUsed != 100 {
t.Fatalf("group totals changed by bulk reset: got %+v, want up=40 down=60 used=100", g)
}
}
func TestGroupTotalsSurviveClientDelete(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "iris", "bronze", 70, 30)
seedGroupedClient(t, "jack", "bronze", 5, 5)
var rec model.ClientRecord
if err := database.GetDB().Where("email = ?", "iris").First(&rec).Error; err != nil {
t.Fatalf("load iris record: %v", err)
}
if _, err := csvc.Delete(isvc, rec.Id, false); err != nil {
t.Fatalf("Delete: %v", err)
}
g := groupByName(t, csvc, "bronze")
if g.Up != 75 || g.Down != 35 || g.TrafficUsed != 110 {
t.Fatalf("group totals changed by client delete: got %+v, want up=75 down=35 used=110", g)
}
if g.ClientCount != 1 {
t.Fatalf("client count = %d, want 1", g.ClientCount)
}
var trafficRows int64
if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "iris").Count(&trafficRows).Error; err != nil {
t.Fatalf("count iris traffic rows: %v", err)
}
if trafficRows != 0 {
t.Fatalf("iris traffic row survived delete")
}
}
func TestGroupResetStillZeroesAfterBaselineAdjustments(t *testing.T) {
initTrafficTestDB(t)
csvc := &ClientService{}
isvc := &InboundService{}
seedGroupedClient(t, "kate", "iron", 100, 100)
if err := isvc.ResetClientTrafficByEmail("kate"); err != nil {
t.Fatalf("ResetClientTrafficByEmail: %v", err)
}
if g := groupByName(t, csvc, "iron"); g.TrafficUsed != 200 {
t.Fatalf("pre group-reset: got %+v, want used=200", g)
}
if err := csvc.ResetGroupTraffic("iron"); err != nil {
t.Fatalf("ResetGroupTraffic: %v", err)
}
if g := groupByName(t, csvc, "iron"); g.Up != 0 || g.Down != 0 || g.TrafficUsed != 0 {
t.Fatalf("group reset did not zero adjusted baselines: got %+v", g)
}
}
+53
View File
@@ -8,6 +8,8 @@ import (
"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" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/common"
"gorm.io/gorm"
) )
type GroupSummary struct { type GroupSummary struct {
@@ -69,6 +71,57 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
return out, nil return out, nil
} }
// adjustGroupBaselinesForRemovedTraffic shifts group baselines down by the clients'
// current counters so ListGroups totals survive a traffic reset or client delete (#5675).
func adjustGroupBaselinesForRemovedTraffic(tx *gorm.DB, emails []string) error {
if len(emails) == 0 {
return nil
}
type groupDelta struct {
Name string
Up int64
Down int64
}
totals := make(map[string]*groupDelta)
for _, batch := range chunkStrings(emails, sqlInChunk) {
var part []groupDelta
if err := tx.Table("clients AS c").
Select("c.group_name AS name, COALESCE(SUM(ct.up), 0) AS up, COALESCE(SUM(ct.down), 0) AS down").
Joins("JOIN client_traffics ct ON ct.email = c.email").
Where("c.group_name <> '' AND c.email IN ?", batch).
Group("c.group_name").
Scan(&part).Error; err != nil {
return err
}
for i := range part {
if agg, ok := totals[part[i].Name]; ok {
agg.Up += part[i].Up
agg.Down += part[i].Down
} else {
totals[part[i].Name] = &part[i]
}
}
}
for name, d := range totals {
if d.Up == 0 && d.Down == 0 {
continue
}
res := tx.Model(&model.ClientGroup{}).Where("name = ?", name).Updates(map[string]any{
"reset_up": gorm.Expr("reset_up - ?", d.Up),
"reset_down": gorm.Expr("reset_down - ?", d.Down),
})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
if err := tx.Create(&model.ClientGroup{Name: name, ResetUp: -d.Up, ResetDown: -d.Down}).Error; err != nil {
return err
}
}
}
return nil
}
func (s *ClientService) EmailsByGroup(name string) ([]string, error) { func (s *ClientService) EmailsByGroup(name string) ([]string, error) {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
@@ -528,6 +528,26 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
} }
if len(clients[0].AllowedIPs) == 0 { if len(clients[0].AllowedIPs) == 0 {
clients[0].AllowedIPs = old.AllowedIPs clients[0].AllowedIPs = old.AllowedIPs
} else {
normalized, nErr := normalizeWireguardAllowedIPs(clients[0].AllowedIPs)
if nErr != nil {
return false, nErr
}
if len(normalized) == 0 {
clients[0].AllowedIPs = old.AllowedIPs
} else {
peers := make([]string, 0, len(oldClients))
for i := range oldClients {
if i == clientIndex {
continue
}
peers = append(peers, oldClients[i].AllowedIPs...)
}
if hit := wireguardAllowedIPsCollision(normalized, peers); hit != "" {
return false, common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
clients[0].AllowedIPs = normalized
}
} }
if clients[0].PreSharedKey == "" { if clients[0].PreSharedKey == "" {
clients[0].PreSharedKey = old.PreSharedKey clients[0].PreSharedKey = old.PreSharedKey
+3
View File
@@ -187,6 +187,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
tombstoneClientEmails(emails) tombstoneClientEmails(emails)
if err := runSerializedTx(func(tx *gorm.DB) error { if err := runSerializedTx(func(tx *gorm.DB) error {
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
return e
}
for _, batch := range chunkInts(ids, sqlInChunk) { for _, batch := range chunkInts(ids, sqlInChunk) {
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil { if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
return e return e
+7
View File
@@ -92,6 +92,9 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
db := database.GetDB() db := database.GetDB()
return db.Transaction(func(tx *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
return err
}
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) { for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
res := tx.Model(xray.ClientTraffic{}). res := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", batch). Where("email IN ?", batch).
@@ -150,6 +153,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
return nil return nil
} }
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
return err
}
result := tx.Model(xray.ClientTraffic{}). result := tx.Model(xray.ClientTraffic{}).
Where("email IN ?", resetEmails). Where("email IN ?", resetEmails).
Updates(map[string]any{"enable": true, "up": 0, "down": 0}) Updates(map[string]any{"enable": true, "up": 0, "down": 0})
+53
View File
@@ -73,6 +73,47 @@ func allocateWireguardAddress(used []string, base string) (string, error) {
return "", common.NewError("wireguard: no free address available in", base) return "", common.NewError("wireguard: no free address available in", base)
} }
// normalizeWireguardAllowedIPs validates user-supplied allowedIPs entries and
// canonicalizes them: bare addresses become single-host prefixes, duplicates drop.
func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
p, err := netip.ParsePrefix(v)
if err != nil {
a, aErr := netip.ParseAddr(v)
if aErr != nil {
return nil, common.NewError("wireguard: invalid allowedIPs entry:", v)
}
p = netip.PrefixFrom(a, a.BitLen())
}
norm := p.String()
if _, dup := seen[norm]; dup {
continue
}
seen[norm] = struct{}{}
out = append(out, norm)
}
return out, nil
}
func wireguardAllowedIPsCollision(entries, used []string) string {
taken := make(map[string]struct{}, len(used))
for _, u := range used {
taken[strings.TrimSpace(u)] = struct{}{}
}
for _, e := range entries {
if _, ok := taken[e]; ok {
return e
}
}
return ""
}
// defaultWireguardClients fills in blank WireGuard credentials for newly added // defaultWireguardClients fills in blank WireGuard credentials for newly added
// clients: a generated keypair when none was provided, a derived public key when // clients: a generated keypair when none was provided, a derived public key when
// only a private key was given, and a unique tunnel address allocated from the // only a private key was given, and a unique tunnel address allocated from the
@@ -107,6 +148,18 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
return err return err
} }
c.AllowedIPs = []string{addr} c.AllowedIPs = []string{addr}
} else {
normalized, err := normalizeWireguardAllowedIPs(c.AllowedIPs)
if err != nil {
return err
}
if len(normalized) == 0 {
return common.NewError("wireguard: allowedIPs has no usable entry")
}
if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
}
c.AllowedIPs = normalized
} }
used = append(used, c.AllowedIPs...) used = append(used, c.AllowedIPs...)
@@ -140,3 +140,67 @@ func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs) t.Fatalf("two clients got the same address: %v", clients[0].AllowedIPs)
} }
} }
func TestNormalizeWireguardAllowedIPs(t *testing.T) {
tests := []struct {
name string
in []string
want []string
err bool
}{
{name: "cidr passes through", in: []string{"10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
{name: "bare ipv4 becomes /32", in: []string{"10.0.0.5"}, want: []string{"10.0.0.5/32"}},
{name: "bare ipv6 becomes /128", in: []string{"fd00::5"}, want: []string{"fd00::5/128"}},
{name: "trims and drops empties", in: []string{" 10.0.0.5/32 ", "", " "}, want: []string{"10.0.0.5/32"}},
{name: "dedupes", in: []string{"10.0.0.5/32", "10.0.0.5/32"}, want: []string{"10.0.0.5/32"}},
{name: "routed subnet allowed", in: []string{"10.0.0.5/32", "192.168.1.0/24"}, want: []string{"10.0.0.5/32", "192.168.1.0/24"}},
{name: "garbage rejected", in: []string{"not-an-ip"}, err: true},
{name: "bad prefix rejected", in: []string{"10.0.0.5/99"}, err: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := normalizeWireguardAllowedIPs(tt.in)
if tt.err {
if err == nil {
t.Fatalf("expected error, got %v", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != len(tt.want) {
t.Fatalf("got %v, want %v", got, tt.want)
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("got %v, want %v", got, tt.want)
}
}
})
}
}
func TestDefaultWireguardClientsHonorsAndValidatesSuppliedAllowedIPs(t *testing.T) {
existing := []model.Client{{Email: "old@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
clients := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.0.0.9"}}}
ifaces := []any{map[string]any{"email": "c@wg"}}
if err := defaultWireguardClients(existing, clients, ifaces); err != nil {
t.Fatalf("defaultWireguardClients: %v", err)
}
if len(clients[0].AllowedIPs) != 1 || clients[0].AllowedIPs[0] != "10.0.0.9/32" {
t.Fatalf("supplied allowedIPs not normalized: %v", clients[0].AllowedIPs)
}
dup := []model.Client{{Email: "d@wg", AllowedIPs: []string{"10.0.0.2/32"}}}
err := defaultWireguardClients(existing, dup, []any{map[string]any{"email": "d@wg"}})
if err == nil {
t.Fatal("duplicate allowedIPs across clients must be rejected")
}
bad := []model.Client{{Email: "e@wg", AllowedIPs: []string{"not-an-ip"}}}
if err := defaultWireguardClients(existing, bad, []any{map[string]any{"email": "e@wg"}}); err == nil {
t.Fatal("invalid allowedIPs entry must be rejected")
}
}
+15
View File
@@ -191,6 +191,17 @@ func mergeActivationExpiry(existing, node int64) int64 {
return node return node
} }
// liftActivatedClientRecordExpiries copies a node-activated deadline from
// client_traffics onto client records still holding the negative duration (#5714).
func liftActivatedClientRecordExpiries(tx *gorm.DB) error {
return tx.Exec(
`UPDATE clients
SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1)
WHERE clients.expiry_time < 0
AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`,
).Error
}
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) { func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
var structuralChange bool var structuralChange bool
err := submitTrafficWrite(func() error { err := submitTrafficWrite(func() error {
@@ -847,6 +858,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
} }
} }
if err := liftActivatedClientRecordExpiries(tx); err != nil {
logger.Warning("setRemoteTraffic: lift activated expiries failed:", err)
}
if err := tx.Commit().Error; err != nil { if err := tx.Commit().Error; err != nil {
return false, err return false, err
} }
+23 -10
View File
@@ -474,6 +474,9 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod
} }
func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error { func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{email}); err != nil {
return err
}
if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil { if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
return err return err
} }
@@ -484,6 +487,9 @@ func (s *InboundService) DelClientStat(tx *gorm.DB, email string) error {
} }
func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error { func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, emails); err != nil {
return err
}
const chunk = 400 const chunk = 400
for start := 0; start < len(emails); start += chunk { for start := 0; start < len(emails); start += chunk {
end := min(start+chunk, len(emails)) end := min(start+chunk, len(emails))
@@ -503,16 +509,20 @@ func (s *InboundService) delClientStatsByEmails(tx *gorm.DB, emails []string) er
func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error { func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
return submitTrafficWrite(func() error { return submitTrafficWrite(func() error {
db := database.GetDB() return database.GetDB().Transaction(func(tx *gorm.DB) error {
if err := clearGlobalTraffic(db, clientEmail); err != nil { if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err return err
} }
if err := db.Model(xray.ClientTraffic{}). if err := clearGlobalTraffic(tx, clientEmail); err != nil {
Where("email = ?", clientEmail). return err
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil { }
return err if err := tx.Model(xray.ClientTraffic{}).
} Where("email = ?", clientEmail).
return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
return err
}
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
})
}) })
} }
@@ -596,6 +606,9 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
return false, err return false, err
} }
if err := db.Transaction(func(tx *gorm.DB) error { if err := db.Transaction(func(tx *gorm.DB) error {
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
return err
}
if err := tx.Save(traffic).Error; err != nil { if err := tx.Save(traffic).Error; err != nil {
return err return err
} }
@@ -3,6 +3,7 @@ package service
import ( import (
"testing" "testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/mhsanaei/3x-ui/v3/internal/xray"
) )
@@ -139,3 +140,44 @@ func TestNodeRenewExtendsExpiry(t *testing.T) {
t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed) t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed)
} }
} }
// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates
// the deadline (positive ClientStats) while its settings JSON still carries the
// negative duration, so SyncInbound keeps writing the stale value into the
// client record and the Clients page shows "not started" forever.
func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) {
db := initTrafficTestDB(t)
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed")
svc := &InboundService{}
const email = "delayed"
const duration = int64(-2592000000)
const activated = int64(1798448344010)
negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}`
if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil {
t.Fatalf("seed client record: %v", err)
}
readRecordExpiry := func() int64 {
t.Helper()
var rec model.ClientRecord
if err := db.Where("email = ?", email).First(&rec).Error; err != nil {
t.Fatalf("read client record: %v", err)
}
return rec.ExpiryTime
}
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true})
if got := readRecordExpiry(); got != duration {
t.Fatalf("before activation: record expiry = %d, want %d", got, duration)
}
syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true})
if got := readTraffic(t, db, email).ExpiryTime; got != activated {
t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated)
}
if got := readRecordExpiry(); got != activated {
t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated)
}
}
+25
View File
@@ -1004,6 +1004,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
// Removals first so changed handlers and port swaps never collide with // Removals first so changed handlers and port swaps never collide with
// the additions that follow. // the additions that follow.
for _, u := range diff.RemovedUsers {
if err := hotAPI.RemoveUser(u.Tag, u.Email); err != nil && !xray.IsMissingHandlerErr(err) {
logger.Info("hot apply: remove user [", u.Email, "] from [", u.Tag, "] failed:", err)
return false
}
}
for _, tag := range diff.RemovedInboundTags { for _, tag := range diff.RemovedInboundTags {
if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) { if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
logger.Info("hot apply: remove inbound [", tag, "] failed:", err) logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
@@ -1028,6 +1034,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
return false return false
} }
} }
for _, u := range diff.AddedUsers {
if err := addUserReconciling(&hotAPI, u); err != nil {
logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err)
return false
}
}
if diff.RoutingConfig != nil { if diff.RoutingConfig != nil {
if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil { if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
logger.Info("hot apply: apply routing config failed:", err) logger.Info("hot apply: apply routing config failed:", err)
@@ -1039,6 +1051,19 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
return true return true
} }
// addUserReconciling adds a user, and on an email conflict (the user was
// already applied through the runtime API) replaces the existing user instead.
func addUserReconciling(api *xray.XrayAPI, u xray.UserOp) error {
err := api.AddUser(u.Protocol, u.Tag, u.User)
if err == nil || !xray.IsUserExistsErr(err) {
return err
}
if delErr := api.RemoveUser(u.Tag, u.Email); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
return delErr
}
return api.AddUser(u.Protocol, u.Tag, u.User)
}
// addInboundReconciling adds an inbound, and on a tag conflict (the handler // addInboundReconciling adds an inbound, and on a tag conflict (the handler
// was already created through the runtime API while the stored snapshot was // was already created through the runtime API while the stored snapshot was
// stale) replaces the existing handler instead. // stale) replaces the existing handler instead.
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "مفتاح وايرغارد العام", "wireguardPublicKey": "مفتاح وايرغارد العام",
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا", "wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد", "wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
"wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
"reverseTag": "وسم عكسي", "reverseTag": "وسم عكسي",
"reverseTagPlaceholder": "Reverse tag اختياري", "reverseTagPlaceholder": "Reverse tag اختياري",
"telegramId": "معرّف مستخدم تلغرام", "telegramId": "معرّف مستخدم تلغرام",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "WireGuard Public Key", "wireguardPublicKey": "WireGuard Public Key",
"wireguardPreSharedKey": "WireGuard Pre-Shared Key", "wireguardPreSharedKey": "WireGuard Pre-Shared Key",
"wireguardAllowedIPs": "WireGuard Allowed IPs", "wireguardAllowedIPs": "WireGuard Allowed IPs",
"wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
"reverseTag": "Reverse tag", "reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Optional reverse tag", "reverseTagPlaceholder": "Optional reverse tag",
"telegramId": "Telegram user ID", "telegramId": "Telegram user ID",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "Clave pública de WireGuard", "wireguardPublicKey": "Clave pública de WireGuard",
"wireguardPreSharedKey": "Clave precompartida de WireGuard", "wireguardPreSharedKey": "Clave precompartida de WireGuard",
"wireguardAllowedIPs": "IP permitidas de WireGuard", "wireguardAllowedIPs": "IP permitidas de WireGuard",
"wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
"reverseTag": "Etiqueta inversa", "reverseTag": "Etiqueta inversa",
"reverseTagPlaceholder": "Reverse tag opcional", "reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuario de Telegram", "telegramId": "ID de usuario de Telegram",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "کلید عمومی وایرگارد", "wireguardPublicKey": "کلید عمومی وایرگارد",
"wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد", "wireguardPreSharedKey": "کلید پیش‌اشتراکی وایرگارد",
"wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد", "wireguardAllowedIPs": "آی‌پی‌های مجاز وایرگارد",
"wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودی‌ها را با کاما جدا کنید",
"reverseTag": "تگ معکوس", "reverseTag": "تگ معکوس",
"reverseTagPlaceholder": "Reverse tag اختیاری", "reverseTagPlaceholder": "Reverse tag اختیاری",
"telegramId": "شناسه کاربر تلگرام", "telegramId": "شناسه کاربر تلگرام",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "Kunci Publik WireGuard", "wireguardPublicKey": "Kunci Publik WireGuard",
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard", "wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard", "wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
"wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
"reverseTag": "Reverse tag", "reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag opsional", "reverseTagPlaceholder": "Reverse tag opsional",
"telegramId": "ID pengguna Telegram", "telegramId": "ID pengguna Telegram",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "WireGuard 公開鍵", "wireguardPublicKey": "WireGuard 公開鍵",
"wireguardPreSharedKey": "WireGuard 事前共有鍵", "wireguardPreSharedKey": "WireGuard 事前共有鍵",
"wireguardAllowedIPs": "WireGuard 許可IP", "wireguardAllowedIPs": "WireGuard 許可IP",
"wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
"reverseTag": "Reverse tag", "reverseTag": "Reverse tag",
"reverseTagPlaceholder": "任意の Reverse tag", "reverseTagPlaceholder": "任意の Reverse tag",
"telegramId": "Telegram ユーザー ID", "telegramId": "Telegram ユーザー ID",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "Chave pública do WireGuard", "wireguardPublicKey": "Chave pública do WireGuard",
"wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard", "wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
"wireguardAllowedIPs": "IPs permitidos do WireGuard", "wireguardAllowedIPs": "IPs permitidos do WireGuard",
"wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
"reverseTag": "Tag reversa", "reverseTag": "Tag reversa",
"reverseTagPlaceholder": "Reverse tag opcional", "reverseTagPlaceholder": "Reverse tag opcional",
"telegramId": "ID de usuário do Telegram", "telegramId": "ID de usuário do Telegram",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "Публичный ключ WireGuard", "wireguardPublicKey": "Публичный ключ WireGuard",
"wireguardPreSharedKey": "Общий ключ WireGuard", "wireguardPreSharedKey": "Общий ключ WireGuard",
"wireguardAllowedIPs": "Разрешённые IP WireGuard", "wireguardAllowedIPs": "Разрешённые IP WireGuard",
"wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"reverseTag": "Обратный тег", "reverseTag": "Обратный тег",
"reverseTagPlaceholder": "Необязательный Reverse tag", "reverseTagPlaceholder": "Необязательный Reverse tag",
"telegramId": "ID пользователя Telegram", "telegramId": "ID пользователя Telegram",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "WireGuard Genel Anahtarı", "wireguardPublicKey": "WireGuard Genel Anahtarı",
"wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar", "wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
"wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler", "wireguardAllowedIPs": "WireGuard İzin Verilen IP'ler",
"wireguardAllowedIPsHint": "Otomatik atama için boş bırakın; girişleri virgülle ayırın",
"reverseTag": "Reverse Tag", "reverseTag": "Reverse Tag",
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag", "reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
"telegramId": "Telegram Kullanıcı ID'si", "telegramId": "Telegram Kullanıcı ID'si",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "Публічний ключ WireGuard", "wireguardPublicKey": "Публічний ключ WireGuard",
"wireguardPreSharedKey": "Спільний ключ WireGuard", "wireguardPreSharedKey": "Спільний ключ WireGuard",
"wireguardAllowedIPs": "Дозволені IP WireGuard", "wireguardAllowedIPs": "Дозволені IP WireGuard",
"wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
"reverseTag": "Зворотний тег", "reverseTag": "Зворотний тег",
"reverseTagPlaceholder": "Необов'язковий Reverse tag", "reverseTagPlaceholder": "Необов'язковий Reverse tag",
"telegramId": "ID користувача Telegram", "telegramId": "ID користувача Telegram",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "Khóa công khai WireGuard", "wireguardPublicKey": "Khóa công khai WireGuard",
"wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard", "wireguardPreSharedKey": "Khóa chia sẻ trước WireGuard",
"wireguardAllowedIPs": "IP được phép WireGuard", "wireguardAllowedIPs": "IP được phép WireGuard",
"wireguardAllowedIPsHint": "Để trống để tự động gán; phân tách các mục bằng dấu phẩy",
"reverseTag": "Reverse tag", "reverseTag": "Reverse tag",
"reverseTagPlaceholder": "Reverse tag tùy chọn", "reverseTagPlaceholder": "Reverse tag tùy chọn",
"telegramId": "ID người dùng Telegram", "telegramId": "ID người dùng Telegram",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "WireGuard 公钥", "wireguardPublicKey": "WireGuard 公钥",
"wireguardPreSharedKey": "WireGuard 预共享密钥", "wireguardPreSharedKey": "WireGuard 预共享密钥",
"wireguardAllowedIPs": "WireGuard 允许的 IP", "wireguardAllowedIPs": "WireGuard 允许的 IP",
"wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
"reverseTag": "反向标签", "reverseTag": "反向标签",
"reverseTagPlaceholder": "可选 Reverse tag", "reverseTagPlaceholder": "可选 Reverse tag",
"telegramId": "Telegram 用户 ID", "telegramId": "Telegram 用户 ID",
+1
View File
@@ -900,6 +900,7 @@
"wireguardPublicKey": "WireGuard 公鑰", "wireguardPublicKey": "WireGuard 公鑰",
"wireguardPreSharedKey": "WireGuard 預共用金鑰", "wireguardPreSharedKey": "WireGuard 預共用金鑰",
"wireguardAllowedIPs": "WireGuard 允許的 IP", "wireguardAllowedIPs": "WireGuard 允許的 IP",
"wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
"reverseTag": "反向標籤", "reverseTag": "反向標籤",
"reverseTagPlaceholder": "選用 Reverse tag", "reverseTagPlaceholder": "選用 Reverse tag",
"telegramId": "Telegram 使用者 ID", "telegramId": "Telegram 使用者 ID",
+9
View File
@@ -397,6 +397,15 @@ func IsExistingTagErr(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "existing tag") return strings.Contains(strings.ToLower(err.Error()), "existing tag")
} }
// IsUserExistsErr reports whether err is xray's response to adding a user whose
// email is already registered on the inbound.
func IsUserExistsErr(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "already exists")
}
// ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core // ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core
// config builders run inside the panel process. The xray binary resolves assets // config builders run inside the panel process. The xray binary resolves assets
// relative to its own executable, but the panel binary lives one level above // relative to its own executable, but the panel binary lives one level above
+137
View File
@@ -15,15 +15,27 @@ import (
type HotDiff struct { type HotDiff struct {
RemovedInboundTags []string RemovedInboundTags []string
AddedInbounds [][]byte AddedInbounds [][]byte
RemovedUsers []UserOp
AddedUsers []UserOp
RemovedOutboundTags []string RemovedOutboundTags []string
AddedOutbounds [][]byte AddedOutbounds [][]byte
RoutingConfig []byte // full new routing section; nil when unchanged RoutingConfig []byte // full new routing section; nil when unchanged
} }
// UserOp is a per-user AlterInbound operation; User is nil for removals.
type UserOp struct {
Tag string
Protocol string
Email string
User map[string]any
}
// Empty reports whether the diff contains no operations. // Empty reports whether the diff contains no operations.
func (d *HotDiff) Empty() bool { func (d *HotDiff) Empty() bool {
return len(d.RemovedInboundTags) == 0 && return len(d.RemovedInboundTags) == 0 &&
len(d.AddedInbounds) == 0 && len(d.AddedInbounds) == 0 &&
len(d.RemovedUsers) == 0 &&
len(d.AddedUsers) == 0 &&
len(d.RemovedOutboundTags) == 0 && len(d.RemovedOutboundTags) == 0 &&
len(d.AddedOutbounds) == 0 && len(d.AddedOutbounds) == 0 &&
d.RoutingConfig == nil d.RoutingConfig == nil
@@ -108,6 +120,13 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
if oldIb.Tag == apiTag || oldIb.Tag == "api" { if oldIb.Tag == apiTag || oldIb.Tag == "api" {
return false return false
} }
if exists && (inboundHasReverseClient(oldIb) || inboundHasReverseClient(newIb)) {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
return false
}
if exists && diffInboundUsers(oldIb, newIb, diff) {
continue
}
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag) diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
if exists { if exists {
raw, err := json.Marshal(newIb) raw, err := json.Marshal(newIb)
@@ -134,6 +153,124 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
return true return true
} }
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}}
// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
// differ only in settings.clients, so the handler (and its listener) survives.
func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
if oldIb.Port != newIb.Port || oldIb.Protocol != newIb.Protocol || oldIb.Tag != newIb.Tag {
return false
}
if _, ok := userDiffableProtocols[oldIb.Protocol]; !ok {
return false
}
if !rawEqualNormalized(oldIb.Listen, newIb.Listen) ||
!rawEqualNormalized(oldIb.StreamSettings, newIb.StreamSettings) ||
!rawEqualNormalized(oldIb.Sniffing, newIb.Sniffing) {
return false
}
oldClients, oldRest, ok := splitSettingsClients(oldIb.Settings)
if !ok {
return false
}
newClients, newRest, ok := splitSettingsClients(newIb.Settings)
if !ok {
return false
}
if !bytes.Equal(oldRest, newRest) {
return false
}
for email, oldC := range oldClients {
newC, exists := newClients[email]
if exists && bytes.Equal(oldC.norm, newC.norm) {
continue
}
diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
if exists {
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
}
}
for email, newC := range newClients {
if _, exists := oldClients[email]; !exists {
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
}
}
return true
}
type clientEntry struct {
user map[string]any
norm []byte
}
// splitSettingsClients indexes settings.clients by email and returns the rest of
// the settings in canonical form; ok is false when a client has no unique email.
func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []byte, bool) {
if len(raw) == 0 {
return nil, nil, false
}
settings := map[string]any{}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&settings); err != nil {
return nil, nil, false
}
clientsRaw, hasClients := settings["clients"].([]any)
if !hasClients {
return nil, nil, false
}
clients := make(map[string]clientEntry, len(clientsRaw))
for _, c := range clientsRaw {
obj, ok := c.(map[string]any)
if !ok {
return nil, nil, false
}
email, _ := obj["email"].(string)
if email == "" {
return nil, nil, false
}
if _, dup := clients[email]; dup {
return nil, nil, false
}
norm, err := json.Marshal(obj)
if err != nil {
return nil, nil, false
}
clients[email] = clientEntry{user: obj, norm: norm}
}
delete(settings, "clients")
rest, err := json.Marshal(settings)
if err != nil {
return nil, nil, false
}
return clients, rest, true
}
func inboundHasReverseClient(ib *InboundConfig) bool {
if ib == nil {
return false
}
var settings struct {
Clients []struct {
Reverse json.RawMessage `json:"reverse"`
} `json:"clients"`
}
if err := json.Unmarshal(ib.Settings, &settings); err != nil {
return false
}
for _, c := range settings.Clients {
if len(c.Reverse) == 0 {
continue
}
var tag any
if err := json.Unmarshal(c.Reverse, &tag); err != nil || tag == nil {
continue
}
return true
}
return false
}
// diffOutbounds fills diff with outbound removals/additions keyed by tag. // diffOutbounds fills diff with outbound removals/additions keyed by tag.
// The first outbound is xray's default handler and the API can only append, // The first outbound is xray's default handler and the API can only append,
// so any change to its identity or content forces a restart. Reordering of // so any change to its identity or content forces a restart. Reordering of
+76 -2
View File
@@ -148,8 +148,8 @@ func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) { func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
oldCfg := makeHotConfig() oldCfg := makeHotConfig()
newCfg := makeHotConfig() newCfg := makeHotConfig()
// change existing // change existing beyond the clients list, so no user-level shortcut applies
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a"}]}`) newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[],"decryption":"none"}`)
// add new // add new
newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{ newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
Port: 2080, Protocol: "vmess", Tag: "inbound-2080", Port: 2080, Protocol: "vmess", Tag: "inbound-2080",
@@ -171,6 +171,80 @@ func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
} }
} }
func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) {
oldCfg := makeHotConfig()
oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
newCfg := makeHotConfig()
// b expired and is stripped from the generated config (#5712); a's id rotated.
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a2"},{"email":"c","id":"uuid-c"}],"decryption":"none"}`)
diff, ok := ComputeHotDiff(oldCfg, newCfg)
if !ok {
t.Fatal("client-only change must be hot-appliable")
}
if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
t.Fatalf("client-only change must not replace the handler, got %+v", diff)
}
removed := map[string]bool{}
for _, u := range diff.RemovedUsers {
if u.Tag != "inbound-1080" || u.Protocol != "vless" {
t.Fatalf("removed user op has wrong target: %+v", u)
}
removed[u.Email] = true
}
if len(removed) != 2 || !removed["a"] || !removed["b"] {
t.Fatalf("expected users a (changed) and b (gone) removed, got %v", removed)
}
added := map[string]string{}
for _, u := range diff.AddedUsers {
id, _ := u.User["id"].(string)
added[u.Email] = id
}
if len(added) != 2 || added["a"] != "uuid-a2" || added["c"] != "uuid-c" {
t.Fatalf("expected users a (new id) and c added, got %v", added)
}
}
func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) {
cases := []struct {
name string
mutate func(cfg *Config)
}{
{
name: "unsupported protocol",
mutate: func(cfg *Config) {
cfg.InboundConfigs[1].Protocol = "shadowsocks"
},
},
{
name: "client without email",
mutate: func(cfg *Config) {
cfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"id":"uuid-a"}]}`)
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
oldCfg := makeHotConfig()
newCfg := makeHotConfig()
tc.mutate(oldCfg)
tc.mutate(newCfg)
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"x","id":"uuid-x","password":"pw"}]}`)
diff, ok := ComputeHotDiff(oldCfg, newCfg)
if !ok {
t.Fatal("change must still be hot-appliable via handler replacement")
}
if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 0 {
t.Fatalf("expected no user ops, got %+v", diff)
}
if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 {
t.Fatalf("expected handler replacement, got %+v", diff)
}
})
}
}
func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) { func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) {
newCfg := makeHotConfig() newCfg := makeHotConfig()
newCfg.InboundConfigs[0].Port = 62790 newCfg.InboundConfigs[0].Port = 62790