diff --git a/backend/xray/config.go b/backend/xray/config.go index 965c3018..d2a1dc26 100644 --- a/backend/xray/config.go +++ b/backend/xray/config.go @@ -1,6 +1,8 @@ package xray import ( + "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "log" @@ -360,6 +362,29 @@ func (i *Inbound) removeUser(email string) { type Stats struct{} +// shadowsocks2022PlaceholderKey is random per process and never leaves it. +var shadowsocks2022PlaceholderKey = func() string { + key := make([]byte, 32) + _, _ = rand.Read(key) + return base64.StdEncoding.EncodeToString(key) +}() + +// shadowsocks2022Placeholder returns a client for a Shadowsocks 2022 inbound without +// users. Xray builds such an inbound as a single-user server, which accepts the bare +// server key (part of every Shadowsocks 2022 link) and rejects every later AddUser. +// A client whose key nobody holds keeps the inbound multi-user. Xray has no multi-user +// mode for chacha20, so there is no placeholder for it. +func shadowsocks2022Placeholder(inbound *Inbound) *api.ShadowsocksAccount { + method, _ := inbound.Settings["method"].(string) + if inbound.Protocol != Shadowsocks || !strings.HasPrefix(method, "2022-blake3-aes") { + return nil + } + return &api.ShadowsocksAccount{ + BaseAccount: api.BaseAccount{Email: "pasarguard-placeholder"}, + Password: common.EnsureBase64Password(shadowsocks2022PlaceholderKey, method), + } +} + func snapshotInboundForSerialization(inbound *Inbound) *Inbound { inbound.mu.RLock() defer inbound.mu.RUnlock() @@ -393,6 +418,8 @@ func snapshotInboundForSerialization(inbound *Inbound) *Inbound { if len(inbound.clients) == 0 { if inbound.Protocol == Wireguard { snap.Settings["peers"] = []any{} + } else if placeholder := shadowsocks2022Placeholder(inbound); placeholder != nil { + snap.Settings["clients"] = []*api.ShadowsocksAccount{placeholder} } else { snap.Settings["clients"] = []any{} } diff --git a/backend/xray/config_tobytes_test.go b/backend/xray/config_tobytes_test.go index 1794f9ee..d6057e79 100644 --- a/backend/xray/config_tobytes_test.go +++ b/backend/xray/config_tobytes_test.go @@ -6,6 +6,9 @@ import ( "sync" "testing" + "github.com/xtls/xray-core/infra/conf" + "github.com/xtls/xray-core/proxy/shadowsocks_2022" + "github.com/pasarguard/node/common" ) @@ -283,3 +286,93 @@ func TestToBytesWhileCloning(t *testing.T) { }) wg.Wait() } + +const shadowsocks2022Config = `{"inbounds":[ + {"tag":"aes128","protocol":"shadowsocks","settings":{"clients":[],"method":"2022-blake3-aes-128-gcm","password":"MDEyMzQ1Njc4OWFiY2RlZg==","network":"tcp,udp"}}, + {"tag":"aes256","protocol":"shadowsocks","settings":{"clients":[],"method":"2022-blake3-aes-256-gcm","password":"MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=","network":"tcp,udp"}}, + {"tag":"chacha","protocol":"shadowsocks","settings":{"clients":[],"method":"2022-blake3-chacha20-poly1305","password":"MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=","network":"tcp,udp"}}, + {"tag":"legacy","protocol":"shadowsocks","settings":{"clients":[],"method":"aes-256-gcm","password":"legacy-server-password","network":"tcp,udp"}} +]}` + +// buildShadowsocksInbound runs Xray's own config builder on a serialized inbound. +func buildShadowsocksInbound(t *testing.T, settings map[string]any) any { + t.Helper() + + raw, err := json.Marshal(settings) + if err != nil { + t.Fatal(err) + } + var server conf.ShadowsocksServerConfig + if err := json.Unmarshal(raw, &server); err != nil { + t.Fatal(err) + } + built, err := server.Build() + if err != nil { + t.Fatalf("xray rejected the serialized inbound %v: %v", settings, err) + } + return built +} + +// Xray builds a single-user Shadowsocks 2022 server from an empty client list. That +// server accepts the bare server key, which every Shadowsocks 2022 link carries, and +// rejects every later AddUser, so the inbound must start in multi-user mode. +func TestToBytesStartsEmptyShadowsocks2022InboundsMultiUser(t *testing.T) { + cfg, err := NewConfig(shadowsocks2022Config, nil) + if err != nil { + t.Fatal(err) + } + out := serializeConfig(t, cfg) + + for _, tag := range []string{"aes128", "aes256"} { + settings := out.settings(t, tag) + built := buildShadowsocksInbound(t, settings) + if _, ok := built.(*shadowsocks_2022.MultiUserServerConfig); !ok { + t.Errorf("%s: an inbound without users was built as %T, want a multi-user server", tag, built) + continue + } + clients, _ := settings["clients"].([]any) + if len(clients) != 1 { + t.Fatalf("%s: clients = %v, want one placeholder", tag, clients) + } + placeholder, _ := clients[0].(map[string]any) + if placeholder["password"] == settings["password"] { + t.Fatalf("%s: the placeholder uses the server key", tag) + } + } + + // Xray has no multi-user mode for chacha20: a client there would stop the whole core. + if got := emails(t, out.settings(t, "chacha"), "clients"); len(got) != 0 { + t.Fatalf("chacha20 inbound clients = %v, want none", got) + } + buildShadowsocksInbound(t, out.settings(t, "chacha")) + + // Legacy Shadowsocks stays multi-user with an empty client list. + if got := emails(t, out.settings(t, "legacy"), "clients"); len(got) != 0 { + t.Fatalf("legacy inbound clients = %v, want none", got) + } + + for _, inbound := range cfg.InboundConfigs { + if len(inbound.clients) != 0 { + t.Fatalf("ToBytes stored clients on inbound %s: %v", inbound.Tag, inbound.clients) + } + } +} + +func TestToBytesWritesOnlyRealShadowsocks2022Users(t *testing.T) { + cfg, err := NewConfig(shadowsocks2022Config, nil) + if err != nil { + t.Fatal(err) + } + cfg.syncUsers([]*common.User{{ + Email: "1", + Inbounds: []string{"aes128", "aes256"}, + Proxies: &common.Proxy{Shadowsocks: &common.Shadowsocks{Password: "user-one-password"}}, + }}) + out := serializeConfig(t, cfg) + + for _, tag := range []string{"aes128", "aes256"} { + if got := emails(t, out.settings(t, tag), "clients"); len(got) != 1 || got[0] != "1" { + t.Fatalf("%s clients = %v, want [1]", tag, got) + } + } +} diff --git a/backend/xray/shadowsocks2022_test.go b/backend/xray/shadowsocks2022_test.go new file mode 100644 index 00000000..f6e43425 --- /dev/null +++ b/backend/xray/shadowsocks2022_test.go @@ -0,0 +1,173 @@ +//go:build !windows + +package xray + +import ( + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/pasarguard/node/common" + "github.com/pasarguard/node/config" + "github.com/pasarguard/node/pkg/netutil" +) + +const ( + shadowsocks2022TestInbound = "SS2022 SESSION" + shadowsocks2022TestMethod = "2022-blake3-aes-128-gcm" + shadowsocks2022TestServerKey = "MDEyMzQ1Njc4OWFiY2RlZg==" +) + +const shadowsocks2022TestConfig = `{ + "log": {"loglevel": "warning"}, + "inbounds": [{ + "tag": "SS2022 SESSION", + "listen": "127.0.0.1", + "port": %d, + "protocol": "shadowsocks", + "settings": {"clients": [], "method": "2022-blake3-aes-128-gcm", "password": "MDEyMzQ1Njc4OWFiY2RlZg==", "network": "tcp"} + }], + "outbounds": [{ + "tag": "direct", + "protocol": "freedom", + "settings": {"finalRules": [{"action": "allow", "ip": ["127.0.0.1"]}]} + }] +}` + +// startShadowsocks2022Client runs a second Xray that forwards a local SOCKS5 port +// to the inbound with the given password and returns that port. +func startShadowsocks2022Client(t *testing.T, exe string, serverPort int, password string) int { + t.Helper() + + socksPort := netutil.FindFreePort() + clientConfig := fmt.Sprintf(`{ + "log": {"loglevel": "warning"}, + "inbounds": [{"listen": "127.0.0.1", "port": %d, "protocol": "socks", "settings": {"auth": "noauth", "udp": false}}], + "outbounds": [{"protocol": "shadowsocks", "settings": {"servers": [ + {"address": "127.0.0.1", "port": %d, "method": %q, "password": %q} + ]}}] + }`, socksPort, serverPort, shadowsocks2022TestMethod, password) + + path := filepath.Join(t.TempDir(), "client.json") + if err := os.WriteFile(path, []byte(clientConfig), 0o600); err != nil { + t.Fatal(err) + } + + cmd := exec.Command(exe, "run", "-c", path) + if err := cmd.Start(); err != nil { + t.Fatalf("failed to start the xray client: %v", err) + } + t.Cleanup(func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + }) + + address := net.JoinHostPort("127.0.0.1", strconv.Itoa(socksPort)) + deadline := time.Now().Add(5 * time.Second) + for { + conn, err := net.DialTimeout("tcp4", address, time.Second) + if err == nil { + _ = conn.Close() + return socksPort + } + if time.Now().After(deadline) { + t.Fatalf("xray client did not listen on %s: %v", address, err) + } + time.Sleep(50 * time.Millisecond) + } +} + +// openSocksStream opens a SOCKS5 CONNECT to target and consumes the reply, so +// only bytes from the target are left to read. +func openSocksStream(t *testing.T, socksPort int, target *net.TCPAddr) net.Conn { + t.Helper() + + conn, err := net.DialTimeout("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(socksPort)), 3*time.Second) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) + + if _, err := conn.Write([]byte{5, 1, 0}); err != nil { + t.Fatal(err) + } + method := make([]byte, 2) + if _, err := io.ReadFull(conn, method); err != nil || method[1] != 0 { + t.Fatalf("socks method reply %v, err %v", method, err) + } + + request := []byte{5, 1, 0, 1} + request = append(request, target.IP.To4()...) + request = append(request, byte(target.Port>>8), byte(target.Port)) + if _, err := conn.Write(request); err != nil { + t.Fatal(err) + } + reply := make([]byte, 10) + if _, err := io.ReadFull(conn, reply); err != nil || reply[1] != 0 { + t.Fatalf("socks connect reply %v, err %v", reply, err) + } + + _ = conn.SetDeadline(time.Time{}) + return conn +} + +func TestShadowsocks2022InboundStartedWithoutUsers(t *testing.T) { + exe := resolveXrayBinary(t) + source := startByteSource(t) + inboundPort := netutil.FindFreePort() + + xrayConfig, err := NewConfig(fmt.Sprintf(shadowsocks2022TestConfig, inboundPort), nil) + if err != nil { + t.Fatal(err) + } + + back, err := New( + context.Background(), + xrayConfig, + nil, + netutil.FindFreePort(), + netutil.FindFreePort(), + &config.Config{ + XrayExecutablePath: exe, + XrayAssetsPath: assetsPath, + GeneratedConfigPath: configPath, + LogBufferSize: 1000, + }, + ) + if err != nil { + t.Fatal(err) + } + defer back.Shutdown() + + // Every Shadowsocks 2022 link the panel hands out starts with the server key. + bareKey := startShadowsocks2022Client(t, exe, inboundPort, shadowsocks2022TestServerKey) + if received, _ := receive(openSocksStream(t, bareKey, source), time.Second); received != 0 { + t.Errorf("the bare server key opened a connection and received %d bytes", received) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + user := &common.User{ + Email: "1", + Inbounds: []string{shadowsocks2022TestInbound}, + Proxies: &common.Proxy{Shadowsocks: &common.Shadowsocks{Password: "user-one-password"}}, + } + if err := back.SyncUser(ctx, user); err != nil { + t.Fatalf("adding the first user to the inbound failed: %v", err) + } + + userKey := common.EnsureBase64Password("user-one-password", shadowsocks2022TestMethod) + userClient := startShadowsocks2022Client(t, exe, inboundPort, shadowsocks2022TestServerKey+":"+userKey) + if received, err := receive(openSocksStream(t, userClient, source), time.Second); received == 0 { + t.Fatalf("the added user received nothing (err %v)", err) + } +}