From 086a74328a79b88857f73464d227c5fe9230d8ef Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 18 May 2026 19:20:02 +0200 Subject: [PATCH] fix(inbounds): gate node selector to multi-node-capable protocols Hide the Deploy-To selector and clear nodeId when switching to a protocol that can't run on a remote node. Also: - subs: return 404 (not 400) when subId matches no inbounds, so VPN clients distinguish "deleted/unknown" from a server error - hysteria link gen: use the inbound's resolved address so node-managed inbounds advertise the node host instead of the central panel - shadowsocks: default network to 'tcp' (udp was causing issues for some clients on first-create) - vite dev proxy: rewrite migrated-route bypass against the live base path instead of a hardcoded single-segment regex Co-Authored-By: Claude Opus 4.7 --- frontend/src/models/inbound.js | 2 +- .../src/pages/inbounds/InboundFormModal.vue | 33 ++++++++----------- frontend/vite.config.js | 13 +++----- sub/subController.go | 18 ++++++++-- sub/subService.go | 7 ++-- 5 files changed, 38 insertions(+), 35 deletions(-) diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index 24a55f85..4ffa782e 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -2879,7 +2879,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings { constructor(protocol, method = SSMethods.BLAKE3_AES_256_GCM, password = RandomUtil.randomShadowsocksPassword(), - network = 'tcp,udp', + network = 'tcp', shadowsockses = [], ivCheck = false, ) { diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue index e0e8f938..791522fa 100644 --- a/frontend/src/pages/inbounds/InboundFormModal.vue +++ b/frontend/src/pages/inbounds/InboundFormModal.vue @@ -34,22 +34,17 @@ import JsonEditor from '@/components/JsonEditor.vue'; import { useNodeList } from '@/composables/useNodeList.js'; const { t } = useI18n(); - -// Node selector — Phase 1 multi-node deployment. Shows all enabled -// nodes regardless of online state so the form is usable while a node -// is briefly offline; the backend's fail-fast path will surface the -// real error when the user submits. const { nodes: availableNodes } = useNodeList(); const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable)); - -// Phase 5f-iii-b: structured per-protocol/per-transport forms instead -// of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound -// pair so the existing model helpers (.toString(), .canEnableTls(), -// genAllLinks(), addPeer(), etc.) keep working unchanged. The -// "Advanced" tab still exposes the full streamSettings JSON for -// transport variants (KCP/XHTTP/sockopt/finalmask) we don't yet have -// dedicated UI for. - +const NODE_ELIGIBLE_PROTOCOLS = new Set([ + Protocols.VLESS, + Protocols.VMESS, + Protocols.TROJAN, + Protocols.SHADOWSOCKS, + Protocols.HYSTERIA, + Protocols.WIREGUARD, +]); +const isNodeEligible = computed(() => NODE_ELIGIBLE_PROTOCOLS.has(inbound.value?.protocol)); const props = defineProps({ open: { type: Boolean, default: false }, mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) }, @@ -72,8 +67,6 @@ const advancedSniffingText = ref(''); const advancedSettingsText = ref(''); const activeTabKey = ref('basic'); const advancedSectionKey = ref('all'); -// Cached default cert/key paths from /panel/setting/defaultSettings — -// powers the "Set default cert" button on the TLS form. const defaultCert = ref(''); const defaultKey = ref(''); @@ -334,6 +327,9 @@ function onProtocolChange(next) { if (props.mode === 'edit' || !inbound.value) return; inbound.value.protocol = next; inbound.value.settings = Inbound.Settings.getSettings(next); + if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) { + dbForm.value.nodeId = null; + } primeAdvancedJson(); } @@ -792,7 +788,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - + {{ t('pages.inbounds.localPanel') }} @@ -1164,8 +1160,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream')); - + diff --git a/frontend/vite.config.js b/frontend/vite.config.js index b414e813..78c4d657 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -78,19 +78,14 @@ function injectBasePathPlugin() { function bypassMigratedRoute(req) { if (req.method !== 'GET') return undefined; const url = req.url.split('?')[0]; + const basePath = refreshBasePath(); - for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) { - if (url === '/' + key) return value; - } + if (url === basePath) return '/login.html'; - const m = url.match(/^\/[^/]+\/(.+)$/); - if (m) { - const stripped = m[1]; + if (url.startsWith(basePath)) { + const stripped = url.slice(basePath.length); if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped]; } - - if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html'; - return undefined; } diff --git a/sub/subController.go b/sub/subController.go index 9c7414c5..2eeeefe7 100644 --- a/sub/subController.go +++ b/sub/subController.go @@ -15,6 +15,18 @@ import ( "github.com/gin-gonic/gin" ) +// writeSubError translates a service-layer result into an HTTP response. +// A nil error with no rows means the subId doesn't match anything (deleted +// client, never-existed id) and becomes 404. A real error becomes 500. No +// body — VPN clients only look at the status. +func writeSubError(c *gin.Context, err error) { + if err == nil { + c.Status(http.StatusNotFound) + return + } + c.Status(http.StatusInternalServerError) +} + // SUBController handles HTTP requests for subscription links and JSON configurations. type SUBController struct { subTitle string @@ -105,7 +117,7 @@ func (a *SUBController) subs(c *gin.Context) { scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c) subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host) if err != nil || len(subs) == 0 { - c.String(400, "Error!") + writeSubError(c, err) } else { result := "" for _, sub := range subs { @@ -240,7 +252,7 @@ func (a *SUBController) subJsons(c *gin.Context) { scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) jsonSub, header, err := a.subJsonService.GetJson(subId, host) if err != nil || len(jsonSub) == 0 { - c.String(400, "Error!") + writeSubError(c, err) } else { profileUrl := a.subProfileUrl if profileUrl == "" { @@ -257,7 +269,7 @@ func (a *SUBController) subClashs(c *gin.Context) { scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c) clashSub, header, err := a.subClashService.GetClash(subId, host) if err != nil || len(clashSub) == 0 { - c.String(400, "Error!") + writeSubError(c, err) } else { profileUrl := a.subProfileUrl if profileUrl == "" { diff --git a/sub/subService.go b/sub/subService.go index afa9afca..c26a1ac1 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -70,7 +70,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C } if len(inbounds) == 0 { - return nil, 0, traffic, common.NewError("No inbounds found with ", subId) + return nil, 0, traffic, nil } s.datepicker, err = s.settingService.GetDatepicker() @@ -535,8 +535,9 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin return strings.Join(links, "\n") } - // No external proxy configured — fall back to the request host. - link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port) + // No external proxy configured — use the inbound's resolved address so + // node-managed inbounds get the node's host instead of the central panel's. + link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port) url, _ := url.Parse(link) q := url.Query() for k, v := range params {