feat: replace panel proxy URL with outbound-based egress bridge

Instead of requiring a manual SOCKS5/HTTP URL, the panel now lets the
admin pick an Xray outbound from a dropdown (same UX as Geodata
Auto-Update). At runtime, injectPanelEgress appends a loopback SOCKS
inbound (tag: panel-egress) and prepends a routing rule so the panel's
own HTTP traffic — version checks, Telegram, normal geo-file updates —
is routed through the chosen outbound. Xray-native Geodata Auto-Update
is unaffected (it uses its own geodata.outbound inside Xray). Blackhole
outbounds are excluded from both picker dropdowns since routing any
download through one just drops it. Translations updated for all 13
locales.
This commit is contained in:
MHSanaei
2026-06-10 23:52:20 +02:00
parent 6b16d8c37a
commit ca4f32e3da
29 changed files with 352 additions and 73 deletions
+2 -2
View File
@@ -26,7 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"panelOutbound": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
@@ -115,7 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"panelOutbound": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
+6 -6
View File
@@ -94,8 +94,8 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"panelProxy": {
"description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
"panelOutbound": {
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
"type": "string"
},
"remarkModel": {
@@ -357,7 +357,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapUserFilter",
"ldapVlessField",
"pageSize",
"panelProxy",
"panelOutbound",
"remarkModel",
"restartXrayOnClientDisable",
"sessionMaxAge",
@@ -528,8 +528,8 @@ export const SCHEMAS: Record<string, unknown> = {
"minimum": 0,
"type": "integer"
},
"panelProxy": {
"description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
"panelOutbound": {
"description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
"type": "string"
},
"remarkModel": {
@@ -797,7 +797,7 @@ export const SCHEMAS: Record<string, unknown> = {
"ldapUserFilter",
"ldapVlessField",
"pageSize",
"panelProxy",
"panelOutbound",
"remarkModel",
"restartXrayOnClientDisable",
"sessionMaxAge",
+2 -2
View File
@@ -30,7 +30,7 @@ export interface AllSetting {
ldapUserFilter: string;
ldapVlessField: string;
pageSize: number;
panelProxy: string;
panelOutbound: string;
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
@@ -120,7 +120,7 @@ export interface AllSettingView {
ldapUserFilter: string;
ldapVlessField: string;
pageSize: number;
panelProxy: string;
panelOutbound: string;
remarkModel: string;
restartXrayOnClientDisable: boolean;
sessionMaxAge: number;
+2 -2
View File
@@ -38,7 +38,7 @@ export const AllSettingSchema = z.object({
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
pageSize: z.number().int().min(0).max(1000),
panelProxy: z.string(),
panelOutbound: z.string(),
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
@@ -129,7 +129,7 @@ export const AllSettingViewSchema = z.object({
ldapUserFilter: z.string(),
ldapVlessField: z.string(),
pageSize: z.number().int().min(0).max(1000),
panelProxy: z.string(),
panelOutbound: z.string(),
remarkModel: z.string(),
restartXrayOnClientDisable: z.boolean(),
sessionMaxAge: z.number().int().min(1).max(525600),
+1 -1
View File
@@ -9,7 +9,7 @@ export class AllSetting {
webBasePath = '/';
sessionMaxAge = 360;
trustedProxyCIDRs = '127.0.0.1/32,::1/128';
panelProxy = '';
panelOutbound = '';
pageSize = 25;
expireDiff = 0;
trafficDiff = 0;
+5 -1
View File
@@ -65,10 +65,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
);
// Download outbound candidates: template outbounds + subscription outbounds.
// Skip blackhole outbounds — routing a download through one just drops it.
const tags = new Set<string>();
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
for (const o of outbounds) {
const tag = o && typeof o === 'object' ? (o as Record<string, unknown>).tag : undefined;
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue;
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags)
+42 -5
View File
@@ -43,6 +43,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]);
useEffect(() => {
let cancelled = false;
@@ -65,6 +66,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
return () => { cancelled = true; };
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
// Outbound tags for the panel egress picker: template outbounds plus
// subscription-derived outbounds, same candidate set as the geodata
// download picker.
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
try {
const payload = JSON.parse(msg.obj) as Record<string, unknown>;
const template = (payload.xraySetting || {}) as Record<string, unknown>;
const tags = new Set<string>();
const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
for (const o of outbounds) {
if (!o || typeof o !== 'object') continue;
const rec = o as Record<string, unknown>;
if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
const tag = rec.tag;
if (typeof tag === 'string' && tag) tags.add(tag);
}
const subTags = Array.isArray(payload.subscriptionOutboundTags) ? payload.subscriptionOutboundTags : [];
for (const tag of subTags) {
if (typeof tag === 'string' && tag) tags.add(tag);
}
setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag })));
} catch {
setOutboundOptions([]);
}
})();
return () => { cancelled = true; };
}, []);
const ldapInboundTagList = useMemo(() => {
const csv = allSetting.ldapInboundTags || '';
return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
@@ -133,11 +166,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
/>
</SettingListItem>
<SettingListItem paddings="small" title={t('pages.settings.panelProxy')} description={t('pages.settings.panelProxyDesc')}>
<Input
value={allSetting.panelProxy}
placeholder="socks5:// or http://user:pass@host:port"
onChange={(e) => updateSetting({ panelProxy: e.target.value })}
<SettingListItem paddings="small" title={t('pages.settings.panelOutbound')} description={t('pages.settings.panelOutboundDesc')}>
<Select
style={{ width: '100%' }}
allowClear
showSearch
value={allSetting.panelOutbound || undefined}
placeholder={t('pages.settings.panelOutboundPh')}
options={outboundOptions}
onChange={(v) => updateSetting({ panelOutbound: (v as string | undefined) || '' })}
/>
</SettingListItem>
+1 -1
View File
@@ -13,7 +13,7 @@ export const AllSettingSchema = z.object({
webBasePath: absolutePath.optional(),
sessionMaxAge: z.number().int().min(1).max(525600).optional(),
trustedProxyCIDRs: z.string().optional(),
panelProxy: z.string().optional(),
panelOutbound: z.string().optional(),
pageSize: z.number().int().min(0).max(1000).optional(),
expireDiff: nonNegativeInt.optional(),
trafficDiff: nonNegativeInt.max(100).optional(),