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 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-18 19:20:02 +02:00
parent e0f41362e2
commit 086a74328a
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
5 changed files with 38 additions and 35 deletions

View file

@ -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,
) {

View file

@ -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'));
<a-form-item :label="t('pages.inbounds.remark')">
<a-input v-model:value="dbForm.remark" />
</a-form-item>
<a-form-item v-if="selectableNodes.length > 0" :label="t('pages.inbounds.deployTo')">
<a-form-item v-if="selectableNodes.length > 0 && isNodeEligible" :label="t('pages.inbounds.deployTo')">
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
@ -1164,8 +1160,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</a-tab-pane>
<!-- ============================== STREAM ============================== -->
<a-tab-pane v-if="canEnableStream" key="stream"
:tab="t('pages.inbounds.streamTab')">
<a-tab-pane v-if="canEnableStream" key="stream" :tab="t('pages.inbounds.streamTab')">
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
<a-form-item v-if="protocol !== Protocols.HYSTERIA" label="Transmission">
<a-select v-model:value="network" :style="{ width: '75%' }">

View file

@ -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;
}

View file

@ -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 == "" {

View file

@ -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 {