Files
3x-ui/internal/web/service/port_conflict.go
T
n0ctal 81cfd8570e fix(inbounds): close the port check-and-claim race on the serial writer (#6225)
* fix(inbounds): close the port check-and-claim race on the serial writer

AddInbound reads the port conflict outside its transaction and then commits in
a bare db.Transaction, so two overlapping creates both pass the read and both
insert. UpdateInbound already runs on the single traffic writer, and so does
the node snapshot path; AddInbound is the one inbound writer left out.

Move it onto runSerializedTx and evaluate the conflict inside the transaction,
in both AddInbound and UpdateInbound. The check and the claim then commit
together on one goroutine, which closes the window on SQLite (immediate write
lock) and PostgreSQL alike without new schema, locks or configuration.

The wildcard/specific pair is the case worth naming: those are two distinct
rows, so no unique index can reject them — only the semantic check can, and
only if nothing can interleave between it and the insert.

* fix(inbounds): restore the port check UpdateInbound lost

The previous commit deleted UpdateInbound's pre-flight conflict check and never
added the in-transaction one, so editing an inbound onto an occupied port was
accepted outright. No test covered that path, so CI stayed green.

Evaluate the conflict inside the transaction, as AddInbound already does, and
add the regression test that fails without it.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <n0ctal@users.noreply.github.com>
2026-08-18 11:48:40 +02:00

325 lines
8.2 KiB
Go

package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
"gorm.io/gorm"
)
type transportBits uint8
const (
transportTCP transportBits = 1 << iota
transportUDP
)
func inboundTransports(protocol model.Protocol, streamSettings, settings string) transportBits {
// protocols that ignore streamSettings entirely.
switch protocol {
case model.Hysteria, model.WireGuard:
return transportUDP
case model.MTProto:
return transportTCP
}
var bits transportBits
// peek at streamSettings.network to spot udp-based transports.
// 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
}
}
}
switch network {
case "kcp", "quic":
bits |= transportUDP
default:
bits |= transportTCP
}
// a few protocols carry their L4 choice in settings instead of (or in
// addition to) streamSettings: SS / Tunnel via a CSV field that wins
// outright, Mixed via an additive udp boolean.
if settings != "" {
var st map[string]any
if json.Unmarshal([]byte(settings), &st) == nil {
switch protocol {
case model.Shadowsocks, model.Tunnel:
key := "network"
if protocol == model.Tunnel {
key = "allowedNetwork"
}
if n, ok := st[key].(string); ok && n != "" {
bits = 0
for part := range strings.SplitSeq(n, ",") {
switch strings.TrimSpace(part) {
case "tcp":
bits |= transportTCP
case "udp":
bits |= transportUDP
}
}
}
case model.Mixed:
// socks/http "mixed" inbound: settings.udp=true means it
// also relays udp on the same port (socks5 udp associate).
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
}
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"
}
type portConflictDetail struct {
InboundID int
Remark string
Tag string
Listen string
Port int
Transports transportBits
}
// String renders the detail as a single-line, user-facing summary.
func (d *portConflictDetail) String() string {
name := d.Remark
if name == "" {
name = d.Tag
}
if name == "" {
name = fmt.Sprintf("#%d", d.InboundID)
} else if d.InboundID > 0 {
name = fmt.Sprintf("'%s' (#%d)", name, d.InboundID)
} else {
// reserved/system inbounds (e.g. the Xray API) have no DB id.
name = fmt.Sprintf("'%s'", name)
}
listen := d.Listen
if isAnyListen(listen) {
listen = "*"
}
return fmt.Sprintf("port %d (%s) already used by inbound %s on %s",
d.Port, transportTagSuffix(d.Transports), name, listen)
}
// defaultXrayAPIPort is the loopback port of the internal Xray API inbound
// (tag "api") seeded into the config template. Used as a fallback when the
// template can't be parsed.
const defaultXrayAPIPort = 62789
// reservedAPIPort returns the port of the internal Xray API inbound declared
// in the config template, falling back to defaultXrayAPIPort.
func reservedAPIPort() int {
tmpl, err := (&SettingService{}).GetXrayConfigTemplate()
if err != nil || tmpl == "" {
return defaultXrayAPIPort
}
var parsed struct {
Inbounds []struct {
Port int `json:"port"`
Tag string `json:"tag"`
} `json:"inbounds"`
}
if json.Unmarshal([]byte(tmpl), &parsed) != nil {
return defaultXrayAPIPort
}
for _, in := range parsed.Inbounds {
if in.Tag == "api" && in.Port > 0 {
return in.Port
}
}
return defaultXrayAPIPort
}
// checkPortConflict reads outside any transaction; callers that must not race a
// concurrent create use checkPortConflictTx inside their own transaction.
func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
return checkPortConflictTx(database.GetDB(), inbound, ignoreId)
}
func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
newBits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
// The internal Xray API inbound (tag "api", loopback TCP) isn't a DB row,
// so a local user inbound reusing its port would leave Xray binding the
// port twice (#5304). Nodes run their own Xray, so this only applies to
// the local panel.
if inbound.NodeID == nil && inbound.Port == reservedAPIPort() &&
newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
return &portConflictDetail{
Tag: "api",
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: transportTCP,
}, nil
}
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 nil, err
}
for _, c := range candidates {
if !sameNode(c.NodeID, inbound.NodeID) {
continue
}
if !listenOverlaps(c.Listen, inbound.Listen) {
continue
}
existingBits := inboundTransports(c.Protocol, c.StreamSettings, c.Settings)
shared := existingBits & newBits
if shared == 0 {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: c.Listen,
Port: c.Port,
Transports: shared,
}, nil
}
return nil, nil
}
func sameNode(a, b *int) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func baseInboundTag(port int) string {
return fmt.Sprintf("in-%v", port)
}
func transportTagSuffix(b transportBits) string {
switch b {
case transportTCP:
return "tcp"
case transportUDP:
return "udp"
case transportTCP | transportUDP:
return "tcpudp"
}
return "any"
}
// nodeTagPrefix scopes a tag to one remote node so the same listen+port
// can live on the central panel and on a node without bumping the global
// UNIQUE(inbounds.tag) constraint. nil → "" (local panel).
func nodeTagPrefix(nodeID *int) string {
if nodeID == nil {
return ""
}
return fmt.Sprintf("n%d-", *nodeID)
}
func composeInboundTag(port int, nodeID *int, bits transportBits) string {
return nodeTagPrefix(nodeID) + baseInboundTag(port) + "-" + transportTagSuffix(bits)
}
func isAutoGeneratedTag(tag string, port int, nodeID *int, bits transportBits) bool {
base := composeInboundTag(port, nodeID, bits)
if tag == base {
return true
}
suffix, ok := strings.CutPrefix(tag, base+"-")
if !ok || suffix == "" {
return false
}
for _, r := range suffix {
if r < '0' || r > '9' {
return false
}
}
return true
}
func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) {
bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings)
candidate := composeInboundTag(inbound.Port, inbound.NodeID, bits)
exists, err := s.tagExists(candidate, ignoreId)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
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)
}
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
}