3x-ui/web/service/port_conflict.go
reza 2562e2eb82 feat(socks): complete backend integration for SOCKS5 inbound
Wraps up the 'help wanted' backend items from the SOCKS5 scaffold PR
(#4452) so the dedicated socks inbound is a fully functional protocol
end-to-end, not just a model constant.

xray/api.go AddUser
-------------------
Live add-user via the gRPC HandlerService now handles 'socks' and
'http' as first-class protocols. Previously these fell through the
default branch and returned nil, so adding a new password-mode account
to a running socks/http inbound silently required a full xray restart.

- New 'socks' case constructs a proxy/socks.Account{Username, Password}
  from the panel-side map keys 'user' and 'pass' (matching how
  Inbound.SocksSettings.SocksAccount serialises in
  frontend/src/models/inbound.js). Username is required, password is
  optional so a no-pass account is still expressible if Xray ever
  allows it on a specific build.
- New 'http' case mirrors the same shape via proxy/http.Account.
  The dedicated HTTP inbound isn't surfaced standalone in the panel
  UI yet, but the runtime API is symmetric with socks and several
  follow-up plans (e.g. exposing HTTP as a separate inbound) become
  one-line UI work instead of a backend refactor.

Both branches reuse the existing getRequiredUserString /
getOptionalUserString helpers, so a malformed userMap surfaces the
same typed error message as the vless / vmess paths above.

web/service/port_conflict.go
----------------------------
inboundTransports() now folds 'socks' into the same branch that already
handles 'mixed': settings.udp=true means the inbound holds both tcp and
udp on the listening port (socks5 UDP ASSOCIATE), settings.udp=false
keeps it tcp-only. Without this, a socks+udp inbound would silently be
classified as tcp-only and the validator would let a hysteria2 udp
inbound coexist with it on the same port — both processes would then
race for the udp socket at xray start, with one of them quietly failing.

The two protocols share the exact same settings JSON shape for this
field (it's the same proxy/socks server type under the hood), so the
sane thing is to merge the case clauses rather than copy/paste the
type-assertion. Comment updated to spell out why.

web/service/tgbot.go
--------------------
Add model.Socks to the excludedProtocols set in getInboundsAddClient
so the Telegram bot doesn't offer a dedicated SOCKS inbound when the
admin asks 'add a client to which inbound?'. SOCKS inbounds, like
Mixed/HTTP, don't produce a per-client subscription URL (see the
existing link-less branch in sub/subService.go::GetLink), so any
client attached via the bot would have no way to actually subscribe.
Added a header comment explaining the criterion so future protocols
fall into the right bucket without an audit.

Tests
-----
web/service/port_conflict_test.go gains four cases that pin the new
behaviour at the transport-bits level (TestInboundTransports):
  - socks + udp=true  -> tcp|udp (matches Mixed)
  - socks + udp=false -> tcp only
  - socks + missing settings -> tcp only
  - socks + empty settings   -> tcp only

…plus two end-to-end conflict checks that mirror the existing
shadowsocks/mixed coverage:
  - TestCheckPortConflict_SocksUDPBlocksUDPNeighbour: a socks+udp
    inbound on port N must clash with a hysteria2/udp on the same
    port. Catches a regression where the Socks branch is dropped
    from inboundTransports.
  - TestCheckPortConflict_SocksTCPCoexistsWithUDPNeighbour: a
    socks-tcp-only inbound must still let a hysteria2/udp neighbour
    bind the same port. Mirrors the #4103 vless+hysteria2 coexistence
    case.

Out-of-scope (still tracked in the PR description)
--------------------------------------------------
- Sub-link generation (sub/subService.go GetLink): SOCKS deliberately
  stays link-less for the reasons documented in the previous commit;
  no socks:// scheme is consistently understood across xray/v2ray
  client ecosystems.
- Routing UI: routing rules in this fork already accept any inbound
  tag, so SOCKS inbounds are routable as-is. A dedicated
  'protocol == socks' helper in the routing rule editor is a UX
  follow-up, not a correctness gap.
- Translations: protocol labels are rendered raw in this fork; no
  per-locale label key exists for vmess/vless/mixed either, so adding
  one only for socks would be inconsistent.
2026-05-25 15:05:20 +00:00

285 lines
9.3 KiB
Go

package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/util/common"
)
// transportBits is a bitmask of L4 transports an inbound listens on.
// 0.0.0.0:443/tcp and 0.0.0.0:443/udp are independent sockets in linux,
// so the conflict check needs more than just the port number.
type transportBits uint8
const (
transportTCP transportBits = 1 << iota
transportUDP
)
// conflicts is true when the two masks share any L4 transport.
func (b transportBits) conflicts(o transportBits) bool { return b&o != 0 }
// inboundTransports returns the L4 transports the given inbound listens on.
// always returns at least one bit (falls back to tcp on parse errors), so
// the validator never gets looser than the old port-only check.
//
// the rules:
// - hysteria, hysteria2, wireguard: udp regardless of streamSettings
// - streamSettings.network=kcp: udp
// - shadowsocks: whatever settings.network says ("tcp" / "udp" / "tcp,udp")
// - 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.
switch protocol {
case model.Hysteria, model.Hysteria2, model.WireGuard:
return transportUDP
}
var bits transportBits
// peek at streamSettings.network to spot udp transports like kcp.
// parse errors are non-fatal: missing or weird streamSettings just
// keeps the default tcp bit below.
network := ""
if streamSettings != "" {
var ss map[string]any
if json.Unmarshal([]byte(streamSettings), &ss) == nil {
if n, _ := ss["network"].(string); n != "" {
network = n
}
}
}
if network == "kcp" {
bits |= transportUDP
} else {
bits |= transportTCP
}
// some protocols also listen on udp on the same port via their own
// settings json. parse and merge.
if settings != "" {
var st map[string]any
if json.Unmarshal([]byte(settings), &st) == nil {
switch protocol {
case model.Shadowsocks:
// shadowsocks settings.network controls both tcp and udp,
// independently of streamSettings. the field takes "tcp",
// "udp", or "tcp,udp". if it's set, it wins outright.
if n, ok := st["network"].(string); ok && n != "" {
bits = 0
for _, part := range strings.Split(n, ",") {
switch strings.TrimSpace(part) {
case "tcp":
bits |= transportTCP
case "udp":
bits |= transportUDP
}
}
}
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
}
}
}
}
// safety net: never return zero, even if every parse failed.
if bits == 0 {
bits = transportTCP
}
return bits
}
// listenOverlaps reports whether two listen addresses can collide on the
// same port. preserves the rule from the original checkPortExist:
// any-address (empty / 0.0.0.0 / :: / ::0) overlaps with everything,
// otherwise only identical specific addresses overlap.
func listenOverlaps(a, b string) bool {
if isAnyListen(a) || isAnyListen(b) {
return true
}
return a == b
}
func isAnyListen(s string) bool {
return s == "" || s == "0.0.0.0" || s == "::" || s == "::0"
}
// checkPortConflict reports whether adding/updating an inbound on
// (listen, port) would clash with an existing inbound. unlike the old
// port-only check, this one understands that tcp/443 and udp/443 are
// independent sockets in linux and may coexist on the same address.
//
// node scope: inbounds with different NodeID run on different physical
// machines (local panel xray vs a remote node, or two remote nodes),
// so their sockets can't collide. only candidates with the same NodeID
// participate in the listen/transport overlap check.
//
// the listen-overlap rule (specific addr conflicts with any-addr on the
// same port, both directions) is preserved from the previous check.
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (bool, error) {
db := database.GetDB()
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("port = ?", inbound.Port)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
if err := q.Find(&candidates).Error; err != nil {
return false, err
}
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
for _, c := range candidates {
if !sameNode(c.NodeID, inbound.NodeID) {
continue
}
if !listenOverlaps(c.Listen, inbound.Listen) {
continue
}
if inboundTransports(c.Protocol, c.StreamSettings, c.Settings).conflicts(newBits) {
return true, nil
}
}
return false, nil
}
// sameNode reports whether two NodeID pointers refer to the same xray
// process. nil/nil means both inbounds run on the local panel; non-nil
// with equal value means they share the same remote node. any mix
// (local vs remote, remote-A vs remote-B) is "different node" and
// can't produce a real socket collision.
func sameNode(a, b *int) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
// baseInboundTag is the historical "inbound-<port>" / "inbound-<listen>:<port>"
// shape. kept exactly so existing routing rules that reference these tags
// keep working after the upgrade.
func baseInboundTag(listen string, port int) string {
if isAnyListen(listen) {
return fmt.Sprintf("inbound-%v", port)
}
return fmt.Sprintf("inbound-%v:%v", listen, port)
}
// transportTagSuffix turns a transport mask into a short, stable string
// for tag disambiguation. only used when the base "inbound-<port>" is
// already taken on a coexisting transport (e.g. tcp inbound already lives
// on 443 and we're now adding a udp one).
func transportTagSuffix(b transportBits) string {
switch b {
case transportTCP:
return "tcp"
case transportUDP:
return "udp"
case transportTCP | transportUDP:
return "mixed"
}
return "any"
}
// generateInboundTag picks a tag for the inbound that doesn't collide with
// any existing row. for the common single-inbound-per-port case the tag
// stays exactly as before ("inbound-443"), so user routing rules don't
// silently change shape on upgrade. only when a same-port neighbour
// already owns the base tag (now possible because tcp/443 and udp/443 can
// coexist after the transport-aware port check) does this append a
// transport suffix like "inbound-443-udp".
//
// ignoreId is the inbound's own id during update so it doesn't see itself
// as a collision; pass 0 on add.
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
base := baseInboundTag(inbound.Listen, inbound.Port)
exists, err := s.tagExists(base, ignoreId)
if err != nil {
return "", err
}
if !exists {
return base, nil
}
suffix := transportTagSuffix(inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings))
candidate := base + "-" + suffix
exists, err = s.tagExists(candidate, ignoreId)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
// the transport-aware port check should have already blocked this
// path, but guard anyway so a unique-constraint failure doesn't reach
// the user as an opaque sqlite error.
for i := 2; i < 100; i++ {
c := fmt.Sprintf("%s-%d", candidate, i)
exists, err = s.tagExists(c, ignoreId)
if err != nil {
return "", err
}
if !exists {
return c, nil
}
}
return "", common.NewError("could not pick a unique inbound tag for port:", inbound.Port)
}
// resolveInboundTag chooses a tag for an Add or Update. when the caller
// supplied a non-empty Tag (e.g. the central panel pushed its picked
// tag to a node during a multi-node sync) and that tag is free in the
// local DB, it's used verbatim so the two panels stay in agreement —
// otherwise the node would regenerate (often back to bare
// "inbound-<port>") and the eventual traffic sync-back would try to
// INSERT a row whose tag already exists, hitting the UNIQUE constraint
// on inbounds.tag and rolling the node-side row right back out.
// when Tag is empty (the common UI path) or collides, fall back to the
// transport-aware generateInboundTag.
//
// ignoreId mirrors generateInboundTag: pass 0 on add, the inbound's
// own id on update so a row doesn't see its own current tag as taken.
func (s *InboundService) resolveInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
if inbound.Tag != "" {
taken, err := s.tagExists(inbound.Tag, ignoreId)
if err != nil {
return "", err
}
if !taken {
return inbound.Tag, nil
}
}
return s.generateInboundTag(inbound, ignoreId)
}
func (s *InboundService) tagExists(tag string, ignoreId int) (bool, error) {
db := database.GetDB()
q := db.Model(model.Inbound{}).Where("tag = ?", tag)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
var count int64
if err := q.Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}