mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-14 17:15:24 +03:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64c306037f | |||
| 8dd3b31ee8 | |||
| e5b56c9444 | |||
| 1153d5db8c | |||
| 539bcc897c | |||
| 273f88721e | |||
| 1f2e3e1447 |
@@ -13,7 +13,7 @@ import type { Sniffing } from '@/schemas/primitives';
|
||||
import type { z } from 'zod';
|
||||
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||
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({});
|
||||
|
||||
@@ -164,7 +164,9 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
|
||||
const streamRecord = streamSettings as unknown as Record<string, unknown>;
|
||||
const xh = streamRecord.xhttpSettings;
|
||||
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;
|
||||
if (xmux && typeof xmux === 'object' && !Array.isArray(xmux)) {
|
||||
xhttp.enableXmux = true;
|
||||
|
||||
@@ -108,6 +108,18 @@ export function validateRealityTarget(target: string): string | 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 {
|
||||
for (const key of keys) {
|
||||
const v = obj[key];
|
||||
@@ -160,6 +172,7 @@ export function normalizeXhttpForWire(
|
||||
side: StreamWireSide,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...raw };
|
||||
liftLegacyXhttpSessionKeys(out);
|
||||
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
||||
const enableXmux = out.enableXmux === true;
|
||||
delete out.enableXmux;
|
||||
|
||||
@@ -492,6 +492,13 @@ export default function ClientFormModal({
|
||||
if (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
|
||||
@@ -802,11 +809,16 @@ export default function ClientFormModal({
|
||||
onChange={(e) => update('wgPreSharedKey', e.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{isEdit && form.wgAllowedIPs && (
|
||||
<Form.Item label={t('pages.clients.wireguardAllowedIPs')}>
|
||||
<Input value={form.wgAllowedIPs} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
label={t('pages.clients.wireguardAllowedIPs')}
|
||||
extra={t('pages.clients.wireguardAllowedIPsHint')}
|
||||
>
|
||||
<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,
|
||||
} from '@/lib/xray/inbound-form-adapter';
|
||||
import { InboundDbFieldsSchema, InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||
import { normalizeXhttpForWire } from '@/lib/xray/stream-wire-normalize';
|
||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||
|
||||
// Round-trip: raw DB row → InboundFormValues → wire payload, asserting
|
||||
@@ -304,3 +305,51 @@ describe('subSortIndex', () => {
|
||||
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{},
|
||||
}
|
||||
for _, mdl := range models {
|
||||
if IsPostgres() && postgresModelSettled(mdl) {
|
||||
continue
|
||||
}
|
||||
if err := db.AutoMigrate(mdl); err != nil {
|
||||
if isIgnorableDuplicateColumnErr(err, mdl) {
|
||||
log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
|
||||
@@ -119,6 +122,30 @@ func initModels() error {
|
||||
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 {
|
||||
if !IsPostgres() {
|
||||
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())
|
||||
}
|
||||
|
||||
// resyncPostgresSequences sets each model's id sequence to MAX(id) so the next
|
||||
// auto-increment INSERT won't collide with an existing row. Table names are
|
||||
// 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.
|
||||
// resyncPostgresSequences sets each model's id sequence to MAX(id); idempotent. Id-less
|
||||
// composite-PK tables are skipped — Postgres rejects MAX(id) at parse time and logs it (#5665).
|
||||
func resyncPostgresSequences(db *gorm.DB, models []any) error {
|
||||
for _, m := range models {
|
||||
stmt := &gorm.Statement{DB: db}
|
||||
if err := stmt.Parse(m); err != nil {
|
||||
t, ok := tableWithIdColumn(db, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
t := stmt.Table
|
||||
// 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.
|
||||
_ = db.Exec(
|
||||
@@ -293,3 +288,16 @@ func resyncPostgresSequences(db *gorm.DB, models []any) error {
|
||||
}
|
||||
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["mldsa65Verify"] = rltyClientSettings["mldsa65Verify"]
|
||||
|
||||
// Set random data
|
||||
rltyData["spiderX"] = "/" + random.Seq(15)
|
||||
if spx, ok := rltyClientSettings["spiderX"].(string); ok && spx != "" {
|
||||
rltyData["spiderX"] = spx
|
||||
}
|
||||
shortIds, ok := rData["shortIds"].([]any)
|
||||
if ok && len(shortIds) > 0 {
|
||||
rltyData["shortId"] = shortIds[random.Num(len(shortIds))].(string)
|
||||
|
||||
@@ -254,3 +254,46 @@ func TestSubJsonServiceGlobalMuxWhenNoXmux(t *testing.T) {
|
||||
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)
|
||||
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
|
||||
// (applyShareRealityParams, service.go:1147). serverNames/shortIds are single-element
|
||||
// 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.
|
||||
// Locks the reality field mapping of applyShareRealityParams; a configured
|
||||
// spiderX must round-trip verbatim (#5718), distinct pbk/sid catch a swap mutant.
|
||||
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 := `{
|
||||
"network":"tcp","security":"reality",
|
||||
"tcpSettings":{"header":{"type":"none"}},
|
||||
@@ -69,21 +101,7 @@ func TestGenVlessLink_RealityParamsMapped(t *testing.T) {
|
||||
s := &SubService{}
|
||||
link := s.genVlessLink(shareLinkInbound(stream), "user")
|
||||
|
||||
wants := []string{
|
||||
"security=reality",
|
||||
"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)
|
||||
if !strings.Contains(link, "spx=%2F") {
|
||||
t.Fatalf("reality link missing random spx fallback\n got: %s", link)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,8 @@ func (j *XrayTrafficJob) Run() {
|
||||
logger.Warning("get RestartXrayOnClientDisable failed:", settingErr)
|
||||
}
|
||||
if restartOnDisable {
|
||||
if err := j.xrayService.RestartXray(true); err != nil {
|
||||
logger.Warning("restart xray after disabling clients failed:", err)
|
||||
if err := j.xrayService.RestartXray(false); err != nil {
|
||||
logger.Warning("reconcile xray after disabling clients failed:", err)
|
||||
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
|
||||
// cross-transaction lock-order deadlock on client_traffics/inbounds.
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(successIds, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -466,24 +466,31 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
|
||||
}
|
||||
|
||||
db := database.GetDB()
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
}
|
||||
if err := db.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
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 := db.Transaction(func(tx *gorm.DB) error {
|
||||
if existing.Email != "" {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{existing.Email}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := clearGlobalTraffic(db, existing.Email); err != nil {
|
||||
return needRestart, err
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientInbound{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Where("client_email = ?", existing.Email).Delete(&model.InboundClientIps{}).Error; err != nil {
|
||||
return needRestart, err
|
||||
if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := db.Delete(&model.ClientRecord{}, id).Error; err != nil {
|
||||
if !keepTraffic && existing.Email != "" {
|
||||
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, nil
|
||||
|
||||
@@ -125,3 +125,105 @@ func TestResetGroupTraffic_EmptyNameRejected(t *testing.T) {
|
||||
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/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GroupSummary struct {
|
||||
@@ -69,6 +71,57 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
||||
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) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
|
||||
@@ -528,6 +528,26 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||
}
|
||||
if len(clients[0].AllowedIPs) == 0 {
|
||||
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 == "" {
|
||||
clients[0].PreSharedKey = old.PreSharedKey
|
||||
|
||||
@@ -187,6 +187,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
|
||||
tombstoneClientEmails(emails)
|
||||
|
||||
if err := runSerializedTx(func(tx *gorm.DB) error {
|
||||
if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
|
||||
return e
|
||||
}
|
||||
for _, batch := range chunkInts(ids, sqlInChunk) {
|
||||
if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
|
||||
return e
|
||||
|
||||
@@ -92,6 +92,9 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
||||
err := submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
|
||||
res := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ?", batch).
|
||||
@@ -150,6 +153,10 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email IN ?", resetEmails).
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -107,6 +148,18 @@ func defaultWireguardClients(existing, clients []model.Client, interfaceClients
|
||||
return err
|
||||
}
|
||||
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...)
|
||||
|
||||
|
||||
@@ -140,3 +140,67 @@ func TestDefaultWireguardClientsAllocatesDistinctIPs(t *testing.T) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var structuralChange bool
|
||||
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 {
|
||||
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 {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{email}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("email = ?", email).Delete(xray.ClientTraffic{}).Error; err != nil {
|
||||
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 {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, emails); err != nil {
|
||||
return err
|
||||
}
|
||||
const chunk = 400
|
||||
for start := 0; start < len(emails); start += chunk {
|
||||
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 {
|
||||
return submitTrafficWrite(func() error {
|
||||
db := database.GetDB()
|
||||
if err := clearGlobalTraffic(db, clientEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", clientEmail).
|
||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(xray.ClientTraffic{}).
|
||||
Where("email = ?", clientEmail).
|
||||
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
|
||||
}
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
|
||||
logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
|
||||
@@ -1028,6 +1034,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
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 err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
|
||||
logger.Info("hot apply: apply routing config failed:", err)
|
||||
@@ -1039,6 +1051,19 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
|
||||
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
|
||||
// was already created through the runtime API while the stored snapshot was
|
||||
// stale) replaces the existing handler instead.
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "مفتاح وايرغارد العام",
|
||||
"wireguardPreSharedKey": "مفتاح وايرغارد المشترك مسبقًا",
|
||||
"wireguardAllowedIPs": "عناوين IP المسموحة لوايرغارد",
|
||||
"wireguardAllowedIPsHint": "اتركه فارغًا للتعيين التلقائي؛ افصل بين الإدخالات بفواصل",
|
||||
"reverseTag": "وسم عكسي",
|
||||
"reverseTagPlaceholder": "Reverse tag اختياري",
|
||||
"telegramId": "معرّف مستخدم تلغرام",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "WireGuard Public Key",
|
||||
"wireguardPreSharedKey": "WireGuard Pre-Shared Key",
|
||||
"wireguardAllowedIPs": "WireGuard Allowed IPs",
|
||||
"wireguardAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "Optional reverse tag",
|
||||
"telegramId": "Telegram user ID",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "Clave pública de WireGuard",
|
||||
"wireguardPreSharedKey": "Clave precompartida de WireGuard",
|
||||
"wireguardAllowedIPs": "IP permitidas de WireGuard",
|
||||
"wireguardAllowedIPsHint": "Déjalo vacío para asignar automáticamente; separa las entradas con comas",
|
||||
"reverseTag": "Etiqueta inversa",
|
||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||
"telegramId": "ID de usuario de Telegram",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "کلید عمومی وایرگارد",
|
||||
"wireguardPreSharedKey": "کلید پیشاشتراکی وایرگارد",
|
||||
"wireguardAllowedIPs": "آیپیهای مجاز وایرگارد",
|
||||
"wireguardAllowedIPsHint": "برای تخصیص خودکار خالی بگذارید؛ ورودیها را با کاما جدا کنید",
|
||||
"reverseTag": "تگ معکوس",
|
||||
"reverseTagPlaceholder": "Reverse tag اختیاری",
|
||||
"telegramId": "شناسه کاربر تلگرام",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "Kunci Publik WireGuard",
|
||||
"wireguardPreSharedKey": "Kunci Pra-Berbagi WireGuard",
|
||||
"wireguardAllowedIPs": "IP yang Diizinkan WireGuard",
|
||||
"wireguardAllowedIPsHint": "Biarkan kosong untuk penetapan otomatis; pisahkan entri dengan koma",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "Reverse tag opsional",
|
||||
"telegramId": "ID pengguna Telegram",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "WireGuard 公開鍵",
|
||||
"wireguardPreSharedKey": "WireGuard 事前共有鍵",
|
||||
"wireguardAllowedIPs": "WireGuard 許可IP",
|
||||
"wireguardAllowedIPsHint": "空欄で自動割り当て。複数指定はカンマ区切り",
|
||||
"reverseTag": "Reverse tag",
|
||||
"reverseTagPlaceholder": "任意の Reverse tag",
|
||||
"telegramId": "Telegram ユーザー ID",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "Chave pública do WireGuard",
|
||||
"wireguardPreSharedKey": "Chave pré-compartilhada do WireGuard",
|
||||
"wireguardAllowedIPs": "IPs permitidos do WireGuard",
|
||||
"wireguardAllowedIPsHint": "Deixe vazio para atribuir automaticamente; separe as entradas com vírgulas",
|
||||
"reverseTag": "Tag reversa",
|
||||
"reverseTagPlaceholder": "Reverse tag opcional",
|
||||
"telegramId": "ID de usuário do Telegram",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "Публичный ключ WireGuard",
|
||||
"wireguardPreSharedKey": "Общий ключ WireGuard",
|
||||
"wireguardAllowedIPs": "Разрешённые IP WireGuard",
|
||||
"wireguardAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
|
||||
"reverseTag": "Обратный тег",
|
||||
"reverseTagPlaceholder": "Необязательный Reverse tag",
|
||||
"telegramId": "ID пользователя Telegram",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "WireGuard Genel Anahtarı",
|
||||
"wireguardPreSharedKey": "WireGuard Ön Paylaşımlı Anahtar",
|
||||
"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",
|
||||
"reverseTagPlaceholder": "İsteğe Bağlı Reverse Tag",
|
||||
"telegramId": "Telegram Kullanıcı ID'si",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "Публічний ключ WireGuard",
|
||||
"wireguardPreSharedKey": "Спільний ключ WireGuard",
|
||||
"wireguardAllowedIPs": "Дозволені IP WireGuard",
|
||||
"wireguardAllowedIPsHint": "Залиште порожнім для автопризначення; розділяйте записи комами",
|
||||
"reverseTag": "Зворотний тег",
|
||||
"reverseTagPlaceholder": "Необов'язковий Reverse tag",
|
||||
"telegramId": "ID користувача Telegram",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "Khóa công khai WireGuard",
|
||||
"wireguardPreSharedKey": "Khóa chia sẻ trước 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",
|
||||
"reverseTagPlaceholder": "Reverse tag tùy chọn",
|
||||
"telegramId": "ID người dùng Telegram",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "WireGuard 公钥",
|
||||
"wireguardPreSharedKey": "WireGuard 预共享密钥",
|
||||
"wireguardAllowedIPs": "WireGuard 允许的 IP",
|
||||
"wireguardAllowedIPsHint": "留空则自动分配;多个条目用逗号分隔",
|
||||
"reverseTag": "反向标签",
|
||||
"reverseTagPlaceholder": "可选 Reverse tag",
|
||||
"telegramId": "Telegram 用户 ID",
|
||||
|
||||
@@ -900,6 +900,7 @@
|
||||
"wireguardPublicKey": "WireGuard 公鑰",
|
||||
"wireguardPreSharedKey": "WireGuard 預共用金鑰",
|
||||
"wireguardAllowedIPs": "WireGuard 允許的 IP",
|
||||
"wireguardAllowedIPsHint": "留空則自動分配;多個條目用逗號分隔",
|
||||
"reverseTag": "反向標籤",
|
||||
"reverseTagPlaceholder": "選用 Reverse tag",
|
||||
"telegramId": "Telegram 使用者 ID",
|
||||
|
||||
@@ -397,6 +397,15 @@ func IsExistingTagErr(err error) bool {
|
||||
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
|
||||
// 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
|
||||
|
||||
@@ -15,15 +15,27 @@ import (
|
||||
type HotDiff struct {
|
||||
RemovedInboundTags []string
|
||||
AddedInbounds [][]byte
|
||||
RemovedUsers []UserOp
|
||||
AddedUsers []UserOp
|
||||
RemovedOutboundTags []string
|
||||
AddedOutbounds [][]byte
|
||||
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.
|
||||
func (d *HotDiff) Empty() bool {
|
||||
return len(d.RemovedInboundTags) == 0 &&
|
||||
len(d.AddedInbounds) == 0 &&
|
||||
len(d.RemovedUsers) == 0 &&
|
||||
len(d.AddedUsers) == 0 &&
|
||||
len(d.RemovedOutboundTags) == 0 &&
|
||||
len(d.AddedOutbounds) == 0 &&
|
||||
d.RoutingConfig == nil
|
||||
@@ -112,6 +124,9 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
||||
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)
|
||||
if exists {
|
||||
raw, err := json.Marshal(newIb)
|
||||
@@ -138,6 +153,99 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
||||
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
|
||||
|
||||
@@ -148,8 +148,8 @@ func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
|
||||
func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
|
||||
oldCfg := makeHotConfig()
|
||||
newCfg := makeHotConfig()
|
||||
// change existing
|
||||
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a"}]}`)
|
||||
// change existing beyond the clients list, so no user-level shortcut applies
|
||||
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[],"decryption":"none"}`)
|
||||
// add new
|
||||
newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
|
||||
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) {
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.InboundConfigs[0].Port = 62790
|
||||
|
||||
Reference in New Issue
Block a user