mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
baef3cdd07
* fix(xray): refuse a config the running core cannot bind RestartXray stopped a working core before handing it a config whose listens collide, so the failed bind exited the whole process (main/run.go:94) and the one-second watchdog retried it in a loop: every protocol down, cause only in the logs. The save-time port guards cannot cover this -- SetInboundEnable, the AmneziaWG relay created on the first peer, template and bridge edits all reach a colliding config with no guard on that path. Probe the generated config at the single restart funnel instead. Collisions the running core already serves are excused, so an established setup is never refused by a static read being wrong about it, and the port-bucketed pass costs nothing on a clean config. * fix(xray): surface a refused config and re-key the bind excuse set Round-1 findings on this PR. Refusing the swap left the running core on its previous config with nothing but a log line to show for it, so the status response now carries the reason while the core runs and the overview marks it; the node list picks the same field up through that response. The excuse set is keyed on the two listens, the port and the shared transports instead of the tag pair, so a pair whose listen moves onto the other's address is refused again, while the same two sockets stay excused however the generator orders them. TestBindConflicts/excused_pair_whose_listen_changed_into_a_real_collision fails without the key change -- watched red first.
148 lines
4.4 KiB
Go
148 lines
4.4 KiB
Go
package service
|
|
|
|
import (
|
|
"cmp"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
// bindConflict names two generated inbounds whose listens cannot coexist.
|
|
type bindConflict struct {
|
|
tagA string
|
|
tagB string
|
|
listen string
|
|
listenB string
|
|
port int
|
|
shared transportBits
|
|
}
|
|
|
|
func (c bindConflict) String() string {
|
|
return fmt.Sprintf("inbounds %q and %q both bind %s:%d (%s)",
|
|
c.tagA, c.tagB, displayListen(c.listen), c.port, transportTagSuffix(c.shared))
|
|
}
|
|
|
|
// bindConflicts reports inbounds of newCfg whose sockets collide. A collision the
|
|
// running config already serves is excused: it is demonstration, not a guess.
|
|
func bindConflicts(newCfg, runningCfg *xray.Config) []bindConflict {
|
|
conflicts := rawBindConflicts(newCfg)
|
|
if len(conflicts) == 0 {
|
|
return nil
|
|
}
|
|
excused := runningBindPairs(runningCfg)
|
|
if len(excused) == 0 {
|
|
return conflicts
|
|
}
|
|
kept := make([]bindConflict, 0, len(conflicts))
|
|
for _, c := range conflicts {
|
|
if _, ok := excused[bindPairKey(c)]; !ok {
|
|
kept = append(kept, c)
|
|
}
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// rawBindConflicts groups inbounds by port first: only a port two inbounds share
|
|
// is worth parsing transports for, which keeps the probe free on clean configs.
|
|
func rawBindConflicts(cfg *xray.Config) []bindConflict {
|
|
if cfg == nil {
|
|
return nil
|
|
}
|
|
byPort := make(map[int][]*xray.InboundConfig, len(cfg.InboundConfigs))
|
|
for i := range cfg.InboundConfigs {
|
|
ib := &cfg.InboundConfigs[i]
|
|
if ib.Port > 0 {
|
|
byPort[ib.Port] = append(byPort[ib.Port], ib)
|
|
}
|
|
}
|
|
|
|
var conflicts []bindConflict
|
|
for port, group := range byPort {
|
|
for i := range group {
|
|
for j := i + 1; j < len(group); j++ {
|
|
left, right := group[i], group[j]
|
|
listenLeft, listenRight := configListen(left.Listen), configListen(right.Listen)
|
|
if !listenOverlaps(listenLeft, listenRight) {
|
|
continue
|
|
}
|
|
// One port carrying tcp on one inbound and udp on another is a
|
|
// supported deployment (vless/tcp + hysteria2/udp), never a clash.
|
|
shared := configTransports(left) & configTransports(right)
|
|
if shared == 0 {
|
|
continue
|
|
}
|
|
conflicts = append(conflicts, bindConflict{
|
|
tagA: left.Tag,
|
|
tagB: right.Tag,
|
|
listen: listenLeft,
|
|
listenB: listenRight,
|
|
port: port,
|
|
shared: shared,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
// Port grouping iterates a map: order the report so the first conflict the
|
|
// caller shows is the same on every restart.
|
|
slices.SortFunc(conflicts, func(a, b bindConflict) int {
|
|
return cmp.Or(cmp.Compare(a.port, b.port), cmp.Compare(a.tagA, b.tagA), cmp.Compare(a.tagB, b.tagB))
|
|
})
|
|
return conflicts
|
|
}
|
|
|
|
// runningBindPairs is what the core is demonstrably binding right now, keyed the
|
|
// way a new config's conflicts are, so only the identical one is excused.
|
|
func runningBindPairs(cfg *xray.Config) map[string]struct{} {
|
|
conflicts := rawBindConflicts(cfg)
|
|
if len(conflicts) == 0 {
|
|
return nil
|
|
}
|
|
pairs := make(map[string]struct{}, len(conflicts))
|
|
for _, c := range conflicts {
|
|
pairs[bindPairKey(c)] = struct{}{}
|
|
}
|
|
return pairs
|
|
}
|
|
|
|
// bindPairKey is the socket set an excuse was granted for: the two listens, the
|
|
// port and the shared transports, never the tags, which the generator reorders.
|
|
func bindPairKey(c bindConflict) string {
|
|
left, right := c.listen, c.listenB
|
|
if left > right {
|
|
left, right = right, left
|
|
}
|
|
return fmt.Sprintf("%d\x00%s\x00%s\x00%d", c.port, left, right, c.shared)
|
|
}
|
|
|
|
func displayListen(listen string) string {
|
|
if isAnyListen(listen) {
|
|
return "*"
|
|
}
|
|
return listen
|
|
}
|
|
|
|
// configListen decodes a generated inbound's listen field: absent or empty means
|
|
// every address, which is what the core does with an empty listen too.
|
|
func configListen(raw json_util.RawMessage) string {
|
|
var listen string
|
|
if len(raw) == 0 || json.Unmarshal(raw, &listen) != nil {
|
|
return ""
|
|
}
|
|
return listen
|
|
}
|
|
|
|
// configTransports reads a generated inbound's transports through the same rule
|
|
// the save-time guards use, so the two cannot drift apart.
|
|
func configTransports(ib *xray.InboundConfig) transportBits {
|
|
// "socks" is the panel's own bridge shape (injectAmneziawgnetSocks and
|
|
// friends): its udp flag lives in settings like a mixed inbound's.
|
|
if ib.Protocol == "socks" {
|
|
return inboundTransports(model.Mixed, string(ib.StreamSettings), string(ib.Settings))
|
|
}
|
|
return inboundTransports(model.Protocol(ib.Protocol), string(ib.StreamSettings), string(ib.Settings))
|
|
}
|