mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 14:55:02 +03:00
cb4f2ec92f
A salamander finalmask carrying a packetSize range is Gecko obfs. The hysteria2:// link generators already export it, but buildHysteriaProxy copied only the password, so Clash/Mihomo received plain salamander and could never complete the handshake against a Gecko server. Emit mihomo's gecko form instead: obfs: gecko plus integer obfs-min-packet-size / obfs-max-packet-size, validated by the same parseHysteriaPacketSize bounds the URI export uses. A password-only mask keeps the plain salamander output. Closes #6822
193 lines
7.2 KiB
Go
193 lines
7.2 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/link"
|
|
)
|
|
|
|
// A salamander mask carrying packetSize (Gecko mode) must export the
|
|
// v2rayN-native gecko URI fields, not an fm=<json> dump.
|
|
func TestGenHysteriaLinkEmitsGeckoParamsForPacketSize(t *testing.T) {
|
|
in := &model.Inbound{
|
|
Id: 920001, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
|
|
Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
|
|
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
|
|
`{"password":"pw","packetSize":"512-1200"}}]}}`,
|
|
}
|
|
got := (&SubService{}).genHysteriaLink(in, "user")
|
|
for _, want := range []string{"obfs=gecko", "obfs-password=pw", "minPacketSize=512", "maxPacketSize=1200"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("missing %q\n got: %s", want, got)
|
|
}
|
|
}
|
|
if strings.Contains(got, "obfs=salamander") {
|
|
t.Fatalf("gecko mask exported as plain salamander:\n %s", got)
|
|
}
|
|
if strings.Contains(got, "fm=") {
|
|
t.Fatalf("expressed salamander mask must not leak into fm= dump:\n %s", got)
|
|
}
|
|
}
|
|
|
|
// Password-only masks keep the plain salamander export.
|
|
func TestGenHysteriaLinkSalamanderWithoutPacketSizeUnchanged(t *testing.T) {
|
|
in := &model.Inbound{
|
|
Id: 920002, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
|
|
Settings: `{"version":2,"clients":[{"auth":"secret","email":"user"}]}`,
|
|
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":{"password":"pw"}}]}}`,
|
|
}
|
|
got := (&SubService{}).genHysteriaLink(in, "user")
|
|
if !strings.Contains(got, "obfs=salamander") || !strings.Contains(got, "obfs-password=pw") {
|
|
t.Fatalf("password-only mask lost its standard export:\n %s", got)
|
|
}
|
|
for _, bad := range []string{"minPacketSize=", "maxPacketSize="} {
|
|
if strings.Contains(got, bad) {
|
|
t.Fatalf("unexpected %s in:\n %s", bad, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Import side: obfs=gecko + min/max rebuild a standard salamander+packetSize mask.
|
|
func TestParseLinkAcceptsGeckoObfs(t *testing.T) {
|
|
parsed, err := link.ParseLink(
|
|
"hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=1200#geo")
|
|
if err != nil {
|
|
t.Fatalf("ParseLink: %v", err)
|
|
}
|
|
rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
|
|
if rawStream == nil {
|
|
t.Fatalf("no streamSettings in outbound: %v", parsed.Outbound)
|
|
}
|
|
streamJSON, err := json.Marshal(rawStream)
|
|
if err != nil {
|
|
t.Fatalf("marshal stream: %v", err)
|
|
}
|
|
var stream map[string]any
|
|
if err := json.Unmarshal(streamJSON, &stream); err != nil {
|
|
t.Fatalf("stream json: %v", err)
|
|
}
|
|
fm, _ := stream["finalmask"].(map[string]any)
|
|
if fm == nil {
|
|
t.Fatalf("no finalmask rebuilt: %s", streamJSON)
|
|
}
|
|
udp, _ := fm["udp"].([]any)
|
|
var mask map[string]any
|
|
for _, m := range udp {
|
|
if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
|
|
mask = mm
|
|
}
|
|
}
|
|
if mask == nil {
|
|
t.Fatalf("no salamander mask rebuilt: %s", streamJSON)
|
|
}
|
|
settings, _ := mask["settings"].(map[string]any)
|
|
if pw, _ := settings["password"].(string); pw != "pw" {
|
|
t.Fatalf("password = %v", settings["password"])
|
|
}
|
|
if ps, _ := settings["packetSize"].(string); ps != "512-1200" {
|
|
t.Fatalf("packetSize = %v, want 512-1200", settings["packetSize"])
|
|
}
|
|
}
|
|
|
|
// Half-specified or out-of-bounds gecko ranges must be dropped, not stored.
|
|
func TestParseLinkRejectsInvalidGeckoPacketSize(t *testing.T) {
|
|
cases := map[string]string{
|
|
"half min only": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512#geo",
|
|
"half max only": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&maxPacketSize=1200#geo",
|
|
"non-numeric": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=abc&maxPacketSize=def#geo",
|
|
"zero min": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=0&maxPacketSize=1200#geo",
|
|
"inverted": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=1200&maxPacketSize=512#geo",
|
|
"over cap": "hysteria2://secret@203.0.113.1:443?security=tls&obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=4096#geo",
|
|
}
|
|
for name, uri := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
parsed, err := link.ParseLink(uri)
|
|
if err != nil {
|
|
t.Fatalf("ParseLink: %v", err)
|
|
}
|
|
rawStream, _ := parsed.Outbound["streamSettings"].(map[string]any)
|
|
streamJSON, _ := json.Marshal(rawStream)
|
|
var stream map[string]any
|
|
_ = json.Unmarshal(streamJSON, &stream)
|
|
fm, _ := stream["finalmask"].(map[string]any)
|
|
if fm == nil {
|
|
t.Fatalf("no finalmask rebuilt: %s", streamJSON)
|
|
}
|
|
udp, _ := fm["udp"].([]any)
|
|
for _, m := range udp {
|
|
if mm, ok := m.(map[string]any); ok && mm["type"] == "salamander" {
|
|
settings, _ := mm["settings"].(map[string]any)
|
|
if ps, _ := settings["packetSize"].(string); ps != "" {
|
|
t.Fatalf("invalid gecko stored packetSize %q", ps)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Export side must mirror the TS bounds exactly (1 <= min <= max <= 2048).
|
|
func TestParseHysteriaPacketSizeBounds(t *testing.T) {
|
|
if got := parseHysteriaPacketSize("0-1200"); got != "" {
|
|
t.Fatalf("min below 1 accepted: %q", got)
|
|
}
|
|
if got := parseHysteriaPacketSize("1200-512"); got != "" {
|
|
t.Fatalf("inverted range accepted: %q", got)
|
|
}
|
|
if got := parseHysteriaPacketSize("512-4096"); got != "" {
|
|
t.Fatalf("range over xray cap accepted: %q", got)
|
|
}
|
|
if got := parseHysteriaPacketSize(" 512 - 1200 "); got != "" {
|
|
t.Fatalf("padded range must be rejected: %q", got)
|
|
}
|
|
if got := parseHysteriaPacketSize("+512-1200"); got != "" {
|
|
t.Fatalf("plus-prefixed range must be rejected: %q", got)
|
|
}
|
|
if got := parseHysteriaPacketSize("512-1200"); got != "512-1200" {
|
|
t.Fatalf("valid range = %q", got)
|
|
}
|
|
}
|
|
|
|
// Mihomo reads Gecko only from obfs: gecko plus integer packet-size keys; plain
|
|
// salamander on a Gecko server never completes the handshake.
|
|
func TestBuildHysteriaProxyExportsGeckoObfs(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
settings string
|
|
want map[string]any
|
|
}{
|
|
{
|
|
name: "packetSize exports gecko",
|
|
settings: `{"password":"pw","packetSize":"512-1200"}`,
|
|
want: map[string]any{
|
|
"obfs": "gecko", "obfs-password": "pw",
|
|
"obfs-min-packet-size": 512, "obfs-max-packet-size": 1200,
|
|
},
|
|
},
|
|
{
|
|
name: "password only stays salamander",
|
|
settings: `{"password":"pw"}`,
|
|
want: map[string]any{"obfs": "salamander", "obfs-password": "pw"},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
in := &model.Inbound{
|
|
Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
|
|
Settings: `{"version":2}`,
|
|
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` +
|
|
tc.settings + `}]}}`,
|
|
}
|
|
proxy := (&SubClashService{}).buildHysteriaProxy(&SubService{}, in, model.Client{Email: "user", Auth: "secret"}, nil)
|
|
for _, key := range []string{"obfs", "obfs-password", "obfs-min-packet-size", "obfs-max-packet-size"} {
|
|
if got, want := proxy[key], tc.want[key]; got != want {
|
|
t.Errorf("%s = %#v, want %#v", key, got, want)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|