Files
3x-ui/internal/web/service/port_conflict.go
T
YoungReckless4 56ac8d0fc7 feat(amneziawg): add opt-in Linux kernel datapath (#6877)
* feat(amneziawg): add opt-in Linux kernel datapath

Add the kernel backend as an explicit per-inbound option while keeping the embedded backend as the default. Include host preflight, UI/API support, the bundled installer CLI, lifecycle and accounting integration, regression tests, and upstream-facing documentation. Kernel mode is NAT-only and rejects unsupported routing, IPv6, forwarding, and pinned-listen configurations. Local saves check the local host; remote nodes check their own host during apply.

Preserve adopted interface accounting across panel restarts, retain manager state after failed teardown so the job can retry, prevent embedded startup while a kernel interface remains live, and prune collector state for removed peers. Tested the restart accounting path on Debian 13 with XanMod 7.1.11 on x86_64; Docker and cross-kernel coverage are not claimed.

* fix(amneziawg): align readiness and endpoint reporting

Expire endpoint sightings using the latest handshake and sweep stale addresses for the polled interface. Keep the readiness API schema, generated client types, and ready-host empty blocker list consistent; watch the nested form field and defer local readiness checks for remote targets. Document safe rollback before using a build without kernel support.

Regression evidence: the idle endpoint test failed before the collector change because the old address remained visible after 31 minutes; endpoint tests pass after the fix. The form component suite passes 3 tests; restoring the invalid array watch made its off-mode severity test fail, while the corrected dot-path watch passes. Targeted Go regressions across amneziawgkern, job, and service pass.

* style(amneziawg): format readiness test

* test: reproduce kernel AWG client validation gaps

* test(service): cover kernel AWG shared client fanout

* fix(amneziawg): validate kernel client fanout and config values

* test(amneziawgkern): cover owned interface lifecycle recovery

* fix(amneziawgkern): recover owned interfaces on restart

* test(amneziawgkern): retain visibility after recovered cleanup failure

* fix(amneziawgkern): report recovered interfaces while cleanup retries

* style(amneziawgkern): satisfy static analysis

* test(amneziawgkern): cover long rendered peer comments

* fix(amneziawgkern): accept long generated config comments

* test(amneziawgkern): refuse foreign occupied interfaces

Capture the current Ensure ordering failure with real temporary config paths and manager side-effect boundaries.

* test(amneziawgkern): cover stale interface collisions

Extend the Ensure regression across absent links, mismatched live interfaces, cached manager state, and Remove teardown.

* fix(amneziawgkern): verify interface ownership before mutation

Refuse to replace or tear down links unless the canonical config is a valid panel-owned file and its key matches the live interface. Preserve normal adoption and key rotation, and document migration of legacy unmarked configs.

Validation: go test ./internal/amneziawgkern -count=1; go test -race ./internal/amneziawgkern -count=1; golangci-lint run ./internal/amneziawgkern/...; git diff --check.

* build: update Go toolchain to 1.27.3

* test(amneziawg): reproduce unsafe key rotation recovery

Keep the previous owned config authoritative until teardown succeeds, and require fresh managers to rebuild structural key changes. These regressions expose the stuck live-key/disk-key mismatch after teardown failure.

* fix(amneziawg): recover interrupted key rotations

Keep the old owned config on disk until a structural rebuild has confirmed the previous interface is down. Fresh managers only adopt when the live config and desired structural identity match, so retries and removals retain the key needed to prove ownership.

* fix(scanner): align Go image with module version

* fix(docs): pin pnpm release with bootstrap fix

pnpm 12.9.1 restores a Node launcher before its postinstall replaces the target with ELF; pnpm 11 then runs that binary as JavaScript. Pin 12.10.0, which includes the shim fix, so the docs CI bootstrap can switch versions safely.

* fix(xray): build core from pinned module

The configured Xray release asset tag is unpublished, and the latest published binary lacks GetUsersStats used by the panel. Build the packaged core from the exact Xray module version already selected in go.mod for Docker and release targets.

* fix(tests): stabilize local network fixtures

Keep the TUIC stream fragmentation check on a working UDP loopback path,
allocate the AmneziaWG fixture ports dynamically, and wait for a real
Xray stats response before starting API mutation assertions. Recreate
failed readiness channels so an early connection cannot stall the probe.

Verified host and offline parallel Go suites, repeated occupied-port and
UDP tests, race/shuffle runs, and golangci-lint. Production behavior and
dependency versions are unchanged.

* fix(i18n): add the kernel-mode AmneziaWG keys to sk-SK

main gained the sk-SK locale after this branch added its kernel-mode keys
to the other 13, so i18n-dead-keys.test.ts failed on the merged tree.

* refactor(amneziawgkern): apply kernel interfaces with direct commands, no awg-quick

awg-quick evaluates PostUp/PostDown through a root shell, so every value the
panel rendered into its config was one validation slip away from root command
execution. The interface is now built with direct ip/awg/nft execs from a
setconf-only file: link add, awg setconf, address, mtu, an atomically replaced
nftables table per inbound, and the IPv4 forwarding sysctl written directly.

- Panel-owned names and paths: xui-awg<id> interfaces, xui_awg<id> NAT tables
  and configs under the panel's data dir, so nothing collides with an
  operator's own awg0/awg1 awg-quick tunnels in /etc/amnezia/amneziawg.
- FORWARD accepts (comment 3x-ui:xui-awg<id>) when iptables is present: Docker
  and ufw set the FORWARD policy to DROP, which silently black-holed the tunnel.
- A full panel stop records the final counters and tears the interfaces down,
  like the embedded datapath; a stopped panel enforces no quota or expiry.
  Crash survivors are still adopted without a rebuild.
- The job reads counters before reconciling, and from every managed interface,
  so a client disabled for quota, or a removed inbound, is charged for its last
  interval instead of losing it with the dropped peer.
- An unchanged reconcile tick forks no process (it probed the live key with
  `awg show` every 10s); readiness no longer loads the module from a GET.

Verified on the real module (WSL2 6.6 kernel, v3.1.20260906): create, peer and
egress changes in place, SIGKILL adoption with the same ifindex, SIGTERM
teardown leaving no link, table, FORWARD rule or config behind.

* fix(amneziawg): gate kernel host readiness only when a save starts the datapath

Every save of a kernel-mode inbound re-ran the host preflight, so a host that
lost its module (a kernel upgrade without a DKMS rebuild) refused even
disabling the inbound or switching it back to embedded mode. Host blockers now
apply only when the save starts the kernel datapath: a new inbound, a switch
from embedded, or enabling a disabled one. Feature blockers (IPv6, forwarded
ports, pinned listen) still apply to every save.

* feat(nodes): refuse kernel-mode AmneziaWG pushes to nodes that predate it

A node built before kernel mode drops the unknown kernelMode field and serves
the inbound on the embedded datapath while the master's form shows kernel
mode on. Nodes now advertise awg-kernel in X-3x-Node-Caps, and Remote refuses
to push a kernel-mode inbound to a node that has not advertised it, learning
the caps with an inbound list first when this Remote has not heard from it.

* fix(amneziawg): pin the kernel module installer to verified commits

The installer ran as root and built whatever upstream tag sorted newest at
install time. It now clones fixed tags (module v3.1.20260906, tools
v3.1.20260812) and refuses unless each resolves to its recorded commit. The
udp_tunnel perl patch is gone: those releases carry the kernel 7.1.5 compat
upstream, so the patch never matched.

* fix(i18n): translate the kernel-mode AmneziaWG strings

All thirteen non-English locales carried the English text verbatim.

* fix(amneziawg): skip the kernel traffic flush when no kernel interface runs

Shutdown listed the desired AmneziaWG inbounds from the database even on a
panel with no kernel interface, an unneeded query on every stop. A server
stopped before its database was opened panicked on it, which turned
TestServerStopWhenXrayNotRunningSucceeds red whenever it ran first.

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-10-25 13:24:50 +01:00

790 lines
22 KiB
Go

package service
import (
"encoding/json"
"fmt"
"net"
"slices"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/tuic"
"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, model.AmneziaWG, model.TUIC:
return transportUDP
case model.MTProto:
return transportTCP
case model.MASQUE:
return masqueTransports(streamSettings)
}
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
}
// masqueTransports mirrors xray-core's MASQUE listener: HTTP/2 on TCP when the TLS
// ALPN offers h2, HTTP/3 on UDP when it offers h3 or does not offer h2.
func masqueTransports(streamSettings string) transportBits {
var stream struct {
TLSSettings struct {
ALPN []string `json:"alpn"`
} `json:"tlsSettings"`
}
_ = json.Unmarshal([]byte(streamSettings), &stream)
h2 := slices.Contains(stream.TLSSettings.ALPN, "h2")
var bits transportBits
if h2 {
bits |= transportTCP
}
if !h2 || slices.Contains(stream.TLSSettings.ALPN, "h3") {
bits |= transportUDP
}
return bits
}
// bindAddr is a listen address plus sockopt.v6only. xray listens on "tcp"/"udp",
// so Go opens every wildcard, 0.0.0.0 included, dual-stack unless v6only is set.
type bindAddr struct {
listen string
v6only bool
}
var loopbackBind = bindAddr{listen: "127.0.0.1"}
func inboundBindAddr(ib *model.Inbound) bindAddr {
return bindAddr{listen: ib.Listen, v6only: streamV6Only(ib.StreamSettings)}
}
func amneziawgKernelMode(ib *model.Inbound) bool {
if ib == nil || ib.Protocol != model.AmneziaWG {
return false
}
return amneziawgKernelModeSettings(ib.Settings)
}
func amneziawgKernelModeSettings(raw string) bool {
var settings amneziawg.InboundSettings
if err := json.Unmarshal([]byte(raw), &settings); err != nil || settings.Server == nil {
return false
}
return settings.Server.KernelMode
}
func streamV6Only(streamSettings string) bool {
if !strings.Contains(streamSettings, "v6only") {
return false
}
var stream struct {
Sockopt struct {
V6Only bool `json:"v6only"`
} `json:"sockopt"`
}
_ = json.Unmarshal([]byte(streamSettings), &stream)
return stream.Sockopt.V6Only
}
func listenOverlaps(a, b bindAddr) bool {
if a.listen == b.listen {
return true
}
familiesA, wildcardA, okA := bindFamilies(a)
familiesB, wildcardB, okB := bindFamilies(b)
if !okA || !okB {
return wildcardA || wildcardB
}
return (wildcardA || wildcardB) && familiesA&familiesB != 0
}
type addrFamily uint8
const (
familyIPv4 addrFamily = 1 << iota
familyIPv6
)
// bindFamilies reports the address families a listen claims; ok is false for a
// listen that is not an IP, such as a unix socket path.
func bindFamilies(a bindAddr) (families addrFamily, wildcard, ok bool) {
if isAnyListen(a.listen) {
if a.v6only {
return familyIPv6, true, true
}
return familyIPv4 | familyIPv6, true, true
}
ip := net.ParseIP(a.listen)
if ip == nil {
return 0, false, false
}
if ip.To4() != nil {
return familyIPv4, false, true
}
return familyIPv6, false, true
}
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
// Relay marks Port as an automatic loopback relay port, not a configured one.
Relay bool
// ForwardedBy is the peer whose port forward holds Port, when that is the
// reason for the conflict.
ForwardedBy string
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 = "*"
}
port := fmt.Sprintf("port %d", d.Port)
if d.Relay {
port = fmt.Sprintf("relay port %d", d.Port)
}
if d.ForwardedBy != "" {
return fmt.Sprintf("%s (%s) already forwarded on inbound %s on %s by its client %s",
port, transportTagSuffix(d.Transports), name, listen, d.ForwardedBy)
}
return fmt.Sprintf("%s (%s) already used by inbound %s on %s",
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(loopbackBind, inboundBindAddr(inbound)) {
return &portConflictDetail{
Tag: "api",
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: transportTCP,
}, nil
}
// Egress SOCKS server holds loopback EgressPort when AWG outbounds are
// active; conflict check prevents inbounds from colliding with it.
if inbound.NodeID == nil && inbound.Port == amneziawgnet.EgressPort() &&
newBits&transportTCP != 0 && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
return &portConflictDetail{
Tag: "amneziawg-egress",
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: transportTCP,
}, nil
}
// Local embedded inbounds reserve an automatically assigned Xray SOCKS relay port.
if inbound.NodeID == nil && listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
conflict, err := checkAmneziawgnetSocksConflict(db, inbound, ignoreId, newBits)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
conflict, err = checkTuicSocksConflict(db, inbound, ignoreId, newBits)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
}
// The reverse direction, only meaningful once the id is known -- AddInbound
// runs it after Save. Only a local row owns a relay slot (#6537 review).
if inbound.NodeID == nil && inbound.Protocol == model.AmneziaWG && ignoreId > 0 && !amneziawgKernelMode(inbound) {
if self := amneziawgnetSocksSelfConflict(inbound, ignoreId); self != "" {
return nil, common.NewError(self)
}
conflict, err := checkAmneziawgnetSocksRelayCollision(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
conflict, err = checkAmneziawgnetSocksReverseConflict(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
}
// A forwarded port is not a column and not a relay slot, so the query below
// cannot see it either: the port is bound by the peer's forward listener.
forwardedBy, err := amneziawgForwardedPortOwner(db, inbound, ignoreId)
if err != nil {
return nil, err
}
if forwardedBy != nil {
forwardedBy.Transports = newBits
return forwardedBy, nil
}
if inbound.NodeID == nil && inbound.Protocol == model.TUIC && ignoreId > 0 {
if self := tuicSocksSelfConflict(inbound, ignoreId); self != "" {
return nil, common.NewError(self)
}
conflict, err := checkTuicSocksRelayCollision(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, nil
}
conflict, err = checkTuicSocksReverseConflict(db, ignoreId)
if err != nil {
return nil, err
}
if conflict != nil {
return conflict, 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(inboundBindAddr(c), inboundBindAddr(inbound)) {
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
}
// amneziawgForwardedPortOwner names the AmneziaWG row on the same host whose peer
// forwards inbound's port -- a bind on every interface that only the AWG side checked.
func amneziawgForwardedPortOwner(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*portConflictDetail, error) {
var rows []*model.Inbound
q := db.Model(model.Inbound{}).Where("protocol = ?", model.AmneziaWG)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
if err := q.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if !sameNode(row.NodeID, inbound.NodeID) {
continue
}
if amneziawgKernelMode(row) {
continue
}
instance, ok := amneziawg.InstanceFromInbound(row)
if !ok {
continue
}
email, forwards := amneziawgnet.ForwardedPortOwner(instance, inbound.Port)
if !forwards {
continue
}
return &portConflictDetail{
InboundID: row.Id,
Remark: row.Remark,
Tag: row.Tag,
// the forward binds :port on every interface, wherever the
// candidate asked to listen.
Listen: "",
Port: inbound.Port,
ForwardedBy: email,
}, nil
}
return nil, nil
}
// checkAmneziawgnetSocksConflict checks the embedded relay port each local row reserves.
func checkAmneziawgnetSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
// A disabled row still owns the slot its id derives: SetInboundEnable flips
// the column with no port check, so enabling it later must not collide.
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("protocol = ? AND node_id IS NULL", model.AmneziaWG)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
if err := q.Find(&candidates).Error; err != nil {
return nil, err
}
// The row reserves its slot before its first peer is added.
for _, c := range candidates {
if amneziawgKernelMode(c) {
continue
}
if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: newBits,
}, nil
}
return nil, nil
}
// checkAmneziawgnetSocksRelayCollision reports whether id's embedded relay port
// is already claimed by another local AmneziaWG inbound, including disabled rows.
func checkAmneziawgnetSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := amneziawgnet.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("protocol = ? AND node_id IS NULL AND id != ?", model.AmneziaWG, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if amneziawgKernelMode(c) {
continue
}
if amneziawgnet.SOCKSPortForInbound(c.Id) != relayPort {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: relayPort,
Relay: true,
Transports: transportTCP,
}, nil
}
return nil, nil
}
// amneziawgnetSocksSelfConflict: a row's own WireGuard port vs the relay port its
// own id derives -- all three checks below exclude that id, so nothing else does.
func amneziawgnetSocksSelfConflict(inbound *model.Inbound, id int) string {
if id <= 0 || inbound.NodeID != nil || amneziawgKernelMode(inbound) || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
return ""
}
relayPort := amneziawgnet.SOCKSPortForInbound(id)
if inbound.Port != relayPort {
return ""
}
return fmt.Sprintf("WireGuard port %d is inbound #%d's own SOCKS5 relay port on 127.0.0.1; choose a different WireGuard port",
relayPort, id)
}
// checkAmneziawgnetSocksReverseConflict mirrors checkAmneziawgnetSocksConflict:
// does id's own derived relay port collide with some other inbound's port.
func checkAmneziawgnetSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := amneziawgnet.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if amneziawgKernelMode(c) {
continue
}
if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: c.Listen,
Port: relayPort,
Relay: true,
Transports: transportTCP,
}, nil
}
return nil, nil
}
func checkTuicSocksConflict(db *gorm.DB, inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) {
var candidates []*model.Inbound
q := db.Model(model.Inbound{}).Where("protocol = ? AND node_id IS NULL", model.TUIC)
if ignoreId > 0 {
q = q.Where("id != ?", ignoreId)
}
if err := q.Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if _, ok := tuic.InstanceFromInbound(c); !ok {
continue
}
if tuic.SOCKSPortForInbound(c.Id) != inbound.Port {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: inbound.Port,
Transports: newBits,
}, nil
}
return nil, nil
}
func checkTuicSocksRelayCollision(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := tuic.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("protocol = ? AND node_id IS NULL AND id != ?", model.TUIC, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if tuic.SOCKSPortForInbound(c.Id) != relayPort {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: "127.0.0.1",
Port: relayPort,
Relay: true,
Transports: transportTCP,
}, nil
}
return nil, nil
}
func tuicSocksSelfConflict(inbound *model.Inbound, id int) string {
if id <= 0 || inbound.NodeID != nil || !listenOverlaps(loopbackBind, inboundBindAddr(inbound)) {
return ""
}
relayPort := tuic.SOCKSPortForInbound(id)
if inbound.Port != relayPort {
return ""
}
return fmt.Sprintf("TUIC port %d is inbound #%d's own SOCKS5 relay port on 127.0.0.1; choose a different TUIC port",
relayPort, id)
}
func checkTuicSocksReverseConflict(db *gorm.DB, id int) (*portConflictDetail, error) {
relayPort := tuic.SOCKSPortForInbound(id)
var candidates []*model.Inbound
if err := db.Model(model.Inbound{}).
Where("port = ? AND node_id IS NULL AND id != ?", relayPort, id).
Find(&candidates).Error; err != nil {
return nil, err
}
for _, c := range candidates {
if !listenOverlaps(loopbackBind, inboundBindAddr(c)) {
continue
}
return &portConflictDetail{
InboundID: c.Id,
Remark: c.Remark,
Tag: c.Tag,
Listen: c.Listen,
Port: relayPort,
Relay: true,
Transports: transportTCP,
}, 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) {
reserved, err := templateInboundTags()
if err != nil {
return false, err
}
if _, exists := reserved[tag]; exists {
return true, nil
}
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
}
func templateInboundTags() (map[string]struct{}, error) {
raw, err := (&SettingService{}).GetXrayConfigTemplate()
if err != nil {
return nil, err
}
var cfg struct {
Inbounds []struct {
Tag string `json:"tag"`
} `json:"inbounds"`
API struct {
Tag string `json:"tag"`
} `json:"api"`
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, err
}
tags := map[string]struct{}{"api": {}}
if cfg.API.Tag != "" {
tags[cfg.API.Tag] = struct{}{}
}
for _, inbound := range cfg.Inbounds {
if inbound.Tag != "" {
tags[inbound.Tag] = struct{}{}
}
}
return tags, nil
}