From 1ed9cd8ea1a741ef53938a82cc0b7b9ebf5adb6d Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Sun, 26 Jul 2026 11:42:24 +0300 Subject: [PATCH] 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 --- internal/web/service/inbound.go | 6 +++++ internal/web/service/inbound_protocol.go | 24 +++++++++++++++++++ internal/web/service/inbound_protocol_test.go | 18 ++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 8e6d55dc9..f4c31f115 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -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 { diff --git a/internal/web/service/inbound_protocol.go b/internal/web/service/inbound_protocol.go index 4d11e11ab..9325195f7 100644 --- a/internal/web/service/inbound_protocol.go +++ b/internal/web/service/inbound_protocol.go @@ -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."); "none" diff --git a/internal/web/service/inbound_protocol_test.go b/internal/web/service/inbound_protocol_test.go index db696db20..3893f9dac 100644 --- a/internal/web/service/inbound_protocol_test.go +++ b/internal/web/service/inbound_protocol_test.go @@ -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) + } + } +}