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) + } + } +}