mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 17:15:24 +03:00
Compare commits
9 Commits
v3.4.2
..
64c306037f
| Author | SHA1 | Date | |
|---|---|---|---|
| 64c306037f | |||
| 8dd3b31ee8 | |||
| e5b56c9444 | |||
| 1153d5db8c | |||
| 539bcc897c | |||
| 273f88721e | |||
| 1f2e3e1447 | |||
| 49773c18de | |||
| 427613b308 |
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -900,6 +900,7 @@
|
|||||||
"wireguardPublicKey": "مفتاح وايرغارد العام",
|
"wireguardPublicKey": "مفتاح وايرغارد العام",
|
||||||
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
|
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
|
||||||
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
|
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
|
||||||
|
"wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
|
||||||
"reverseTag": "وسم عكسي",
|
"reverseTag": "وسم عكسي",
|
||||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||||
"telegramId": "معرّف مستخدم تلغرام",
|
"telegramId": "معرّف مستخدم تلغرام",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -900,6 +900,7 @@
|
|||||||
"wireguardPublicKey": "کلید عمومی وایرگارد",
|
"wireguardPublicKey": "کلید عمومی وایرگارد",
|
||||||
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
|
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
|
||||||
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
|
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
|
||||||
|
"wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودیها را با کاما جدا کنید",
|
||||||
"reverseTag": "تگ معکوس",
|
"reverseTag": "تگ معکوس",
|
||||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||||
"telegramId": "شناسه کاربر تلگرام",
|
"telegramId": "شناسه کاربر تلگرام",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user