mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-05 09:57:14 +00:00
fix(inbound): enforce node-eligibility server-side, not just in the UI
Investigated multi-node interaction with AmneziaWG: the master's own reconcile (DesiredAmneziaWGInstances) and Xray config generation (injectAmneziawgEgress, the GenXrayInboundConfig protocol skip) all correctly filter on NodeID IS NULL, so a node-assigned AmneziaWG (or MTProto) inbound would never be managed by the master. But nothing stopped one from being created that way: NODE_ELIGIBLE_PROTOCOLS (frontend/src/pages/inbounds/form/InboundFormModal.tsx) only hides the node picker client-side -- a direct API call could set nodeId on an AmneziaWG inbound, which every node then reconciles as an ordinary local inbound (nodes run the identical binary, full cron suite included), leaving it running unmanaged and untracked by the master's own AmneziaWG bookkeeping. Added isNodeEligibleProtocol (inbound_protocol.go), mirroring the frontend's allowlist, and enforced it in both AddInbound (the actually exploitable path -- nodeId comes straight from the request) and UpdateInbound (defense in depth; NodeID is already restored from the stored row there before this check, so it mainly guards against a protocol change on an existing node-hosted inbound). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -758,6 +758,9 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||
if err := s.normalizeAmneziaWGSettings(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
}
|
||||
if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
|
||||
return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
|
||||
}
|
||||
inbound.SubSortIndex = normalizeSubSortIndex(inbound.SubSortIndex)
|
||||
if err := normalizeInboundShareAddressStrict(inbound); err != nil {
|
||||
return inbound, false, err
|
||||
@@ -1190,6 +1193,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||
// Restore the stored NodeID before the port-conflict check so a node inbound
|
||||
// stays scoped to its own node (the payload's nodeId is unreliable, often absent).
|
||||
inbound.NodeID = oldInbound.NodeID
|
||||
if inbound.NodeID != nil && !isNodeEligibleProtocol(inbound.Protocol) {
|
||||
return inbound, false, common.NewErrorf("%s inbounds cannot be assigned to a node", inbound.Protocol)
|
||||
}
|
||||
|
||||
conflict, err := s.checkPortConflict(inbound, inbound.Id)
|
||||
if err != nil {
|
||||
|
||||
@@ -53,6 +53,30 @@ func inboundCanEnableTlsFlow(protocol, streamSettings, settings string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// nodeEligibleProtocols mirrors NODE_ELIGIBLE_PROTOCOLS from the frontend
|
||||
// (frontend/src/pages/inbounds/form/InboundFormModal.tsx), which hides the
|
||||
// "Deploy To" node picker for anything not in this set. MTProto and
|
||||
// AmneziaWG are both sidecar-managed rather than plain Xray inbounds, and
|
||||
// their reconcile loops (mtproto.Manager, amneziawg.Manager) only ever
|
||||
// query for NodeID IS NULL rows -- a node-assigned instance of either would
|
||||
// never be reconciled by the master, yet nothing previously stopped one
|
||||
// from being created that way (the frontend allowlist has no server-side
|
||||
// mirror). A future protocol defaults to ineligible until added here,
|
||||
// matching the frontend's own opt-in shape.
|
||||
var nodeEligibleProtocols = map[model.Protocol]bool{
|
||||
model.VLESS: true,
|
||||
model.VMESS: true,
|
||||
model.Trojan: true,
|
||||
model.Shadowsocks: true,
|
||||
model.Hysteria: true,
|
||||
model.WireGuard: true,
|
||||
}
|
||||
|
||||
// isNodeEligibleProtocol reports whether protocol may be assigned to a node.
|
||||
func isNodeEligibleProtocol(protocol model.Protocol) bool {
|
||||
return nodeEligibleProtocols[protocol]
|
||||
}
|
||||
|
||||
// vlessEncryptionEnabled reports whether a VLESS inbound has VLESS-level
|
||||
// encryption (vlessenc / ML-KEM) configured. When enabled these fields hold a
|
||||
// generated dotted string (e.g. "mlkem768x25519plus.native.0rtt.<key>"); "none"
|
||||
|
||||
@@ -88,3 +88,21 @@ func TestInboundCanHostFallbacks_StaysTcpOnly(t *testing.T) {
|
||||
t.Errorf("inboundCanHostFallbacks(nil) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// Mirrors NODE_ELIGIBLE_PROTOCOLS in
|
||||
// frontend/src/pages/inbounds/form/InboundFormModal.tsx -- keep both lists
|
||||
// in sync if a protocol's node-eligibility ever changes.
|
||||
func TestIsNodeEligibleProtocol(t *testing.T) {
|
||||
eligible := []model.Protocol{model.VLESS, model.VMESS, model.Trojan, model.Shadowsocks, model.Hysteria, model.WireGuard}
|
||||
for _, p := range eligible {
|
||||
if !isNodeEligibleProtocol(p) {
|
||||
t.Errorf("isNodeEligibleProtocol(%q) = false, want true", p)
|
||||
}
|
||||
}
|
||||
ineligible := []model.Protocol{model.MTProto, model.AmneziaWG, model.Mixed, model.HTTP, model.Tunnel}
|
||||
for _, p := range ineligible {
|
||||
if isNodeEligibleProtocol(p) {
|
||||
t.Errorf("isNodeEligibleProtocol(%q) = true, want false", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user