diff --git a/.vscode/launch.json b/.vscode/launch.json
index 8a969702..29426d7c 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -10,26 +10,12 @@
"program": "${workspaceFolder}",
"cwd": "${workspaceFolder}",
"env": {
- "XUI_DEBUG": "true"
+ "XUI_DEBUG": "true",
+ "XUI_DB_FOLDER": "x-ui",
+ "XUI_LOG_FOLDER": "x-ui",
+ "XUI_BIN_FOLDER": "x-ui"
},
"console": "integratedTerminal"
},
- {
- "name": "Run 3x-ui (Debug, custom env)",
- "type": "go",
- "request": "launch",
- "mode": "auto",
- "program": "${workspaceFolder}",
- "cwd": "${workspaceFolder}",
- "env": {
- // Set to true to serve assets/templates directly from disk for development
- "XUI_DEBUG": "true",
- // Uncomment to override DB folder location (by default uses working dir on Windows when debug)
- // "XUI_DB_FOLDER": "${workspaceFolder}",
- // Example: override log level (debug|info|notice|warn|error)
- // "XUI_LOG_LEVEL": "debug"
- },
- "console": "integratedTerminal"
- }
]
-}
+}
\ No newline at end of file
diff --git a/database/db.go b/database/db.go
index 974aaadc..0ca9e3e7 100644
--- a/database/db.go
+++ b/database/db.go
@@ -67,6 +67,7 @@ func initModels() error {
&model.ApiToken{},
&model.ClientRecord{},
&model.ClientInbound{},
+ &model.InboundFallback{},
}
for _, mdl := range models {
if err := db.AutoMigrate(mdl); err != nil {
diff --git a/database/migrate_data.go b/database/migrate_data.go
index f56df75a..11f1a40f 100644
--- a/database/migrate_data.go
+++ b/database/migrate_data.go
@@ -35,6 +35,7 @@ func migrationModels() []any {
&model.InboundClientIps{},
&model.ClientRecord{},
&model.ClientInbound{},
+ &model.InboundFallback{},
}
}
diff --git a/database/model/model.go b/database/model/model.go
index 6ca2bd03..e0ed73db 100644
--- a/database/model/model.go
+++ b/database/model/model.go
@@ -66,6 +66,23 @@ type Inbound struct {
Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
+
+ // FallbackParent is populated by the API layer when this inbound is
+ // attached as a fallback child of a VLESS/Trojan TCP-TLS master.
+ // The frontend uses it to rewrite client-share links so they advertise
+ // the master's externally reachable endpoint instead of the child's
+ // loopback listen. Not persisted.
+ FallbackParent *FallbackParentInfo `json:"fallbackParent,omitempty" gorm:"-"`
+}
+
+// FallbackParentInfo carries everything the frontend needs to rewrite a
+// child inbound's client link: where to connect (the master's address
+// and port) and which path matched on the master's fallbacks array.
+// The frontend already has the master inbound in its dbInbounds list,
+// so we only ship identifiers + the match path here.
+type FallbackParentInfo struct {
+ MasterId int `json:"masterId"`
+ Path string `json:"path,omitempty"`
}
// OutboundTraffics tracks traffic statistics for Xray outbound connections.
@@ -362,6 +379,24 @@ type ClientInbound struct {
func (ClientInbound) TableName() string { return "client_inbounds" }
+// InboundFallback is one routing rule on a master inbound's
+// settings.fallbacks array. The master is always a VLESS or Trojan
+// inbound on TCP transport with TLS or Reality. The child is any other
+// inbound — its listen+port becomes the fallback dest, with optional
+// SNI/ALPN/path match criteria pulled from the same row.
+type InboundFallback struct {
+ Id int `json:"id" gorm:"primaryKey;autoIncrement"`
+ MasterId int `json:"masterId" gorm:"index;not null;column:master_id"`
+ ChildId int `json:"childId" gorm:"index;not null;column:child_id"`
+ Name string `json:"name"`
+ Alpn string `json:"alpn"`
+ Path string `json:"path"`
+ Xver int `json:"xver"`
+ SortOrder int `json:"sortOrder" gorm:"default:0;column:sort_order"`
+}
+
+func (InboundFallback) TableName() string { return "inbound_fallbacks" }
+
func (c *Client) ToRecord() *ClientRecord {
rec := &ClientRecord{
Email: c.Email,
diff --git a/frontend/src/models/dbinbound.js b/frontend/src/models/dbinbound.js
index 90bdca95..49c19eaf 100644
--- a/frontend/src/models/dbinbound.js
+++ b/frontend/src/models/dbinbound.js
@@ -40,6 +40,9 @@ export class DBInbound {
// Optional FK to web/runtime registered Node. null/undefined =
// local panel; otherwise the inbound lives on the named node.
this.nodeId = null;
+ // Populated by the API when this inbound is a fallback child of
+ // a VLESS/Trojan TCP-TLS master. Shape: { masterId, path }.
+ this.fallbackParent = null;
if (data == null) {
return;
}
diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js
index ff384a3f..431e1e08 100644
--- a/frontend/src/pages/api-docs/endpoints.js
+++ b/frontend/src/pages/api-docs/endpoints.js
@@ -150,6 +150,27 @@ export const sections = [
{ name: 'data', in: 'body (form)', type: 'string', desc: 'JSON-encoded inbound payload.' },
],
},
+ {
+ method: 'GET',
+ path: '/panel/api/inbounds/:id/fallbacks',
+ summary: 'List the fallback rules attached to a master VLESS/Trojan TCP-TLS inbound. Each rule links one child inbound (the dest) to optional SNI/ALPN/path/xver match criteria.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
+ ],
+ response:
+ '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "masterId": 10,\n "childId": 11,\n "name": "",\n "alpn": "",\n "path": "/vlws",\n "xver": 2,\n "sortOrder": 0\n }\n ]\n}',
+ },
+ {
+ method: 'POST',
+ path: '/panel/api/inbounds/:id/fallbacks',
+ summary: 'Replace the entire fallback list for a master inbound. Body is JSON. Triggers an Xray restart.',
+ params: [
+ { name: 'id', in: 'path', type: 'number', desc: 'Master inbound ID.' },
+ { name: 'fallbacks', in: 'body (json)', type: 'object[]', desc: 'Array of {childId, name, alpn, path, xver, sortOrder} entries.' },
+ ],
+ body: '{\n "fallbacks": [\n { "childId": 11, "path": "/vlws", "xver": 2 },\n { "childId": 12, "alpn": "h2" }\n ]\n}',
+ response: '{\n "success": true,\n "msg": "Inbound updated"\n}',
+ },
],
},
diff --git a/frontend/src/pages/inbounds/InboundFormModal.vue b/frontend/src/pages/inbounds/InboundFormModal.vue
index ba74a1a9..0c876c7b 100644
--- a/frontend/src/pages/inbounds/InboundFormModal.vue
+++ b/frontend/src/pages/inbounds/InboundFormModal.vue
@@ -3,7 +3,15 @@ import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { message } from 'ant-design-vue';
-import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
+import {
+ SyncOutlined,
+ PlusOutlined,
+ MinusOutlined,
+ DeleteOutlined,
+ CaretUpOutlined,
+ CaretDownOutlined,
+ SettingOutlined,
+} from '@ant-design/icons-vue';
import {
HttpUtil,
@@ -117,6 +125,193 @@ const isVlessLike = computed(() => {
return inbound.value.protocol === Protocols.VLESS;
});
+const FALLBACK_ELIGIBLE_TRANSPORTS = new Set(['tcp', 'ws', 'grpc', 'httpupgrade', 'xhttp']);
+
+const isFallbackHost = computed(() => {
+ const ib = inbound.value;
+ if (!ib) return false;
+ if (ib.protocol !== Protocols.VLESS && ib.protocol !== Protocols.TROJAN) return false;
+ if (ib.stream?.network !== 'tcp') return false;
+ const sec = ib.stream?.security;
+ return sec === 'tls' || sec === 'reality';
+});
+
+const fallbacks = ref([]);
+let fallbackRowKey = 0;
+const fallbackEditing = ref(new Set());
+
+const fallbackChildOptions = computed(() => {
+ const list = props.dbInbounds || [];
+ const masterId = props.dbInbound?.id;
+ return list
+ .filter((ib) => ib.id !== masterId)
+ .map((ib) => ({
+ label: `${ib.remark || `#${ib.id}`} · ${ib.protocol}:${ib.port}`,
+ value: ib.id,
+ }));
+});
+
+function getChildStream(childDb) {
+ if (!childDb) return null;
+ try { return childDb.toInbound()?.stream || null; } catch (_e) { return null; }
+}
+
+function deriveFallbackDefaults(childDb) {
+ const out = { name: '', alpn: '', path: '', xver: 0 };
+ const stream = getChildStream(childDb);
+ if (!stream) return out;
+ switch (stream.network) {
+ case 'tcp': {
+ const tcp = stream.tcp;
+ if (tcp?.type === 'http') {
+ const p = tcp?.request?.path;
+ if (Array.isArray(p) && p.length) out.path = p[0];
+ }
+ if (tcp?.acceptProxyProtocol) out.xver = 2;
+ break;
+ }
+ case 'ws': {
+ out.path = stream.ws?.path || '';
+ if (stream.ws?.acceptProxyProtocol) out.xver = 2;
+ break;
+ }
+ case 'grpc': {
+ out.path = stream.grpc?.serviceName || '';
+ out.alpn = 'h2';
+ break;
+ }
+ case 'httpupgrade': {
+ out.path = stream.httpupgrade?.path || '';
+ if (stream.httpupgrade?.acceptProxyProtocol) out.xver = 2;
+ break;
+ }
+ case 'xhttp': {
+ out.path = stream.xhttp?.path || '';
+ break;
+ }
+ }
+ return out;
+}
+
+function addFallback(childId = null) {
+ const row = { rowKey: `fb-${++fallbackRowKey}`, childId: childId || null, name: '', alpn: '', path: '', xver: 0 };
+ if (childId) {
+ const child = (props.dbInbounds || []).find((ib) => ib.id === childId);
+ Object.assign(row, deriveFallbackDefaults(child));
+ }
+ fallbacks.value.push(row);
+}
+
+function removeFallback(idx) {
+ fallbacks.value.splice(idx, 1);
+}
+
+function moveFallback(idx, dir) {
+ const arr = fallbacks.value;
+ const j = idx + dir;
+ if (j < 0 || j >= arr.length) return;
+ const tmp = arr[idx];
+ arr[idx] = arr[j];
+ arr[j] = tmp;
+}
+
+function onFallbackChildPicked(record, childId) {
+ record.childId = childId;
+ const child = (props.dbInbounds || []).find((ib) => ib.id === childId);
+ const defaults = deriveFallbackDefaults(child);
+ record.name = defaults.name;
+ record.alpn = defaults.alpn;
+ record.path = defaults.path;
+ record.xver = defaults.xver;
+}
+
+function rederiveFallback(record) {
+ if (!record?.childId) return;
+ const child = (props.dbInbounds || []).find((ib) => ib.id === record.childId);
+ const defaults = deriveFallbackDefaults(child);
+ record.name = defaults.name;
+ record.alpn = defaults.alpn;
+ record.path = defaults.path;
+ record.xver = defaults.xver;
+ message.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child');
+}
+
+function quickAddAllFallbacks() {
+ const masterId = props.dbInbound?.id;
+ const list = props.dbInbounds || [];
+ const existing = new Set(fallbacks.value.map((r) => r.childId).filter(Boolean));
+ let added = 0;
+ for (const ib of list) {
+ if (ib.id === masterId) continue;
+ if (existing.has(ib.id)) continue;
+ const stream = getChildStream(ib);
+ if (!stream || !FALLBACK_ELIGIBLE_TRANSPORTS.has(stream.network)) continue;
+ addFallback(ib.id);
+ added += 1;
+ }
+ if (added > 0) {
+ message.success(t('pages.inbounds.fallbacks.quickAdded', { n: added }) || `Added ${added} fallback(s)`);
+ } else {
+ message.info(t('pages.inbounds.fallbacks.quickAddedNone') || 'No new eligible inbounds to add');
+ }
+}
+
+function isFallbackEditing(rowKey) { return fallbackEditing.value.has(rowKey); }
+function toggleFallbackEdit(rowKey) {
+ const next = new Set(fallbackEditing.value);
+ if (next.has(rowKey)) next.delete(rowKey); else next.add(rowKey);
+ fallbackEditing.value = next;
+}
+
+function describeFallback(record) {
+ const parts = [];
+ if (record.name) parts.push(`SNI=${record.name}`);
+ if (record.alpn) parts.push(`ALPN=${record.alpn}`);
+ if (record.path) parts.push(`path=${record.path}`);
+ const condition = parts.length
+ ? `${t('pages.inbounds.fallbacks.routesWhen') || 'Routes when'} ${parts.join(' · ')}`
+ : (t('pages.inbounds.fallbacks.defaultCatchAll') || 'Default — catches anything else');
+ const proxyTag = record.xver === 2 ? ' · PROXY v2' : record.xver === 1 ? ' · PROXY v1' : '';
+ return { condition, proxyTag };
+}
+
+async function loadFallbacks(masterId) {
+ fallbacks.value = [];
+ if (!masterId) return;
+ const msg = await HttpUtil.get(`/panel/api/inbounds/${masterId}/fallbacks`);
+ if (!msg?.success || !Array.isArray(msg.obj)) return;
+ fallbacks.value = msg.obj.map((r) => ({
+ rowKey: `fb-${++fallbackRowKey}`,
+ childId: r.childId,
+ name: r.name || '',
+ alpn: r.alpn || '',
+ path: r.path || '',
+ xver: r.xver || 0,
+ }));
+}
+
+async function saveFallbacks(masterId) {
+ if (!masterId) return true;
+ const payload = {
+ fallbacks: fallbacks.value
+ .filter((c) => c.childId)
+ .map((c, i) => ({
+ childId: c.childId,
+ name: c.name,
+ alpn: c.alpn,
+ path: c.path,
+ xver: Number(c.xver) || 0,
+ sortOrder: i,
+ })),
+ };
+ const msg = await HttpUtil.post(
+ `/panel/api/inbounds/${masterId}/fallbacks`,
+ payload,
+ { headers: { 'Content-Type': 'application/json' } },
+ );
+ return !!msg?.success;
+}
+
const canEnableStream = computed(() => inbound.value?.canEnableStream?.() === true);
const canEnableTls = computed(() => inbound.value?.canEnableTls?.() === true);
const canEnableReality = computed(() => inbound.value?.canEnableReality?.() === true);
@@ -124,6 +319,7 @@ const canEnableReality = computed(() => inbound.value?.canEnableReality?.() ===
const hasProtocolTabContent = computed(() => {
if (!inbound.value) return false;
if (isVlessLike.value) return true;
+ if (isFallbackHost.value) return true;
switch (inbound.value.protocol) {
case Protocols.SHADOWSOCKS:
case Protocols.HTTP:
@@ -184,12 +380,20 @@ function primeAdvancedJson() {
watch(() => props.open, (next) => {
if (!next) return;
+ fallbackEditing.value = new Set();
if (props.mode === 'edit' && props.dbInbound) {
loadFromDbInbound(props.dbInbound);
+ const proto = props.dbInbound.protocol;
+ if (proto === Protocols.VLESS || proto === Protocols.TROJAN) {
+ loadFallbacks(props.dbInbound.id);
+ } else {
+ fallbacks.value = [];
+ }
} else {
inbound.value = makeFreshInbound(Protocols.VLESS);
dbForm.value = freshDbForm();
primeAdvancedJson();
+ fallbacks.value = [];
}
activeTabKey.value = 'basic';
advancedSectionKey.value = 'all';
@@ -655,6 +859,12 @@ async function submit() {
: '/panel/api/inbounds/add';
const msg = await HttpUtil.post(url, payload);
if (msg?.success) {
+ if (isFallbackHost.value) {
+ const masterId = props.mode === 'edit'
+ ? props.dbInbound.id
+ : (msg.obj?.id || msg.obj?.Id);
+ if (masterId) await saveFallbacks(masterId);
+ }
emit('saved');
close();
}
@@ -769,6 +979,81 @@ watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
+
+
+ {{ t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ onFallbackChildPicked(record, v)" />
+
+ {{ describeFallback(record).condition }}{{ describeFallback(record).proxyTag }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('pages.inbounds.fallbacks.add') || 'Add fallback' }}
+
+
+ {{ t('pages.inbounds.fallbacks.quickAddAll') || 'Quick add all eligible' }}
+
+
+
+
diff --git a/frontend/src/pages/inbounds/InboundsPage.vue b/frontend/src/pages/inbounds/InboundsPage.vue
index a3858b2a..3f8917c1 100644
--- a/frontend/src/pages/inbounds/InboundsPage.vue
+++ b/frontend/src/pages/inbounds/InboundsPage.vue
@@ -178,7 +178,8 @@ function exportInboundSubs(dbInbound) {
function exportAllLinks() {
const out = [];
for (const ib of dbInbounds.value) {
- out.push(ib.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
+ const projected = checkFallback(ib);
+ out.push(projected.genInboundLinks(remarkModel.value, hostOverrideFor(ib)));
}
openText({
title: 'Export all inbound links',
@@ -227,8 +228,18 @@ function importInbound() {
// the root inbound that owns the listen address so QRs/links carry
// the externally-reachable host:port and the right TLS state.
function checkFallback(dbInbound) {
- // We don't keep parsed Inbounds in state right now (the page works
- // off DBInbounds); compute on the fly.
+ // Path 1: panel-tracked fallback relationship (inbound_fallbacks row).
+ // The backend annotates each child inbound with fallbackParent so the
+ // child's client-share link advertises the master's reachable endpoint
+ // and inherits its TLS / Reality state.
+ const parent = dbInbound.fallbackParent;
+ if (parent?.masterId) {
+ const master = dbInbounds.value.find((ib) => ib.id === parent.masterId);
+ if (master) return projectChildThroughMaster(dbInbound, master);
+ }
+ // Path 2: legacy unix-socket convention (`@vless-ws` etc.) — walk the
+ // VLESS/Trojan TCP inbounds and look for one whose settings.fallbacks
+ // references this child's listen address.
if (!dbInbound.listen?.startsWith?.('@')) return dbInbound;
for (const candidate of dbInbounds.value) {
if (candidate.id === dbInbound.id) continue;
@@ -237,23 +248,30 @@ function checkFallback(dbInbound) {
if (!['trojan', 'vless'].includes(parsed.protocol)) continue;
const fallbacks = parsed.settings.fallbacks || [];
if (!fallbacks.find((f) => f.dest === dbInbound.listen)) continue;
- // Build a one-off DBInbound copy with the parent's listen/port +
- // copied stream so the link gen sees the public endpoint.
- const projected = JSON.parse(JSON.stringify(dbInbound));
- projected.listen = candidate.listen;
- projected.port = candidate.port;
- const inheritedStream = parsed.stream;
- const ownInbound = dbInbound.toInbound();
- ownInbound.stream.security = inheritedStream.security;
- ownInbound.stream.tls = inheritedStream.tls;
- ownInbound.stream.externalProxy = inheritedStream.externalProxy;
- projected.streamSettings = ownInbound.stream.toString();
- // Re-wrap so callers get the same DBInbound shape they had.
- return new dbInbound.constructor(projected);
+ return projectChildThroughMaster(dbInbound, candidate);
}
return dbInbound;
}
+// projectChildThroughMaster returns a one-off DBInbound copy whose
+// listen/port + TLS/Reality state come from the master, while the
+// protocol/transport/clients stay the child's. This is what makes a
+// `vless://uuid@server:443?type=ws&path=/vlws&security=tls` link work
+// for a child VLESS-WS bound to 127.0.0.1.
+function projectChildThroughMaster(child, master) {
+ const projected = JSON.parse(JSON.stringify(child));
+ projected.listen = master.listen;
+ projected.port = master.port;
+ const masterStream = master.toInbound().stream;
+ const childInbound = child.toInbound();
+ childInbound.stream.security = masterStream.security;
+ childInbound.stream.tls = masterStream.tls;
+ childInbound.stream.reality = masterStream.reality;
+ childInbound.stream.externalProxy = masterStream.externalProxy;
+ projected.streamSettings = childInbound.stream.toString();
+ return new child.constructor(projected);
+}
+
function findClientIndex(dbInbound, client) {
if (!client) return 0;
const inbound = dbInbound.toInbound();
diff --git a/sub/links.go b/sub/links.go
index 234f8d79..c961ca95 100644
--- a/sub/links.go
+++ b/sub/links.go
@@ -41,6 +41,7 @@ func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
svc := p.build(host)
+ svc.projectThroughFallbackMaster(inbound)
return splitLinkLines(svc.GetLink(inbound, email))
}
diff --git a/sub/subClashService.go b/sub/subClashService.go
index 74bcad94..7b638dfe 100644
--- a/sub/subClashService.go
+++ b/sub/subClashService.go
@@ -50,14 +50,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
if clients == nil {
continue
}
- if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
- listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
- if err == nil {
- inbound.Listen = listen
- inbound.Port = port
- inbound.StreamSettings = streamSettings
- }
- }
+ s.SubService.projectThroughFallbackMaster(inbound)
for _, client := range clients {
if client.SubID == subId {
_, clientTraffics = s.SubService.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
diff --git a/sub/subJsonService.go b/sub/subJsonService.go
index 3b34ed68..bbc0a381 100644
--- a/sub/subJsonService.go
+++ b/sub/subJsonService.go
@@ -110,14 +110,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
if clients == nil {
continue
}
- if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
- listen, port, streamSettings, err := s.SubService.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
- if err == nil {
- inbound.Listen = listen
- inbound.Port = port
- inbound.StreamSettings = streamSettings
- }
- }
+ s.SubService.projectThroughFallbackMaster(inbound)
for _, client := range clients {
if client.SubID == subId {
diff --git a/sub/subService.go b/sub/subService.go
index c26a1ac1..077ab9a5 100644
--- a/sub/subService.go
+++ b/sub/subService.go
@@ -92,14 +92,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
if clients == nil {
continue
}
- if len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
- listen, port, streamSettings, err := s.getFallbackMaster(inbound.Listen, inbound.StreamSettings)
- if err == nil {
- inbound.Listen = listen
- inbound.Port = port
- inbound.StreamSettings = streamSettings
- }
- }
+ s.projectThroughFallbackMaster(inbound)
for _, client := range clients {
if client.SubID == subId {
if client.Enable {
@@ -192,16 +185,89 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
return "", 0, "", err
}
- var stream map[string]any
- json.Unmarshal([]byte(streamSettings), &stream)
- var masterStream map[string]any
- json.Unmarshal([]byte(inbound.StreamSettings), &masterStream)
- stream["security"] = masterStream["security"]
- stream["tlsSettings"] = masterStream["tlsSettings"]
- stream["externalProxy"] = masterStream["externalProxy"]
- modifiedStream, _ := json.MarshalIndent(stream, "", " ")
+ return inbound.Listen, inbound.Port, mergeStreamFromMaster(streamSettings, inbound.StreamSettings), nil
+}
- return inbound.Listen, inbound.Port, string(modifiedStream), nil
+// projectThroughFallbackMaster mutates the inbound in place so its
+// Listen/Port/StreamSettings reflect the externally reachable master
+// when applicable. Covers both fallback mechanisms:
+// - panel-tracked: an inbound_fallbacks row where child_id = inbound.Id
+// - legacy unix-socket: inbound.Listen begins with "@" and some VLESS/
+// Trojan inbound's settings.fallbacks references that listen address
+//
+// Returns true when a projection happened; sub services call this before
+// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
+// master's :443 + TLS state instead of its own loopback endpoint.
+func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
+ if inbound == nil {
+ return false
+ }
+ db := database.GetDB()
+ var master *model.Inbound
+
+ var rule model.InboundFallback
+ if err := db.Where("child_id = ?", inbound.Id).
+ Order("sort_order ASC, id ASC").
+ First(&rule).Error; err == nil {
+ var m model.Inbound
+ if err := db.Where("id = ?", rule.MasterId).First(&m).Error; err == nil {
+ master = &m
+ }
+ }
+
+ if master == nil && len(inbound.Listen) > 0 && inbound.Listen[0] == '@' {
+ var m model.Inbound
+ if err := db.Model(model.Inbound{}).
+ Where("JSON_TYPE(settings, '$.fallbacks') = 'array'").
+ Where("EXISTS (SELECT * FROM json_each(settings, '$.fallbacks') WHERE json_extract(value, '$.dest') = ?)", inbound.Listen).
+ First(&m).Error; err == nil {
+ master = &m
+ }
+ }
+
+ if master == nil {
+ return false
+ }
+ inbound.StreamSettings = mergeStreamFromMaster(inbound.StreamSettings, master.StreamSettings)
+ inbound.Listen = master.Listen
+ inbound.Port = master.Port
+ return true
+}
+
+// mergeStreamFromMaster copies the master's security + tlsSettings +
+// realitySettings + externalProxy onto the child's stream so the child's
+// link advertises the master's TLS / Reality state. Transport (network
+// + ws/grpc/etc. settings) stays the child's.
+func mergeStreamFromMaster(childStream, masterStream string) string {
+ var stream map[string]any
+ json.Unmarshal([]byte(childStream), &stream)
+ if stream == nil {
+ stream = map[string]any{}
+ }
+ var mst map[string]any
+ json.Unmarshal([]byte(masterStream), &mst)
+ if mst == nil {
+ return childStream
+ }
+ stream["security"] = mst["security"]
+ if v, ok := mst["tlsSettings"]; ok {
+ stream["tlsSettings"] = v
+ } else {
+ delete(stream, "tlsSettings")
+ }
+ if v, ok := mst["realitySettings"]; ok {
+ stream["realitySettings"] = v
+ } else {
+ delete(stream, "realitySettings")
+ }
+ if v, ok := mst["externalProxy"]; ok {
+ stream["externalProxy"] = v
+ }
+ out, err := json.MarshalIndent(stream, "", " ")
+ if err != nil {
+ return childStream
+ }
+ return string(out)
}
// GetLink dispatches to the protocol-specific generator for one (inbound, client)
diff --git a/web/controller/inbound.go b/web/controller/inbound.go
index f5280920..8436fd80 100644
--- a/web/controller/inbound.go
+++ b/web/controller/inbound.go
@@ -17,8 +17,9 @@ import (
// InboundController handles HTTP requests related to Xray inbounds management.
type InboundController struct {
- inboundService service.InboundService
- xrayService service.XrayService
+ inboundService service.InboundService
+ xrayService service.XrayService
+ fallbackService service.FallbackService
}
// NewInboundController creates a new InboundController and sets up its routes.
@@ -62,6 +63,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds)
g.GET("/options", a.getInboundOptions)
g.GET("/get/:id", a.getInbound)
+ g.GET("/:id/fallbacks", a.getFallbacks)
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
@@ -70,6 +72,7 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/resetTraffic", a.resetInboundTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/import", a.importInbound)
+ g.POST("/:id/fallbacks", a.setFallbacks)
}
// getInbounds retrieves the list of inbounds for the logged-in user.
@@ -330,3 +333,42 @@ func resolveHost(c *gin.Context) string {
return c.Request.Host
}
+// getFallbacks returns the fallback rules attached to the master inbound.
+func (a *InboundController) getFallbacks(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "get"), err)
+ return
+ }
+ rows, err := a.fallbackService.GetByMaster(id)
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "get"), err)
+ return
+ }
+ jsonObj(c, rows, nil)
+}
+
+// setFallbacks atomically replaces the master inbound's fallback list
+// and triggers an Xray restart so the new settings.fallbacks take effect.
+func (a *InboundController) setFallbacks(c *gin.Context) {
+ id, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ type body struct {
+ Fallbacks []service.FallbackInput `json:"fallbacks"`
+ }
+ var b body
+ if err := c.ShouldBindJSON(&b); err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ if err := a.fallbackService.SetByMaster(id, b.Fallbacks); err != nil {
+ jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+ return
+ }
+ a.xrayService.SetToNeedRestart()
+ jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
+}
+
diff --git a/web/service/fallback.go b/web/service/fallback.go
new file mode 100644
index 00000000..4eb2b6e8
--- /dev/null
+++ b/web/service/fallback.go
@@ -0,0 +1,147 @@
+package service
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/mhsanaei/3x-ui/v3/database"
+ "github.com/mhsanaei/3x-ui/v3/database/model"
+
+ "gorm.io/gorm"
+)
+
+type FallbackService struct{}
+
+// FallbackInput is the payload shape POSTed by the inbound form.
+type FallbackInput struct {
+ ChildId int `json:"childId"`
+ Name string `json:"name"`
+ Alpn string `json:"alpn"`
+ Path string `json:"path"`
+ Xver int `json:"xver"`
+ SortOrder int `json:"sortOrder"`
+}
+
+// GetByMaster returns every fallback rule attached to the master inbound.
+func (s *FallbackService) GetByMaster(masterId int) ([]model.InboundFallback, error) {
+ var rows []model.InboundFallback
+ err := database.GetDB().
+ Where("master_id = ?", masterId).
+ Order("sort_order ASC, id ASC").
+ Find(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+ return rows, nil
+}
+
+// GetParentForChild finds the first fallback rule that points at childId.
+// Used by client-link generation: when a child inbound is attached as a
+// fallback, its client links should advertise the master's address+port
+// and TLS instead of the child's loopback listen.
+func (s *FallbackService) GetParentForChild(childId int) (*model.InboundFallback, error) {
+ var row model.InboundFallback
+ err := database.GetDB().
+ Where("child_id = ?", childId).
+ Order("sort_order ASC, id ASC").
+ First(&row).Error
+ if err == gorm.ErrRecordNotFound {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &row, nil
+}
+
+// SetByMaster replaces the master's entire fallback list atomically.
+func (s *FallbackService) SetByMaster(masterId int, items []FallbackInput) error {
+ db := database.GetDB()
+ return db.Transaction(func(tx *gorm.DB) error {
+ if err := tx.Where("master_id = ?", masterId).Delete(&model.InboundFallback{}).Error; err != nil {
+ return err
+ }
+ for i, c := range items {
+ if c.ChildId <= 0 || c.ChildId == masterId {
+ continue
+ }
+ row := model.InboundFallback{
+ MasterId: masterId,
+ ChildId: c.ChildId,
+ Name: c.Name,
+ Alpn: c.Alpn,
+ Path: c.Path,
+ Xver: c.Xver,
+ SortOrder: c.SortOrder,
+ }
+ if row.SortOrder == 0 {
+ row.SortOrder = i
+ }
+ if err := tx.Create(&row).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// BuildFallbacksJSON resolves the master's fallback rows into Xray's
+// expected settings.fallbacks shape, looking up each child's listen+port
+// to fill the dest field. Returns nil when the master has no rules.
+func (s *FallbackService) BuildFallbacksJSON(tx *gorm.DB, masterId int) ([]map[string]any, error) {
+ if tx == nil {
+ tx = database.GetDB()
+ }
+ var rows []model.InboundFallback
+ err := tx.Where("master_id = ?", masterId).
+ Order("sort_order ASC, id ASC").
+ Find(&rows).Error
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) == 0 {
+ return nil, nil
+ }
+
+ childIds := make([]int, 0, len(rows))
+ for i := range rows {
+ childIds = append(childIds, rows[i].ChildId)
+ }
+ var children []model.Inbound
+ if err := tx.Where("id IN ?", childIds).Find(&children).Error; err != nil {
+ return nil, err
+ }
+ byId := make(map[int]*model.Inbound, len(children))
+ for i := range children {
+ byId[children[i].Id] = &children[i]
+ }
+
+ out := make([]map[string]any, 0, len(rows))
+ for _, r := range rows {
+ child, ok := byId[r.ChildId]
+ if !ok {
+ continue
+ }
+ listen := strings.TrimSpace(child.Listen)
+ if listen == "" || listen == "0.0.0.0" || listen == "::" || listen == "::0" {
+ listen = "127.0.0.1"
+ }
+ entry := map[string]any{
+ "dest": fmt.Sprintf("%s:%d", listen, child.Port),
+ }
+ if r.Name != "" {
+ entry["name"] = r.Name
+ }
+ if r.Alpn != "" {
+ entry["alpn"] = r.Alpn
+ }
+ if r.Path != "" {
+ entry["path"] = r.Path
+ }
+ if r.Xver > 0 {
+ entry["xver"] = r.Xver
+ }
+ out = append(out, entry)
+ }
+ return out, nil
+}
diff --git a/web/service/inbound.go b/web/service/inbound.go
index b3c27953..7fab7c48 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -24,8 +24,9 @@ import (
)
type InboundService struct {
- xrayApi xray.XrayAPI
- clientService ClientService
+ xrayApi xray.XrayAPI
+ clientService ClientService
+ fallbackService FallbackService
}
func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) {
@@ -130,9 +131,44 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
return nil, err
}
s.enrichClientStats(db, inbounds)
+ s.annotateFallbackParents(db, inbounds)
return inbounds, nil
}
+// annotateFallbackParents fills FallbackParent on each inbound that is
+// the child side of a fallback rule. One DB round-trip serves the full
+// list — the frontend needs this to rewrite the child's client-share
+// link so it points at the master's reachable endpoint.
+func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.Inbound) {
+ if len(inbounds) == 0 {
+ return
+ }
+ childIds := make([]int, 0, len(inbounds))
+ for _, ib := range inbounds {
+ childIds = append(childIds, ib.Id)
+ }
+ var rows []model.InboundFallback
+ if err := db.Where("child_id IN ?", childIds).
+ Order("sort_order ASC, id ASC").
+ Find(&rows).Error; err != nil {
+ return
+ }
+ first := make(map[int]model.InboundFallback, len(rows))
+ for _, r := range rows {
+ if _, ok := first[r.ChildId]; !ok {
+ first[r.ChildId] = r
+ }
+ }
+ for _, ib := range inbounds {
+ if r, ok := first[ib.Id]; ok {
+ ib.FallbackParent = &model.FallbackParentInfo{
+ MasterId: r.MasterId,
+ Path: r.Path,
+ }
+ }
+ }
+}
+
// InboundOption is the lightweight projection of an inbound used by client UI
// pickers — only the fields needed to render labels, filter by protocol, and
// decide whether the XTLS Vision flow selector should appear. Keeping this
@@ -202,6 +238,39 @@ func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
return stream.Security == "tls" || stream.Security == "reality"
}
+// inboundCanHostFallbacks gates the settings.fallbacks injection.
+// Xray only honors fallbacks on VLESS and Trojan inbounds carried over
+// TCP transport with TLS or Reality security.
+func inboundCanHostFallbacks(ib *model.Inbound) bool {
+ if ib == nil {
+ return false
+ }
+ if ib.Protocol != model.VLESS && ib.Protocol != model.Trojan {
+ return false
+ }
+ return inboundCanEnableTlsFlow(string(ib.Protocol), ib.StreamSettings) ||
+ (ib.Protocol == model.Trojan && trojanStreamSupportsFallbacks(ib.StreamSettings))
+}
+
+// trojanStreamSupportsFallbacks mirrors the Trojan side of the same gate
+// (Trojan reuses XTLS-Vision capable streams: tcp + tls or reality).
+func trojanStreamSupportsFallbacks(streamSettings string) bool {
+ if streamSettings == "" {
+ return false
+ }
+ var stream struct {
+ Network string `json:"network"`
+ Security string `json:"security"`
+ }
+ if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
+ return false
+ }
+ if stream.Network != "tcp" {
+ return false
+ }
+ return stream.Security == "tls" || stream.Security == "reality"
+}
+
// GetAllInbounds retrieves all inbounds with client stats.
func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
db := database.GetDB()
diff --git a/web/service/xray.go b/web/service/xray.go
index f7024517..d86da4c0 100644
--- a/web/service/xray.go
+++ b/web/service/xray.go
@@ -193,6 +193,21 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
settings["clients"] = finalClients
}
+ if inboundCanHostFallbacks(inbound) {
+ fallbacks, fbErr := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
+ if fbErr != nil {
+ return nil, fbErr
+ }
+ if len(fallbacks) > 0 {
+ generic := make([]any, 0, len(fallbacks))
+ for _, f := range fallbacks {
+ generic = append(generic, f)
+ }
+ settings["fallbacks"] = generic
+ mutated = true
+ }
+ }
+
if mutated {
modifiedSettings, err := json.MarshalIndent(settings, "", " ")
if err != nil {
diff --git a/web/translation/ar-EG.json b/web/translation/ar-EG.json
index 6456e46b..e1ed1aed 100644
--- a/web/translation/ar-EG.json
+++ b/web/translation/ar-EG.json
@@ -250,11 +250,22 @@
"node": "نود",
"deployTo": "نشر على",
"localPanel": "بانل محلي",
- "portFallback": {
- "title": "العناصر الاحتياطية",
- "help": "حدد الاتصالات الواردة التي يجب أن تستقبل حركة المرور التي لا تتطابق مع اتصال VLESS-TLS الوارد. يجب أن يستمع كل عنصر فرعي على 127.0.0.1 لاستقبال الاتصالات المُحوَّلة.",
- "child": "الاتصال الوارد",
- "path": "المسار"
+ "fallbacks": {
+ "title": "الـ Fallbacks",
+ "help": "عند وصول اتصال إلى هذا الـ inbound لا يطابق أي عميل، يتم توجيهه إلى inbound آخر. اختر فرعًا أدناه وسيتم ملء حقول التوجيه (SNI / ALPN / Path / xver) تلقائيًا من نقل الفرع — في الغالب لا تحتاج إلى أي تعديل إضافي. يجب أن يستمع كل فرع على 127.0.0.1 مع security=none.",
+ "empty": "لا توجد fallbacks بعد",
+ "add": "إضافة fallback",
+ "pickInbound": "اختر inbound",
+ "matchAny": "أي",
+ "rederive": "إعادة الملء من الفرع",
+ "rederived": "تم إعادة الملء من الفرع",
+ "editAdvanced": "تحرير حقول التوجيه",
+ "hideAdvanced": "إخفاء المتقدم",
+ "quickAddAll": "إضافة سريعة لكل الـ inbounds المؤهلة",
+ "quickAdded": "تمت إضافة {n} fallback",
+ "quickAddedNone": "لا توجد inbounds جديدة مؤهلة للإضافة",
+ "routesWhen": "يوجَّه عندما",
+ "defaultCatchAll": "افتراضي — يلتقط أي شيء آخر"
},
"protocol": "بروتوكول",
"port": "بورت",
diff --git a/web/translation/en-US.json b/web/translation/en-US.json
index a0fe3fa8..89a2a9f8 100644
--- a/web/translation/en-US.json
+++ b/web/translation/en-US.json
@@ -250,6 +250,23 @@
"node": "Node",
"deployTo": "Deploy to",
"localPanel": "Local panel",
+ "fallbacks": {
+ "title": "Fallbacks",
+ "help": "When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.",
+ "empty": "No fallbacks yet",
+ "add": "Add fallback",
+ "pickInbound": "Pick an inbound",
+ "matchAny": "any",
+ "rederive": "Re-fill from child",
+ "rederived": "Re-filled from child",
+ "editAdvanced": "Edit routing fields",
+ "hideAdvanced": "Hide advanced",
+ "quickAddAll": "Quick add all eligible",
+ "quickAdded": "Added {n} fallback(s)",
+ "quickAddedNone": "No new eligible inbounds to add",
+ "routesWhen": "Routes when",
+ "defaultCatchAll": "Default — catches anything else"
+ },
"protocol": "Protocol",
"port": "Port",
"portMap": "Port Mapping",
diff --git a/web/translation/es-ES.json b/web/translation/es-ES.json
index feeff066..4415d864 100644
--- a/web/translation/es-ES.json
+++ b/web/translation/es-ES.json
@@ -250,11 +250,22 @@
"node": "Nodo",
"deployTo": "Desplegar en",
"localPanel": "Panel local",
- "portFallback": {
- "title": "Inbounds de respaldo",
- "help": "Selecciona los inbounds que deben recibir el tráfico que este inbound VLESS-TLS no coincida. Cada hijo debe escuchar en 127.0.0.1 para recibir las conexiones reenviadas.",
- "child": "Inbound",
- "path": "Ruta"
+ "fallbacks": {
+ "title": "Fallbacks",
+ "help": "Cuando una conexión en este inbound no coincide con ningún cliente, redirígela a otro inbound. Elige un hijo abajo y los campos de enrutamiento (SNI / ALPN / Path / xver) se rellenan automáticamente desde su transporte; la mayoría de las configuraciones no necesitan más ajustes. Cada hijo debe escuchar en 127.0.0.1 con security=none.",
+ "empty": "Aún no hay fallbacks",
+ "add": "Añadir fallback",
+ "pickInbound": "Selecciona un inbound",
+ "matchAny": "cualquiera",
+ "rederive": "Rellenar desde el hijo",
+ "rederived": "Rellenado desde el hijo",
+ "editAdvanced": "Editar campos de enrutamiento",
+ "hideAdvanced": "Ocultar avanzado",
+ "quickAddAll": "Añadir todos los elegibles",
+ "quickAdded": "Se añadieron {n} fallback(s)",
+ "quickAddedNone": "No hay nuevos inbounds elegibles",
+ "routesWhen": "Enruta cuando",
+ "defaultCatchAll": "Por defecto — captura cualquier otra cosa"
},
"protocol": "Protocolo",
"port": "Puerto",
diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json
index f887ea0e..71dc3d68 100644
--- a/web/translation/fa-IR.json
+++ b/web/translation/fa-IR.json
@@ -250,11 +250,22 @@
"node": "نود",
"deployTo": "استقرار روی",
"localPanel": "پنل لوکال",
- "portFallback": {
- "title": "فرزندان فالبک",
- "help": "اینباندهایی را که باید ترافیک نامطابق این اینباند VLESS-TLS را دریافت کنند انتخاب کنید. هر فرزند باید روی 127.0.0.1 گوش دهد تا اتصالات ارجاعشده را بپذیرد.",
- "child": "اینباند",
- "path": "مسیر"
+ "fallbacks": {
+ "title": "فالبکها",
+ "help": "وقتی اتصالی روی این اینباند با هیچ کلاینتی تطبیق پیدا نمیکند، به یک اینباند دیگر ارجاع داده میشود. یک فرزند انتخاب کنید، فیلدهای مسیریابی (SNI / ALPN / Path / xver) خودکار از روی transport آن پر میشود — برای بیشتر تنظیمات نیازی به ویرایش نیست. هر فرزند باید روی 127.0.0.1 با security=none گوش بدهد.",
+ "empty": "هنوز فالبکی اضافه نشده",
+ "add": "افزودن فالبک",
+ "pickInbound": "یک اینباند انتخاب کنید",
+ "matchAny": "همه",
+ "rederive": "پر کردن مجدد از فرزند",
+ "rederived": "از فرزند پر شد",
+ "editAdvanced": "ویرایش فیلدهای مسیریابی",
+ "hideAdvanced": "بستن پیشرفته",
+ "quickAddAll": "افزودن سریع همهی موارد واجد شرایط",
+ "quickAdded": "{n} فالبک افزوده شد",
+ "quickAddedNone": "اینباند جدیدی برای افزودن وجود ندارد",
+ "routesWhen": "هدایت میشود وقتی",
+ "defaultCatchAll": "پیشفرض — همهی موارد دیگر را میگیرد"
},
"protocol": "پروتکل",
"port": "پورت",
diff --git a/web/translation/id-ID.json b/web/translation/id-ID.json
index fb2c5269..ce740d50 100644
--- a/web/translation/id-ID.json
+++ b/web/translation/id-ID.json
@@ -250,11 +250,22 @@
"node": "Node",
"deployTo": "Terapkan ke",
"localPanel": "Panel lokal",
- "portFallback": {
- "title": "Inbound cadangan",
- "help": "Pilih inbound yang harus menangkap lalu lintas yang tidak cocok pada inbound VLESS-TLS ini. Setiap anak harus mendengarkan di 127.0.0.1 untuk menerima koneksi yang diteruskan.",
- "child": "Inbound",
- "path": "Path"
+ "fallbacks": {
+ "title": "Fallback",
+ "help": "Saat koneksi pada inbound ini tidak cocok dengan client mana pun, arahkan ke inbound lain. Pilih child di bawah dan field routing (SNI / ALPN / Path / xver) terisi otomatis dari transport-nya — sebagian besar konfigurasi tidak perlu disesuaikan lagi. Setiap child harus listen di 127.0.0.1 dengan security=none.",
+ "empty": "Belum ada fallback",
+ "add": "Tambah fallback",
+ "pickInbound": "Pilih inbound",
+ "matchAny": "apa pun",
+ "rederive": "Isi ulang dari child",
+ "rederived": "Diisi ulang dari child",
+ "editAdvanced": "Edit field routing",
+ "hideAdvanced": "Sembunyikan lanjutan",
+ "quickAddAll": "Tambah cepat semua yang memenuhi syarat",
+ "quickAdded": "Menambahkan {n} fallback",
+ "quickAddedNone": "Tidak ada inbound baru yang memenuhi syarat",
+ "routesWhen": "Diarahkan ketika",
+ "defaultCatchAll": "Default — menangkap apa pun lainnya"
},
"protocol": "Protokol",
"port": "Port",
diff --git a/web/translation/ja-JP.json b/web/translation/ja-JP.json
index 0611902f..7cba08ed 100644
--- a/web/translation/ja-JP.json
+++ b/web/translation/ja-JP.json
@@ -250,11 +250,22 @@
"node": "ノード",
"deployTo": "デプロイ先",
"localPanel": "ローカルパネル",
- "portFallback": {
- "title": "フォールバック子",
- "help": "この VLESS-TLS インバウンドが一致しないトラフィックを受け取るインバウンドを選択してください。各子インバウンドは転送された接続を受信するために 127.0.0.1 でリッスンする必要があります。",
- "child": "インバウンド",
- "path": "パス"
+ "fallbacks": {
+ "title": "フォールバック",
+ "help": "このインバウンドへの接続がどのクライアントにも一致しない場合、別のインバウンドへルーティングします。下から子インバウンドを選ぶと、ルーティング項目(SNI / ALPN / Path / xver)はその子のトランスポートから自動的に埋められます — ほとんどの構成で追加の調整は不要です。各子インバウンドは 127.0.0.1 で security=none をリッスンする必要があります。",
+ "empty": "フォールバックはまだありません",
+ "add": "フォールバックを追加",
+ "pickInbound": "インバウンドを選択",
+ "matchAny": "任意",
+ "rederive": "子から再取得",
+ "rederived": "子から再取得しました",
+ "editAdvanced": "ルーティング項目を編集",
+ "hideAdvanced": "詳細を隠す",
+ "quickAddAll": "対象のインバウンドをすべて一括追加",
+ "quickAdded": "{n} 件のフォールバックを追加しました",
+ "quickAddedNone": "追加可能な新規インバウンドはありません",
+ "routesWhen": "次の条件でルーティング",
+ "defaultCatchAll": "デフォルト — その他すべてを捕捉"
},
"protocol": "プロトコル",
"port": "ポート",
diff --git a/web/translation/pt-BR.json b/web/translation/pt-BR.json
index 85b2b382..711021a9 100644
--- a/web/translation/pt-BR.json
+++ b/web/translation/pt-BR.json
@@ -250,11 +250,22 @@
"node": "Nó",
"deployTo": "Implantar em",
"localPanel": "Painel local",
- "portFallback": {
- "title": "Inbounds de fallback",
- "help": "Selecione os inbounds que devem receber o tráfego que este inbound VLESS-TLS não atender. Cada filho precisa escutar em 127.0.0.1 para receber as conexões encaminhadas.",
- "child": "Inbound",
- "path": "Caminho"
+ "fallbacks": {
+ "title": "Fallbacks",
+ "help": "Quando uma conexão neste inbound não corresponde a nenhum cliente, redirecione-a para outro inbound. Escolha um filho abaixo e os campos de roteamento (SNI / ALPN / Path / xver) são preenchidos automaticamente a partir do transporte dele — a maioria das configurações não precisa de mais ajustes. Cada filho deve escutar em 127.0.0.1 com security=none.",
+ "empty": "Ainda sem fallbacks",
+ "add": "Adicionar fallback",
+ "pickInbound": "Escolha um inbound",
+ "matchAny": "qualquer",
+ "rederive": "Preencher a partir do filho",
+ "rederived": "Preenchido a partir do filho",
+ "editAdvanced": "Editar campos de roteamento",
+ "hideAdvanced": "Ocultar avançado",
+ "quickAddAll": "Adicionar todos os elegíveis",
+ "quickAdded": "{n} fallback(s) adicionado(s)",
+ "quickAddedNone": "Nenhum inbound novo elegível para adicionar",
+ "routesWhen": "Roteia quando",
+ "defaultCatchAll": "Padrão — captura qualquer outra coisa"
},
"protocol": "Protocolo",
"port": "Porta",
diff --git a/web/translation/ru-RU.json b/web/translation/ru-RU.json
index df4fd229..27d7c129 100644
--- a/web/translation/ru-RU.json
+++ b/web/translation/ru-RU.json
@@ -250,11 +250,22 @@
"node": "Узел",
"deployTo": "Развернуть на",
"localPanel": "Локальная панель",
- "portFallback": {
- "title": "Резервные входящие",
- "help": "Выберите входящие, которые должны принимать трафик, не соответствующий этому входящему VLESS-TLS. Каждый дочерний элемент должен слушать на 127.0.0.1 для приёма перенаправленных соединений.",
- "child": "Входящий",
- "path": "Путь"
+ "fallbacks": {
+ "title": "Фолбэки",
+ "help": "Когда соединение на этом инбаунде не совпадает ни с одним клиентом, оно перенаправляется на другой инбаунд. Выберите дочерний инбаунд ниже — поля маршрутизации (SNI / ALPN / Path / xver) заполнятся автоматически из его транспорта, для большинства конфигураций больше ничего менять не нужно. Каждый дочерний должен слушать на 127.0.0.1 с security=none.",
+ "empty": "Фолбэков пока нет",
+ "add": "Добавить фолбэк",
+ "pickInbound": "Выберите инбаунд",
+ "matchAny": "любой",
+ "rederive": "Заполнить из дочернего",
+ "rederived": "Заполнено из дочернего",
+ "editAdvanced": "Изменить поля маршрутизации",
+ "hideAdvanced": "Скрыть расширенные",
+ "quickAddAll": "Быстро добавить все подходящие",
+ "quickAdded": "Добавлено {n} фолбэк(ов)",
+ "quickAddedNone": "Нет новых подходящих инбаундов",
+ "routesWhen": "Маршрутизирует, когда",
+ "defaultCatchAll": "По умолчанию — ловит всё остальное"
},
"protocol": "Протокол",
"port": "Порт",
diff --git a/web/translation/tr-TR.json b/web/translation/tr-TR.json
index 7d42765e..0999658b 100644
--- a/web/translation/tr-TR.json
+++ b/web/translation/tr-TR.json
@@ -250,11 +250,22 @@
"node": "Düğüm",
"deployTo": "Şuraya dağıt",
"localPanel": "Yerel panel",
- "portFallback": {
- "title": "Yedek inbound'lar",
- "help": "Bu VLESS-TLS inbound'unun eşleşmediği trafiği almasını istediğiniz inbound'ları seçin. Yönlendirilen bağlantıları alabilmek için her alt inbound 127.0.0.1 üzerinde dinlemelidir.",
- "child": "Inbound",
- "path": "Yol"
+ "fallbacks": {
+ "title": "Fallback'ler",
+ "help": "Bu inbound üzerindeki bir bağlantı hiçbir client ile eşleşmediğinde, başka bir inbound'a yönlendirilir. Aşağıdan bir child seçin; yönlendirme alanları (SNI / ALPN / Path / xver) onun transport'undan otomatik dolar — çoğu kurulum için ek ayar gerekmez. Her child 127.0.0.1 üzerinde security=none ile dinlemelidir.",
+ "empty": "Henüz fallback yok",
+ "add": "Fallback ekle",
+ "pickInbound": "Bir inbound seç",
+ "matchAny": "herhangi",
+ "rederive": "Child'dan yeniden doldur",
+ "rederived": "Child'dan yeniden dolduruldu",
+ "editAdvanced": "Yönlendirme alanlarını düzenle",
+ "hideAdvanced": "Gelişmişi gizle",
+ "quickAddAll": "Uygun olan tümünü hızlı ekle",
+ "quickAdded": "{n} fallback eklendi",
+ "quickAddedNone": "Eklenecek yeni uygun inbound yok",
+ "routesWhen": "Şu durumda yönlendirir",
+ "defaultCatchAll": "Varsayılan — başka her şeyi yakalar"
},
"protocol": "Protokol",
"port": "Port",
diff --git a/web/translation/uk-UA.json b/web/translation/uk-UA.json
index ee3ed16b..cb9f789b 100644
--- a/web/translation/uk-UA.json
+++ b/web/translation/uk-UA.json
@@ -250,11 +250,22 @@
"node": "Вузол",
"deployTo": "Розгорнути на",
"localPanel": "Локальна панель",
- "portFallback": {
- "title": "Резервні вхідні",
- "help": "Виберіть вхідні, які мають отримувати трафік, що не відповідає цьому вхідному VLESS-TLS. Кожен дочірній вхідний повинен слухати на 127.0.0.1 для приймання переадресованих з'єднань.",
- "child": "Вхідний",
- "path": "Шлях"
+ "fallbacks": {
+ "title": "Фолбеки",
+ "help": "Коли з'єднання на цьому інбаунді не збігається з жодним клієнтом, воно перенаправляється на інший інбаунд. Оберіть дочірній інбаунд нижче — поля маршрутизації (SNI / ALPN / Path / xver) заповняться автоматично з його транспорту; для більшості налаштувань більше нічого змінювати не треба. Кожен дочірній має слухати на 127.0.0.1 з security=none.",
+ "empty": "Фолбеків поки немає",
+ "add": "Додати фолбек",
+ "pickInbound": "Оберіть інбаунд",
+ "matchAny": "будь-який",
+ "rederive": "Заповнити з дочірнього",
+ "rederived": "Заповнено з дочірнього",
+ "editAdvanced": "Редагувати поля маршрутизації",
+ "hideAdvanced": "Сховати розширені",
+ "quickAddAll": "Швидко додати всі придатні",
+ "quickAdded": "Додано {n} фолбек(ів)",
+ "quickAddedNone": "Немає нових придатних інбаундів",
+ "routesWhen": "Маршрутизує, коли",
+ "defaultCatchAll": "За замовчуванням — ловить усе інше"
},
"protocol": "Протокол",
"port": "Порт",
diff --git a/web/translation/vi-VN.json b/web/translation/vi-VN.json
index 2beaac2d..b2ffa275 100644
--- a/web/translation/vi-VN.json
+++ b/web/translation/vi-VN.json
@@ -250,11 +250,22 @@
"node": "Nút",
"deployTo": "Triển khai tới",
"localPanel": "Panel cục bộ",
- "portFallback": {
- "title": "Inbound dự phòng",
- "help": "Chọn các inbound sẽ tiếp nhận lưu lượng mà inbound VLESS-TLS này không khớp. Mỗi inbound con phải lắng nghe trên 127.0.0.1 để nhận các kết nối được chuyển tiếp.",
- "child": "Inbound",
- "path": "Đường dẫn"
+ "fallbacks": {
+ "title": "Fallback",
+ "help": "Khi một kết nối trên inbound này không khớp với client nào, nó sẽ được chuyển hướng tới inbound khác. Chọn một child bên dưới và các trường định tuyến (SNI / ALPN / Path / xver) sẽ được tự động điền từ transport của child — hầu hết cấu hình không cần chỉnh thêm. Mỗi child nên lắng nghe trên 127.0.0.1 với security=none.",
+ "empty": "Chưa có fallback nào",
+ "add": "Thêm fallback",
+ "pickInbound": "Chọn một inbound",
+ "matchAny": "bất kỳ",
+ "rederive": "Điền lại từ child",
+ "rederived": "Đã điền lại từ child",
+ "editAdvanced": "Sửa trường định tuyến",
+ "hideAdvanced": "Ẩn nâng cao",
+ "quickAddAll": "Thêm nhanh tất cả các inbound đủ điều kiện",
+ "quickAdded": "Đã thêm {n} fallback",
+ "quickAddedNone": "Không có inbound mới nào đủ điều kiện",
+ "routesWhen": "Định tuyến khi",
+ "defaultCatchAll": "Mặc định — bắt mọi thứ khác"
},
"protocol": "Giao thức",
"port": "Cổng",
diff --git a/web/translation/zh-CN.json b/web/translation/zh-CN.json
index 92a34fe0..3ff99e4d 100644
--- a/web/translation/zh-CN.json
+++ b/web/translation/zh-CN.json
@@ -250,11 +250,22 @@
"node": "节点",
"deployTo": "部署到",
"localPanel": "本地面板",
- "portFallback": {
- "title": "回退入站",
- "help": "选择当此 VLESS-TLS 入站不匹配时应接收流量的入站。每个子入站必须监听 127.0.0.1 才能接收转发的连接。",
- "child": "入站",
- "path": "路径"
+ "fallbacks": {
+ "title": "回落",
+ "help": "当此入站的连接未匹配任何客户端时,将其路由到另一个入站。在下方选择一个子入站,路由字段(SNI / ALPN / Path / xver)会从子入站的传输方式中自动填充——大多数场景无需再调整。每个子入站应监听 127.0.0.1,security=none。",
+ "empty": "暂无回落",
+ "add": "添加回落",
+ "pickInbound": "选择一个入站",
+ "matchAny": "任意",
+ "rederive": "从子入站重新填充",
+ "rederived": "已从子入站重新填充",
+ "editAdvanced": "编辑路由字段",
+ "hideAdvanced": "隐藏高级",
+ "quickAddAll": "一键添加所有可用入站",
+ "quickAdded": "已添加 {n} 条回落",
+ "quickAddedNone": "没有可添加的新入站",
+ "routesWhen": "当满足条件时路由",
+ "defaultCatchAll": "默认 — 兜底匹配其他所有"
},
"protocol": "协议",
"port": "端口",
diff --git a/web/translation/zh-TW.json b/web/translation/zh-TW.json
index a6ac97b0..6965c9b1 100644
--- a/web/translation/zh-TW.json
+++ b/web/translation/zh-TW.json
@@ -250,11 +250,22 @@
"node": "節點",
"deployTo": "部署到",
"localPanel": "本機面板",
- "portFallback": {
- "title": "回退入站",
- "help": "選擇當此 VLESS-TLS 入站未匹配時應接收流量的入站。每個子入站必須監聽 127.0.0.1 才能接收轉發的連線。",
- "child": "入站",
- "path": "路徑"
+ "fallbacks": {
+ "title": "回落",
+ "help": "當此入站的連線未匹配任何用戶時,將其路由到另一個入站。在下方選擇一個子入站,路由欄位(SNI / ALPN / Path / xver)會自動從子入站的傳輸方式填入——大多數情境不需要再調整。每個子入站應監聽 127.0.0.1,security=none。",
+ "empty": "尚未新增回落",
+ "add": "新增回落",
+ "pickInbound": "選擇一個入站",
+ "matchAny": "任何",
+ "rederive": "從子入站重新填入",
+ "rederived": "已從子入站重新填入",
+ "editAdvanced": "編輯路由欄位",
+ "hideAdvanced": "隱藏進階",
+ "quickAddAll": "一鍵新增所有符合的入站",
+ "quickAdded": "已新增 {n} 個回落",
+ "quickAddedNone": "沒有可新增的新入站",
+ "routesWhen": "當條件成立時路由",
+ "defaultCatchAll": "預設 — 兜底匹配其餘"
},
"protocol": "協議",
"port": "埠",