mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-12 08:55:01 +03:00
fix(finalmask): keep a single udphop mask last in every client finalmask
xray-core reverses finalmask.udp and refuses, at dial time, a mask that dials its own socket (udphop) anywhere but the socket end: "incorrect index". xray run -test still says Configuration OK, so the failure only shows as a client that never connects. UDPHopClientMaskFix turned a global subJsonFinalMask hop and a host hop into udphop masks; the JSON subscription then appended both, leaving two hoppers with the first one misplaced, and a host mask merged after a global hop pushed it off the end too. A hand-ordered template outbound or an imported fm= could carry the same order. maskcompat.NormalizeUDPHop keeps the last-listed udphop alone at the end of udp, and runs wherever a client finalmask is produced: each JSON subscription endpoint, share-link fm=, the Go and frontend link importers and the template outbound heal in GetXrayConfig. The JSON subscription resolves competing hop ranges most specific first: a host hop wins over the inbound's own, which wins over the global default.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Base64 } from '@/utils';
|
||||
|
||||
import { upgradeLegacyUdpHop } from './udphop-mask';
|
||||
import { normalizeUdpHop, upgradeLegacyUdpHop } from './udphop-mask';
|
||||
import { upgradeLegacyXdnsMasks } from './xdns-mask';
|
||||
|
||||
// Focused share-link parser for the OutboundFormModal's link-import
|
||||
@@ -283,7 +283,7 @@ function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
sanitizeFinalMaskQuicParams(parsed);
|
||||
if (Array.isArray(parsed.udp)) parsed.udp = upgradeLegacyXdnsMasks(parsed.udp).next;
|
||||
stream.finalmask = upgradeLegacyUdpHop(parsed).next;
|
||||
stream.finalmask = normalizeUdpHop(upgradeLegacyUdpHop(parsed).next).next;
|
||||
}
|
||||
} catch {
|
||||
// malformed fm — leave streamSettings.finalmask absent
|
||||
|
||||
@@ -18,7 +18,7 @@ export function upgradeLegacyUdpHop(finalmask: Raw): { next: Raw; changed: boole
|
||||
|
||||
const hop = (udpHop && typeof udpHop === 'object' ? udpHop : {}) as Raw;
|
||||
const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : [];
|
||||
const hasMask = udp.some((m) => String((m as Raw | null)?.type ?? '').toLowerCase() === 'udphop');
|
||||
const hasMask = udp.some(isUdpHopMask);
|
||||
if (!isEmptyHopValue(hop.ports) && !hasMask) {
|
||||
const settings: Raw = { mode: 'intervalRemote', remotePorts: hop.ports };
|
||||
if (!isEmptyHopValue(hop.interval)) settings.interval = hop.interval;
|
||||
@@ -27,3 +27,22 @@ export function upgradeLegacyUdpHop(finalmask: Raw): { next: Raw; changed: boole
|
||||
}
|
||||
return { next, changed: true };
|
||||
}
|
||||
|
||||
function isUdpHopMask(mask: unknown): boolean {
|
||||
return String((mask as Raw | null)?.type ?? '').toLowerCase() === 'udphop';
|
||||
}
|
||||
|
||||
// Mirrors maskcompat.NormalizeUDPHop: the core reverses the UDP masks and refuses ("incorrect
|
||||
// index") a udphop anywhere but the socket end, so the last one listed is kept alone, last.
|
||||
export function normalizeUdpHop(finalmask: Raw): { next: Raw; changed: boolean } {
|
||||
const udp = Array.isArray(finalmask.udp) ? (finalmask.udp as unknown[]) : [];
|
||||
const hops = udp.filter(isUdpHopMask);
|
||||
if (hops.length === 0 || (hops.length === 1 && isUdpHopMask(udp[udp.length - 1]))) {
|
||||
return { next: finalmask, changed: false };
|
||||
}
|
||||
const next = {
|
||||
...finalmask,
|
||||
udp: [...udp.filter((m) => !isUdpHopMask(m)), hops[hops.length - 1]],
|
||||
};
|
||||
return { next, changed: true };
|
||||
}
|
||||
|
||||
@@ -507,6 +507,23 @@ describe('parseHysteria2Link', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The core reverses the UDP masks and refuses a udphop anywhere but last ("incorrect index"),
|
||||
// so an import of a link that lists it first must reorder it like the Go importer does.
|
||||
it('moves a udphop mask listed first in fm= to the end', () => {
|
||||
const hop = {
|
||||
type: 'udphop',
|
||||
settings: { mode: 'intervalRemote', remotePorts: '20000-30000' },
|
||||
};
|
||||
const salamander = { type: 'salamander', settings: { password: 's3cr3t' } };
|
||||
const fm = encodeURIComponent(JSON.stringify({ udp: [hop, salamander] }));
|
||||
const out = parseHysteria2Link(
|
||||
`hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=${fm}#hy`,
|
||||
);
|
||||
expect((out!.streamSettings as Record<string, unknown>).finalmask).toEqual({
|
||||
udp: [salamander, hop],
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a hysteria2:// link with sni', () => {
|
||||
const link = 'hysteria2://auth-secret@srv.example:443?sni=example.com#imported-hy2';
|
||||
const out = parseHysteria2Link(link);
|
||||
|
||||
@@ -641,8 +641,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
|
||||
if finalmask, ok := newStream["finalmask"].(map[string]any); ok {
|
||||
newStream["finalmask"] = withLegacyFragmentRanges(finalmask)
|
||||
}
|
||||
// An inbound keeps its hop range as quicParams.udpHop; clients hop only with the mask.
|
||||
// A host's legacy hop still needs the mask; the last-merged hop, the host's, wins.
|
||||
maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"])
|
||||
maskcompat.NormalizeUDPHop(newStream["finalmask"])
|
||||
streamSettings, _ := json.MarshalIndent(newStream, "", " ")
|
||||
hostMux := hostMuxOverride(extPrxy)
|
||||
|
||||
@@ -715,6 +716,8 @@ func (s *SubJsonService) streamData(stream string, clientKey string) map[string]
|
||||
}
|
||||
delete(streamSettings, "sockopt")
|
||||
|
||||
// The inbound's own hop range becomes a mask first, so the global default yields to it.
|
||||
maskcompat.UpgradeLegacyUDPHop(streamSettings["finalmask"])
|
||||
if s.finalMask != "" {
|
||||
s.applyGlobalFinalMask(streamSettings)
|
||||
}
|
||||
@@ -754,6 +757,10 @@ func (s *SubJsonService) applyGlobalFinalMask(streamSettings map[string]any) {
|
||||
if err := json.Unmarshal([]byte(s.finalMask), &fm); err != nil || len(fm) == 0 {
|
||||
return
|
||||
}
|
||||
// The global hop range is a default: an inbound that advertises its own keeps it.
|
||||
if maskcompat.HasUDPHopMask(streamSettings["finalmask"]) {
|
||||
fm = maskcompat.WithoutUDPHop(fm)
|
||||
}
|
||||
merged := mergeFinalMask(streamSettings["finalmask"], fm)
|
||||
if len(merged) > 0 {
|
||||
streamSettings["finalmask"] = merged
|
||||
@@ -1005,6 +1012,7 @@ func (s *SubJsonService) genHy(inbound *model.Inbound, newStream map[string]any,
|
||||
newStream["finalmask"] = mergeFinalMask(newStream["finalmask"], finalmask)
|
||||
}
|
||||
maskcompat.UpgradeLegacyUDPHop(newStream["finalmask"])
|
||||
maskcompat.NormalizeUDPHop(newStream["finalmask"])
|
||||
|
||||
newStream["network"] = "hysteria"
|
||||
newStream["security"] = "tls"
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
xnet "github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
"github.com/xtls/xray-core/transport/internet"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
@@ -115,6 +123,104 @@ func TestSubJsonServiceTurnsInboundHopIntoClientUDPHopMask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// coreDialsUDP dials a client stream's finalmask the way the core does: it reverses the
|
||||
// UDP masks and refuses a udphop anywhere but the socket end ("incorrect index").
|
||||
func coreDialsUDP(t *testing.T, stream map[string]any) {
|
||||
t.Helper()
|
||||
listener, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen udp: %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
raw, _ := json.Marshal(map[string]any{"finalmask": stream["finalmask"]})
|
||||
var sc conf.StreamConfig
|
||||
if err := json.Unmarshal(raw, &sc); err != nil {
|
||||
t.Fatalf("decode stream: %v", err)
|
||||
}
|
||||
built, err := sc.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("the core refuses the stream: %v", err)
|
||||
}
|
||||
mss, err := internet.ToMemoryStreamConfig(built)
|
||||
if err != nil {
|
||||
t.Fatalf("memory stream: %v", err)
|
||||
}
|
||||
port := listener.LocalAddr().(*net.UDPAddr).Port
|
||||
conn, err := mss.FinalMask.DialUDP(context.Background(), xnet.UDPDestination(xnet.LocalHostIP, xnet.Port(port)))
|
||||
if err != nil {
|
||||
t.Fatalf("the core cannot dial through the finalmask: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
// A client dials through one hopper and the core takes it only last in udp, so the JSON
|
||||
// subscription keeps the most specific hop range (host, then inbound, then global) at the end.
|
||||
func TestSubJsonServiceKeepsOneTrailingUDPHopMask(t *testing.T) {
|
||||
const hop = `{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"%s","interval":"5-10"}}`
|
||||
const salamander = `{"type":"salamander","settings":{"password":"host-pass"}}`
|
||||
const inboundHop = `"finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}},`
|
||||
tests := []struct {
|
||||
name, inboundMask, hostMask, global, wantPorts string
|
||||
}{
|
||||
{
|
||||
"a host hop overrides the inbound and the global one", inboundHop,
|
||||
`{"udp":[` + salamander + `,` + fmt.Sprintf(hop, "40000-41000") + `]}`, fmt.Sprintf(hop, "30000-31000"), "40000-41000",
|
||||
},
|
||||
{
|
||||
"the inbound hop overrides the global one", inboundHop,
|
||||
`{"udp":[` + salamander + `]}`, fmt.Sprintf(hop, "30000-31000"), "20000-30000",
|
||||
},
|
||||
{
|
||||
"the global hop stays last behind host masks", ``,
|
||||
`{"udp":[` + salamander + `]}`, fmt.Sprintf(hop, "30000-31000"), "30000-31000",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
inbound := &model.Inbound{
|
||||
Listen: "203.0.113.10", Port: 443, Protocol: model.Hysteria, Remark: "hy",
|
||||
Settings: `{"version":2,"clients":[{"email":"ivy@example.test","auth":"a"}]}`,
|
||||
StreamSettings: `{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2},
|
||||
"tlsSettings":{"serverName":"hy.example.test","alpn":["h3"]},` + tc.inboundMask + `
|
||||
"externalProxy":[{"forceTls":"same","dest":"a.example.test","port":443,"remark":"a",
|
||||
"finalMask":` + strconv.Quote(tc.hostMask) + `}]}`,
|
||||
}
|
||||
client := model.Client{Email: "ivy@example.test", Auth: "a"}
|
||||
global := `{"udp":[` + tc.global + `]}`
|
||||
|
||||
configs := NewSubJsonService("", "", global, "", nil).getConfig(&SubService{address: "sub.example.com"}, inbound, client, "sub.example.com")
|
||||
if len(configs) != 1 {
|
||||
t.Fatalf("got %d configs, want one", len(configs))
|
||||
}
|
||||
var config struct {
|
||||
Outbounds []struct {
|
||||
StreamSettings map[string]any `json:"streamSettings"`
|
||||
} `json:"outbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(configs[0], &config); err != nil {
|
||||
t.Fatalf("decode config: %v", err)
|
||||
}
|
||||
stream := config.Outbounds[0].StreamSettings
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
masks, _ := finalmask["udp"].([]any)
|
||||
var hops []string
|
||||
for _, entry := range masks {
|
||||
mask, _ := entry.(map[string]any)
|
||||
if mask["type"] == "udphop" {
|
||||
settings, _ := mask["settings"].(map[string]any)
|
||||
hops = append(hops, fmt.Sprint(settings["remotePorts"]))
|
||||
}
|
||||
}
|
||||
last, _ := masks[len(masks)-1].(map[string]any)
|
||||
if len(hops) != 1 || hops[0] != tc.wantPorts || last["type"] != "udphop" {
|
||||
got, _ := json.Marshal(finalmask)
|
||||
t.Fatalf("client finalmask = %s, want one trailing udphop on %s", got, tc.wantPorts)
|
||||
}
|
||||
coreDialsUDP(t, stream)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Every endpoint's config starts from the same parsed inbound finalmask; a host whose own
|
||||
// masks are merged in first must not use up the hop range the endpoints after it need.
|
||||
func TestSubJsonServiceKeepsUDPHopMaskOnEveryEndpoint(t *testing.T) {
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/maskcompat"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
@@ -2855,6 +2856,7 @@ func applyFinalMaskObj(finalmask map[string]any, obj map[string]any) {
|
||||
|
||||
func marshalFinalMask(finalmask map[string]any) (string, bool) {
|
||||
normalized := withLegacyFragmentRanges(normalizeFinalMask(finalmask))
|
||||
maskcompat.NormalizeUDPHop(normalized)
|
||||
if !hasFinalMaskContent(normalized) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
@@ -1195,6 +1195,20 @@ func TestMarshalFinalMask_KeepsSudokuUdpMask(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A share link's fm= is what a client dials with; a udphop listed before another UDP mask
|
||||
// makes the core refuse every dial ("incorrect index"), so the link carries it last.
|
||||
func TestMarshalFinalMask_PutsUDPHopLast(t *testing.T) {
|
||||
fm := map[string]any{"udp": []any{
|
||||
map[string]any{"type": "udphop", "settings": map[string]any{"mode": "intervalRemote", "remotePorts": "20000-30000"}},
|
||||
map[string]any{"type": "salamander", "settings": map[string]any{"password": "s3cr3t"}},
|
||||
}}
|
||||
out, ok := marshalFinalMask(fm)
|
||||
want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"salamander"},{"settings":{"mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}`
|
||||
if !ok || out != want {
|
||||
t.Fatalf("marshalFinalMask = %s, want %s", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasFinalMaskContent(t *testing.T) {
|
||||
if hasFinalMaskContent(nil) {
|
||||
t.Fatal("nil should not count as content")
|
||||
|
||||
@@ -770,6 +770,7 @@ func applyFinalMask(stream map[string]any, p url.Values) {
|
||||
sanitizeFinalMaskQuicParams(parsed)
|
||||
maskcompat.UpgradeLegacyXdns(parsed)
|
||||
maskcompat.UpgradeLegacyUDPHop(parsed)
|
||||
maskcompat.NormalizeUDPHop(parsed)
|
||||
stream["finalmask"] = parsed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -596,3 +596,19 @@ func TestParseLink_UpgradesLegacyUDPHopFinalMask(t *testing.T) {
|
||||
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A link from a panel that listed udphop before another UDP mask would leave an outbound
|
||||
// whose every dial the core refuses ("incorrect index"); the import puts the hop last.
|
||||
func TestParseLink_PutsUDPHopMaskLast(t *testing.T) {
|
||||
fm := url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"20000-30000"}},{"type":"salamander","settings":{"password":"s3cr3t"}}]}`)
|
||||
res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy")
|
||||
if err != nil {
|
||||
t.Fatalf("parse hysteria2 with fm: %v", err)
|
||||
}
|
||||
stream, _ := res.Outbound["streamSettings"].(map[string]any)
|
||||
got, _ := json.Marshal(stream["finalmask"])
|
||||
want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"salamander"},{"settings":{"mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}`
|
||||
if string(got) != want {
|
||||
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,15 +37,57 @@ func UpgradeLegacyUDPHop(finalmask any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func hasUDPHopMask(fm map[string]any) bool {
|
||||
// NormalizeUDPHop keeps the last udphop mask alone at the end of udp: the core reverses the list
|
||||
// and refuses ("incorrect index") a mask that dials its own socket anywhere but the socket end.
|
||||
func NormalizeUDPHop(finalmask any) bool {
|
||||
fm, _ := finalmask.(map[string]any)
|
||||
masks, _ := fm["udp"].([]any)
|
||||
for _, entry := range masks {
|
||||
mask, _ := entry.(map[string]any)
|
||||
if maskType, _ := mask["type"].(string); strings.EqualFold(maskType, "udphop") {
|
||||
return true
|
||||
last, hops := -1, 0
|
||||
for i, mask := range masks {
|
||||
if isUDPHopMask(mask) {
|
||||
last, hops = i, hops+1
|
||||
}
|
||||
}
|
||||
return false
|
||||
if last < 0 || (hops == 1 && last == len(masks)-1) {
|
||||
return false
|
||||
}
|
||||
kept := make([]any, 0, len(masks)-hops+1)
|
||||
for _, mask := range masks {
|
||||
if !isUDPHopMask(mask) {
|
||||
kept = append(kept, mask)
|
||||
}
|
||||
}
|
||||
fm["udp"] = append(kept, masks[last])
|
||||
return true
|
||||
}
|
||||
|
||||
// WithoutUDPHop returns finalmask minus its udphop masks, for a default that yields to a hop
|
||||
// range already set; finalmask itself is left untouched.
|
||||
func WithoutUDPHop(finalmask map[string]any) map[string]any {
|
||||
masks, _ := finalmask["udp"].([]any)
|
||||
if !slices.ContainsFunc(masks, isUDPHopMask) {
|
||||
return finalmask
|
||||
}
|
||||
out := maps.Clone(finalmask)
|
||||
out["udp"] = slices.DeleteFunc(slices.Clone(masks), isUDPHopMask)
|
||||
return out
|
||||
}
|
||||
|
||||
// HasUDPHopMask reports whether finalmask already dials through a udphop mask.
|
||||
func HasUDPHopMask(finalmask any) bool {
|
||||
fm, _ := finalmask.(map[string]any)
|
||||
return hasUDPHopMask(fm)
|
||||
}
|
||||
|
||||
func hasUDPHopMask(fm map[string]any) bool {
|
||||
masks, _ := fm["udp"].([]any)
|
||||
return slices.ContainsFunc(masks, isUDPHopMask)
|
||||
}
|
||||
|
||||
func isUDPHopMask(entry any) bool {
|
||||
mask, _ := entry.(map[string]any)
|
||||
maskType, _ := mask["type"].(string)
|
||||
return strings.EqualFold(maskType, "udphop")
|
||||
}
|
||||
|
||||
func isEmptyHopValue(value any) bool {
|
||||
|
||||
@@ -114,37 +114,46 @@ func TestGetXrayConfig_UpgradesTemplateOutboundXdnsMask(t *testing.T) {
|
||||
t.Fatal("outbound dns-tunnel not found in the generated config")
|
||||
}
|
||||
|
||||
// A hysteria outbound pasted from a pre-26.9.9 template still carries quicParams.udpHop,
|
||||
// which the core now ignores; the build heal hands it the udphop mask instead.
|
||||
func TestGetXrayConfig_UpgradesTemplateOutboundUDPHop(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"hysteria","tag":"hy-out",
|
||||
"settings":{"version":2,"address":"hy.example.com","port":443},
|
||||
"streamSettings":{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2,"auth":"a"},
|
||||
"finalmask":{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}}}]}`
|
||||
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil {
|
||||
t.Fatalf("seed template: %v", err)
|
||||
// A template outbound pasted from before 26.9.9 carries quicParams.udpHop, which the core ignores,
|
||||
// and a hand-ordered udphop not last makes it refuse every dial; the build heals both.
|
||||
func TestGetXrayConfig_HealsTemplateOutboundUDPHop(t *testing.T) {
|
||||
const hop = `{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}`
|
||||
const salamander = `{"settings":{"password":"s3cr3t"},"type":"salamander"}`
|
||||
tests := []struct{ name, finalmask, want string }{
|
||||
{"legacy quicParams.udpHop", `{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}`, `{"udp":[` + hop + `]}`},
|
||||
{"udphop listed first", `{"udp":[` + hop + `,` + salamander + `]}`, `{"udp":[` + salamander + `,` + hop + `]}`},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
template := `{"outbounds":[{"protocol":"freedom","tag":"direct"},{"protocol":"hysteria","tag":"hy-out",
|
||||
"settings":{"version":2,"address":"hy.example.com","port":443},
|
||||
"streamSettings":{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2,"auth":"a"},
|
||||
"finalmask":` + tc.finalmask + `}}]}`
|
||||
if err := (&SettingService{}).saveSetting("xrayTemplateConfig", template); err != nil {
|
||||
t.Fatalf("seed template: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := (&XrayService{}).GetXrayConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfig: %v", err)
|
||||
cfg, err := (&XrayService{}).GetXrayConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("GetXrayConfig: %v", err)
|
||||
}
|
||||
var outbounds []map[string]any
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatalf("decode emitted outbounds: %v", err)
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
if outbound["tag"] != "hy-out" {
|
||||
continue
|
||||
}
|
||||
stream, _ := outbound["streamSettings"].(map[string]any)
|
||||
got, _ := json.Marshal(stream["finalmask"])
|
||||
if string(got) != tc.want {
|
||||
t.Fatalf("emitted finalmask\n got: %s\nwant: %s", got, tc.want)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("outbound hy-out not found in the generated config")
|
||||
})
|
||||
}
|
||||
var outbounds []map[string]any
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
t.Fatalf("decode emitted outbounds: %v", err)
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
if outbound["tag"] != "hy-out" {
|
||||
continue
|
||||
}
|
||||
stream, _ := outbound["streamSettings"].(map[string]any)
|
||||
got, _ := json.Marshal(stream["finalmask"])
|
||||
const want = `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}`
|
||||
if string(got) != want {
|
||||
t.Fatalf("emitted finalmask\n got: %s\nwant: %s", got, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("outbound hy-out not found in the generated config")
|
||||
}
|
||||
|
||||
@@ -180,10 +180,10 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
xrayConfig.API = ensureAPIServices(xrayConfig.API)
|
||||
xrayConfig.Policy = ensureStatsPolicy(xrayConfig.Policy)
|
||||
xrayConfig.RouterConfig = stripDisabledRules(xrayConfig.RouterConfig)
|
||||
// A pasted or restored template can still carry pre-#6258 XHTTP session keys, a pre-26.10.10
|
||||
// xdns mask or a pre-26.9.9 quicParams.udpHop, all of which the core silently ignores.
|
||||
// A pasted or restored template can carry pre-#6258 XHTTP keys, a pre-26.10.10 xdns mask or a
|
||||
// pre-26.9.9 quicParams.udpHop, which the core ignores, or a udphop not last, which it cannot dial.
|
||||
xrayConfig.OutboundConfigs = healOutboundStreams(xrayConfig.OutboundConfigs,
|
||||
liftXhttpSessionIDKeys, upgradeStreamLegacyXdns, upgradeStreamLegacyUDPHop)
|
||||
liftXhttpSessionIDKeys, upgradeStreamLegacyXdns, upgradeStreamLegacyUDPHop, normalizeStreamUDPHop)
|
||||
// Bridge amneziawg outbounds before anything else reads OutboundConfigs;
|
||||
// the core has no amneziawg proxy and would reject the raw entry.
|
||||
if err := transformAmneziaWGOutbounds(xrayConfig); err != nil {
|
||||
@@ -1752,3 +1752,7 @@ func upgradeStreamLegacyXdns(stream map[string]any) bool {
|
||||
func upgradeStreamLegacyUDPHop(stream map[string]any) bool {
|
||||
return maskcompat.UpgradeLegacyUDPHop(stream["finalmask"])
|
||||
}
|
||||
|
||||
func normalizeStreamUDPHop(stream map[string]any) bool {
|
||||
return maskcompat.NormalizeUDPHop(stream["finalmask"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user