mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 23:27:14 +00:00
e790f46757
* fix(xray): restart when a diff strands a client's live session Disabling or deleting a client took it out of the generated config and the hot path applied that with AlterInbound/RemoveUser, which only drops the credential (vless, vmess, trojan and shadowsocks all keep the established session running) -- so the panel showed a disabled client whose connection kept passing traffic, and the core offers no API to close one session. A diff that removes a user without re-adding the same email under the same tag is that case: honour the operator's restart-on-client-disable setting and let the caller replace the process, which is already how an auto-disabled client loses its session. An edit re-adds the email and keeps the hot path. * chore(i18n): cover manual disable and delete in the restart-setting description The setting now also decides what happens when a client is disabled or deleted by hand, so the description cannot keep naming only the automatic path. All 13 locales updated in the same commit to keep the wording consistent. * fix(xray): reach the guard from the manual switch and from every protocol Round-1 findings on this PR. The guard sat in tryHotApply, but a manual disable or delete applies through runtime.Runtime and finishes with needRestart false, so none of the three RestartXray schedulers fired and the predicate was never reached: the session in #6533 kept flowing. The apply layer now asks for the restart the setting promises when the client actually leaves the config, on the single-client update and delete paths and on bulk disable, and only for local inbounds so a node row cannot make the master restart its own core. The predicate itself could not fire for shadowsocks or hysteria either, because RemovedUsers is only produced for the protocols diffInboundUsers will diff. The diff now also compares settings.clients of an inbound present in both configs, which is the one shape every account list shares, so those protocols reach the guard through the inbound instead of through nothing. TestManualClientDisableHonoursRestartSetting fails without the apply-layer fix ("needRestart = false, want true" with the setting on) and TestHotDiffDropsUsersOnProtocolsItCannotDiff fails without the diff fix -- both watched red. The two three-line comments this PR added are back inside the cap. * docs(i18n): stop scoping restartXrayOnClientDisable to auto-disable The setting now covers a client disabled or deleted by hand as well, so its title no longer says "Auto" in all 13 locales, and the docs callouts in en, ru, zh and fa describe the same behaviour instead of the auto-only one.
632 lines
20 KiB
Go
632 lines
20 KiB
Go
package xray
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
|
)
|
|
|
|
// HotDiff describes the gRPC API operations needed to bring a running Xray
|
|
// instance from one generated config to another without restarting the
|
|
// process. It only covers the sections Xray can reload at runtime: inbounds,
|
|
// outbounds and routing rules/balancers.
|
|
type HotDiff struct {
|
|
RemovedInboundTags []string
|
|
AddedInbounds [][]byte
|
|
RemovedUsers []UserOp
|
|
AddedUsers []UserOp
|
|
// DroppedClients are emails an inbound that survives the change stopped
|
|
// serving, including the protocols diffInboundUsers will not diff.
|
|
DroppedClients []UserOp
|
|
RemovedOutboundTags []string
|
|
AddedOutbounds [][]byte
|
|
RoutingConfig []byte // full new routing section; nil when unchanged
|
|
}
|
|
|
|
// UserOp is a per-user AlterInbound operation; User is nil for removals.
|
|
type UserOp struct {
|
|
Tag string
|
|
Protocol string
|
|
Email string
|
|
User map[string]any
|
|
}
|
|
|
|
// DropsUsers reports users removed without being re-added under the same tag:
|
|
// a disable or a delete, where an edit re-adds the email with new values.
|
|
func (d *HotDiff) DropsUsers() bool {
|
|
if len(d.DroppedClients) > 0 {
|
|
return true
|
|
}
|
|
if len(d.RemovedUsers) == 0 {
|
|
return false
|
|
}
|
|
readded := make(map[string]struct{}, len(d.AddedUsers))
|
|
for _, u := range d.AddedUsers {
|
|
readded[u.Tag+"\x00"+u.Email] = struct{}{}
|
|
}
|
|
for _, u := range d.RemovedUsers {
|
|
if _, ok := readded[u.Tag+"\x00"+u.Email]; !ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Empty reports whether the diff contains no operations.
|
|
func (d *HotDiff) Empty() bool {
|
|
return len(d.RemovedInboundTags) == 0 &&
|
|
len(d.AddedInbounds) == 0 &&
|
|
len(d.RemovedUsers) == 0 &&
|
|
len(d.AddedUsers) == 0 &&
|
|
len(d.RemovedOutboundTags) == 0 &&
|
|
len(d.AddedOutbounds) == 0 &&
|
|
d.RoutingConfig == nil
|
|
}
|
|
|
|
// ComputeHotDiff compares two generated configs and returns the API operations
|
|
// that transform a running instance from oldCfg to newCfg. ok is false when
|
|
// the change touches anything that has no runtime reload API (log, dns,
|
|
// policy, ...) and therefore requires a full process restart.
|
|
func ComputeHotDiff(oldCfg, newCfg *Config) (*HotDiff, bool) {
|
|
if oldCfg == nil || newCfg == nil {
|
|
return nil, false
|
|
}
|
|
|
|
// Sections without a reload API must be semantically identical.
|
|
// Comparison is whitespace-insensitive: a template save that merely
|
|
// reformats the JSON (frontend textarea, API clients) must not be
|
|
// mistaken for a real change that forces a restart.
|
|
static := []struct {
|
|
name string
|
|
old, new json_util.RawMessage
|
|
}{
|
|
{"log", oldCfg.LogConfig, newCfg.LogConfig},
|
|
{"dns", oldCfg.DNSConfig, newCfg.DNSConfig},
|
|
{"transport", oldCfg.Transport, newCfg.Transport},
|
|
{"policy", oldCfg.Policy, newCfg.Policy},
|
|
{"api", oldCfg.API, newCfg.API},
|
|
{"stats", oldCfg.Stats, newCfg.Stats},
|
|
{"reverse", oldCfg.Reverse, newCfg.Reverse},
|
|
{"fakedns", oldCfg.FakeDNS, newCfg.FakeDNS},
|
|
{"observatory", oldCfg.Observatory, newCfg.Observatory},
|
|
{"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
|
|
{"metrics", oldCfg.Metrics, newCfg.Metrics},
|
|
{"geodata", oldCfg.Geodata, newCfg.Geodata},
|
|
{"env", oldCfg.Env, newCfg.Env},
|
|
}
|
|
for _, section := range static {
|
|
if !rawEqualNormalized(section.old, section.new) {
|
|
logger.Debug("hot diff: section [", section.name, "] changed and has no reload API")
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
diff := &HotDiff{}
|
|
|
|
if ok := diffInbounds(oldCfg, newCfg, diff); !ok {
|
|
logger.Debug("hot diff: inbound change is not API-applicable")
|
|
return nil, false
|
|
}
|
|
if ok := diffOutbounds(oldCfg, newCfg, diff); !ok {
|
|
logger.Debug("hot diff: outbound change is not API-applicable (default outbound or tags)")
|
|
return nil, false
|
|
}
|
|
if ok := diffRouting(oldCfg, newCfg, diff); !ok {
|
|
logger.Debug("hot diff: routing change is not API-applicable (domainStrategy or section shape)")
|
|
return nil, false
|
|
}
|
|
|
|
return diff, true
|
|
}
|
|
|
|
// diffInbounds fills diff with inbound removals/additions (a changed inbound
|
|
// becomes remove+add). The api inbound carries the gRPC server the panel is
|
|
// talking through, so any change touching it forces a restart.
|
|
func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
|
oldByTag, ok := inboundsByTag(oldCfg.InboundConfigs)
|
|
if !ok {
|
|
return false
|
|
}
|
|
newByTag, ok := inboundsByTag(newCfg.InboundConfigs)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
apiTag := apiTagFromConfig(newCfg.API)
|
|
|
|
for i := range oldCfg.InboundConfigs {
|
|
oldIb := &oldCfg.InboundConfigs[i]
|
|
newIb, exists := newByTag[oldIb.Tag]
|
|
if exists && inboundEqualNormalized(oldIb, newIb) {
|
|
continue
|
|
}
|
|
if oldIb.Tag == apiTag || oldIb.Tag == "api" {
|
|
return false
|
|
}
|
|
if exists && (inboundHasReverseClient(oldIb) || inboundHasReverseClient(newIb)) {
|
|
logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
|
|
return false
|
|
}
|
|
if exists {
|
|
diff.DroppedClients = append(diff.DroppedClients, droppedClients(oldIb, newIb)...)
|
|
}
|
|
if exists && diffInboundUsers(oldIb, newIb, diff) {
|
|
continue
|
|
}
|
|
if exists && (inboundUsesReality(oldIb) || inboundUsesReality(newIb)) {
|
|
logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart")
|
|
return false
|
|
}
|
|
if exists && (inboundUsesTproxy(oldIb) || inboundUsesTproxy(newIb)) {
|
|
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a TPROXY target; a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot swap")
|
|
return false
|
|
}
|
|
if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
|
|
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
|
|
return false
|
|
}
|
|
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
|
|
if exists {
|
|
raw, err := json.Marshal(newIb)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
diff.AddedInbounds = append(diff.AddedInbounds, raw)
|
|
}
|
|
}
|
|
for i := range newCfg.InboundConfigs {
|
|
newIb := &newCfg.InboundConfigs[i]
|
|
if _, exists := oldByTag[newIb.Tag]; exists {
|
|
continue
|
|
}
|
|
if newIb.Tag == apiTag || newIb.Tag == "api" {
|
|
return false
|
|
}
|
|
if inboundUsesTproxy(newIb) {
|
|
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
|
|
return false
|
|
}
|
|
if inboundUsesSocksAccounts(newIb) {
|
|
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
|
|
return false
|
|
}
|
|
raw, err := json.Marshal(newIb)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
diff.AddedInbounds = append(diff.AddedInbounds, raw)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// droppedClients lists the emails an inbound present in both configs stopped
|
|
// serving, whatever its protocol: settings.clients is the shape they all share.
|
|
func droppedClients(oldIb, newIb *InboundConfig) []UserOp {
|
|
oldClients, _, ok := splitSettingsClients(oldIb.Settings)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
newClients, _, ok := splitSettingsClients(newIb.Settings)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var dropped []UserOp
|
|
for email := range oldClients {
|
|
if _, still := newClients[email]; !still {
|
|
dropped = append(dropped, UserOp{Tag: newIb.Tag, Protocol: newIb.Protocol, Email: email})
|
|
}
|
|
}
|
|
return dropped
|
|
}
|
|
|
|
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}}
|
|
|
|
// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
|
|
// differ only in settings.clients, so the handler (and its listener) survives.
|
|
func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool {
|
|
if oldIb.Port != newIb.Port || oldIb.Protocol != newIb.Protocol || oldIb.Tag != newIb.Tag {
|
|
return false
|
|
}
|
|
if _, ok := userDiffableProtocols[oldIb.Protocol]; !ok {
|
|
return false
|
|
}
|
|
if !rawEqualNormalized(oldIb.Listen, newIb.Listen) ||
|
|
!rawEqualNormalized(oldIb.StreamSettings, newIb.StreamSettings) ||
|
|
!rawEqualNormalized(oldIb.Sniffing, newIb.Sniffing) {
|
|
return false
|
|
}
|
|
oldClients, oldRest, ok := splitSettingsClients(oldIb.Settings)
|
|
if !ok {
|
|
return false
|
|
}
|
|
newClients, newRest, ok := splitSettingsClients(newIb.Settings)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if !bytes.Equal(oldRest, newRest) {
|
|
return false
|
|
}
|
|
for email, oldC := range oldClients {
|
|
newC, exists := newClients[email]
|
|
if exists && bytes.Equal(oldC.norm, newC.norm) {
|
|
continue
|
|
}
|
|
diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email})
|
|
if exists {
|
|
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
|
|
}
|
|
}
|
|
for email, newC := range newClients {
|
|
if _, exists := oldClients[email]; !exists {
|
|
diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user})
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type clientEntry struct {
|
|
user map[string]any
|
|
norm []byte
|
|
}
|
|
|
|
// splitSettingsClients indexes settings.clients by email and returns the rest of
|
|
// the settings in canonical form; ok is false when a client has no unique email.
|
|
func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []byte, bool) {
|
|
if len(raw) == 0 {
|
|
return nil, nil, false
|
|
}
|
|
settings := map[string]any{}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&settings); err != nil {
|
|
return nil, nil, false
|
|
}
|
|
clientsRaw, hasClients := settings["clients"].([]any)
|
|
if !hasClients {
|
|
return nil, nil, false
|
|
}
|
|
clients := make(map[string]clientEntry, len(clientsRaw))
|
|
for _, c := range clientsRaw {
|
|
obj, ok := c.(map[string]any)
|
|
if !ok {
|
|
return nil, nil, false
|
|
}
|
|
email, _ := obj["email"].(string)
|
|
if email == "" {
|
|
return nil, nil, false
|
|
}
|
|
if _, dup := clients[email]; dup {
|
|
return nil, nil, false
|
|
}
|
|
norm, err := json.Marshal(obj)
|
|
if err != nil {
|
|
return nil, nil, false
|
|
}
|
|
clients[email] = clientEntry{user: obj, norm: norm}
|
|
}
|
|
delete(settings, "clients")
|
|
rest, err := json.Marshal(settings)
|
|
if err != nil {
|
|
return nil, nil, false
|
|
}
|
|
return clients, rest, true
|
|
}
|
|
|
|
func inboundUsesReality(ib *InboundConfig) bool {
|
|
if ib == nil || len(ib.StreamSettings) == 0 {
|
|
return false
|
|
}
|
|
var stream struct {
|
|
Security string `json:"security"`
|
|
}
|
|
if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
|
|
return false
|
|
}
|
|
return stream.Security == "reality"
|
|
}
|
|
|
|
// inboundUsesTproxy: a sockopt.tproxy inbound (the tunnel protocol's TProxy
|
|
// mode) hot-adds over gRPC "successfully" but binds no listener — restart.
|
|
func inboundUsesTproxy(ib *InboundConfig) bool {
|
|
if ib == nil || len(ib.StreamSettings) == 0 {
|
|
return false
|
|
}
|
|
var stream struct {
|
|
Sockopt struct {
|
|
Tproxy string `json:"tproxy"`
|
|
} `json:"sockopt"`
|
|
}
|
|
if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
|
|
return false
|
|
}
|
|
return stream.Sockopt.Tproxy != "" && stream.Sockopt.Tproxy != "off"
|
|
}
|
|
|
|
// inboundUsesSocksAccounts reports whether an inbound is a password-auth
|
|
// SOCKS5 inbound with one or more named accounts -- the shape
|
|
// internal/amneziawgnet's per-inbound relay (internal/web/service/xray.go's
|
|
// injectAmneziawgnetSocks) is the only generator of in this fork; every
|
|
// other SOCKS5 bridge this fork builds (panel egress, per-node egress,
|
|
// mtproto egress) uses "noauth" with no per-account identity at all, so this
|
|
// check can't accidentally rope in one of those lower-churn bridges.
|
|
//
|
|
// Real production incident, not a theoretical concern: a single client
|
|
// edit under an AmneziaWG inbound left this inbound's settings unchanged in
|
|
// every way relevant to accounts.user was already correct in the freshly
|
|
// regenerated config, yet the account for a peer whose email contained
|
|
// non-ASCII characters silently vanished from the running Xray process
|
|
// after a gRPC remove+add hot swap -- while a full process restart (reading
|
|
// the same JSON straight from disk) always produced the correct account
|
|
// list. socks isn't in userDiffableProtocols (that only covers vless/vmess/
|
|
// trojan, which use a wholly different clients+email shape, not
|
|
// accounts+user), so without this check any settings drift on this inbound
|
|
// -- even one unrelated to the account list itself -- falls through to the
|
|
// generic remove+add path and can reproduce the same silent drop. Forcing a
|
|
// full restart here is the same defensive choice already made above for
|
|
// REALITY and TPROXY.
|
|
func inboundUsesSocksAccounts(ib *InboundConfig) bool {
|
|
if ib == nil || ib.Protocol != "socks" || len(ib.Settings) == 0 {
|
|
return false
|
|
}
|
|
var settings struct {
|
|
Auth string `json:"auth"`
|
|
}
|
|
if err := json.Unmarshal(ib.Settings, &settings); err != nil {
|
|
return false
|
|
}
|
|
return settings.Auth == "password"
|
|
}
|
|
|
|
func inboundHasReverseClient(ib *InboundConfig) bool {
|
|
if ib == nil {
|
|
return false
|
|
}
|
|
var settings struct {
|
|
Clients []struct {
|
|
Reverse json.RawMessage `json:"reverse"`
|
|
} `json:"clients"`
|
|
}
|
|
if err := json.Unmarshal(ib.Settings, &settings); err != nil {
|
|
return false
|
|
}
|
|
for _, c := range settings.Clients {
|
|
if len(c.Reverse) == 0 {
|
|
continue
|
|
}
|
|
var tag any
|
|
if err := json.Unmarshal(c.Reverse, &tag); err != nil || tag == nil {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// diffOutbounds fills diff with outbound removals/additions keyed by tag.
|
|
// The first outbound is xray's default handler and the API can only append,
|
|
// so any change to its identity or content forces a restart. Reordering of
|
|
// the remaining outbounds is ignored — routing addresses them by tag.
|
|
func diffOutbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
|
oldOut, ok := parseOutbounds(oldCfg.OutboundConfigs)
|
|
if !ok {
|
|
return false
|
|
}
|
|
newOut, ok := parseOutbounds(newCfg.OutboundConfigs)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
if (len(oldOut) == 0) != (len(newOut) == 0) {
|
|
return false
|
|
}
|
|
if len(oldOut) > 0 {
|
|
if oldOut[0].tag != newOut[0].tag || !bytes.Equal(oldOut[0].norm, newOut[0].norm) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
oldByTag := make(map[string]outboundEntry, len(oldOut))
|
|
for _, e := range oldOut {
|
|
oldByTag[e.tag] = e
|
|
}
|
|
newByTag := make(map[string]outboundEntry, len(newOut))
|
|
for _, e := range newOut {
|
|
newByTag[e.tag] = e
|
|
}
|
|
|
|
for _, oldE := range oldOut {
|
|
newE, exists := newByTag[oldE.tag]
|
|
if exists && bytes.Equal(oldE.norm, newE.norm) {
|
|
continue
|
|
}
|
|
diff.RemovedOutboundTags = append(diff.RemovedOutboundTags, oldE.tag)
|
|
if exists {
|
|
diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
|
|
}
|
|
}
|
|
for _, newE := range newOut {
|
|
if _, exists := oldByTag[newE.tag]; !exists {
|
|
diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// diffRouting decides whether the routing change is limited to rules and
|
|
// balancers — the only parts RoutingService.AddRule can replace at runtime.
|
|
// domainStrategy/domainMatcher and any other key in the section are fixed at
|
|
// process start.
|
|
func diffRouting(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
|
if bytes.Equal(oldCfg.RouterConfig, newCfg.RouterConfig) {
|
|
return true
|
|
}
|
|
// No routing section at start likely means no router feature (and no
|
|
// RoutingService) in the running instance — only a restart can add it.
|
|
if len(oldCfg.RouterConfig) == 0 || len(newCfg.RouterConfig) == 0 {
|
|
return false
|
|
}
|
|
oldRest, ok := routingWithoutReloadable(oldCfg.RouterConfig)
|
|
if !ok {
|
|
return false
|
|
}
|
|
newRest, ok := routingWithoutReloadable(newCfg.RouterConfig)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if !bytes.Equal(oldRest, newRest) {
|
|
return false
|
|
}
|
|
diff.RoutingConfig = newCfg.RouterConfig
|
|
return true
|
|
}
|
|
|
|
// routingWithoutReloadable returns the routing section normalized with the
|
|
// runtime-reloadable keys removed, for comparing the restart-only remainder.
|
|
func routingWithoutReloadable(raw []byte) ([]byte, bool) {
|
|
parsed := map[string]any{}
|
|
if len(raw) > 0 {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&parsed); err != nil {
|
|
return nil, false
|
|
}
|
|
}
|
|
delete(parsed, "rules")
|
|
delete(parsed, "balancers")
|
|
out, err := json.Marshal(parsed)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// inboundEqualNormalized compares two inbounds ignoring JSON formatting in
|
|
// their raw sections, so a reformatted template does not read as a changed
|
|
// inbound.
|
|
func inboundEqualNormalized(a, b *InboundConfig) bool {
|
|
return a.Port == b.Port &&
|
|
a.Protocol == b.Protocol &&
|
|
a.Tag == b.Tag &&
|
|
rawEqualNormalized(a.Listen, b.Listen) &&
|
|
rawEqualNormalized(a.Settings, b.Settings) &&
|
|
rawEqualNormalized(a.StreamSettings, b.StreamSettings) &&
|
|
rawEqualNormalized(a.Sniffing, b.Sniffing)
|
|
}
|
|
|
|
// rawEqualNormalized reports whether two raw JSON values are semantically
|
|
// equal: whitespace, object key order and an explicit `null` versus an
|
|
// absent section are all ignored. UI editors rebuild objects on save (new
|
|
// key order) and emit `null` for switched-off sections — none of that is a
|
|
// reason to restart the core. Number precision is preserved via json.Number,
|
|
// so genuinely different values never compare equal. Unparsable values only
|
|
// compare equal byte-for-byte.
|
|
func rawEqualNormalized(a, b json_util.RawMessage) bool {
|
|
if bytes.Equal(a, b) {
|
|
return true
|
|
}
|
|
na, ok := canonicalJSON(a)
|
|
if !ok {
|
|
return false
|
|
}
|
|
nb, ok := canonicalJSON(b)
|
|
if !ok {
|
|
return false
|
|
}
|
|
return bytes.Equal(na, nb)
|
|
}
|
|
|
|
// canonicalJSON renders a JSON value in canonical form: sorted object keys,
|
|
// no insignificant whitespace, exact number digits (json.Number). Empty
|
|
// input and JSON null both canonicalize to nil.
|
|
func canonicalJSON(raw json_util.RawMessage) ([]byte, bool) {
|
|
if len(raw) == 0 {
|
|
return nil, true
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var value any
|
|
if err := decoder.Decode(&value); err != nil {
|
|
return nil, false
|
|
}
|
|
if value == nil {
|
|
return nil, true
|
|
}
|
|
out, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
// inboundsByTag indexes inbounds by tag; ok is false when a tag is empty or
|
|
// duplicated, since such handlers can't be addressed through the API.
|
|
func inboundsByTag(inbounds []InboundConfig) (map[string]*InboundConfig, bool) {
|
|
byTag := make(map[string]*InboundConfig, len(inbounds))
|
|
for i := range inbounds {
|
|
tag := inbounds[i].Tag
|
|
if tag == "" {
|
|
return nil, false
|
|
}
|
|
if _, dup := byTag[tag]; dup {
|
|
return nil, false
|
|
}
|
|
byTag[tag] = &inbounds[i]
|
|
}
|
|
return byTag, true
|
|
}
|
|
|
|
type outboundEntry struct {
|
|
tag string
|
|
raw []byte // original JSON, used for AddOutbound
|
|
norm []byte // canonical JSON, used for change detection
|
|
}
|
|
|
|
// parseOutbounds splits the outbounds array into per-entry raw/normalized
|
|
// JSON. ok is false when the array is unparsable or an entry has an empty or
|
|
// duplicate tag — those can't be addressed through the API.
|
|
func parseOutbounds(raw json_util.RawMessage) ([]outboundEntry, bool) {
|
|
if len(raw) == 0 {
|
|
return nil, true
|
|
}
|
|
var elems []json.RawMessage
|
|
if err := json.Unmarshal(raw, &elems); err != nil {
|
|
return nil, false
|
|
}
|
|
entries := make([]outboundEntry, 0, len(elems))
|
|
seen := make(map[string]struct{}, len(elems))
|
|
for _, elem := range elems {
|
|
var meta struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
if err := json.Unmarshal(elem, &meta); err != nil {
|
|
return nil, false
|
|
}
|
|
if meta.Tag == "" {
|
|
return nil, false
|
|
}
|
|
if _, dup := seen[meta.Tag]; dup {
|
|
return nil, false
|
|
}
|
|
seen[meta.Tag] = struct{}{}
|
|
norm, ok := canonicalJSON(json_util.RawMessage(elem))
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
entries = append(entries, outboundEntry{tag: meta.Tag, raw: elem, norm: norm})
|
|
}
|
|
return entries, true
|
|
}
|
|
|
|
// apiTagFromConfig extracts api.tag from the api section, defaulting to "api".
|
|
func apiTagFromConfig(api json_util.RawMessage) string {
|
|
var parsed struct {
|
|
Tag string `json:"tag"`
|
|
}
|
|
if len(api) > 0 && json.Unmarshal(api, &parsed) == nil && parsed.Tag != "" {
|
|
return parsed.Tag
|
|
}
|
|
return "api"
|
|
}
|