diff --git a/web/service/port_conflict.go b/web/service/port_conflict.go index a2dd2183..4bac91bd 100644 --- a/web/service/port_conflict.go +++ b/web/service/port_conflict.go @@ -31,7 +31,9 @@ func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 } // - hysteria, hysteria2, wireguard: udp regardless of streamSettings // - streamSettings.network=kcp: udp // - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp") -// - mixed (socks/http combo): tcp + udp when settings.udp is true +// - mixed (socks/http combo) and socks: tcp + udp when settings.udp is true +// (xray's dedicated socks5 inbound supports UDP ASSOCIATE on the same +// port via settings.udp, exactly the same shape as mixed) // - everything else: tcp func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits { // protocols that ignore streamSettings entirely. @@ -81,9 +83,13 @@ func inboundTransports(protocol model.Protocol, streamSettings, settings string) } } } - case model.Mixed: - // socks/http "mixed" inbound: settings.udp=true means it - // also relays udp on the same port (socks5 udp associate). + case model.Mixed, model.Socks: + // socks/http "mixed" inbound and the dedicated socks5 + // inbound: settings.udp=true means the inbound also relays + // udp on the same port (socks5 udp associate). Mixed and + // Socks share the exact same settings shape here, so we + // route them through the same branch instead of duplicating + // the type-assertion. if udpOn, _ := st["udp"].(bool); udpOn { bits |= transportUDP } diff --git a/web/service/port_conflict_test.go b/web/service/port_conflict_test.go index 1a7f0c1e..fb90cd85 100644 --- a/web/service/port_conflict_test.go +++ b/web/service/port_conflict_test.go @@ -87,6 +87,17 @@ func TestInboundTransports(t *testing.T) { {"mixed udp on", model.Mixed, `{"network":"tcp"}`, `{"udp":true}`, transportTCP | transportUDP}, {"mixed udp off", model.Mixed, `{"network":"tcp"}`, `{"udp":false}`, transportTCP}, {"mixed udp missing", model.Mixed, `{"network":"tcp"}`, `{}`, transportTCP}, + + // SOCKS (the dedicated socks5 inbound) shares the udp-associate + // shape with Mixed: settings.udp=true means the same port also + // accepts UDP. These cases pin that the port-conflict check + // treats Socks the same way Mixed is treated above, so a + // stale tcp neighbour won't silently block UDP-only socks + // traffic (or vice versa). + {"socks udp on", model.Socks, ``, `{"udp":true}`, transportTCP | transportUDP}, + {"socks udp off", model.Socks, ``, `{"udp":false}`, transportTCP}, + {"socks udp missing", model.Socks, ``, `{}`, transportTCP}, + {"socks empty settings", model.Socks, ``, ``, transportTCP}, } for _, c := range cases { @@ -470,6 +481,47 @@ func TestResolveInboundTag_RegeneratesOnCollision(t *testing.T) { } } +// the dedicated socks5 inbound with settings.udp=true takes both tcp +// and udp on the same port (same UDP-ASSOCIATE shape as Mixed). pinning +// that here so a future refactor of inboundTransports can't silently +// drop the Socks branch and start letting a hysteria2 udp inbound +// coexist with a dual-transport socks listener. +func TestCheckPortConflict_SocksUDPBlocksUDPNeighbour(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "socks-443-dual", "0.0.0.0", 443, model.Socks, ``, `{"udp":true}`) + + svc := &InboundService{} + udpClash := &model.Inbound{ + Tag: "hyst2-443", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.Hysteria2, + } + if exist, err := svc.checkPortConflict(udpClash, 0); err != nil || !exist { + t.Fatalf("hysteria2/udp must clash with socks+udp on same port; exist=%v err=%v", exist, err) + } +} + +// counterpart of the above: socks with udp=false (the default) only +// holds the tcp socket, so a udp-only neighbour on the same port must +// still be allowed. mirrors the vless/tcp + hysteria2/udp coexistence +// case from #4103. +func TestCheckPortConflict_SocksTCPCoexistsWithUDPNeighbour(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "socks-443-tcp", "0.0.0.0", 443, model.Socks, ``, `{"udp":false}`) + + svc := &InboundService{} + udpOnly := &model.Inbound{ + Tag: "hyst2-443", + Listen: "0.0.0.0", + Port: 443, + Protocol: model.Hysteria2, + } + if exist, err := svc.checkPortConflict(udpOnly, 0); err != nil || exist { + t.Fatalf("socks-tcp and hysteria2-udp on same port must coexist; exist=%v err=%v", exist, err) + } +} + // updating an inbound must not see itself as a conflict, that's what // ignoreId is for. func TestCheckPortConflict_IgnoreSelfOnUpdate(t *testing.T) { diff --git a/web/service/tgbot.go b/web/service/tgbot.go index 179c082f..c665a74a 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -2983,9 +2983,16 @@ func (t *Tgbot) getInboundsAddClient() (*telego.InlineKeyboardMarkup, error) { return nil, errors.New(t.I18nBot("tgbot.answers.getInboundsFailed")) } + // Protocols listed here are skipped when the bot asks "which inbound do + // you want to add a client to?". They're inbounds that either don't + // have per-client subscription links (Mixed/HTTP/Socks/Tunnel) or + // don't have per-client credentials at all (WireGuard), so attaching + // a tg-managed client to them would produce something the user can't + // actually subscribe to. excludedProtocols := map[model.Protocol]bool{ model.Tunnel: true, model.Mixed: true, + model.Socks: true, model.WireGuard: true, model.HTTP: true, } diff --git a/xray/api.go b/xray/api.go index 2bc9c61f..d074eb63 100644 --- a/xray/api.go +++ b/xray/api.go @@ -19,9 +19,11 @@ import ( "github.com/xtls/xray-core/common/protocol" "github.com/xtls/xray-core/common/serial" "github.com/xtls/xray-core/infra/conf" + httpProxy "github.com/xtls/xray-core/proxy/http" hysteriaAccount "github.com/xtls/xray-core/proxy/hysteria/account" "github.com/xtls/xray-core/proxy/shadowsocks" "github.com/xtls/xray-core/proxy/shadowsocks_2022" + "github.com/xtls/xray-core/proxy/socks" "github.com/xtls/xray-core/proxy/trojan" "github.com/xtls/xray-core/proxy/vless" "github.com/xtls/xray-core/proxy/vmess" @@ -240,6 +242,56 @@ func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]an account = serial.ToTypedMessage(&hysteriaAccount.Account{ Auth: auth, }) + case "socks": + // Xray's dedicated socks5 inbound. Live add-user via the gRPC + // HandlerService takes a socks.Account whose fields are + // {username, password} — distinct from the JSON inbound config, + // where the same data lives under settings.accounts[].{user,pass}. + // We map the panel-side "user"/"pass" (matching the SocksSettings + // model in frontend/src/models/inbound.js) onto those wire-level + // field names here so the runtime sees the credentials in the + // shape xray-core expects. + // + // "username" is treated as required: the dedicated socks inbound + // in noauth mode doesn't have per-user accounts at all, so any + // AddUser request we see here is a password-mode user and must + // carry a non-empty username. + username, err := getRequiredUserString(user, "user") + if err != nil { + return err + } + password, err := getOptionalUserString(user, "pass") + if err != nil { + return err + } + + account = serial.ToTypedMessage(&socks.Account{ + Username: username, + Password: password, + }) + case "http": + // Xray's dedicated http inbound exposes its own + // {username, password} Account in proxy/http. Same wire-level + // shape as socks (and the same panel-side "user"/"pass" mapping + // from HttpSettings), but the proto types are distinct, so we + // can't share one branch — the typed-message wrapper needs the + // exact proto.Message type the runtime registered under + // proxy.http inbound. Adding HTTP here keeps the dispatch table + // symmetric with socks and lets the panel push live-add-user + // for the dedicated HTTP inbound just like it does for socks. + username, err := getRequiredUserString(user, "user") + if err != nil { + return err + } + password, err := getOptionalUserString(user, "pass") + if err != nil { + return err + } + + account = serial.ToTypedMessage(&httpProxy.Account{ + Username: username, + Password: password, + }) default: return nil }