diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index c04ef9e10..a782ae85a 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -530,6 +530,50 @@ func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) { } } +// finalMaskRealityTcpMasks returns the stream's finalmask.tcp masks when the +// stream uses REALITY security, or nil otherwise. A non-empty result means +// this stream carries the finalmask+REALITY combination that panics +// Xray-core (see https://github.com/XTLS/Xray-core/issues/6453): finalmask +// wraps the connection before REALITY's handshake ever sees it, and +// reality.Server() does an unchecked type assertion assuming a raw +// *net.TCPConn, which panics once finalmask is in front of it. +// +// Only finalmask.tcp matters here — TcpmaskManager (the thing that wraps the +// listener ahead of REALITY's handshake, in xray-core's own +// transport/internet/memory_settings.go) is only constructed when tcp masks +// are present; a finalmask.udp-only config never touches the TCP accept path +// REALITY runs on, so it doesn't reproduce this panic and shouldn't be +// rejected. +func finalMaskRealityTcpMasks(stream map[string]any) []any { + if stream["security"] != "reality" { + return nil + } + finalmask, ok := stream["finalmask"].(map[string]any) + if !ok { + return nil + } + tcp, _ := finalmask["tcp"].([]any) + return tcp +} + +// validateFinalMaskRealityCombo rejects finalmask.tcp configured together +// with REALITY security at save time. Upstream has confirmed this +// combination will be documented as unsupported rather than made graceful, +// so the panel must not let it be saved. +func validateFinalMaskRealityCombo(streamSettings string) error { + if streamSettings == "" { + return nil + } + var stream map[string]any + if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { + return nil + } + if len(finalMaskRealityTcpMasks(stream)) == 0 { + return nil + } + return common.NewError("Finalmask is not supported with REALITY security — it crashes Xray-core on the first connection (see XTLS/Xray-core#6453). Remove the finalmask configuration or switch security to tls/none.") +} + // normalizeMtprotoSecret rebuilds every mtproto client's FakeTLS secret so it is // always valid before the row is persisted, and drops the vestigial inbound-level // secret and adTag: MTProto is multi-client, so mtg and every share link read @@ -659,6 +703,9 @@ func (s *InboundService) normalizeMtprotoXrayPort(inbound *model.Inbound, oldSet func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) + if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil { + return inbound, false, err + } s.normalizeMtprotoSecret(inbound) if err := s.normalizeMtprotoXrayPort(inbound, ""); err != nil { return inbound, false, err @@ -1082,6 +1129,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) { func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) { // Normalize streamSettings based on protocol s.normalizeStreamSettings(inbound) + if err := validateFinalMaskRealityCombo(inbound.StreamSettings); err != nil { + return inbound, false, err + } s.normalizeMtprotoSecret(inbound) inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex) diff --git a/internal/web/service/inbound_finalmask_reality_test.go b/internal/web/service/inbound_finalmask_reality_test.go new file mode 100644 index 000000000..3fb1086cc --- /dev/null +++ b/internal/web/service/inbound_finalmask_reality_test.go @@ -0,0 +1,157 @@ +package service + +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +const realityFinalMaskStream = `{"network":"tcp","security":"reality","realitySettings":{},"finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}` + +func TestValidateFinalMaskRealityCombo(t *testing.T) { + tests := []struct { + name string + streamSettings string + wantErr bool + }{ + { + name: "empty streamSettings", + streamSettings: "", + wantErr: false, + }, + { + name: "reality without finalmask", + streamSettings: `{"security":"reality","realitySettings":{}}`, + wantErr: false, + }, + { + name: "reality with empty finalmask", + streamSettings: `{"security":"reality","finalmask":{"tcp":[],"udp":[]}}`, + wantErr: false, + }, + { + name: "reality with tcp fragment finalmask", + streamSettings: `{"security":"reality","finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`, + wantErr: true, + }, + { + // UDP masks never touch the TCP accept path REALITY runs on — + // TcpmaskManager (the thing that wraps the listener ahead of + // REALITY's handshake) is only built when tcp masks are present, + // so a udp-only config doesn't reproduce the panic and shouldn't + // be rejected. + name: "reality with udp-only finalmask (does not reproduce the panic)", + streamSettings: `{"security":"reality","finalmask":{"udp":[{"type":"salamander"}]}}`, + wantErr: false, + }, + { + name: "non-reality security with finalmask", + streamSettings: `{"security":"tls","finalmask":{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}}`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateFinalMaskRealityCombo(tt.streamSettings) + if (err != nil) != tt.wantErr { + t.Errorf("validateFinalMaskRealityCombo(%q) error = %v, wantErr %v", tt.streamSettings, err, tt.wantErr) + } + }) + } +} + +// end-to-end: the guard must actually be wired into AddInbound, not just +// exist as a standalone helper a caller could forget to invoke. +func TestAddInbound_RejectsFinalMaskRealityCombo(t *testing.T) { + setupConflictDB(t) + + svc := &InboundService{} + in := &model.Inbound{ + Tag: "in-44300-tcp", + Enable: true, + Listen: "0.0.0.0", + Port: 44300, + Protocol: model.VLESS, + StreamSettings: realityFinalMaskStream, + Settings: `{"clients":[]}`, + } + if _, _, err := svc.AddInbound(in); err == nil { + t.Fatal("AddInbound: want error for finalmask+reality, got nil") + } + + var count int64 + if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "in-44300-tcp").Count(&count).Error; err != nil { + t.Fatalf("count rows: %v", err) + } + if count != 0 { + t.Fatalf("AddInbound: rejected inbound was persisted anyway, row count = %d", count) + } +} + +// end-to-end: same guard on the update path, on a row that was valid before +// the edit — the rejected StreamSettings must not overwrite the stored row. +func TestUpdateInbound_RejectsFinalMaskRealityCombo(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "in-44301-tcp", "0.0.0.0", 44301, model.VLESS, + `{"network":"tcp","security":"reality","realitySettings":{}}`, `{"clients":[]}`) + + var existing model.Inbound + if err := database.GetDB().Where("tag = ?", "in-44301-tcp").First(&existing).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + + svc := &InboundService{} + update := existing + update.StreamSettings = realityFinalMaskStream + if _, _, err := svc.UpdateInbound(&update); err == nil { + t.Fatal("UpdateInbound: want error for finalmask+reality, got nil") + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.StreamSettings != existing.StreamSettings { + t.Fatalf("UpdateInbound: rejected StreamSettings was persisted anyway\ngot: %s\nwant: %s", reloaded.StreamSettings, existing.StreamSettings) + } +} + +// GetXrayConfig must heal a row that already carries finalmask+reality in the +// DB (saved before this guard existed - an upgrade, a node sync, a restored +// backup, or a direct DB edit) rather than handing xray-core a config that +// panics it on the first connection. Bypasses AddInbound/UpdateInbound +// entirely by writing the row directly, the same way a pre-existing bad row +// would already be sitting in a real database. +func TestGetXrayConfig_HealsFinalMaskRealityCombo(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "in-44302-tcp", "0.0.0.0", 44302, model.VLESS, + realityFinalMaskStream, `{"clients":[]}`) + + svc := &XrayService{} + cfg, err := svc.GetXrayConfig() + if err != nil { + t.Fatalf("GetXrayConfig: %v", err) + } + + for i := range cfg.InboundConfigs { + ic := cfg.InboundConfigs[i] + if ic.Tag != "in-44302-tcp" { + continue + } + var stream map[string]any + if err := json.Unmarshal(ic.StreamSettings, &stream); err != nil { + t.Fatalf("unmarshal emitted streamSettings: %v", err) + } + if stream["security"] != "reality" { + t.Fatalf("security = %v, want reality (test setup broken)", stream["security"]) + } + if _, has := stream["finalmask"]; has { + t.Fatalf("emitted config still carries finalmask alongside reality — this crashes Xray-core: %v", stream["finalmask"]) + } + return + } + t.Fatalf("inbound in-44302-tcp not found in generated config") +} diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 4b84e4c34..afff99176 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -282,6 +282,18 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { delete(stream, "externalProxy") + // finalmask.tcp + REALITY panics Xray-core on the first connection + // (XTLS/Xray-core#6453). AddInbound/UpdateInbound reject this + // combination at save time, but a row saved before that guard + // existed (upgrade, node sync, restored backup, direct DB edit) + // would still crash Xray on the next restart without this — drop + // it here too, the same way liftXhttpSessionIDKeys and + // HealShadowsocksClientMethods heal other legacy data in place. + if len(finalMaskRealityTcpMasks(stream)) > 0 { + logger.Warningf("Inbound %q: dropping finalmask, incompatible with REALITY security (crashes Xray-core, see XTLS/Xray-core#6453)", inbound.Tag) + delete(stream, "finalmask") + } + // xray-core v26.6.22 (#6258) renamed the XHTTP session keys and // kept no fallback. Lift legacy sessionPlacement/sessionKey onto the // new names here so inbounds stored before the rename keep working