fix(clients): refuse a ":" email on a MASQUE inbound

MASQUE logs a user in with the email as the Basic-auth user name, so
xray-core refuses the whole inbound (MASQUE: invalid "email") over one
email holding ":", and with it the Xray start. The panel accepts such an
email for every other protocol, so attaching or creating that client on
a MASQUE inbound took every inbound down on the next restart.

syncInboundClients is the write every create, attach, bulk, import and
inbound-edit path shares inside its transaction, before the runtime
push, so the per-protocol check there now refuses the email for MASQUE
next to the existing TUIC identity check, loading the protocol once.
This commit is contained in:
MHSanaei
2026-10-11 16:35:20 +02:00
parent 27e45f3039
commit c4b9b3f9c7
2 changed files with 65 additions and 4 deletions
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -57,6 +58,46 @@ func TestGetXrayConfig_EmitsMasqueUserPasswords(t *testing.T) {
t.Fatal("inbound in-8443-masque not found in the generated config")
}
// MASQUE logs users in over Basic auth, so xray-core refuses the whole inbound over one
// email holding ":"; the panel allows that email elsewhere and must refuse it here.
func TestCreateClient_RefusesColonEmailOnMasque(t *testing.T) {
setupConflictDB(t)
in := &model.Inbound{
Tag: "in-8443-masque", Enable: true, Listen: "127.0.0.1", Port: 8443, Protocol: model.MASQUE,
Settings: `{"clients":[{"email":"ivy@example.test","password":"masque-pass-ivy","enable":true}],"address":["10.14.0.1/24"]}`,
StreamSettings: masqueTestStream,
}
if _, _, err := (&InboundService{}).AddInbound(in); err != nil {
t.Fatalf("AddInbound: %v", err)
}
_, err := (&ClientService{}).Create(&InboundService{}, &ClientCreatePayload{
Client: model.Client{Email: "tg:42", Enable: true},
InboundIds: []int{in.Id},
})
if want := `MASQUE: client email "tg:42" cannot contain ":"`; err == nil || !strings.Contains(err.Error(), want) {
t.Fatalf("Create err = %v, want one containing %q", err, want)
}
cfg, err := (&XrayService{}).GetXrayConfig()
if err != nil {
t.Fatalf("GetXrayConfig: %v", err)
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag != in.Tag {
continue
}
raw, _ := json.Marshal(cfg.InboundConfigs[i])
var emitted map[string]any
if err := json.Unmarshal(raw, &emitted); err != nil {
t.Fatalf("decode emitted inbound: %v", err)
}
assertXrayAccepts(t, "the emitted MASQUE inbound", buildGoldenInbound(t, emitted))
return
}
t.Fatalf("inbound %s not found in the generated config", in.Tag)
}
// A client added to a MASQUE inbound without a password could never authenticate,
// and xray-core refuses the whole inbound over one empty pass.
func TestFillProtocolDefaults_MintsMasquePassword(t *testing.T) {