mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 05:04:22 +00:00
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:
parent
e0f41362e2
commit
086a74328a
5 changed files with 38 additions and 35 deletions
|
|
@ -2879,7 +2879,7 @@ Inbound.ShadowsocksSettings = class extends Inbound.Settings {
|
||||||
constructor(protocol,
|
constructor(protocol,
|
||||||
method = SSMethods.BLAKE3_AES_256_GCM,
|
method = SSMethods.BLAKE3_AES_256_GCM,
|
||||||
password = RandomUtil.randomShadowsocksPassword(),
|
password = RandomUtil.randomShadowsocksPassword(),
|
||||||
network = 'tcp,udp',
|
network = 'tcp',
|
||||||
shadowsockses = [],
|
shadowsockses = [],
|
||||||
ivCheck = false,
|
ivCheck = false,
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -34,22 +34,17 @@ import JsonEditor from '@/components/JsonEditor.vue';
|
||||||
import { useNodeList } from '@/composables/useNodeList.js';
|
import { useNodeList } from '@/composables/useNodeList.js';
|
||||||
|
|
||||||
const { t } = useI18n();
|
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 { nodes: availableNodes } = useNodeList();
|
||||||
const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable));
|
const selectableNodes = computed(() => (availableNodes.value || []).filter((n) => n.enable));
|
||||||
|
const NODE_ELIGIBLE_PROTOCOLS = new Set([
|
||||||
// Phase 5f-iii-b: structured per-protocol/per-transport forms instead
|
Protocols.VLESS,
|
||||||
// of raw JSON textareas. Edits a deeply-reactive Inbound + DBInbound
|
Protocols.VMESS,
|
||||||
// pair so the existing model helpers (.toString(), .canEnableTls(),
|
Protocols.TROJAN,
|
||||||
// genAllLinks(), addPeer(), etc.) keep working unchanged. The
|
Protocols.SHADOWSOCKS,
|
||||||
// "Advanced" tab still exposes the full streamSettings JSON for
|
Protocols.HYSTERIA,
|
||||||
// transport variants (KCP/XHTTP/sockopt/finalmask) we don't yet have
|
Protocols.WIREGUARD,
|
||||||
// dedicated UI for.
|
]);
|
||||||
|
const isNodeEligible = computed(() => NODE_ELIGIBLE_PROTOCOLS.has(inbound.value?.protocol));
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
open: { type: Boolean, default: false },
|
open: { type: Boolean, default: false },
|
||||||
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
mode: { type: String, default: 'add', validator: (v) => ['add', 'edit'].includes(v) },
|
||||||
|
|
@ -72,8 +67,6 @@ const advancedSniffingText = ref('');
|
||||||
const advancedSettingsText = ref('');
|
const advancedSettingsText = ref('');
|
||||||
const activeTabKey = ref('basic');
|
const activeTabKey = ref('basic');
|
||||||
const advancedSectionKey = ref('all');
|
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 defaultCert = ref('');
|
||||||
const defaultKey = ref('');
|
const defaultKey = ref('');
|
||||||
|
|
||||||
|
|
@ -334,6 +327,9 @@ function onProtocolChange(next) {
|
||||||
if (props.mode === 'edit' || !inbound.value) return;
|
if (props.mode === 'edit' || !inbound.value) return;
|
||||||
inbound.value.protocol = next;
|
inbound.value.protocol = next;
|
||||||
inbound.value.settings = Inbound.Settings.getSettings(next);
|
inbound.value.settings = Inbound.Settings.getSettings(next);
|
||||||
|
if (!NODE_ELIGIBLE_PROTOCOLS.has(next)) {
|
||||||
|
dbForm.value.nodeId = null;
|
||||||
|
}
|
||||||
primeAdvancedJson();
|
primeAdvancedJson();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -792,7 +788,7 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
|
||||||
<a-form-item :label="t('pages.inbounds.remark')">
|
<a-form-item :label="t('pages.inbounds.remark')">
|
||||||
<a-input v-model:value="dbForm.remark" />
|
<a-input v-model:value="dbForm.remark" />
|
||||||
</a-form-item>
|
</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'"
|
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
|
||||||
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
|
||||||
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
|
<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>
|
</a-tab-pane>
|
||||||
|
|
||||||
<!-- ============================== STREAM ============================== -->
|
<!-- ============================== STREAM ============================== -->
|
||||||
<a-tab-pane v-if="canEnableStream" key="stream"
|
<a-tab-pane v-if="canEnableStream" key="stream" :tab="t('pages.inbounds.streamTab')">
|
||||||
:tab="t('pages.inbounds.streamTab')">
|
|
||||||
<a-form :colon="false" :label-col="{ sm: { span: 8 } }" :wrapper-col="{ sm: { span: 14 } }">
|
<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-form-item v-if="protocol !== Protocols.HYSTERIA" label="Transmission">
|
||||||
<a-select v-model:value="network" :style="{ width: '75%' }">
|
<a-select v-model:value="network" :style="{ width: '75%' }">
|
||||||
|
|
|
||||||
|
|
@ -78,19 +78,14 @@ function injectBasePathPlugin() {
|
||||||
function bypassMigratedRoute(req) {
|
function bypassMigratedRoute(req) {
|
||||||
if (req.method !== 'GET') return undefined;
|
if (req.method !== 'GET') return undefined;
|
||||||
const url = req.url.split('?')[0];
|
const url = req.url.split('?')[0];
|
||||||
|
const basePath = refreshBasePath();
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
|
if (url === basePath) return '/login.html';
|
||||||
if (url === '/' + key) return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = url.match(/^\/[^/]+\/(.+)$/);
|
if (url.startsWith(basePath)) {
|
||||||
if (m) {
|
const stripped = url.slice(basePath.length);
|
||||||
const stripped = m[1];
|
|
||||||
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
|
if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
|
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,18 @@ import (
|
||||||
"github.com/gin-gonic/gin"
|
"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.
|
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||||
type SUBController struct {
|
type SUBController struct {
|
||||||
subTitle string
|
subTitle string
|
||||||
|
|
@ -105,7 +117,7 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||||
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, hostHeader := a.subService.ResolveRequest(c)
|
||||||
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
|
subs, lastOnline, traffic, err := a.subService.GetSubs(subId, host)
|
||||||
if err != nil || len(subs) == 0 {
|
if err != nil || len(subs) == 0 {
|
||||||
c.String(400, "Error!")
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
result := ""
|
result := ""
|
||||||
for _, sub := range subs {
|
for _, sub := range subs {
|
||||||
|
|
@ -240,7 +252,7 @@ func (a *SUBController) subJsons(c *gin.Context) {
|
||||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
jsonSub, header, err := a.subJsonService.GetJson(subId, host)
|
||||||
if err != nil || len(jsonSub) == 0 {
|
if err != nil || len(jsonSub) == 0 {
|
||||||
c.String(400, "Error!")
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
profileUrl := a.subProfileUrl
|
profileUrl := a.subProfileUrl
|
||||||
if profileUrl == "" {
|
if profileUrl == "" {
|
||||||
|
|
@ -257,7 +269,7 @@ func (a *SUBController) subClashs(c *gin.Context) {
|
||||||
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
scheme, host, hostWithPort, _ := a.subService.ResolveRequest(c)
|
||||||
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
clashSub, header, err := a.subClashService.GetClash(subId, host)
|
||||||
if err != nil || len(clashSub) == 0 {
|
if err != nil || len(clashSub) == 0 {
|
||||||
c.String(400, "Error!")
|
writeSubError(c, err)
|
||||||
} else {
|
} else {
|
||||||
profileUrl := a.subProfileUrl
|
profileUrl := a.subProfileUrl
|
||||||
if profileUrl == "" {
|
if profileUrl == "" {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(inbounds) == 0 {
|
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()
|
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")
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// No external proxy configured — fall back to the request host.
|
// No external proxy configured — use the inbound's resolved address so
|
||||||
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port)
|
// 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)
|
url, _ := url.Parse(link)
|
||||||
q := url.Query()
|
q := url.Query()
|
||||||
for k, v := range params {
|
for k, v := range params {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue