feat(sub): modern xray JSON format with unified finalmask editor (#4912)

* feat(sub): add finalmask support to JSON subscriptions

* feat(sub): modern xray JSON format with unified finalmask editor

Drop the legacy JSON subscription format entirely and always emit the
modern xray shape:

- Flatten proxy outbounds (no vnext/servers) for vless/vmess/trojan/
  shadowsocks; hysteria was already flat.
- Express fragment/noise via streamSettings.finalmask instead of the
  legacy direct_out freedom dialer + dialerProxy sockopt.

The global finalmask (tcp/udp masks + quicParams) is stored as a single
setting (subJsonFinalMask) and merged into every generated stream,
replacing the separate subJsonFragment/subJsonNoises/subJsonQuicParams
settings.

Reuse the existing FinalMaskForm (used by inbound/outbound) for the
settings UI via a small bridge component; add a showAll prop so all
TCP/UDP/QUIC sections render for the global case. This supersedes the
hand-rolled Fragment/Noises/quicParams tabs with the full mask editor
(all mask types).

Note: this is a breaking change — JSON subscriptions now require a
recent xray client on the consumer side.

* fix

---------

Co-authored-by: biohazardous-man <biohazardous-man@users.noreply.github.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
biohazardous-man
2026-06-05 00:51:48 +03:00
committed by GitHub
parent f947fbd6c6
commit 97f88fb1a9
27 changed files with 352 additions and 277 deletions
+6 -11
View File
@@ -120,16 +120,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubUpdates = "10"
}
SubJsonFragment, err := s.settingService.GetSubJsonFragment()
if err != nil {
SubJsonFragment = ""
}
SubJsonNoises, err := s.settingService.GetSubJsonNoises()
if err != nil {
SubJsonNoises = ""
}
SubJsonMux, err := s.settingService.GetSubJsonMux()
if err != nil {
SubJsonMux = ""
@@ -140,6 +130,11 @@ func (s *Server) initRouter() (*gin.Engine, error) {
SubJsonRules = ""
}
SubJsonFinalMask, err := s.settingService.GetSubJsonFinalMask()
if err != nil {
SubJsonFinalMask = ""
}
SubClashEnableRouting, err := s.settingService.GetSubClashEnableRouting()
if err != nil {
SubClashEnableRouting = false
@@ -236,7 +231,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
s.sub = NewSUBController(
g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules)
return engine, nil
+2 -3
View File
@@ -62,10 +62,9 @@ func NewSUBController(
showInfo bool,
rModel string,
update string,
jsonFragment string,
jsonNoise string,
jsonMux string,
jsonRules string,
jsonFinalMask string,
clashEnableRouting bool,
clashRules string,
subTitle string,
@@ -92,7 +91,7 @@ func NewSUBController(
updateInterval: update,
subService: sub,
subJsonService: NewSubJsonService(jsonFragment, jsonNoise, jsonMux, jsonRules, sub),
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
}
a.initRouter(g)
+78 -70
View File
@@ -21,7 +21,7 @@ var defaultJson string
type SubJsonService struct {
configJson map[string]any
defaultOutbounds []json_util.RawMessage
fragmentOrNoises bool
finalMask string
mux string
inboundService service.InboundService
@@ -29,7 +29,7 @@ type SubJsonService struct {
}
// NewSubJsonService creates a new JSON subscription service with the given configuration.
func NewSubJsonService(fragment string, noises string, mux string, rules string, subService *SubService) *SubJsonService {
func NewSubJsonService(mux string, rules string, finalMask string, subService *SubService) *SubJsonService {
var configJson map[string]any
var defaultOutbounds []json_util.RawMessage
json.Unmarshal([]byte(defaultJson), &configJson)
@@ -40,31 +40,6 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
}
}
fragmentOrNoises := false
if fragment != "" || noises != "" {
fragmentOrNoises = true
defaultOutboundsSettings := map[string]any{
"domainStrategy": "UseIP",
"redirect": "",
}
if fragment != "" {
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
}
if noises != "" {
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
}
defaultDirectOutbound := map[string]any{
"protocol": "freedom",
"settings": defaultOutboundsSettings,
"tag": "direct_out",
}
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
defaultOutbounds = append(defaultOutbounds, jsonBytes)
}
if rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
@@ -78,7 +53,7 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
return &SubJsonService{
configJson: configJson,
defaultOutbounds: defaultOutbounds,
fragmentOrNoises: fragmentOrNoises,
finalMask: finalMask,
mux: mux,
SubService: subService,
}
@@ -230,8 +205,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
}
delete(streamSettings, "sockopt")
if s.fragmentOrNoises {
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
if s.finalMask != "" {
s.applyGlobalFinalMask(streamSettings)
}
// remove proxy protocol
@@ -255,6 +230,17 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
return streamSettings
}
func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
var fm map[string]any
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
return
}
merged := mergeFinalMask(streamSettings["finalmask"], fm)
if len(merged) > 0 {
streamSettings["finalmask"] = merged
}
}
func (s *SubJsonService) removeAcceptProxy(setting any) map[string]any {
netSettings, ok := setting.(map[string]any)
if ok {
@@ -307,17 +293,6 @@ func (s *SubJsonService) realityData(rData map[string]any) map[string]any {
func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_util.RawMessage, client model.Client) json_util.RawMessage {
outbound := Outbound{}
usersData := make([]UserVnext, 1)
usersData[0].ID = client.ID
usersData[0].Email = client.Email
usersData[0].Security = client.Security
vnextData := make([]VnextSetting, 1)
vnextData[0] = VnextSetting{
Address: inbound.Listen,
Port: inbound.Port,
Users: usersData,
}
outbound.Protocol = string(inbound.Protocol)
outbound.Tag = "proxy"
@@ -325,8 +300,17 @@ func (s *SubJsonService) genVnext(inbound *model.Inbound, streamSettings json_ut
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
security := client.Security
if security == "" {
security = "auto"
}
outbound.Settings = map[string]any{
"vnext": vnextData,
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"security": security,
"level": 8,
}
result, _ := json.MarshalIndent(outbound, "", " ")
@@ -347,24 +331,17 @@ func (s *SubJsonService) genVless(inbound *model.Inbound, streamSettings json_ut
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
encryption, _ := inboundSettings["encryption"].(string)
user := map[string]any{
settings := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"id": client.ID,
"level": 8,
"encryption": encryption,
"level": 8,
}
if client.Flow != "" {
user["flow"] = client.Flow
}
vnext := map[string]any{
"address": inbound.Listen,
"port": inbound.Port,
"users": []any{user},
}
outbound.Settings = map[string]any{
"vnext": []any{vnext},
settings["flow"] = client.Flow
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
}
@@ -400,9 +377,17 @@ func (s *SubJsonService) genServer(inbound *model.Inbound, streamSettings json_u
outbound.Mux = json_util.RawMessage(s.mux)
}
outbound.StreamSettings = streamSettings
outbound.Settings = map[string]any{
"servers": serverData,
settings := map[string]any{
"address": serverData[0].Address,
"port": serverData[0].Port,
"password": serverData[0].Password,
"level": 8,
}
if inbound.Protocol == model.Shadowsocks {
settings["method"] = serverData[0].Method
}
outbound.Settings = settings
result, _ := json.MarshalIndent(outbound, "", " ")
return result
@@ -442,7 +427,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
newStream["hysteriaSettings"] = outHyStream
if finalmask, ok := hyStream["finalmask"].(map[string]any); ok {
newStream["finalmask"] = finalmask
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
}
newStream["network"] = "hysteria"
@@ -454,6 +439,41 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
return result
}
func mergeFinalMask(base any, extra map[string]any) map[string]any {
merged := map[string]any{}
if baseMap, ok := base.(map[string]any); ok {
for key, value := range baseMap {
switch key {
case "tcp", "udp":
if masks, ok := value.([]any); ok {
merged[key] = append([]any(nil), masks...)
}
default:
merged[key] = value
}
}
}
for key, value := range extra {
switch key {
case "tcp", "udp":
baseMasks, _ := merged[key].([]any)
extraMasks, _ := value.([]any)
if len(extraMasks) > 0 {
merged[key] = append(baseMasks, extraMasks...)
}
case "quicParams":
if _, exists := merged[key]; !exists {
merged[key] = value
}
default:
merged[key] = value
}
}
return merged
}
type Outbound struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
@@ -462,18 +482,6 @@ type Outbound struct {
Settings map[string]any `json:"settings,omitempty"`
}
type VnextSetting struct {
Address string `json:"address"`
Port int `json:"port"`
Users []UserVnext `json:"users"`
}
type UserVnext struct {
ID string `json:"id"`
Email string `json:"email,omitempty"`
Security string `json:"security,omitempty"`
}
type ServerSetting struct {
Password string `json:"password"`
Level int `json:"level"`
+148
View File
@@ -0,0 +1,148 @@
package sub
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
func hasDirectOutOutbound(svc *SubJsonService) bool {
for _, raw := range svc.defaultOutbounds {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
continue
}
if outbound["tag"] == "direct_out" {
return true
}
}
return false
}
func outboundSettings(t *testing.T, raw []byte) map[string]any {
t.Helper()
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("failed to unmarshal outbound: %v", err)
}
settings, _ := parsed["settings"].(map[string]any)
if settings == nil {
t.Fatal("outbound has no settings")
}
return settings
}
func TestSubJsonServiceInjectsGlobalFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","length":"100-200","delay":"10-20"}}],"udp":[{"type":"noise","settings":{"noise":[{"type":"base64","packet":"SGVsbG8="}]}}],"quicParams":{"congestion":"bbr"}}`
svc := NewSubJsonService("", "", finalMask, nil)
if hasDirectOutOutbound(svc) {
t.Fatal("direct_out outbound must never be emitted")
}
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out dialerProxy sockopt must never be set")
}
finalmask, _ := stream["finalmask"].(map[string]any)
if finalmask == nil {
t.Fatal("streamSettings is missing finalmask")
}
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 1 {
t.Fatalf("tcp masks len = %d, want 1", len(tcp))
}
if first, _ := tcp[0].(map[string]any); first["type"] != "fragment" {
t.Fatalf("tcp[0] type = %v, want fragment", first["type"])
}
udp, _ := finalmask["udp"].([]any)
if len(udp) != 1 {
t.Fatalf("udp masks len = %d, want 1", len(udp))
}
quic, _ := finalmask["quicParams"].(map[string]any)
if quic == nil || quic["congestion"] != "bbr" {
t.Fatalf("quicParams missing/wrong: %#v", finalmask["quicParams"])
}
}
func TestSubJsonServiceMergesWithExistingFinalMask(t *testing.T) {
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello"}}]}`
svc := NewSubJsonService("", "", finalMask, nil)
stream := svc.streamData(`{
"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}},
"finalmask":{"tcp":[{"type":"sudoku"}]}
}`)
finalmask, _ := stream["finalmask"].(map[string]any)
tcp, _ := finalmask["tcp"].([]any)
if len(tcp) != 2 {
t.Fatalf("tcp masks len = %d, want 2 (existing + global)", len(tcp))
}
a, _ := tcp[0].(map[string]any)
b, _ := tcp[1].(map[string]any)
if a["type"] != "sudoku" || b["type"] != "fragment" {
t.Fatalf("tcp masks = %#v, want existing sudoku then global fragment", tcp)
}
}
func TestSubJsonServiceNoFinalMaskWhenEmpty(t *testing.T) {
svc := NewSubJsonService("", "", "", nil)
stream := svc.streamData(`{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`)
if _, ok := stream["finalmask"]; ok {
t.Fatal("no finalmask should be emitted when subJsonFinalMask is empty")
}
if _, ok := stream["sockopt"]; ok {
t.Fatal("legacy direct_out sockopt must never be set")
}
}
func TestSubJsonServiceVlessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VLESS, Settings: `{"encryption":"none"}`}
client := model.Client{ID: "uuid-1", Flow: "xtls-rprx-vision"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVless(inbound, nil, client))
if _, ok := settings["vnext"]; ok {
t.Fatal("vless outbound must not use vnext")
}
if settings["address"] != "1.2.3.4" || settings["id"] != "uuid-1" || settings["encryption"] != "none" || settings["flow"] != "xtls-rprx-vision" {
t.Fatalf("flat vless settings wrong: %#v", settings)
}
}
func TestSubJsonServiceVmessFlattened(t *testing.T) {
inbound := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.VMESS, Settings: `{}`}
client := model.Client{ID: "uuid-2"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genVnext(inbound, nil, client))
if _, ok := settings["vnext"]; ok {
t.Fatal("vmess outbound must not use vnext")
}
if settings["id"] != "uuid-2" || settings["security"] != "auto" {
t.Fatalf("flat vmess settings wrong: %#v", settings)
}
}
func TestSubJsonServiceServerFlattened(t *testing.T) {
trojan := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Trojan, Settings: `{}`}
client := model.Client{Password: "p4ss"}
settings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(trojan, nil, client))
if _, ok := settings["servers"]; ok {
t.Fatal("trojan outbound must not use servers array")
}
if settings["password"] != "p4ss" || settings["address"] != "1.2.3.4" {
t.Fatalf("flat trojan settings wrong: %#v", settings)
}
ss := &model.Inbound{Listen: "1.2.3.4", Port: 443, Protocol: model.Shadowsocks, Settings: `{"method":"aes-256-gcm"}`}
ssSettings := outboundSettings(t, NewSubJsonService("", "", "", nil).genServer(ss, nil, client))
if ssSettings["method"] != "aes-256-gcm" {
t.Fatalf("flat shadowsocks must carry method: %#v", ssSettings)
}
}