P9(4): remove local-core live-test trio — outbound speed test + egress probe, restart/stop xray endpoints
Per user decision 2026-10-21 (reverses the earlier keep call in PANEL-LITE-SPEC.md, now amended): the lite panel never runs a local core, so measuring/probing from the panel host is meaningless. Backend: - delete service/outbound probe implementation (probe_http.go and its tests) and the TestOutbound/TestOutbounds batch prober; outbound traffic accounting (AddTraffic/GetOutboundsTraffic/Reset) unchanged - drop POST /panel/api/xray/testOutbound + testOutbounds routes and the outboundTestUrl setting (get/update plumbing removed; stored rows in existing DBs are inert) - drop POST /panel/api/server/restartXrayService + stopXrayService routes/handlers and the node-sync allowlist entry; the service-level Stop/RestartXrayService stay for internal import/restore flows - node-sync allowlist contract test updated to the new inventory Frontend: - remove test buttons/mode switch/test-all from outbounds tab, card list and table columns; delete TestResultPopover and CountryPill; drop the egress/country/latency columns fed by probe results - remove outboundTestUrl state and save plumbing from useXraySetting and the Basics tab field; drop OutboundTestResult schemas - delete the 15 now-dead i18n keys from all 13 locales (incl. the restart/stop keys orphaned since P6/P7 — i18n-dead-keys test green) - tests: drop the test-URL hook test, re-probe the loopback index mapping through Move-to-top, regenerate API types (gen)
This commit is contained in:
+2
-1
@@ -24,11 +24,12 @@
|
||||
- 安全设置:双重验证(2FA,用户不用)
|
||||
- 邮件模块(SMTP 发送:到期/流量提醒等,用户不用)
|
||||
- 分组功能(客户端归类标签 + 按组批量操作/聚合订阅;用户从未用过,裁功能不影响已建客户端与订阅)
|
||||
- 本地核心实测三件套(P9 第 4 条,用户 2026-10-21 晚改口):出站测速(临时 xray 实例批量探测 `testOutbound(s)` 接口 + 前端测试按钮/模式切换/结果弹层)、出口探测(同一动作的 egress IP/国家展示)、重启/停止核心接口(`restartXrayService`/`stopXrayService`,与首页已摘按钮同族);出站与路由的模板编辑(增删改存)保留不动
|
||||
|
||||
## 范围确认(2026-10-21 用户拍板:四项全部保留,不裁)
|
||||
|
||||
- 协议范围:保留全部协议模板(VLESS、SS2022、VMess、Trojan、WireGuard、TUIC、Hysteria、MTProto 等),不收窄
|
||||
- IP 检查 / 测速类后台任务:保留
|
||||
- IP 检查 / 测速类后台任务:保留 → **2026-10-21 晚用户改口推翻本项**:出站测速 + 出口探测(同一动作,测的是主控本地网络位置;精简主控不跑本机核心、无本地出站,测速结果无意义)连同重启/停止核心控件与接口,已于 P9 第 4 条摘除。其余三项(协议范围 / ACME / 多用户)保留不变,不要再翻出来问。
|
||||
- 面板内置 ACME 签证书:保留
|
||||
- 多用户功能:保留
|
||||
- 用户理由:这四块以静态代码为主,裁掉省不了多少内存。此节关闭,后续轮次不要再翻出来问。
|
||||
|
||||
@@ -3,64 +3,24 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"AllSetting": {
|
||||
"datepicker": "",
|
||||
"expireDiff": 0,
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"ipLimitAllowlist": "",
|
||||
"ldapAutoCreate": false,
|
||||
"ldapAutoDelete": false,
|
||||
"ldapBaseDN": "",
|
||||
"ldapBindDN": "",
|
||||
"ldapDefaultExpiryDays": 0,
|
||||
"ldapDefaultLimitIP": 0,
|
||||
"ldapDefaultTotalGB": 0,
|
||||
"ldapEnable": false,
|
||||
"ldapFlagField": "",
|
||||
"ldapHost": "",
|
||||
"ldapInboundTags": "",
|
||||
"ldapInsecureSkipVerify": false,
|
||||
"ldapInvertFlag": false,
|
||||
"ldapPassword": "",
|
||||
"ldapPort": 0,
|
||||
"ldapSyncCron": "",
|
||||
"ldapTruthyValues": "",
|
||||
"ldapUseTLS": false,
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpHost": "",
|
||||
"smtpMemory": 0,
|
||||
"smtpPassword": "",
|
||||
"smtpPort": 1,
|
||||
"smtpTo": "",
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
@@ -68,37 +28,22 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subJsonMux": "",
|
||||
"subJsonObservatory": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subPort": 0,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
"subURI": "",
|
||||
"subUpdates": 0,
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotBackup": false,
|
||||
"tgBotChatId": "",
|
||||
"tgBotEnable": false,
|
||||
"tgBotProxy": "",
|
||||
"tgBotToken": "",
|
||||
"tgCpu": 0,
|
||||
"tgEnabledEvents": "",
|
||||
"tgLang": "",
|
||||
"tgMemory": 0,
|
||||
"tgRunTime": "",
|
||||
"timeLocation": "",
|
||||
"trafficDiff": 0,
|
||||
"trustedProxyCIDRs": "",
|
||||
"twoFactorEnable": false,
|
||||
"twoFactorToken": "",
|
||||
"warpUpdateInterval": 0,
|
||||
"webBasePath": "",
|
||||
"webCertFile": "",
|
||||
@@ -110,71 +55,27 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"AllSettingView": {
|
||||
"datepicker": "",
|
||||
"expireDiff": 0,
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"hasApiToken": false,
|
||||
"hasLdapPassword": false,
|
||||
"hasNordSecret": false,
|
||||
"hasSmtpPassword": false,
|
||||
"hasTgBotToken": false,
|
||||
"hasTwoFactorToken": false,
|
||||
"hasWarpSecret": false,
|
||||
"ipLimitAllowlist": "",
|
||||
"ldapAutoCreate": false,
|
||||
"ldapAutoDelete": false,
|
||||
"ldapBaseDN": "",
|
||||
"ldapBindDN": "",
|
||||
"ldapDefaultExpiryDays": 0,
|
||||
"ldapDefaultLimitIP": 0,
|
||||
"ldapDefaultTotalGB": 0,
|
||||
"ldapEnable": false,
|
||||
"ldapFlagField": "",
|
||||
"ldapHost": "",
|
||||
"ldapInboundTags": "",
|
||||
"ldapInsecureSkipVerify": false,
|
||||
"ldapInvertFlag": false,
|
||||
"ldapPassword": "",
|
||||
"ldapPort": 0,
|
||||
"ldapSyncCron": "",
|
||||
"ldapTruthyValues": "",
|
||||
"ldapUseTLS": false,
|
||||
"ldapUserAttr": "",
|
||||
"ldapUserFilter": "",
|
||||
"ldapVlessField": "",
|
||||
"outboundDownThreshold": 1,
|
||||
"pageSize": 0,
|
||||
"panelOutbound": "",
|
||||
"remarkTemplate": "",
|
||||
"restartXrayOnClientDisable": false,
|
||||
"sessionMaxAge": 1,
|
||||
"smtpCpu": 0,
|
||||
"smtpEnable": false,
|
||||
"smtpEnabledEvents": "",
|
||||
"smtpEncryptionType": "",
|
||||
"smtpFrom": "",
|
||||
"smtpFromName": "",
|
||||
"smtpHost": "",
|
||||
"smtpMemory": 0,
|
||||
"smtpPassword": "",
|
||||
"smtpPort": 1,
|
||||
"smtpTo": "",
|
||||
"smtpUsername": "",
|
||||
"subAnnounce": "",
|
||||
"subCertFile": "",
|
||||
"subClashAutoDetect": false,
|
||||
"subClashEnable": false,
|
||||
"subClashEnableRouting": false,
|
||||
"subClashPath": "",
|
||||
"subClashRules": "",
|
||||
"subClashURI": "",
|
||||
"subClashUserAgentRegex": "",
|
||||
"subDomain": "",
|
||||
"subEnable": false,
|
||||
"subEnableRouting": false,
|
||||
"subEncrypt": false,
|
||||
"subHideSettings": false,
|
||||
"subIncyEnableRouting": false,
|
||||
"subIncyRoutingRules": "",
|
||||
"subJsonAlwaysArray": false,
|
||||
"subJsonAutoDetect": false,
|
||||
"subJsonEnable": false,
|
||||
@@ -182,37 +83,22 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subJsonMux": "",
|
||||
"subJsonObservatory": "",
|
||||
"subJsonPath": "",
|
||||
"subJsonRules": "",
|
||||
"subJsonURI": "",
|
||||
"subJsonUserAgentRegex": "",
|
||||
"subKeyFile": "",
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subPort": 0,
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
"subSupportUrl": "",
|
||||
"subThemeDir": "",
|
||||
"subTitle": "",
|
||||
"subURI": "",
|
||||
"subUpdates": 0,
|
||||
"tgBotAPIServer": "",
|
||||
"tgBotBackup": false,
|
||||
"tgBotChatId": "",
|
||||
"tgBotEnable": false,
|
||||
"tgBotProxy": "",
|
||||
"tgBotToken": "",
|
||||
"tgCpu": 0,
|
||||
"tgEnabledEvents": "",
|
||||
"tgLang": "",
|
||||
"tgMemory": 0,
|
||||
"tgRunTime": "",
|
||||
"timeLocation": "",
|
||||
"trafficDiff": 0,
|
||||
"trustedProxyCIDRs": "",
|
||||
"twoFactorEnable": false,
|
||||
"twoFactorToken": "",
|
||||
"warpUpdateInterval": 0,
|
||||
"webBasePath": "",
|
||||
"webCertFile": "",
|
||||
@@ -221,26 +107,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"webListen": "",
|
||||
"webPort": 1
|
||||
},
|
||||
"AmneziaWGLogs": {
|
||||
"events": [
|
||||
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||
],
|
||||
"peers": [
|
||||
{
|
||||
"allowedIPs": "10.8.1.2/32",
|
||||
"down": 4194304,
|
||||
"email": "peer@example.com",
|
||||
"endpoint": "203.0.113.9:51820",
|
||||
"handshake": 1735732800000,
|
||||
"inboundId": 1,
|
||||
"interface": "awg1",
|
||||
"online": true,
|
||||
"tag": "inbound-51820",
|
||||
"up": 1048576
|
||||
}
|
||||
],
|
||||
"running": true
|
||||
},
|
||||
"ApiToken": {
|
||||
"createdAt": 0,
|
||||
"enabled": false,
|
||||
@@ -648,9 +514,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
""
|
||||
],
|
||||
"name": "",
|
||||
"nodeType": "traditional",
|
||||
"outboundTag": "",
|
||||
"pinnedCertSha256": "",
|
||||
"port": 1,
|
||||
"port": 0,
|
||||
"remark": "",
|
||||
"scheme": "http",
|
||||
"tlsVerifyMode": "verify"
|
||||
@@ -683,6 +550,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"name": "edge-1",
|
||||
"netDown": 1048576,
|
||||
"netUp": 2097152,
|
||||
"nodeType": "traditional",
|
||||
"onlineCount": 5,
|
||||
"outboundTag": "direct",
|
||||
"panelVersion": "v3.x.x",
|
||||
@@ -713,18 +581,6 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"runId": "1735689600123456789",
|
||||
"state": "success"
|
||||
},
|
||||
"PeerActivity": {
|
||||
"allowedIPs": "10.8.1.2/32",
|
||||
"down": 4194304,
|
||||
"email": "peer@example.com",
|
||||
"endpoint": "203.0.113.9:51820",
|
||||
"handshake": 1735732800000,
|
||||
"inboundId": 1,
|
||||
"interface": "awg1",
|
||||
"online": true,
|
||||
"tag": "inbound-51820",
|
||||
"up": 1048576
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"cpuPct": 12.5,
|
||||
"error": "",
|
||||
|
||||
@@ -9,83 +9,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalTrafficInformURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"ipLimitAllowlist": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapAutoCreate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapAutoDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapBaseDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapBindDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapDefaultExpiryDays": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultLimitIP": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultTotalGB": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapFlagField": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapSyncCron": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapTruthyValues": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUseTLS": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapUserAttr": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUserFilter": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
@@ -110,48 +36,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpMemory": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -164,15 +48,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -185,21 +63,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEncrypt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -221,9 +90,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -240,16 +106,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"subPort": {
|
||||
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -270,43 +134,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgBotAPIServer": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgBotBackup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotChatId": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgBotEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotProxy": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgBotToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgCpu": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgEnabledEvents": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgLang": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgMemory": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgRunTime": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeLocation": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -318,12 +145,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"trustedProxyCIDRs": {
|
||||
"type": "string"
|
||||
},
|
||||
"twoFactorEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"twoFactorToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"warpUpdateInterval": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
@@ -352,64 +173,24 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"datepicker",
|
||||
"expireDiff",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"ipLimitAllowlist",
|
||||
"ldapAutoCreate",
|
||||
"ldapAutoDelete",
|
||||
"ldapBaseDN",
|
||||
"ldapBindDN",
|
||||
"ldapDefaultExpiryDays",
|
||||
"ldapDefaultLimitIP",
|
||||
"ldapDefaultTotalGB",
|
||||
"ldapEnable",
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
"ldapSyncCron",
|
||||
"ldapTruthyValues",
|
||||
"ldapUseTLS",
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
@@ -417,7 +198,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
@@ -425,29 +205,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
"subURI",
|
||||
"subUpdates",
|
||||
"tgBotAPIServer",
|
||||
"tgBotBackup",
|
||||
"tgBotChatId",
|
||||
"tgBotEnable",
|
||||
"tgBotProxy",
|
||||
"tgBotToken",
|
||||
"tgCpu",
|
||||
"tgEnabledEvents",
|
||||
"tgLang",
|
||||
"tgMemory",
|
||||
"tgRunTime",
|
||||
"timeLocation",
|
||||
"trafficDiff",
|
||||
"trustedProxyCIDRs",
|
||||
"twoFactorEnable",
|
||||
"twoFactorToken",
|
||||
"warpUpdateInterval",
|
||||
"webBasePath",
|
||||
"webCertFile",
|
||||
@@ -467,104 +233,18 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"externalTrafficInformURI": {
|
||||
"type": "string"
|
||||
},
|
||||
"hasApiToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasLdapPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasNordSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasSmtpPassword": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasTgBotToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasTwoFactorToken": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hasWarpSecret": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ipLimitAllowlist": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapAutoCreate": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapAutoDelete": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapBaseDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapBindDN": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapDefaultExpiryDays": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultLimitIP": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapDefaultTotalGB": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapFlagField": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInboundTags": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapInsecureSkipVerify": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapInvertFlag": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"ldapSyncCron": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapTruthyValues": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUseTLS": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ldapUserAttr": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapUserFilter": {
|
||||
"type": "string"
|
||||
},
|
||||
"ldapVlessField": {
|
||||
"type": "string"
|
||||
},
|
||||
"outboundDownThreshold": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
@@ -589,48 +269,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpCpu": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"smtpEnabledEvents": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpEncryptionType": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFrom": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpFromName": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpHost": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpMemory": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpPassword": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpPort": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"smtpTo": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtpUsername": {
|
||||
"type": "string"
|
||||
},
|
||||
"subAnnounce": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -643,15 +281,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subClashEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subClashPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subClashURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -664,21 +296,12 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subEncrypt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subHideSettings": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyEnableRouting": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"subIncyRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonAlwaysArray": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -700,9 +323,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subJsonURI": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -719,16 +339,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "string"
|
||||
},
|
||||
"subPort": {
|
||||
"description": "SubPort 0 means \"no explicit port\": subscription links are generated\nwithout a port (standard scheme port behind a reverse proxy) while the\nsub server still binds the default port. See SettingService.BuildSubURIBase\nand the sub server bootstrap for the two sides of that split.",
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subRoutingRules": {
|
||||
"type": "string"
|
||||
},
|
||||
"subShowIdentityOnAllLinks": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -749,43 +367,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgBotAPIServer": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgBotBackup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotChatId": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgBotEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tgBotProxy": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgBotToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgCpu": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgEnabledEvents": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgLang": {
|
||||
"type": "string"
|
||||
},
|
||||
"tgMemory": {
|
||||
"maximum": 100,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"tgRunTime": {
|
||||
"type": "string"
|
||||
},
|
||||
"timeLocation": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -797,12 +378,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"trustedProxyCIDRs": {
|
||||
"type": "string"
|
||||
},
|
||||
"twoFactorEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"twoFactorToken": {
|
||||
"type": "string"
|
||||
},
|
||||
"warpUpdateInterval": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
@@ -831,71 +406,27 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"datepicker",
|
||||
"expireDiff",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"hasApiToken",
|
||||
"hasLdapPassword",
|
||||
"hasNordSecret",
|
||||
"hasSmtpPassword",
|
||||
"hasTgBotToken",
|
||||
"hasTwoFactorToken",
|
||||
"hasWarpSecret",
|
||||
"ipLimitAllowlist",
|
||||
"ldapAutoCreate",
|
||||
"ldapAutoDelete",
|
||||
"ldapBaseDN",
|
||||
"ldapBindDN",
|
||||
"ldapDefaultExpiryDays",
|
||||
"ldapDefaultLimitIP",
|
||||
"ldapDefaultTotalGB",
|
||||
"ldapEnable",
|
||||
"ldapFlagField",
|
||||
"ldapHost",
|
||||
"ldapInboundTags",
|
||||
"ldapInsecureSkipVerify",
|
||||
"ldapInvertFlag",
|
||||
"ldapPassword",
|
||||
"ldapPort",
|
||||
"ldapSyncCron",
|
||||
"ldapTruthyValues",
|
||||
"ldapUseTLS",
|
||||
"ldapUserAttr",
|
||||
"ldapUserFilter",
|
||||
"ldapVlessField",
|
||||
"outboundDownThreshold",
|
||||
"pageSize",
|
||||
"panelOutbound",
|
||||
"remarkTemplate",
|
||||
"restartXrayOnClientDisable",
|
||||
"sessionMaxAge",
|
||||
"smtpCpu",
|
||||
"smtpEnable",
|
||||
"smtpEnabledEvents",
|
||||
"smtpEncryptionType",
|
||||
"smtpFrom",
|
||||
"smtpFromName",
|
||||
"smtpHost",
|
||||
"smtpMemory",
|
||||
"smtpPassword",
|
||||
"smtpPort",
|
||||
"smtpTo",
|
||||
"smtpUsername",
|
||||
"subAnnounce",
|
||||
"subCertFile",
|
||||
"subClashAutoDetect",
|
||||
"subClashEnable",
|
||||
"subClashEnableRouting",
|
||||
"subClashPath",
|
||||
"subClashRules",
|
||||
"subClashURI",
|
||||
"subClashUserAgentRegex",
|
||||
"subDomain",
|
||||
"subEnable",
|
||||
"subEnableRouting",
|
||||
"subEncrypt",
|
||||
"subHideSettings",
|
||||
"subIncyEnableRouting",
|
||||
"subIncyRoutingRules",
|
||||
"subJsonAlwaysArray",
|
||||
"subJsonAutoDetect",
|
||||
"subJsonEnable",
|
||||
@@ -903,7 +434,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subJsonMux",
|
||||
"subJsonObservatory",
|
||||
"subJsonPath",
|
||||
"subJsonRules",
|
||||
"subJsonURI",
|
||||
"subJsonUserAgentRegex",
|
||||
"subKeyFile",
|
||||
@@ -911,29 +441,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
"subSupportUrl",
|
||||
"subThemeDir",
|
||||
"subTitle",
|
||||
"subURI",
|
||||
"subUpdates",
|
||||
"tgBotAPIServer",
|
||||
"tgBotBackup",
|
||||
"tgBotChatId",
|
||||
"tgBotEnable",
|
||||
"tgBotProxy",
|
||||
"tgBotToken",
|
||||
"tgCpu",
|
||||
"tgEnabledEvents",
|
||||
"tgLang",
|
||||
"tgMemory",
|
||||
"tgRunTime",
|
||||
"timeLocation",
|
||||
"trafficDiff",
|
||||
"trustedProxyCIDRs",
|
||||
"twoFactorEnable",
|
||||
"twoFactorToken",
|
||||
"warpUpdateInterval",
|
||||
"webBasePath",
|
||||
"webCertFile",
|
||||
@@ -944,36 +460,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AmneziaWGLogs": {
|
||||
"description": "AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live\nper-peer activity of every running embedded interface, plus the panel's\nown recent AmneziaWG lifecycle log lines that explain a peer being absent\nfrom Peers at all.",
|
||||
"properties": {
|
||||
"events": {
|
||||
"example": [
|
||||
"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"peers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PeerActivity"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"running": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"events",
|
||||
"peers",
|
||||
"running"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ApiToken": {
|
||||
"properties": {
|
||||
"createdAt": {
|
||||
@@ -2617,6 +2103,14 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType distinguishes traditional panel-to-panel nodes from lightweight\nagent nodes. Agent nodes skip the panel reachability probe and the API\ntoken requirement; an agent token is issued on creation instead.",
|
||||
"enum": [
|
||||
"traditional",
|
||||
"agent"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"outboundTag": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2625,7 +2119,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"port": {
|
||||
"maximum": 65535,
|
||||
"minimum": 1,
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
@@ -2778,6 +2272,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"nodeType": {
|
||||
"description": "NodeType is \"agent\" for lightweight agent-managed nodes (an agent is\nbound to the node) and \"traditional\" otherwise. It is derived from the\nagent binding, not stored on the node row; read paths that serve the\npanel UI annotate it via annotateNodeTypes. Empty on views built\noutside those paths.",
|
||||
"example": "traditional",
|
||||
"type": "string"
|
||||
},
|
||||
"onlineCount": {
|
||||
"example": 5,
|
||||
"type": "integer"
|
||||
@@ -2945,68 +2444,6 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PeerActivity": {
|
||||
"description": "PeerActivity is one peer's live embedded-Device-reported state, the\ncounterpart of an Xray access-log entry: a tunnel logs no requests, only\nhandshakes and bytes.",
|
||||
"properties": {
|
||||
"allowedIPs": {
|
||||
"example": "10.8.1.2/32",
|
||||
"type": "string"
|
||||
},
|
||||
"down": {
|
||||
"example": 4194304,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"email": {
|
||||
"example": "peer@example.com",
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"example": "203.0.113.9:51820",
|
||||
"type": "string"
|
||||
},
|
||||
"handshake": {
|
||||
"description": "Handshake is unix milliseconds, 0 when the peer has never connected.",
|
||||
"example": 1735732800000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"inboundId": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"interface": {
|
||||
"example": "awg1",
|
||||
"type": "string"
|
||||
},
|
||||
"online": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"tag": {
|
||||
"example": "inbound-51820",
|
||||
"type": "string"
|
||||
},
|
||||
"up": {
|
||||
"example": 1048576,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"allowedIPs",
|
||||
"down",
|
||||
"email",
|
||||
"endpoint",
|
||||
"handshake",
|
||||
"inboundId",
|
||||
"interface",
|
||||
"online",
|
||||
"tag",
|
||||
"up"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProbeResultUI": {
|
||||
"properties": {
|
||||
"cpuPct": {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type SubLinkProvider = unknown;
|
||||
export type rawBytes = number[];
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
@@ -11,64 +12,24 @@ export type transportBits = number;
|
||||
export interface AllSetting {
|
||||
datepicker: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
ipLimitAllowlist: string;
|
||||
ldapAutoCreate: boolean;
|
||||
ldapAutoDelete: boolean;
|
||||
ldapBaseDN: string;
|
||||
ldapBindDN: string;
|
||||
ldapDefaultExpiryDays: number;
|
||||
ldapDefaultLimitIP: number;
|
||||
ldapDefaultTotalGB: number;
|
||||
ldapEnable: boolean;
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInsecureSkipVerify: boolean;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
ldapSyncCron: string;
|
||||
ldapTruthyValues: string;
|
||||
ldapUseTLS: boolean;
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpFrom: string;
|
||||
smtpFromName: string;
|
||||
smtpHost: string;
|
||||
smtpMemory: number;
|
||||
smtpPassword: string;
|
||||
smtpPort: number;
|
||||
smtpTo: string;
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
@@ -76,7 +37,6 @@ export interface AllSetting {
|
||||
subJsonMux: string;
|
||||
subJsonObservatory: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
@@ -84,29 +44,15 @@ export interface AllSetting {
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
subURI: string;
|
||||
subUpdates: number;
|
||||
tgBotAPIServer: string;
|
||||
tgBotBackup: boolean;
|
||||
tgBotChatId: string;
|
||||
tgBotEnable: boolean;
|
||||
tgBotProxy: string;
|
||||
tgBotToken: string;
|
||||
tgCpu: number;
|
||||
tgEnabledEvents: string;
|
||||
tgLang: string;
|
||||
tgMemory: number;
|
||||
tgRunTime: string;
|
||||
timeLocation: string;
|
||||
trafficDiff: number;
|
||||
trustedProxyCIDRs: string;
|
||||
twoFactorEnable: boolean;
|
||||
twoFactorToken: string;
|
||||
warpUpdateInterval: number;
|
||||
webBasePath: string;
|
||||
webCertFile: string;
|
||||
@@ -119,71 +65,27 @@ export interface AllSetting {
|
||||
export interface AllSettingView {
|
||||
datepicker: string;
|
||||
expireDiff: number;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
hasApiToken: boolean;
|
||||
hasLdapPassword: boolean;
|
||||
hasNordSecret: boolean;
|
||||
hasSmtpPassword: boolean;
|
||||
hasTgBotToken: boolean;
|
||||
hasTwoFactorToken: boolean;
|
||||
hasWarpSecret: boolean;
|
||||
ipLimitAllowlist: string;
|
||||
ldapAutoCreate: boolean;
|
||||
ldapAutoDelete: boolean;
|
||||
ldapBaseDN: string;
|
||||
ldapBindDN: string;
|
||||
ldapDefaultExpiryDays: number;
|
||||
ldapDefaultLimitIP: number;
|
||||
ldapDefaultTotalGB: number;
|
||||
ldapEnable: boolean;
|
||||
ldapFlagField: string;
|
||||
ldapHost: string;
|
||||
ldapInboundTags: string;
|
||||
ldapInsecureSkipVerify: boolean;
|
||||
ldapInvertFlag: boolean;
|
||||
ldapPassword: string;
|
||||
ldapPort: number;
|
||||
ldapSyncCron: string;
|
||||
ldapTruthyValues: string;
|
||||
ldapUseTLS: boolean;
|
||||
ldapUserAttr: string;
|
||||
ldapUserFilter: string;
|
||||
ldapVlessField: string;
|
||||
outboundDownThreshold: number;
|
||||
pageSize: number;
|
||||
panelOutbound: string;
|
||||
remarkTemplate: string;
|
||||
restartXrayOnClientDisable: boolean;
|
||||
sessionMaxAge: number;
|
||||
smtpCpu: number;
|
||||
smtpEnable: boolean;
|
||||
smtpEnabledEvents: string;
|
||||
smtpEncryptionType: string;
|
||||
smtpFrom: string;
|
||||
smtpFromName: string;
|
||||
smtpHost: string;
|
||||
smtpMemory: number;
|
||||
smtpPassword: string;
|
||||
smtpPort: number;
|
||||
smtpTo: string;
|
||||
smtpUsername: string;
|
||||
subAnnounce: string;
|
||||
subCertFile: string;
|
||||
subClashAutoDetect: boolean;
|
||||
subClashEnable: boolean;
|
||||
subClashEnableRouting: boolean;
|
||||
subClashPath: string;
|
||||
subClashRules: string;
|
||||
subClashURI: string;
|
||||
subClashUserAgentRegex: string;
|
||||
subDomain: string;
|
||||
subEnable: boolean;
|
||||
subEnableRouting: boolean;
|
||||
subEncrypt: boolean;
|
||||
subHideSettings: boolean;
|
||||
subIncyEnableRouting: boolean;
|
||||
subIncyRoutingRules: string;
|
||||
subJsonAlwaysArray: boolean;
|
||||
subJsonAutoDetect: boolean;
|
||||
subJsonEnable: boolean;
|
||||
@@ -191,7 +93,6 @@ export interface AllSettingView {
|
||||
subJsonMux: string;
|
||||
subJsonObservatory: string;
|
||||
subJsonPath: string;
|
||||
subJsonRules: string;
|
||||
subJsonURI: string;
|
||||
subJsonUserAgentRegex: string;
|
||||
subKeyFile: string;
|
||||
@@ -199,29 +100,15 @@ export interface AllSettingView {
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
subSupportUrl: string;
|
||||
subThemeDir: string;
|
||||
subTitle: string;
|
||||
subURI: string;
|
||||
subUpdates: number;
|
||||
tgBotAPIServer: string;
|
||||
tgBotBackup: boolean;
|
||||
tgBotChatId: string;
|
||||
tgBotEnable: boolean;
|
||||
tgBotProxy: string;
|
||||
tgBotToken: string;
|
||||
tgCpu: number;
|
||||
tgEnabledEvents: string;
|
||||
tgLang: string;
|
||||
tgMemory: number;
|
||||
tgRunTime: string;
|
||||
timeLocation: string;
|
||||
trafficDiff: number;
|
||||
trustedProxyCIDRs: string;
|
||||
twoFactorEnable: boolean;
|
||||
twoFactorToken: string;
|
||||
warpUpdateInterval: number;
|
||||
webBasePath: string;
|
||||
webCertFile: string;
|
||||
@@ -231,12 +118,6 @@ export interface AllSettingView {
|
||||
webPort: number;
|
||||
}
|
||||
|
||||
export interface AmneziaWGLogs {
|
||||
events: string[];
|
||||
peers: PeerActivity[];
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface ApiToken {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
@@ -601,6 +482,7 @@ export interface NodeMutationRequest {
|
||||
inboundSyncMode: string;
|
||||
inboundTags: string[];
|
||||
name: string;
|
||||
nodeType?: string;
|
||||
outboundTag: string;
|
||||
pinnedCertSha256: string;
|
||||
port: number;
|
||||
@@ -635,6 +517,7 @@ export interface NodeView {
|
||||
name: string;
|
||||
netDown: number;
|
||||
netUp: number;
|
||||
nodeType?: string;
|
||||
onlineCount: number;
|
||||
outboundTag: string;
|
||||
panelVersion: string;
|
||||
@@ -668,19 +551,6 @@ export interface PanelUpdateStatus {
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface PeerActivity {
|
||||
allowedIPs: string;
|
||||
down: number;
|
||||
email: string;
|
||||
endpoint: string;
|
||||
handshake: number;
|
||||
inboundId: number;
|
||||
interface: string;
|
||||
online: boolean;
|
||||
tag: string;
|
||||
up: number;
|
||||
}
|
||||
|
||||
export interface ProbeResultUI {
|
||||
cpuPct: number;
|
||||
error: string;
|
||||
|
||||
@@ -15,6 +15,9 @@ export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
export const SubLinkProviderSchema = z.unknown();
|
||||
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||
|
||||
export const rawBytesSchema = z.array(z.number().int());
|
||||
export type rawBytes = z.infer<typeof rawBytesSchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
@@ -27,64 +30,24 @@ export type transportBits = z.infer<typeof transportBitsSchema>;
|
||||
export const AllSettingSchema = z.object({
|
||||
datepicker: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
ipLimitAllowlist: z.string(),
|
||||
ldapAutoCreate: z.boolean(),
|
||||
ldapAutoDelete: z.boolean(),
|
||||
ldapBaseDN: z.string(),
|
||||
ldapBindDN: z.string(),
|
||||
ldapDefaultExpiryDays: z.number().int().min(0),
|
||||
ldapDefaultLimitIP: z.number().int().min(0),
|
||||
ldapDefaultTotalGB: z.number().int().min(0),
|
||||
ldapEnable: z.boolean(),
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInsecureSkipVerify: z.boolean(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
ldapSyncCron: z.string(),
|
||||
ldapTruthyValues: z.string(),
|
||||
ldapUseTLS: z.boolean(),
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpFromName: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpMemory: z.number().int().min(0).max(100),
|
||||
smtpPassword: z.string(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpTo: z.string(),
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
@@ -92,7 +55,6 @@ export const AllSettingSchema = z.object({
|
||||
subJsonMux: z.string(),
|
||||
subJsonObservatory: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
@@ -100,29 +62,15 @@ export const AllSettingSchema = z.object({
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(0).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
subURI: z.string(),
|
||||
subUpdates: z.number().int().min(0).max(525600),
|
||||
tgBotAPIServer: z.string(),
|
||||
tgBotBackup: z.boolean(),
|
||||
tgBotChatId: z.string(),
|
||||
tgBotEnable: z.boolean(),
|
||||
tgBotProxy: z.string(),
|
||||
tgBotToken: z.string(),
|
||||
tgCpu: z.number().int().min(0).max(100),
|
||||
tgEnabledEvents: z.string(),
|
||||
tgLang: z.string(),
|
||||
tgMemory: z.number().int().min(0).max(100),
|
||||
tgRunTime: z.string(),
|
||||
timeLocation: z.string(),
|
||||
trafficDiff: z.number().int().min(0).max(100),
|
||||
trustedProxyCIDRs: z.string(),
|
||||
twoFactorEnable: z.boolean(),
|
||||
twoFactorToken: z.string(),
|
||||
warpUpdateInterval: z.number().int().min(0),
|
||||
webBasePath: z.string(),
|
||||
webCertFile: z.string(),
|
||||
@@ -136,71 +84,27 @@ export type AllSetting = z.infer<typeof AllSettingSchema>;
|
||||
export const AllSettingViewSchema = z.object({
|
||||
datepicker: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
hasApiToken: z.boolean(),
|
||||
hasLdapPassword: z.boolean(),
|
||||
hasNordSecret: z.boolean(),
|
||||
hasSmtpPassword: z.boolean(),
|
||||
hasTgBotToken: z.boolean(),
|
||||
hasTwoFactorToken: z.boolean(),
|
||||
hasWarpSecret: z.boolean(),
|
||||
ipLimitAllowlist: z.string(),
|
||||
ldapAutoCreate: z.boolean(),
|
||||
ldapAutoDelete: z.boolean(),
|
||||
ldapBaseDN: z.string(),
|
||||
ldapBindDN: z.string(),
|
||||
ldapDefaultExpiryDays: z.number().int().min(0),
|
||||
ldapDefaultLimitIP: z.number().int().min(0),
|
||||
ldapDefaultTotalGB: z.number().int().min(0),
|
||||
ldapEnable: z.boolean(),
|
||||
ldapFlagField: z.string(),
|
||||
ldapHost: z.string(),
|
||||
ldapInboundTags: z.string(),
|
||||
ldapInsecureSkipVerify: z.boolean(),
|
||||
ldapInvertFlag: z.boolean(),
|
||||
ldapPassword: z.string(),
|
||||
ldapPort: z.number().int().min(0).max(65535),
|
||||
ldapSyncCron: z.string(),
|
||||
ldapTruthyValues: z.string(),
|
||||
ldapUseTLS: z.boolean(),
|
||||
ldapUserAttr: z.string(),
|
||||
ldapUserFilter: z.string(),
|
||||
ldapVlessField: z.string(),
|
||||
outboundDownThreshold: z.number().int().min(1).max(100),
|
||||
pageSize: z.number().int().min(0).max(1000),
|
||||
panelOutbound: z.string(),
|
||||
remarkTemplate: z.string(),
|
||||
restartXrayOnClientDisable: z.boolean(),
|
||||
sessionMaxAge: z.number().int().min(1).max(525600),
|
||||
smtpCpu: z.number().int().min(0).max(100),
|
||||
smtpEnable: z.boolean(),
|
||||
smtpEnabledEvents: z.string(),
|
||||
smtpEncryptionType: z.string(),
|
||||
smtpFrom: z.string(),
|
||||
smtpFromName: z.string(),
|
||||
smtpHost: z.string(),
|
||||
smtpMemory: z.number().int().min(0).max(100),
|
||||
smtpPassword: z.string(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpTo: z.string(),
|
||||
smtpUsername: z.string(),
|
||||
subAnnounce: z.string(),
|
||||
subCertFile: z.string(),
|
||||
subClashAutoDetect: z.boolean(),
|
||||
subClashEnable: z.boolean(),
|
||||
subClashEnableRouting: z.boolean(),
|
||||
subClashPath: z.string(),
|
||||
subClashRules: z.string(),
|
||||
subClashURI: z.string(),
|
||||
subClashUserAgentRegex: z.string(),
|
||||
subDomain: z.string(),
|
||||
subEnable: z.boolean(),
|
||||
subEnableRouting: z.boolean(),
|
||||
subEncrypt: z.boolean(),
|
||||
subHideSettings: z.boolean(),
|
||||
subIncyEnableRouting: z.boolean(),
|
||||
subIncyRoutingRules: z.string(),
|
||||
subJsonAlwaysArray: z.boolean(),
|
||||
subJsonAutoDetect: z.boolean(),
|
||||
subJsonEnable: z.boolean(),
|
||||
@@ -208,7 +112,6 @@ export const AllSettingViewSchema = z.object({
|
||||
subJsonMux: z.string(),
|
||||
subJsonObservatory: z.string(),
|
||||
subJsonPath: z.string(),
|
||||
subJsonRules: z.string(),
|
||||
subJsonURI: z.string(),
|
||||
subJsonUserAgentRegex: z.string(),
|
||||
subKeyFile: z.string(),
|
||||
@@ -216,29 +119,15 @@ export const AllSettingViewSchema = z.object({
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(0).max(65535),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
subSupportUrl: z.string(),
|
||||
subThemeDir: z.string(),
|
||||
subTitle: z.string(),
|
||||
subURI: z.string(),
|
||||
subUpdates: z.number().int().min(0).max(525600),
|
||||
tgBotAPIServer: z.string(),
|
||||
tgBotBackup: z.boolean(),
|
||||
tgBotChatId: z.string(),
|
||||
tgBotEnable: z.boolean(),
|
||||
tgBotProxy: z.string(),
|
||||
tgBotToken: z.string(),
|
||||
tgCpu: z.number().int().min(0).max(100),
|
||||
tgEnabledEvents: z.string(),
|
||||
tgLang: z.string(),
|
||||
tgMemory: z.number().int().min(0).max(100),
|
||||
tgRunTime: z.string(),
|
||||
timeLocation: z.string(),
|
||||
trafficDiff: z.number().int().min(0).max(100),
|
||||
trustedProxyCIDRs: z.string(),
|
||||
twoFactorEnable: z.boolean(),
|
||||
twoFactorToken: z.string(),
|
||||
warpUpdateInterval: z.number().int().min(0),
|
||||
webBasePath: z.string(),
|
||||
webCertFile: z.string(),
|
||||
@@ -249,13 +138,6 @@ export const AllSettingViewSchema = z.object({
|
||||
});
|
||||
export type AllSettingView = z.infer<typeof AllSettingViewSchema>;
|
||||
|
||||
export const AmneziaWGLogsSchema = z.object({
|
||||
events: z.array(z.string()),
|
||||
peers: z.array(z.lazy(() => PeerActivitySchema)),
|
||||
running: z.boolean(),
|
||||
});
|
||||
export type AmneziaWGLogs = z.infer<typeof AmneziaWGLogsSchema>;
|
||||
|
||||
export const ApiTokenSchema = z.object({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
@@ -643,9 +525,10 @@ export const NodeMutationRequestSchema = z.object({
|
||||
inboundSyncMode: z.enum(['all', 'selected']),
|
||||
inboundTags: z.array(z.string()),
|
||||
name: z.string(),
|
||||
nodeType: z.enum(['traditional', 'agent']).optional(),
|
||||
outboundTag: z.string(),
|
||||
pinnedCertSha256: z.string(),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
remark: z.string(),
|
||||
scheme: z.enum(['http', 'https']),
|
||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
|
||||
@@ -678,6 +561,7 @@ export const NodeViewSchema = z.object({
|
||||
name: z.string(),
|
||||
netDown: z.number().int(),
|
||||
netUp: z.number().int(),
|
||||
nodeType: z.string().optional(),
|
||||
onlineCount: z.number().int(),
|
||||
outboundTag: z.string(),
|
||||
panelVersion: z.string(),
|
||||
@@ -714,20 +598,6 @@ export const PanelUpdateStatusSchema = z.object({
|
||||
});
|
||||
export type PanelUpdateStatus = z.infer<typeof PanelUpdateStatusSchema>;
|
||||
|
||||
export const PeerActivitySchema = z.object({
|
||||
allowedIPs: z.string(),
|
||||
down: z.number().int(),
|
||||
email: z.string(),
|
||||
endpoint: z.string(),
|
||||
handshake: z.number().int(),
|
||||
inboundId: z.number().int(),
|
||||
interface: z.string(),
|
||||
online: z.boolean(),
|
||||
tag: z.string(),
|
||||
up: z.number().int(),
|
||||
});
|
||||
export type PeerActivity = z.infer<typeof PeerActivitySchema>;
|
||||
|
||||
export const ProbeResultUISchema = z.object({
|
||||
cpuPct: z.number(),
|
||||
error: z.string(),
|
||||
|
||||
@@ -7,46 +7,15 @@ import { parseMsg } from '@/utils/zodValidate';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import {
|
||||
OutboundTrafficListSchema,
|
||||
OutboundTestResultListSchema,
|
||||
XrayConfigPayloadSchema,
|
||||
XraySettingsValueSchema,
|
||||
type OutboundTestResult,
|
||||
type OutboundTrafficRow,
|
||||
} from '@/schemas/xray';
|
||||
|
||||
const DEFAULT_TEST_URL = 'https://www.google.com/generate_204';
|
||||
// One HTTP-mode batch request tests this many outbounds through a single
|
||||
// shared temp xray instance; chunking keeps responses bounded (~30s worst
|
||||
// case — each probe is a cold plus a warm request) and lands Test All
|
||||
// results progressively.
|
||||
const HTTP_BATCH_CHUNK = 16;
|
||||
|
||||
function normalizeOutboundTestUrl(url: string) {
|
||||
return url || DEFAULT_TEST_URL;
|
||||
}
|
||||
|
||||
export function isUdpOutbound(outbound: unknown): boolean {
|
||||
const o = outbound as
|
||||
| { protocol?: string; streamSettings?: { network?: string } }
|
||||
| null
|
||||
| undefined;
|
||||
const p = o?.protocol;
|
||||
const n = o?.streamSettings?.network;
|
||||
return p === 'wireguard' || p === 'hysteria' || n === 'hysteria' || n === 'kcp' || n === 'quic';
|
||||
}
|
||||
|
||||
export type OutboundTestMode = 'tcp' | 'http' | 'real';
|
||||
|
||||
export type { OutboundTrafficRow, OutboundTestResult };
|
||||
export type { OutboundTrafficRow };
|
||||
|
||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||
|
||||
export interface OutboundTestState {
|
||||
testing?: boolean;
|
||||
result?: OutboundTestResult | null;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export type SetTemplate = (
|
||||
next: XraySettingsValue | null | ((prev: XraySettingsValue | null) => XraySettingsValue | null),
|
||||
) => void;
|
||||
@@ -60,30 +29,14 @@ export interface UseXraySettingResult {
|
||||
setXraySetting: (next: string) => void;
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundTestUrl: string;
|
||||
setOutboundTestUrl: (v: string) => void;
|
||||
inboundTags: string[];
|
||||
clientReverseTags: string[];
|
||||
subscriptionOutbounds: unknown[];
|
||||
subscriptionOutboundTags: string[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testingAll: boolean;
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchOutboundsTraffic: () => Promise<void>;
|
||||
resetOutboundsTraffic: (tag: string) => Promise<void>;
|
||||
testOutbound: (
|
||||
index: number,
|
||||
outbound: unknown,
|
||||
mode?: string,
|
||||
) => Promise<OutboundTestResult | null>;
|
||||
testSubscriptionOutbound: (
|
||||
tag: string,
|
||||
outbound: unknown,
|
||||
mode?: string,
|
||||
) => Promise<OutboundTestResult | null>;
|
||||
testAllOutbounds: (mode?: string) => Promise<void>;
|
||||
saveAll: () => Promise<void>;
|
||||
resetToDefault: () => Promise<void>;
|
||||
}
|
||||
@@ -136,9 +89,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
const [xraySetting, setXraySettingState] = useState('');
|
||||
const [templateSettings, setTemplateSettingsState] = useState<XraySettingsValue | null>(null);
|
||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||
const [savedXraySetting, setSavedXraySetting] = useState('');
|
||||
const [savedOutboundTestUrl, setSavedOutboundTestUrl] = useState(DEFAULT_TEST_URL);
|
||||
const config = configQuery.data;
|
||||
const inboundTags = useMemo(() => config?.inboundTags || [], [config]);
|
||||
const clientReverseTags = useMemo(() => config?.clientReverseTags || [], [config]);
|
||||
@@ -147,21 +98,10 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
[config],
|
||||
);
|
||||
const subscriptionOutboundTags = useMemo(() => config?.subscriptionOutboundTags || [], [config]);
|
||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>(
|
||||
{},
|
||||
);
|
||||
// Subscription outbounds aren't in templateSettings.outbounds, so their test
|
||||
// results are keyed by tag rather than by index.
|
||||
const [subscriptionTestStates, setSubscriptionTestStates] = useState<
|
||||
Record<string, OutboundTestState>
|
||||
>({});
|
||||
const [testingAll, setTestingAll] = useState(false);
|
||||
|
||||
const syncingRef = useRef(false);
|
||||
const xraySettingRef = useRef('');
|
||||
const outboundTestUrlRef = useRef(outboundTestUrl);
|
||||
const savedXraySettingRef = useRef(savedXraySetting);
|
||||
const savedOutboundTestUrlRef = useRef(savedOutboundTestUrl);
|
||||
const templateSettingsRef = useRef<XraySettingsValue | null>(null);
|
||||
const subscriptionOutboundsRef = useRef<unknown[]>([]);
|
||||
|
||||
@@ -169,9 +109,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
useEffect(() => {
|
||||
xraySettingRef.current = xraySetting;
|
||||
outboundTestUrlRef.current = outboundTestUrl;
|
||||
savedXraySettingRef.current = savedXraySetting;
|
||||
savedOutboundTestUrlRef.current = savedOutboundTestUrl;
|
||||
templateSettingsRef.current = templateSettings;
|
||||
subscriptionOutboundsRef.current = subscriptionOutbounds;
|
||||
});
|
||||
@@ -180,17 +118,12 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
// of the previous config after a refetch. Local edits win over the refetch.
|
||||
if (config && config !== syncedConfig) {
|
||||
setSyncedConfig(config);
|
||||
const isDirty =
|
||||
savedXraySetting !== xraySetting ||
|
||||
savedOutboundTestUrl !== normalizeOutboundTestUrl(outboundTestUrl);
|
||||
const isDirty = savedXraySetting !== xraySetting;
|
||||
if (!isDirty) {
|
||||
const pretty = JSON.stringify(config.xraySetting, null, 2);
|
||||
const nextUrl = normalizeOutboundTestUrl(config.outboundTestUrl || '');
|
||||
setXraySettingState(pretty);
|
||||
setTemplateSettingsState(config.xraySetting);
|
||||
setSavedXraySetting(pretty);
|
||||
setOutboundTestUrlState(nextUrl);
|
||||
setSavedOutboundTestUrl(nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,10 +159,6 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setOutboundTestUrl = useCallback((v: string) => {
|
||||
setOutboundTestUrlState(v);
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
}, [queryClient]);
|
||||
@@ -241,17 +170,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const sentXraySetting = xraySettingRef.current;
|
||||
const sentTestUrl = normalizeOutboundTestUrl(outboundTestUrlRef.current);
|
||||
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||
xraySetting: sentXraySetting,
|
||||
outboundTestUrl: sentTestUrl,
|
||||
});
|
||||
return { msg, sentXraySetting, sentTestUrl };
|
||||
return { msg, sentXraySetting };
|
||||
},
|
||||
onSuccess: ({ msg, sentXraySetting, sentTestUrl }) => {
|
||||
onSuccess: ({ msg, sentXraySetting }) => {
|
||||
if (!msg?.success) return;
|
||||
setSavedXraySetting(sentXraySetting);
|
||||
setSavedOutboundTestUrl(sentTestUrl);
|
||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() });
|
||||
},
|
||||
});
|
||||
@@ -291,193 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
|
||||
const spinning = saveMut.isPending || resetDefaultMut.isPending;
|
||||
|
||||
// Shared POST + parse for a batch of outbound tests. The backend probes the
|
||||
// whole batch through one shared temp xray instance and returns results in
|
||||
// request order; this aligns them by index and shapes failures so every
|
||||
// input gets an OutboundTestResult.
|
||||
const postOutboundTestBatch = useCallback(
|
||||
async (outbounds: unknown[], effMode: string): Promise<OutboundTestResult[]> => {
|
||||
const failAll = (error: string): OutboundTestResult[] =>
|
||||
outbounds.map(() => ({ success: false, error, mode: effMode }));
|
||||
try {
|
||||
const raw = await HttpUtil.post('/panel/api/xray/testOutbounds', {
|
||||
outbounds: JSON.stringify(outbounds),
|
||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||
mode: effMode,
|
||||
});
|
||||
const msg = parseMsg(raw, OutboundTestResultListSchema, 'xray/testOutbounds');
|
||||
if (!msg?.success || !Array.isArray(msg.obj)) return failAll(msg?.msg || 'Unknown error');
|
||||
const list = msg.obj;
|
||||
return outbounds.map(
|
||||
(_ob, i) => list[i] ?? { success: false, error: 'Missing result', mode: effMode },
|
||||
);
|
||||
} catch (e) {
|
||||
return failAll(String(e));
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const testOutbound = useCallback(
|
||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound) return null;
|
||||
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
|
||||
setOutboundTestStates((prev) => ({
|
||||
...prev,
|
||||
[index]: { testing: true, result: null, mode: effMode },
|
||||
}));
|
||||
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
||||
return result.success ? result : null;
|
||||
},
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
// Test a subscription outbound (not present in templateSettings.outbounds);
|
||||
// results are keyed by tag in subscriptionTestStates.
|
||||
const testSubscriptionOutbound = useCallback(
|
||||
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||
if (!outbound || !tag) return null;
|
||||
const effMode = mode === 'tcp' && isUdpOutbound(outbound) ? 'http' : mode;
|
||||
setSubscriptionTestStates((prev) => ({
|
||||
...prev,
|
||||
[tag]: { testing: true, result: null, mode: effMode },
|
||||
}));
|
||||
const [result] = await postOutboundTestBatch([outbound], effMode);
|
||||
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
||||
return result.success ? result : null;
|
||||
},
|
||||
[postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const testAllOutbounds = useCallback(
|
||||
async (mode = 'tcp') => {
|
||||
// Template outbounds key their results by index (outboundTestStates);
|
||||
// subscription outbounds aren't in the template, so they key by tag
|
||||
// (subscriptionTestStates). Both go through the same probe endpoint.
|
||||
const templateList = templateSettingsRef.current?.outbounds || [];
|
||||
const subList = (subscriptionOutboundsRef.current || []) as Array<{
|
||||
tag?: string;
|
||||
protocol?: string;
|
||||
}>;
|
||||
if ((templateList.length === 0 && subList.length === 0) || testingAll) return;
|
||||
setTestingAll(true);
|
||||
try {
|
||||
type TcpEntry =
|
||||
| { kind: 'tpl'; index: number; outbound: unknown }
|
||||
| { kind: 'sub'; tag: string; outbound: unknown };
|
||||
const tcpQueue: TcpEntry[] = [];
|
||||
// HTTP batches stay homogeneous (all template or all subscription) so a
|
||||
// tag shared between a template and a subscription outbound can't collide
|
||||
// inside one batch, and each batch's results route to one state map.
|
||||
const probeMode = mode === 'real' ? 'real' : 'http';
|
||||
const httpTplQueue: { index: number; outbound: unknown }[] = [];
|
||||
const httpSubQueue: { tag: string; outbound: unknown }[] = [];
|
||||
const enqueue = (
|
||||
ob: { tag?: string; protocol?: string },
|
||||
kind: 'tpl' | 'sub',
|
||||
index: number,
|
||||
tag: string,
|
||||
) => {
|
||||
const proto = ob?.protocol;
|
||||
if (proto === 'blackhole' || proto === 'loopback' || ob?.tag === 'blocked') return;
|
||||
// freedom ("direct") and dns aren't proxies — skip them in every mode.
|
||||
if (proto === 'freedom' || proto === 'dns') return;
|
||||
if (kind === 'sub' && !tag) return;
|
||||
const toHttp = mode !== 'tcp' || isUdpOutbound(ob);
|
||||
if (kind === 'tpl') {
|
||||
if (toHttp) httpTplQueue.push({ index, outbound: ob });
|
||||
else tcpQueue.push({ kind: 'tpl', index, outbound: ob });
|
||||
} else if (toHttp) {
|
||||
httpSubQueue.push({ tag, outbound: ob });
|
||||
} else {
|
||||
tcpQueue.push({ kind: 'sub', tag, outbound: ob });
|
||||
}
|
||||
};
|
||||
templateList.forEach((ob, i) => enqueue(ob, 'tpl', i, ''));
|
||||
subList.forEach((ob) => enqueue(ob, 'sub', -1, typeof ob?.tag === 'string' ? ob.tag : ''));
|
||||
|
||||
// TCP probes are dial-only and cheap server-side; per-item requests
|
||||
// keep results landing one by one, each routed to its own state map.
|
||||
const runTcpLane = async () => {
|
||||
const queue = [...tcpQueue];
|
||||
const worker = async () => {
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) break;
|
||||
if (item.kind === 'sub')
|
||||
await testSubscriptionOutbound(item.tag, item.outbound, mode);
|
||||
else await testOutbound(item.index, item.outbound, mode);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(8, queue.length) }, () => worker()));
|
||||
};
|
||||
// HTTP probes go out as chunked batches — one temp xray spawn per
|
||||
// chunk instead of one per outbound, with results landing per chunk.
|
||||
const runTplHttpLane = async () => {
|
||||
for (let at = 0; at < httpTplQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpTplQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk)
|
||||
next[item.index] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(
|
||||
chunk.map((c) => c.outbound),
|
||||
probeMode,
|
||||
);
|
||||
setOutboundTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.index] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
const runSubHttpLane = async () => {
|
||||
for (let at = 0; at < httpSubQueue.length; at += HTTP_BATCH_CHUNK) {
|
||||
const chunk = httpSubQueue.slice(at, at + HTTP_BATCH_CHUNK);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const item of chunk)
|
||||
next[item.tag] = { testing: true, result: null, mode: probeMode };
|
||||
return next;
|
||||
});
|
||||
const results = await postOutboundTestBatch(
|
||||
chunk.map((c) => c.outbound),
|
||||
probeMode,
|
||||
);
|
||||
setSubscriptionTestStates((prev) => {
|
||||
const next = { ...prev };
|
||||
chunk.forEach((item, i) => {
|
||||
next[item.tag] = { testing: false, result: results[i] };
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
// HTTP batches must not overlap: the backend serialises them with a
|
||||
// non-blocking lock and rejects a second concurrent batch ("Another
|
||||
// outbound test is already running"). Run the template and subscription
|
||||
// HTTP lanes one after the other; TCP probes don't take that lock, so
|
||||
// they still run alongside.
|
||||
const runHttpLane = async () => {
|
||||
await runTplHttpLane();
|
||||
await runSubHttpLane();
|
||||
};
|
||||
await Promise.all([runTcpLane(), runHttpLane()]);
|
||||
} finally {
|
||||
setTestingAll(false);
|
||||
}
|
||||
},
|
||||
[testingAll, testOutbound, testSubscriptionOutbound, postOutboundTestBatch],
|
||||
);
|
||||
|
||||
const saveDisabled =
|
||||
savedXraySetting === xraySetting &&
|
||||
savedOutboundTestUrl === normalizeOutboundTestUrl(outboundTestUrl);
|
||||
const saveDisabled = savedXraySetting === xraySetting;
|
||||
|
||||
const outboundsTraffic = useMemo(() => trafficQuery.data ?? [], [trafficQuery.data]);
|
||||
|
||||
@@ -491,22 +231,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testSubscriptionOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
}),
|
||||
@@ -519,22 +251,14 @@ export function useXraySetting(): UseXraySettingResult {
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
fetchOutboundsTrafficCb,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testSubscriptionOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
],
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
.xray-page .restart-icon {
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.xray-page .restart-result {
|
||||
max-width: 480px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.xray-page .advanced-meta h4 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
@@ -55,21 +55,13 @@ export default function XrayPage() {
|
||||
setXraySetting,
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
setOutboundTestUrl,
|
||||
inboundTags,
|
||||
clientReverseTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
fetchAll,
|
||||
resetOutboundsTraffic,
|
||||
testOutbound,
|
||||
testSubscriptionOutbound,
|
||||
testAllOutbounds,
|
||||
saveAll,
|
||||
resetToDefault,
|
||||
} = xs;
|
||||
@@ -99,16 +91,6 @@ export default function XrayPage() {
|
||||
[setTemplateSettings],
|
||||
);
|
||||
|
||||
async function onTestOutbound(idx: number, mode: string) {
|
||||
const outbound = templateSettings?.outbounds?.[idx];
|
||||
if (outbound) await testOutbound(idx, outbound, mode);
|
||||
}
|
||||
|
||||
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
|
||||
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
|
||||
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
|
||||
}
|
||||
|
||||
function onAddOutbound(outbound: Record<string, unknown>) {
|
||||
mutate((tt) => {
|
||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
||||
@@ -196,17 +178,11 @@ export default function XrayPage() {
|
||||
templateSettings={templateSettings}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
outboundTestStates={outboundTestStates}
|
||||
subscriptionTestStates={subscriptionTestStates}
|
||||
testingAll={testingAll}
|
||||
inboundTags={inboundTags}
|
||||
subscriptionOutbounds={subscriptionOutbounds}
|
||||
subscriptionOutboundTags={subscriptionOutboundTags}
|
||||
isMobile={isMobile}
|
||||
onResetTraffic={resetOutboundsTraffic}
|
||||
onTest={onTestOutbound}
|
||||
onTestSubscription={onTestSubscription}
|
||||
onTestAll={testAllOutbounds}
|
||||
onShowWarp={() => setWarpOpen(true)}
|
||||
onShowNord={() => setNordOpen(true)}
|
||||
onShowPia={() => setPiaOpen(true)}
|
||||
@@ -218,8 +194,6 @@ export default function XrayPage() {
|
||||
<BasicsTab
|
||||
templateSettings={templateSettings}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
outboundTestUrl={outboundTestUrl}
|
||||
onChangeOutboundTestUrl={setOutboundTestUrl}
|
||||
onResetDefault={resetToDefault}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -29,16 +29,12 @@ import {
|
||||
interface BasicsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundTestUrl: string;
|
||||
onChangeOutboundTestUrl: (v: string) => void;
|
||||
onResetDefault: () => void;
|
||||
}
|
||||
|
||||
export default function BasicsTab({
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundTestUrl,
|
||||
onChangeOutboundTestUrl,
|
||||
onResetDefault,
|
||||
}: BasicsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -276,18 +272,6 @@ export default function BasicsTab({
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<SettingListItem
|
||||
title={t('pages.xray.outboundTestUrl')}
|
||||
description={t('pages.xray.outboundTestUrlDesc')}
|
||||
paddings="small"
|
||||
control={
|
||||
<Input
|
||||
value={outboundTestUrl}
|
||||
onChange={(e) => onChangeOutboundTestUrl(e.target.value)}
|
||||
placeholder="https://www.google.com/generate_204"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CloudOutlined } from '@ant-design/icons';
|
||||
|
||||
interface CountryPillProps {
|
||||
flag: string;
|
||||
name: string;
|
||||
warp?: string;
|
||||
}
|
||||
|
||||
export default function CountryPill({ flag, name, warp }: CountryPillProps) {
|
||||
const isWarp = !!warp && warp.toLowerCase() !== 'off';
|
||||
return (
|
||||
<span className={isWarp ? 'country-pill warp-on' : 'country-pill'}>
|
||||
{isWarp && <CloudOutlined className="warp-cloud-icon" />}
|
||||
{flag && <span>{flag}</span>}
|
||||
<span>{name}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -7,127 +6,34 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ExportOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
countryFlag,
|
||||
countryName,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
||||
|
||||
interface OutboundCardListProps {
|
||||
rows: OutboundRow[];
|
||||
testMode: OutboundTestMode;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
setFirst: (idx: number) => void;
|
||||
openEdit: (idx: number) => void;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
confirmDelete: (idx: number) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
}
|
||||
|
||||
export default function OutboundCardList({
|
||||
rows,
|
||||
testMode,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
setFirst,
|
||||
openEdit,
|
||||
onResetTraffic,
|
||||
confirmDelete,
|
||||
onTest,
|
||||
}: OutboundCardListProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showEgressIp, setShowEgressIp] = useState<Record<string, boolean>>({});
|
||||
|
||||
const setCardEgressVisible = (key: string, visible: boolean) => {
|
||||
setShowEgressIp((prev) => ({ ...prev, [key]: visible }));
|
||||
};
|
||||
|
||||
const renderEgress = (testKey: number, rowKey: string) => {
|
||||
const result = testResult(outboundTestStates, testKey);
|
||||
const egress = result?.egress;
|
||||
const isEgressVisible = !!showEgressIp[rowKey];
|
||||
const flag = countryFlag(egress?.country);
|
||||
const name = countryName(egress?.country, i18n.language);
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
|
||||
if (!egress || (addresses.length === 0 && !egress.country)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-egress">
|
||||
<div className="card-egress-row">
|
||||
<span>{t('pages.xray.outbound.egress')}:</span>
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{isEgressVisible ? (
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setCardEgressVisible(rowKey, false)}
|
||||
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setCardEgressVisible(rowKey, true)}
|
||||
onKeyDown={activateOnKey(() => setCardEgressVisible(rowKey, true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
{egress.country && (
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
)}
|
||||
</div>
|
||||
{addresses.map((addr) => (
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<div className="card-egress-row">
|
||||
<span className="egress-family">{addr.label}:</span>
|
||||
<span
|
||||
className={
|
||||
isEgressVisible ? 'address-visible egress-ip' : 'address-hidden egress-ip'
|
||||
}
|
||||
>
|
||||
{addr.value}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@@ -219,7 +125,6 @@ export default function OutboundCardList({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{renderEgress(record.key, String(record.key))}
|
||||
<div className="card-foot">
|
||||
<span className="traffic-up">
|
||||
↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)}
|
||||
@@ -228,23 +133,6 @@ export default function OutboundCardList({
|
||||
<span className="traffic-down">
|
||||
↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)}
|
||||
</span>
|
||||
<span className="card-test">
|
||||
{testResult(outboundTestStates, record.key) ? (
|
||||
<TestResultPopover result={testResult(outboundTestStates, record.key)!} />
|
||||
) : isTesting(outboundTestStates, record.key) ? (
|
||||
<LoadingOutlined />
|
||||
) : null}
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="small"
|
||||
loading={isTesting(outboundTestStates, record.key)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('check')}
|
||||
onClick={() => onTest(record.key, testMode)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -75,51 +75,8 @@
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.egress-header {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.egress-ip {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.egress-stack {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.egress-address {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.egress-family {
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.country-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
line-height: 20px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.country-pill.warp-on {
|
||||
border-color: #f48120;
|
||||
}
|
||||
|
||||
.warp-cloud-icon {
|
||||
width: 1em;
|
||||
@@ -185,30 +142,6 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-egress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-egress-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.card-test {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traffic-up {
|
||||
color: #008771;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Radio,
|
||||
Row,
|
||||
Space,
|
||||
Switch,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
ApiOutlined,
|
||||
MoreOutlined,
|
||||
RetweetOutlined,
|
||||
PlayCircleOutlined,
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
@@ -47,13 +45,7 @@ import { propagateOutboundTagRename } from '../basics/helpers';
|
||||
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
||||
import DeletionImpactList from '../DeletionImpactList';
|
||||
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
||||
import type {
|
||||
XraySettingsValue,
|
||||
SetTemplate,
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { XraySettingsValue, SetTemplate, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
import './OutboundsTab.css';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
@@ -82,17 +74,11 @@ interface OutboundsTabProps {
|
||||
templateSettings: XraySettingsValue | null;
|
||||
setTemplateSettings: SetTemplate;
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testingAll: boolean;
|
||||
inboundTags: string[];
|
||||
subscriptionOutbounds?: unknown[];
|
||||
subscriptionOutboundTags?: string[];
|
||||
isMobile: boolean;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||
onTestAll: (mode: string) => void;
|
||||
onShowWarp: () => void;
|
||||
onShowNord: () => void;
|
||||
onShowPia: () => void;
|
||||
@@ -103,17 +89,11 @@ export default function OutboundsTab({
|
||||
templateSettings,
|
||||
setTemplateSettings,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
subscriptionTestStates,
|
||||
testingAll,
|
||||
inboundTags: _inboundTags,
|
||||
subscriptionOutbounds,
|
||||
subscriptionOutboundTags,
|
||||
isMobile,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
onTestSubscription,
|
||||
onTestAll,
|
||||
onShowWarp,
|
||||
onShowNord,
|
||||
onShowPia,
|
||||
@@ -122,7 +102,6 @@ export default function OutboundsTab({
|
||||
const { t } = useTranslation();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
@@ -519,17 +498,14 @@ export default function OutboundsTab({
|
||||
}
|
||||
|
||||
const columns = useOutboundColumns({
|
||||
testMode,
|
||||
rows,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
openEdit,
|
||||
setFirst,
|
||||
moveUp,
|
||||
moveDown,
|
||||
confirmDelete,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -581,26 +557,6 @@ export default function OutboundsTab({
|
||||
</Col>
|
||||
<Col xs={24} sm={12} className="toolbar-right">
|
||||
<Space size="small" wrap>
|
||||
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
|
||||
<Radio.Group
|
||||
value={testMode}
|
||||
onChange={(e) => setTestMode(e.target.value)}
|
||||
buttonStyle="solid"
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="tcp">TCP</Radio.Button>
|
||||
<Radio.Button value="http">HTTP</Radio.Button>
|
||||
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={testingAll}
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => onTestAll(testMode)}
|
||||
>
|
||||
{!isMobile && t('pages.xray.outbound.testAll')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
placement="topRight"
|
||||
okText={t('reset')}
|
||||
@@ -617,14 +573,11 @@ export default function OutboundsTab({
|
||||
{isMobile ? (
|
||||
<OutboundCardList
|
||||
rows={rows}
|
||||
testMode={testMode}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
outboundTestStates={outboundTestStates}
|
||||
setFirst={setFirst}
|
||||
openEdit={openEdit}
|
||||
onResetTraffic={onResetTraffic}
|
||||
confirmDelete={confirmDelete}
|
||||
onTest={onTest}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
@@ -675,10 +628,7 @@ export default function OutboundsTab({
|
||||
<SubscriptionOutbounds
|
||||
subscriptionOutbounds={subscriptionOutbounds}
|
||||
outboundsTraffic={outboundsTraffic}
|
||||
subscriptionTestStates={subscriptionTestStates}
|
||||
testMode={testMode}
|
||||
isMobile={isMobile}
|
||||
onTestSubscription={onTestSubscription}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -1,49 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Table, Tag, Tooltip } from 'antd';
|
||||
import { ThunderboltOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { Table, Tag, Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testModeLabel,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
||||
|
||||
interface SubscriptionOutboundsProps {
|
||||
subscriptionOutbounds: unknown[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||
testMode: OutboundTestMode;
|
||||
isMobile: boolean;
|
||||
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||
}
|
||||
|
||||
// Read-only view of outbounds imported from active subscriptions. They are not
|
||||
// part of the editable template (so no edit/delete/move), but traffic is matched
|
||||
// by tag and they can be latency-tested via the same backend endpoint.
|
||||
// by tag.
|
||||
export default function SubscriptionOutbounds({
|
||||
subscriptionOutbounds,
|
||||
outboundsTraffic,
|
||||
subscriptionTestStates,
|
||||
testMode,
|
||||
isMobile,
|
||||
onTestSubscription,
|
||||
}: SubscriptionOutboundsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -104,36 +83,6 @@ export default function SubscriptionOutbounds({
|
||||
);
|
||||
};
|
||||
|
||||
const latencyCell = (record: OutboundRow) => {
|
||||
const key = record.tag || '';
|
||||
const r = testResult(subscriptionTestStates, key);
|
||||
if (!r)
|
||||
return isTesting(subscriptionTestStates, key) ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
};
|
||||
|
||||
const testButton = (record: OutboundRow) => {
|
||||
const key = record.tag || '';
|
||||
return (
|
||||
<Tooltip title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}>
|
||||
<Button
|
||||
aria-label={t('check')}
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size={isMobile ? 'small' : undefined}
|
||||
loading={isTesting(subscriptionTestStates, key)}
|
||||
disabled={!record.tag || isUntestable(record) || isTesting(subscriptionTestStates, key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const header = (
|
||||
<div className="subscription-outbounds-head">
|
||||
<div className="subscription-outbounds-title">
|
||||
@@ -154,13 +103,9 @@ export default function SubscriptionOutbounds({
|
||||
<span className="card-num">{index + 1}</span>
|
||||
{identityCell(record)}
|
||||
</div>
|
||||
{testButton(record)}
|
||||
</div>
|
||||
{outboundAddresses(record).length > 0 && addressCell(record)}
|
||||
<div className="card-foot">
|
||||
{trafficCell(record)}
|
||||
<span className="card-test">{latencyCell(record)}</span>
|
||||
</div>
|
||||
<div className="card-foot">{trafficCell(record)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -194,20 +139,6 @@ export default function SubscriptionOutbounds({
|
||||
width: 200,
|
||||
render: (_v, record) => trafficCell(record),
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.latency'),
|
||||
key: 'testResult',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
render: (_v, record) => latencyCell(record),
|
||||
},
|
||||
{
|
||||
title: t('check'),
|
||||
key: 'test',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record) => testButton(record),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Popover } from 'antd';
|
||||
import { CheckCircleFilled, CloseCircleFilled } from '@ant-design/icons';
|
||||
|
||||
import type { OutboundTestResult } from '@/hooks/useXraySetting';
|
||||
|
||||
import { testModeLabel } from './outbounds-tab-helpers';
|
||||
|
||||
interface TestResultPopoverProps {
|
||||
result: OutboundTestResult;
|
||||
// Custom trigger element; defaults to the ok/fail latency pill.
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
// Latency pill + detail popover for an outbound test result: per-endpoint
|
||||
// dial outcomes for TCP probes, HTTP status and the timing breakdown for
|
||||
// HTTP probes.
|
||||
export default function TestResultPopover({ result: r, children }: TestResultPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const breakdown: Array<{ key: string; label: string; value: string }> = [];
|
||||
if (typeof r.httpStatus === 'number') {
|
||||
breakdown.push({
|
||||
key: 'status',
|
||||
label: t('pages.xray.outbound.httpStatus'),
|
||||
value: String(r.httpStatus),
|
||||
});
|
||||
}
|
||||
if (typeof r.connectMs === 'number') {
|
||||
breakdown.push({
|
||||
key: 'connect',
|
||||
label: t('pages.xray.outbound.breakdownConnect'),
|
||||
value: `${r.connectMs} ms`,
|
||||
});
|
||||
}
|
||||
if (typeof r.tlsMs === 'number') {
|
||||
breakdown.push({
|
||||
key: 'tls',
|
||||
label: t('pages.xray.outbound.breakdownTls'),
|
||||
value: `${r.tlsMs} ms`,
|
||||
});
|
||||
}
|
||||
if (typeof r.ttfbMs === 'number') {
|
||||
breakdown.push({
|
||||
key: 'ttfb',
|
||||
label: t('pages.xray.outbound.breakdownTtfb'),
|
||||
value: `${r.ttfbMs} ms`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
placement="topLeft"
|
||||
rootClassName="outbound-test-popover"
|
||||
content={
|
||||
<div className="timing-breakdown">
|
||||
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||
{r.mode && <span className="mode-badge">{testModeLabel(String(r.mode), t)}</span>}
|
||||
</div>
|
||||
{(r.endpoints || []).map((ep) => (
|
||||
<div key={ep.address} className="endpoint-row">
|
||||
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||
<span className="ep-addr">{ep.address}</span>
|
||||
<span className="ep-meta">
|
||||
{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{breakdown.map((row) => (
|
||||
<div key={row.key} className="breakdown-row">
|
||||
<span className="bd-label">{row.label}</span>
|
||||
<span className="bd-value">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children ?? (
|
||||
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||
</span>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
|
||||
@@ -54,33 +47,10 @@ export function outboundAddresses(o: OutboundRow): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function isUntestable(o: OutboundRow): boolean {
|
||||
if (!o) return true;
|
||||
if (
|
||||
o.protocol === Protocols.Blackhole ||
|
||||
o.protocol === Protocols.Loopback ||
|
||||
o.tag === 'blocked'
|
||||
)
|
||||
return true;
|
||||
// freedom ("direct") and dns aren't proxies — a TCP dial has no endpoint and
|
||||
// an HTTP probe would only measure the host's own direct reachability, so
|
||||
// they're untestable in every mode.
|
||||
if (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function showSecurity(security?: string): boolean {
|
||||
return security === 'tls' || security === 'reality';
|
||||
}
|
||||
|
||||
export function effectiveTestMode(o: unknown, mode: OutboundTestMode): OutboundTestMode {
|
||||
return mode === 'tcp' && isUdpOutbound(o) ? 'http' : mode;
|
||||
}
|
||||
|
||||
export function testModeLabel(mode: string, t: TFunction): string {
|
||||
return mode === 'real' ? t('pages.xray.outbound.modeRealDelay') : mode.toUpperCase();
|
||||
}
|
||||
|
||||
export function trafficFor(
|
||||
outboundsTraffic: OutboundTrafficRow[],
|
||||
o: OutboundRow,
|
||||
@@ -106,17 +76,3 @@ export function countryName(country?: string, locale?: string): string {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isTesting<K extends string | number>(
|
||||
states: Record<K, OutboundTestState>,
|
||||
idx: K,
|
||||
): boolean {
|
||||
return !!states?.[idx]?.testing;
|
||||
}
|
||||
|
||||
export function testResult<K extends string | number>(
|
||||
states: Record<K, OutboundTestState>,
|
||||
idx: K,
|
||||
) {
|
||||
return states?.[idx]?.result || null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Dropdown, Tag, Tooltip } from 'antd';
|
||||
import {
|
||||
@@ -7,69 +7,40 @@ import {
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
VerticalAlignTopOutlined,
|
||||
ThunderboltOutlined,
|
||||
LoadingOutlined,
|
||||
ArrowUpOutlined,
|
||||
ArrowDownOutlined,
|
||||
EyeInvisibleOutlined,
|
||||
EyeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||
import type {
|
||||
OutboundTestMode,
|
||||
OutboundTestState,
|
||||
OutboundTrafficRow,
|
||||
} from '@/hooks/useXraySetting';
|
||||
import type { OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||
|
||||
import type { OutboundRow } from './outbounds-tab-types';
|
||||
import CountryPill from './CountryPill';
|
||||
import TestResultPopover from './TestResultPopover';
|
||||
import {
|
||||
effectiveTestMode,
|
||||
countryFlag,
|
||||
countryName,
|
||||
isTesting,
|
||||
isUntestable,
|
||||
outboundAddresses,
|
||||
showSecurity,
|
||||
testModeLabel,
|
||||
testResult,
|
||||
trafficFor,
|
||||
} from './outbounds-tab-helpers';
|
||||
import { outboundAddresses, showSecurity, trafficFor } from './outbounds-tab-helpers';
|
||||
|
||||
interface OutboundColumnsParams {
|
||||
testMode: OutboundTestMode;
|
||||
rows: OutboundRow[];
|
||||
outboundsTraffic: OutboundTrafficRow[];
|
||||
outboundTestStates: Record<number, OutboundTestState>;
|
||||
openEdit: (idx: number) => void;
|
||||
setFirst: (idx: number) => void;
|
||||
moveUp: (idx: number) => void;
|
||||
moveDown: (idx: number) => void;
|
||||
confirmDelete: (idx: number) => void;
|
||||
onResetTraffic: (tag: string) => void;
|
||||
onTest: (index: number, mode: string) => void;
|
||||
}
|
||||
|
||||
export function useOutboundColumns({
|
||||
testMode,
|
||||
rows,
|
||||
outboundsTraffic,
|
||||
outboundTestStates,
|
||||
openEdit,
|
||||
setFirst,
|
||||
moveUp,
|
||||
moveDown,
|
||||
confirmDelete,
|
||||
onResetTraffic,
|
||||
onTest,
|
||||
}: OutboundColumnsParams): ColumnsType<OutboundRow> {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [showEgressIp, setShowEgressIp] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
return useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -204,94 +175,6 @@ export function useOutboundColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<span className="egress-header">
|
||||
{t('pages.xray.outbound.egress')}
|
||||
<Tooltip title={t('pages.index.toggleIpVisibility')}>
|
||||
{showEgressIp ? (
|
||||
<EyeOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowEgressIp(false)}
|
||||
onKeyDown={activateOnKey(() => setShowEgressIp(false))}
|
||||
/>
|
||||
) : (
|
||||
<EyeInvisibleOutlined
|
||||
className="ip-toggle-icon"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('pages.index.toggleIpVisibility')}
|
||||
onClick={() => setShowEgressIp(true)}
|
||||
onKeyDown={activateOnKey(() => setShowEgressIp(true))}
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</span>
|
||||
),
|
||||
key: 'egress',
|
||||
align: 'left',
|
||||
width: 210,
|
||||
render: (_v, record) => {
|
||||
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||
const addresses = [
|
||||
egress?.ipv4 ? { label: 'v4', value: egress.ipv4 } : null,
|
||||
egress?.ipv6 ? { label: 'v6', value: egress.ipv6 } : null,
|
||||
].filter((item): item is { label: string; value: string } => Boolean(item));
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
<span className="empty">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="egress-stack">
|
||||
{addresses.map((addr) => (
|
||||
<Tooltip key={addr.label} title={addr.value}>
|
||||
<span className="egress-address">
|
||||
<span className="egress-family">{addr.label}</span>
|
||||
<span
|
||||
className={
|
||||
showEgressIp ? 'address-visible egress-ip' : 'address-hidden egress-ip'
|
||||
}
|
||||
>
|
||||
{addr.value}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.xray.outbound.country'),
|
||||
key: 'egressCountry',
|
||||
align: 'left',
|
||||
width: 160,
|
||||
render: (_v, record) => {
|
||||
const egress = testResult(outboundTestStates, record.key)?.egress;
|
||||
if (!egress?.country) {
|
||||
return (
|
||||
<Tooltip title={t('pages.xray.outbound.egressHint')}>
|
||||
<span className="empty">—</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const flag = countryFlag(egress.country);
|
||||
const name = countryName(egress.country, i18n.language);
|
||||
return (
|
||||
<Tooltip
|
||||
title={egress.warp ? `Cloudflare trace · WARP ${egress.warp}` : 'Cloudflare trace'}
|
||||
>
|
||||
<CountryPill flag={flag} name={name || egress.country} warp={egress.warp} />
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.inbounds.traffic'),
|
||||
key: 'traffic',
|
||||
@@ -308,45 +191,8 @@ export function useOutboundColumns({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('pages.nodes.latency'),
|
||||
key: 'testResult',
|
||||
align: 'left',
|
||||
width: 140,
|
||||
render: (_v, record) => {
|
||||
const r = testResult(outboundTestStates, record.key);
|
||||
if (!r)
|
||||
return isTesting(outboundTestStates, record.key) ? (
|
||||
<LoadingOutlined />
|
||||
) : (
|
||||
<span className="empty">—</span>
|
||||
);
|
||||
return <TestResultPopover result={r} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('check'),
|
||||
key: 'test',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (_v, record) => (
|
||||
<Tooltip
|
||||
title={`${t('check')} (${testModeLabel(effectiveTestMode(record, testMode), t)})`}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
loading={isTesting(outboundTestStates, record.key)}
|
||||
disabled={isUntestable(record) || isTesting(outboundTestStates, record.key)}
|
||||
icon={<ThunderboltOutlined />}
|
||||
aria-label={t('check')}
|
||||
onClick={() => onTest(record.key, testMode)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[t, i18n.language, testMode, rows, outboundTestStates, outboundsTraffic, showEgressIp],
|
||||
[t, rows, outboundsTraffic],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ export const XrayConfigPayloadSchema = z
|
||||
xraySetting: XraySettingsValueSchema,
|
||||
inboundTags: z.array(z.string()).optional(),
|
||||
clientReverseTags: z.array(z.string()).optional(),
|
||||
outboundTestUrl: z.string().optional(),
|
||||
// Subscription outbounds are injected at runtime (not persisted in xraySetting).
|
||||
// They are provided here so the UI can display them and use their tags in
|
||||
// balancers / routing rules.
|
||||
@@ -67,47 +66,6 @@ export const OutboundTrafficRowSchema = z.object({
|
||||
|
||||
export const OutboundTrafficListSchema = z.array(OutboundTrafficRowSchema);
|
||||
|
||||
export const OutboundTestResultSchema = z
|
||||
.object({
|
||||
tag: z.string().optional(),
|
||||
success: z.boolean(),
|
||||
delay: z.number().optional(),
|
||||
error: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
// HTTP-mode extras: status answered by the test URL plus the httptrace
|
||||
// timing breakdown (dial to local inbound / target TLS via the outbound /
|
||||
// time to first byte).
|
||||
httpStatus: z.number().optional(),
|
||||
connectMs: z.number().optional(),
|
||||
tlsMs: z.number().optional(),
|
||||
ttfbMs: z.number().optional(),
|
||||
endpoints: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
address: z.string(),
|
||||
delay: z.number().optional(),
|
||||
success: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.loose(),
|
||||
)
|
||||
.optional(),
|
||||
egress: z
|
||||
.object({
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
country: z.string().optional(),
|
||||
warp: z.string().optional(),
|
||||
})
|
||||
.loose()
|
||||
.optional(),
|
||||
})
|
||||
.loose();
|
||||
|
||||
// Batch results from /xray/testOutbounds, aligned with the request order.
|
||||
export const OutboundTestResultListSchema = z.array(OutboundTestResultSchema);
|
||||
|
||||
export const RuleFormSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
domain: z.string(),
|
||||
@@ -148,4 +106,3 @@ export type RuleFormValues = z.infer<typeof RuleFormSchema>;
|
||||
export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
|
||||
export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||
export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;
|
||||
export type OutboundTestResult = z.infer<typeof OutboundTestResultSchema>;
|
||||
|
||||
@@ -25,8 +25,6 @@ describe('BasicsTab malformed happyEyeballs', () => {
|
||||
<BasicsTab
|
||||
templateSettings={settingsWithMalformedHappyEyeballs()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
outboundTestUrl=""
|
||||
onChangeOutboundTestUrl={vi.fn()}
|
||||
onResetDefault={vi.fn()}
|
||||
/>,
|
||||
),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent } from '@testing-library/react';
|
||||
import { fireEvent, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
import OutboundsTab from '@/pages/xray/outbounds/OutboundsTab';
|
||||
@@ -17,79 +17,72 @@ function settingsWithHiddenLoopback(): XraySettingsValue {
|
||||
} as unknown as XraySettingsValue;
|
||||
}
|
||||
|
||||
// The visible rows hide the balancer-loopback outbound, so every row action
|
||||
// must translate the positional row index back to the outbound's real index
|
||||
// in the full array. "Move to top" on the second visible row (proxy-b, real
|
||||
// index 2) must move proxy-b — not the hidden loopback at positional index 1.
|
||||
describe('OutboundsTab hidden-loopback index mapping', () => {
|
||||
it('probes the outbound at its real array index, not the positional row index', () => {
|
||||
const onTest = vi.fn();
|
||||
function renderTab(isMobile: boolean) {
|
||||
const setTemplateSettings = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OutboundsTab
|
||||
templateSettings={settingsWithHiddenLoopback()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
setTemplateSettings={setTemplateSettings}
|
||||
outboundsTraffic={[]}
|
||||
outboundTestStates={{}}
|
||||
subscriptionTestStates={{}}
|
||||
testingAll={false}
|
||||
inboundTags={[]}
|
||||
isMobile={false}
|
||||
isMobile={isMobile}
|
||||
onResetTraffic={vi.fn()}
|
||||
onTest={onTest}
|
||||
onTestSubscription={vi.fn()}
|
||||
onTestAll={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
onShowPia={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return setTemplateSettings;
|
||||
}
|
||||
|
||||
function expectMovedProxyBToTop(setTemplateSettings: ReturnType<typeof vi.fn>) {
|
||||
expect(setTemplateSettings).toHaveBeenCalled();
|
||||
const updater = setTemplateSettings.mock.calls.at(-1)?.[0];
|
||||
expect(typeof updater).toBe('function');
|
||||
const next = updater(structuredClone(settingsWithHiddenLoopback()));
|
||||
expect(next.outbounds.map((o: { tag?: string }) => o.tag)).toEqual([
|
||||
'proxy-b',
|
||||
'proxy-a',
|
||||
'_bl_bal1',
|
||||
]);
|
||||
}
|
||||
|
||||
async function clickMoveToTop(scope: Element) {
|
||||
const moreButton = scope.querySelector('button[aria-label="more"]') as HTMLButtonElement;
|
||||
expect(moreButton).toBeTruthy();
|
||||
fireEvent.click(moreButton);
|
||||
const item = await waitFor(() => {
|
||||
const found = [...document.querySelectorAll('.ant-dropdown-menu-item')].find((el) =>
|
||||
el.textContent?.includes('Move to top'),
|
||||
);
|
||||
expect(found).toBeTruthy();
|
||||
return found as HTMLElement;
|
||||
});
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
it('acts on the real array index from the desktop table', async () => {
|
||||
const setTemplateSettings = renderTab(false);
|
||||
const tbody = document.querySelector('.ant-table-tbody');
|
||||
const tableRows = tbody?.querySelectorAll('tr.ant-table-row') ?? [];
|
||||
expect(tableRows.length).toBe(2);
|
||||
|
||||
const checkButton = tableRows[1].querySelector(
|
||||
'button[aria-label="Check"]',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(checkButton);
|
||||
|
||||
expect(onTest).toHaveBeenCalledTimes(1);
|
||||
expect(onTest.mock.calls[0][0]).toBe(2);
|
||||
await clickMoveToTop(tableRows[1]);
|
||||
expectMovedProxyBToTop(setTemplateSettings);
|
||||
});
|
||||
|
||||
it('probes the real array index from the mobile card list as well', () => {
|
||||
const onTest = vi.fn();
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
|
||||
renderWithProviders(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<OutboundsTab
|
||||
templateSettings={settingsWithHiddenLoopback()}
|
||||
setTemplateSettings={vi.fn()}
|
||||
outboundsTraffic={[]}
|
||||
outboundTestStates={{}}
|
||||
subscriptionTestStates={{}}
|
||||
testingAll={false}
|
||||
inboundTags={[]}
|
||||
isMobile
|
||||
onResetTraffic={vi.fn()}
|
||||
onTest={onTest}
|
||||
onTestSubscription={vi.fn()}
|
||||
onTestAll={vi.fn()}
|
||||
onShowWarp={vi.fn()}
|
||||
onShowNord={vi.fn()}
|
||||
onShowPia={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
it('acts on the real array index from the mobile card list as well', async () => {
|
||||
const setTemplateSettings = renderTab(true);
|
||||
const cards = document.querySelectorAll('.outbound-card');
|
||||
expect(cards.length).toBe(2);
|
||||
|
||||
const checkButton = cards[1].querySelector('button[aria-label="Check"]') as HTMLButtonElement;
|
||||
fireEvent.click(checkButton);
|
||||
|
||||
expect(onTest).toHaveBeenCalledTimes(1);
|
||||
expect(onTest.mock.calls[0][0]).toBe(2);
|
||||
await clickMoveToTop(cards[1]);
|
||||
expectMovedProxyBToTop(setTemplateSettings);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ function xrayPayload(overrides: Record<string, unknown> = {}) {
|
||||
xraySetting: {},
|
||||
inboundTags: [],
|
||||
clientReverseTags: [],
|
||||
outboundTestUrl: 'https://test.example',
|
||||
subscriptionOutbounds: [],
|
||||
subscriptionOutboundTags: [],
|
||||
...overrides,
|
||||
@@ -48,23 +47,4 @@ describe('useXraySetting', () => {
|
||||
await waitFor(() => expect(result.current.subscriptionOutbounds).toEqual([{ tag: 'after' }]));
|
||||
expect(result.current.xraySetting).toBe('{"outbounds":[]}');
|
||||
});
|
||||
|
||||
it('keeps the outbound test URL input empty when it is cleared', async () => {
|
||||
const payload = xrayPayload({ outboundTestUrl: 'https://www.google.com/generate_204' });
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async (url) => {
|
||||
if (url === '/panel/api/xray/') return new Msg(true, '', JSON.stringify(payload));
|
||||
return new Msg(true, '');
|
||||
});
|
||||
const queryClient = makeTestQueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useXraySetting(), { wrapper });
|
||||
|
||||
await waitFor(() => expect(result.current.fetched).toBe(true));
|
||||
act(() => result.current.setOutboundTestUrl(''));
|
||||
|
||||
expect(result.current.outboundTestUrl).toBe('');
|
||||
expect(result.current.saveDisabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,7 +90,6 @@ var nodeSyncScopeAllow = map[string]map[string]struct{}{
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/server/restartXrayService": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||
|
||||
@@ -151,7 +151,6 @@ func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) {
|
||||
"/clients/del/:email": {http.MethodPost: {}},
|
||||
"/clients/:email/detach": {http.MethodPost: {}},
|
||||
"/clients/update/:email": {http.MethodPost: {}},
|
||||
"/server/restartXrayService": {http.MethodPost: {}},
|
||||
"/server/getWebCertFiles": {http.MethodGet: {}},
|
||||
"/server/descendants": {http.MethodGet: {}},
|
||||
"/clients/resetTraffic/:email": {http.MethodPost: {}},
|
||||
|
||||
@@ -56,8 +56,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/clientIps", a.getClientIps)
|
||||
g.GET("/fail2banStatus", a.getFail2banStatus)
|
||||
|
||||
g.POST("/stopXrayService", a.stopXrayService)
|
||||
g.POST("/restartXrayService", a.restartXrayService)
|
||||
g.POST("/updatePanel", a.updatePanel)
|
||||
g.POST("/setUpdateChannel", a.setUpdateChannel)
|
||||
g.POST("/importDB", a.importDB)
|
||||
@@ -165,40 +163,6 @@ func (a *ServerController) setUpdateChannel(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err)
|
||||
}
|
||||
|
||||
// stopXrayService stops the Xray service.
|
||||
func (a *ServerController) stopXrayService(c *gin.Context) {
|
||||
err := a.serverService.StopXrayService()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopError"), err)
|
||||
websocket.BroadcastXrayState("error", err.Error())
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.stopSuccess"), err)
|
||||
websocket.BroadcastXrayState("stop", "")
|
||||
websocket.BroadcastNotification(
|
||||
I18nWeb(c, "pages.xray.stopSuccess"),
|
||||
"Xray service has been stopped",
|
||||
"warning",
|
||||
)
|
||||
}
|
||||
|
||||
// restartXrayService restarts the Xray service.
|
||||
func (a *ServerController) restartXrayService(c *gin.Context) {
|
||||
err := a.serverService.RestartXrayService()
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartError"), err)
|
||||
websocket.BroadcastXrayState("error", err.Error())
|
||||
return
|
||||
}
|
||||
jsonMsg(c, I18nWeb(c, "pages.xray.restartSuccess"), err)
|
||||
websocket.BroadcastXrayState("running", "")
|
||||
websocket.BroadcastNotification(
|
||||
I18nWeb(c, "pages.xray.restartSuccess"),
|
||||
"Xray service has been restarted successfully",
|
||||
"success",
|
||||
)
|
||||
}
|
||||
|
||||
// getConfigJson retrieves the Xray configuration as JSON.
|
||||
func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||
configJson, err := a.serverService.GetConfigJson()
|
||||
|
||||
@@ -52,8 +52,6 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
|
||||
g.POST("/pia/:action", a.pia)
|
||||
g.POST("/update", a.updateSetting)
|
||||
g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
|
||||
g.POST("/testOutbound", a.testOutbound)
|
||||
g.POST("/testOutbounds", a.testOutbounds)
|
||||
g.POST("/balancerStatus", a.balancerStatus)
|
||||
g.POST("/balancerOverride", a.balancerOverride)
|
||||
g.POST("/routeTest", a.routeTest)
|
||||
@@ -107,15 +105,10 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||
if err != nil {
|
||||
clientReverseTags = "[]"
|
||||
}
|
||||
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
if outboundTestUrl == "" {
|
||||
outboundTestUrl = "https://www.google.com/generate_204"
|
||||
}
|
||||
xrayResponse := map[string]any{
|
||||
"xraySetting": json.RawMessage(xraySetting),
|
||||
"inboundTags": json.RawMessage(inboundTags),
|
||||
"clientReverseTags": json.RawMessage(clientReverseTags),
|
||||
"outboundTestUrl": outboundTestUrl,
|
||||
}
|
||||
|
||||
// Surface subscription outbounds (and their tags) so the frontend can:
|
||||
@@ -145,14 +138,6 @@ func (a *XraySettingController) updateSetting(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
outboundTestUrl := c.PostForm("outboundTestUrl")
|
||||
if outboundTestUrl == "" {
|
||||
outboundTestUrl = "https://www.google.com/generate_204"
|
||||
}
|
||||
if err := a.SettingService.SetXrayOutboundTestUrl(outboundTestUrl); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
|
||||
return
|
||||
}
|
||||
// Only reconcile a running core; a manually stopped xray stays stopped.
|
||||
if a.XrayService.IsXrayRunning() {
|
||||
if err := a.XrayService.RestartXray(false); err != nil {
|
||||
@@ -299,70 +284,6 @@ func (a *XraySettingController) resetOutboundsTraffic(c *gin.Context) {
|
||||
jsonObj(c, "", nil)
|
||||
}
|
||||
|
||||
// testOutbound tests an outbound configuration and returns the delay/response time.
|
||||
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||
// Optional form "mode": "tcp" for a fast dial-only probe, "real" for the cold
|
||||
// full-request delay, anything else (default) for a full HTTP probe through a temp xray instance.
|
||||
func (a *XraySettingController) testOutbound(c *gin.Context) {
|
||||
outboundJSON := c.PostForm("outbound")
|
||||
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||
mode := c.PostForm("mode")
|
||||
|
||||
if outboundJSON == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbound parameter is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := a.OutboundService.TestOutbound(outboundJSON, testURL, allOutboundsJSON, mode)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonObj(c, result, nil)
|
||||
}
|
||||
|
||||
// testOutbounds tests a batch of outbound configurations through one shared
|
||||
// temp xray instance and returns an array of results in input order.
|
||||
// Form "outbounds": JSON array of outbound configs (required).
|
||||
// Optional form "allOutbounds": JSON array of all outbounds; used to resolve sockopt.dialerProxy dependencies.
|
||||
// Optional form "mode": "tcp" for fast dial-only probes, "real" for the cold
|
||||
// full-request delay, anything else (default) for real HTTP requests routed through each outbound.
|
||||
func (a *XraySettingController) testOutbounds(c *gin.Context) {
|
||||
outboundsJSON := c.PostForm("outbounds")
|
||||
allOutboundsJSON := c.PostForm("allOutbounds")
|
||||
mode := c.PostForm("mode")
|
||||
|
||||
if outboundsJSON == "" {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("outbounds parameter is required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Load the test URL from server settings to prevent SSRF via user-controlled URLs
|
||||
testURL, _ := a.SettingService.GetXrayOutboundTestUrl()
|
||||
testURL, err := service.SanitizePublicHTTPURL(testURL, false)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := a.OutboundService.TestOutbounds(outboundsJSON, testURL, allOutboundsJSON, mode)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonObj(c, results, nil)
|
||||
}
|
||||
|
||||
// balancerStatus reports the live state (override + strategy picks) of the
|
||||
// balancer tags given as a comma-separated "tags" form field.
|
||||
func (a *XraySettingController) balancerStatus(c *gin.Context) {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package outbound
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCloudflareTrace(t *testing.T) {
|
||||
values := parseCloudflareTrace("ip=104.28.1.2\nloc=NL\nwarp=on\n")
|
||||
|
||||
if values["ip"] != "104.28.1.2" {
|
||||
t.Fatalf("ip = %q", values["ip"])
|
||||
}
|
||||
if values["loc"] != "NL" {
|
||||
t.Fatalf("loc = %q", values["loc"])
|
||||
}
|
||||
if values["warp"] != "on" {
|
||||
t.Fatalf("warp = %q", values["warp"])
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,6 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
@@ -104,196 +96,3 @@ func (s *OutboundService) ResetOutboundTraffic(tag string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestOutboundResult represents the result of testing an outbound.
|
||||
// Delay is in milliseconds. Endpoints is only populated for TCP-mode
|
||||
// probes; HTTP mode reports the round-trip of a real HTTP request on an
|
||||
// established connection through the outbound (the cold first request
|
||||
// supplies the timing breakdown).
|
||||
type TestOutboundResult struct {
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
|
||||
// HTTP-mode extras. Any HTTP response counts as reachable; HTTPStatus
|
||||
// records what the test URL answered. ConnectMs is the dial to the local
|
||||
// test inbound; TLSMs covers outbound-chain establishment + target TLS
|
||||
// (https URLs only, since xray ACKs the SOCKS CONNECT before dialing
|
||||
// upstream); TTFBMs is request start → first response byte.
|
||||
HTTPStatus int `json:"httpStatus,omitempty"`
|
||||
ConnectMs int64 `json:"connectMs,omitempty"`
|
||||
TLSMs int64 `json:"tlsMs,omitempty"`
|
||||
TTFBMs int64 `json:"ttfbMs,omitempty"`
|
||||
|
||||
Endpoints []TestEndpointResult `json:"endpoints,omitempty"`
|
||||
Egress *TestEgressResult `json:"egress,omitempty"`
|
||||
}
|
||||
|
||||
// TestEndpointResult is one entry in a TCP-mode probe — the per-endpoint
|
||||
// dial outcome for outbounds that expose multiple servers/peers.
|
||||
type TestEndpointResult struct {
|
||||
Address string `json:"address"`
|
||||
Success bool `json:"success"`
|
||||
Delay int64 `json:"delay"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TestEgressResult is populated by HTTP-mode probes from Cloudflare's trace
|
||||
// endpoint. It reports what an external service sees after the outbound chain.
|
||||
type TestEgressResult struct {
|
||||
IPv4 string `json:"ipv4,omitempty"`
|
||||
IPv6 string `json:"ipv6,omitempty"`
|
||||
Country string `json:"country,omitempty"`
|
||||
Warp string `json:"warp,omitempty"`
|
||||
}
|
||||
|
||||
func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundResult, error) {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||
return &TestOutboundResult{Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||
}
|
||||
tag, _ := ob["tag"].(string)
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
if protocol == "blackhole" || protocol == "freedom" || tag == "blocked" {
|
||||
return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "Outbound has no testable endpoint"}, nil
|
||||
}
|
||||
|
||||
endpoints := extractOutboundEndpoints(ob)
|
||||
if len(endpoints) == 0 {
|
||||
return &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: "No testable endpoint"}, nil
|
||||
}
|
||||
|
||||
results := make([]TestEndpointResult, len(endpoints))
|
||||
var wg sync.WaitGroup
|
||||
for i := range endpoints {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var bestDelay int64 = -1
|
||||
var firstErr string
|
||||
for _, r := range results {
|
||||
if r.Success {
|
||||
if bestDelay < 0 || r.Delay < bestDelay {
|
||||
bestDelay = r.Delay
|
||||
}
|
||||
} else if firstErr == "" {
|
||||
firstErr = r.Error
|
||||
}
|
||||
}
|
||||
|
||||
out := &TestOutboundResult{Tag: tag, Mode: "tcp", Endpoints: results}
|
||||
if bestDelay >= 0 {
|
||||
out.Success = true
|
||||
out.Delay = bestDelay
|
||||
} else {
|
||||
out.Error = firstErr
|
||||
if out.Error == "" {
|
||||
out.Error = "All endpoints unreachable"
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
|
||||
r := TestEndpointResult{Address: endpoint}
|
||||
start := time.Now()
|
||||
conn, err := (&net.Dialer{Timeout: timeout}).DialContext(context.Background(), "tcp", endpoint)
|
||||
r.Delay = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
r.Error = err.Error()
|
||||
return r
|
||||
}
|
||||
conn.Close()
|
||||
r.Success = true
|
||||
return r
|
||||
}
|
||||
|
||||
// outboundTransportIsUDP reports whether the outbound's proxy speaks UDP
|
||||
// (wireguard, hysteria, or a kcp/quic/hysteria stream transport). A bare
|
||||
// UDP dial can't probe these — they ignore unauthenticated packets, so a
|
||||
// dial neither proves reachability nor measures latency. Such outbounds
|
||||
// must go through the real xray handshake probe instead.
|
||||
func outboundTransportIsUDP(ob map[string]any) bool {
|
||||
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
|
||||
return true
|
||||
}
|
||||
if stream, ok := ob["streamSettings"].(map[string]any); ok {
|
||||
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func extractOutboundEndpoints(ob map[string]any) []string {
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
settings, _ := ob["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var out []string
|
||||
addServer := func(addr any, port any) {
|
||||
host, _ := addr.(string)
|
||||
p := numAsInt(port)
|
||||
if host != "" && p > 0 {
|
||||
out = append(out, fmt.Sprintf("%s:%d", host, p))
|
||||
}
|
||||
}
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
if vnext, ok := settings["vnext"].([]any); ok {
|
||||
for _, v := range vnext {
|
||||
if vm, ok := v.(map[string]any); ok {
|
||||
addServer(vm["address"], vm["port"])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "vless":
|
||||
addServer(settings["address"], settings["port"])
|
||||
case "hysteria":
|
||||
addServer(settings["address"], settings["port"])
|
||||
case "trojan", "shadowsocks", "http", "socks":
|
||||
if servers, ok := settings["servers"].([]any); ok {
|
||||
for _, sv := range servers {
|
||||
if sm, ok := sv.(map[string]any); ok {
|
||||
addServer(sm["address"], sm["port"])
|
||||
}
|
||||
}
|
||||
}
|
||||
case "wireguard":
|
||||
if peers, ok := settings["peers"].([]any); ok {
|
||||
for _, p := range peers {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
if ep, _ := pm["endpoint"].(string); ep != "" {
|
||||
out = append(out, ep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func numAsInt(v any) int {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int(n)
|
||||
case int:
|
||||
return n
|
||||
case int64:
|
||||
return int(n)
|
||||
case string:
|
||||
if i, err := strconv.Atoi(n); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1,759 +0,0 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptrace"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// HTTP-mode probing works by spinning up ONE temporary xray instance per
|
||||
// batch: every outbound under test gets its own loopback SOCKS inbound plus
|
||||
// an inboundTag→outboundTag routing rule, and the panel then issues a real,
|
||||
// individually-timed HTTP request through each inbound. Measuring the request
|
||||
// client-side (instead of polling xray's observatory) returns the moment the
|
||||
// response lands, yields the actual HTTP status, and allows an httptrace
|
||||
// timing breakdown — while the shared process keeps "Test All" at one xray
|
||||
// spawn per batch instead of one per outbound. The reported delay comes from
|
||||
// a second request on the kept-alive connection, so it reflects the tunnel's
|
||||
// real per-request round-trip rather than the stacked SOCKS/proxy/TLS
|
||||
// handshakes of connection establishment. Mode "real" instead reports the
|
||||
// cold request's full elapsed time and skips the warm request.
|
||||
|
||||
const (
|
||||
// httpProbeTimeout bounds each probe request end-to-end (a probe makes
|
||||
// two: a cold one for the breakdown, a warm one for the delay).
|
||||
httpProbeTimeout = 10 * time.Second
|
||||
// probeDrainLimit caps how much response body a probe reads back to keep
|
||||
// the connection reusable for the warm request.
|
||||
probeDrainLimit = 256 << 10
|
||||
// httpProbeConcurrency caps parallel probe requests within a batch —
|
||||
// enough to keep a batch fast, low enough not to spike CPU with TLS
|
||||
// handshakes on small VPSes.
|
||||
httpProbeConcurrency = 16
|
||||
// batchPortsReadyTimeout bounds the wait for the temp instance to open
|
||||
// its test inbounds.
|
||||
batchPortsReadyTimeout = 10 * time.Second
|
||||
// maxBatchItems caps one batch request; the frontend chunks below this.
|
||||
maxBatchItems = 50
|
||||
// tcpBatchConcurrency caps parallel TCP-mode items in a batch (each item
|
||||
// already dials its endpoints concurrently).
|
||||
tcpBatchConcurrency = 8
|
||||
// egressTraceTimeout keeps diagnostic trace metadata from extending a
|
||||
// successful HTTP probe by the full reachability timeout.
|
||||
egressTraceTimeout = 3 * time.Second
|
||||
|
||||
defaultTestURL = "https://www.google.com/generate_204"
|
||||
egressTraceHost = "cloudflare.com"
|
||||
egressTracePath = "/cdn-cgi/trace"
|
||||
)
|
||||
|
||||
// httpTestSemaphore serialises HTTP-mode batches (each spawns a temp xray
|
||||
// instance, which is too expensive to run in parallel). TCP-mode probes are
|
||||
// dial-only and don't need the semaphore.
|
||||
var httpTestSemaphore sync.Mutex
|
||||
|
||||
// batchProcess is the slice of xray.Process the batch engine needs; a seam
|
||||
// so unit tests can stub the process without an xray binary.
|
||||
type batchProcess interface {
|
||||
Start() error
|
||||
Stop() error
|
||||
IsRunning() bool
|
||||
GetResult() string
|
||||
}
|
||||
|
||||
var newBatchProcess = func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return xray.NewTestProcess(cfg, configPath)
|
||||
}
|
||||
|
||||
var egressTraceProbe = probeEgressTrace
|
||||
|
||||
// httpBatchItem is one outbound inside an HTTP-mode batch. result is the
|
||||
// pre-allocated entry in the caller's result slice, filled in place.
|
||||
type httpBatchItem struct {
|
||||
index int
|
||||
tag string
|
||||
outbound map[string]any
|
||||
result *TestOutboundResult
|
||||
}
|
||||
|
||||
func probeModeLabel(mode string) string {
|
||||
switch mode {
|
||||
case "tcp", "real":
|
||||
return mode
|
||||
default:
|
||||
return "http"
|
||||
}
|
||||
}
|
||||
|
||||
// TestOutbound probes a single outbound; legacy single-test API kept for the
|
||||
// /testOutbound endpoint. Dispatch matches TestOutbounds: mode "tcp" dials
|
||||
// the outbound's endpoints directly, anything else routes a real HTTP request
|
||||
// through a temp xray instance (UDP-transport outbounds are always forced to
|
||||
// the HTTP probe — a raw dial can't measure them).
|
||||
func (s *OutboundService) TestOutbound(outboundJSON string, testURL string, allOutboundsJSON string, mode string) (*TestOutboundResult, error) {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal([]byte(outboundJSON), &ob); err != nil {
|
||||
return &TestOutboundResult{Mode: probeModeLabel(mode), Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}, nil
|
||||
}
|
||||
results := s.testOutboundsParsed([]map[string]any{ob}, testURL, allOutboundsJSON, mode)
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
// TestOutbounds probes a JSON array of outbounds and returns one result per
|
||||
// input, in input order, each carrying the outbound's tag. allOutboundsJSON
|
||||
// supplies the config context (sockopt.dialerProxy chains); testURL falls
|
||||
// back to the default probe URL when empty.
|
||||
func (s *OutboundService) TestOutbounds(outboundsJSON string, testURL string, allOutboundsJSON string, mode string) ([]*TestOutboundResult, error) {
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(outboundsJSON), &raw); err != nil {
|
||||
return nil, fmt.Errorf("invalid outbounds JSON: %w", err)
|
||||
}
|
||||
if len(raw) > maxBatchItems {
|
||||
return nil, fmt.Errorf("too many outbounds in one request (max %d)", maxBatchItems)
|
||||
}
|
||||
items := make([]map[string]any, len(raw))
|
||||
for i, r := range raw {
|
||||
var ob map[string]any
|
||||
if err := json.Unmarshal(r, &ob); err == nil {
|
||||
items[i] = ob
|
||||
}
|
||||
}
|
||||
return s.testOutboundsParsed(items, testURL, allOutboundsJSON, mode), nil
|
||||
}
|
||||
|
||||
// testOutboundsParsed splits items into the TCP lane (direct dials, bounded
|
||||
// worker pool) and the HTTP lane (one shared temp xray instance), runs both,
|
||||
// and returns results aligned with items. A nil item marks unparseable input.
|
||||
func (s *OutboundService) testOutboundsParsed(items []map[string]any, testURL string, allOutboundsJSON string, mode string) []*TestOutboundResult {
|
||||
results := make([]*TestOutboundResult, len(items))
|
||||
|
||||
modeLabel := probeModeLabel(mode)
|
||||
probeLabel := modeLabel
|
||||
if probeLabel == "tcp" {
|
||||
probeLabel = "http"
|
||||
}
|
||||
realDelay := mode == "real"
|
||||
|
||||
type tcpEntry struct {
|
||||
idx int
|
||||
ob map[string]any
|
||||
}
|
||||
var tcpLane []tcpEntry
|
||||
var httpItems []*httpBatchItem
|
||||
seenTags := make(map[string]bool)
|
||||
|
||||
for i, ob := range items {
|
||||
if ob == nil {
|
||||
results[i] = &TestOutboundResult{Mode: modeLabel, Success: false, Error: "Invalid outbound JSON"}
|
||||
continue
|
||||
}
|
||||
// A bare TCP dial only proves reachability for TCP-based proxies.
|
||||
// UDP protocols (wireguard, hysteria, kcp/quic transports) ignore
|
||||
// unauthenticated packets, so a raw dial can't tell "reachable" from
|
||||
// "dead" — route them through the real xray probe.
|
||||
if mode == "tcp" && !outboundTransportIsUDP(ob) {
|
||||
tcpLane = append(tcpLane, tcpEntry{idx: i, ob: ob})
|
||||
continue
|
||||
}
|
||||
|
||||
tag, _ := ob["tag"].(string)
|
||||
r := &TestOutboundResult{Tag: tag, Mode: probeLabel}
|
||||
results[i] = r
|
||||
protocol, _ := ob["protocol"].(string)
|
||||
switch {
|
||||
case tag == "":
|
||||
r.Error = "Outbound has no tag"
|
||||
case protocol == "blackhole" || tag == "blocked":
|
||||
r.Error = "Blocked/blackhole outbound cannot be tested"
|
||||
case protocol == "loopback":
|
||||
r.Error = "Loopback outbound cannot be tested"
|
||||
case protocol == "freedom" || protocol == "dns":
|
||||
// Direct/DNS outbounds aren't proxies — an HTTP probe through them
|
||||
// would only measure the host's own reachability, not a tunnel.
|
||||
r.Error = "Direct/DNS outbound cannot be tested"
|
||||
case seenTags[tag]:
|
||||
r.Error = fmt.Sprintf("Duplicate outbound tag in batch: %s", tag)
|
||||
default:
|
||||
seenTags[tag] = true
|
||||
httpItems = append(httpItems, &httpBatchItem{index: i, tag: tag, outbound: ob, result: r})
|
||||
}
|
||||
}
|
||||
|
||||
if len(tcpLane) > 0 {
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, tcpBatchConcurrency)
|
||||
for _, e := range tcpLane {
|
||||
wg.Add(1)
|
||||
go func(e tcpEntry) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
obJSON, err := json.Marshal(e.ob)
|
||||
if err != nil {
|
||||
tag, _ := e.ob["tag"].(string)
|
||||
results[e.idx] = &TestOutboundResult{Tag: tag, Mode: "tcp", Success: false, Error: fmt.Sprintf("Invalid outbound JSON: %v", err)}
|
||||
return
|
||||
}
|
||||
r, _ := s.testOutboundTCP(string(obJSON))
|
||||
results[e.idx] = r
|
||||
}(e)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
if len(httpItems) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
failAll := func(msg string) {
|
||||
for _, it := range httpItems {
|
||||
it.result.Success = false
|
||||
it.result.Error = msg
|
||||
}
|
||||
}
|
||||
|
||||
var allOutbounds []any
|
||||
if allOutboundsJSON != "" {
|
||||
if err := json.Unmarshal([]byte(allOutboundsJSON), &allOutbounds); err != nil {
|
||||
failAll(fmt.Sprintf("Invalid allOutbounds JSON: %v", err))
|
||||
return results
|
||||
}
|
||||
}
|
||||
|
||||
if testURL == "" {
|
||||
testURL = defaultTestURL
|
||||
}
|
||||
|
||||
if !httpTestSemaphore.TryLock() {
|
||||
failAll("Another outbound test is already running, please wait")
|
||||
return results
|
||||
}
|
||||
defer httpTestSemaphore.Unlock()
|
||||
|
||||
retryPerItem, err := runHTTPProbeBatch(httpItems, allOutbounds, testURL, realDelay)
|
||||
if err == nil {
|
||||
return results
|
||||
}
|
||||
if !retryPerItem || len(httpItems) == 1 {
|
||||
failAll(err.Error())
|
||||
return results
|
||||
}
|
||||
// The shared process never came up — one structurally-bad outbound can
|
||||
// poison the whole batch config. Retry each item in its own isolated
|
||||
// instance so the broken outbound reports xray's real error and the
|
||||
// rest still get tested. Serial: the poisoned case fails fast (~1s).
|
||||
for _, it := range httpItems {
|
||||
if _, ferr := runHTTPProbeBatch([]*httpBatchItem{it}, allOutbounds, testURL, realDelay); ferr != nil {
|
||||
it.result.Success = false
|
||||
it.result.Error = ferr.Error()
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// runHTTPProbeBatch makes one shared-process attempt for the given items,
|
||||
// writing per-request outcomes into the items' results. It returns a non-nil
|
||||
// error only when the process never became usable; retryPerItem reports
|
||||
// whether splitting the batch into per-item instances could help (true for
|
||||
// start failures / early exits that a poisoned config would explain, false
|
||||
// for environmental failures like a missing binary or no free ports).
|
||||
func runHTTPProbeBatch(items []*httpBatchItem, allOutbounds []any, testURL string, realDelay bool) (retryPerItem bool, err error) {
|
||||
ports, release, err := reserveLoopbackPorts(len(items))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Failed to reserve test ports: %w", err)
|
||||
}
|
||||
defer release()
|
||||
|
||||
cfg := buildBatchTestConfig(items, allOutbounds, ports)
|
||||
|
||||
configPath, err := createTestConfigPath()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("Failed to create test config path: %w", err)
|
||||
}
|
||||
defer os.Remove(configPath)
|
||||
|
||||
proc := newBatchProcess(cfg, configPath)
|
||||
defer func() {
|
||||
if proc.IsRunning() {
|
||||
_ = proc.Stop()
|
||||
}
|
||||
}()
|
||||
|
||||
// Free the reserved ports just before xray binds them; the window is
|
||||
// milliseconds, and a lost race makes xray exit fast, which surfaces
|
||||
// below and triggers the per-item retry with fresh ports.
|
||||
release()
|
||||
if err := proc.Start(); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// Binary missing — per-item retries would all fail the same way.
|
||||
return false, fmt.Errorf("Failed to start test xray instance: %w", err)
|
||||
}
|
||||
return true, fmt.Errorf("Failed to start test xray instance: %w", err)
|
||||
}
|
||||
|
||||
if err := waitForPortsReady(proc, ports, batchPortsReadyTimeout); err != nil {
|
||||
return err.exited, err
|
||||
}
|
||||
|
||||
sem := make(chan struct{}, httpProbeConcurrency)
|
||||
var wg sync.WaitGroup
|
||||
for i := range items {
|
||||
wg.Add(1)
|
||||
go func(it *httpBatchItem, port int) {
|
||||
defer wg.Done()
|
||||
sem <- struct{}{}
|
||||
defer func() { <-sem }()
|
||||
probeThroughSocks(port, testURL, httpProbeTimeout, realDelay, it.result)
|
||||
}(items[i], ports[i])
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if !proc.IsRunning() {
|
||||
detail := proc.GetResult()
|
||||
for _, it := range items {
|
||||
if !it.result.Success {
|
||||
it.result.Error = "Xray process exited: " + detail
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// portsReadyError distinguishes "process died" (a poisoned config — worth a
|
||||
// per-item retry) from "ports never opened while alive" (environmental).
|
||||
type portsReadyError struct {
|
||||
msg string
|
||||
exited bool
|
||||
}
|
||||
|
||||
func (e *portsReadyError) Error() string { return e.msg }
|
||||
|
||||
// waitForPortsReady polls until every test inbound accepts connections,
|
||||
// aborting as soon as the process exits.
|
||||
func waitForPortsReady(proc batchProcess, ports []int, timeout time.Duration) *portsReadyError {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for _, port := range ports {
|
||||
for {
|
||||
if !proc.IsRunning() {
|
||||
return &portsReadyError{msg: "Xray process exited: " + proc.GetResult(), exited: true}
|
||||
}
|
||||
conn, err := (&net.Dialer{Timeout: 100 * time.Millisecond}).DialContext(context.Background(), "tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return &portsReadyError{msg: fmt.Sprintf("Xray failed to open test inbounds: port %d not ready after %v", port, timeout)}
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBatchTestConfig assembles the temp instance config: one loopback SOCKS
|
||||
// inbound per tested outbound, a routing rule binding each inbound to its
|
||||
// outbound tag, and the full outbound context so dialerProxy chains resolve.
|
||||
func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []int) *xray.Config {
|
||||
// allOutbounds is the template's outbound list; subscription outbounds
|
||||
// are injected at runtime and aren't part of it, so append any tested
|
||||
// outbound whose tag is missing. When a tested outbound's tag collides
|
||||
// with a template outbound, the template version wins — same semantics
|
||||
// as the pre-batch tester.
|
||||
outbounds := make([]any, 0, len(allOutbounds)+len(items))
|
||||
outbounds = append(outbounds, allOutbounds...)
|
||||
for _, it := range items {
|
||||
if !outboundsContainTag(outbounds, it.tag) {
|
||||
outbounds = append(outbounds, it.outbound)
|
||||
}
|
||||
}
|
||||
for _, ob := range outbounds {
|
||||
outbound, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// The temp instance must not touch kernel WireGuard devices.
|
||||
if protocol, ok := outbound["protocol"].(string); ok && protocol == "wireguard" {
|
||||
if settings, ok := outbound["settings"].(map[string]any); ok {
|
||||
settings["noKernelTun"] = true
|
||||
} else {
|
||||
outbound["settings"] = map[string]any{"noKernelTun": true}
|
||||
}
|
||||
}
|
||||
}
|
||||
outboundsJSON, _ := json.Marshal(outbounds)
|
||||
|
||||
inbounds := make([]xray.InboundConfig, len(items))
|
||||
rules := make([]any, len(items))
|
||||
for i, it := range items {
|
||||
inTag := fmt.Sprintf("test-in-%d", i)
|
||||
inbounds[i] = xray.InboundConfig{
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Port: ports[i],
|
||||
Protocol: "socks",
|
||||
Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
|
||||
Tag: inTag,
|
||||
}
|
||||
rules[i] = map[string]any{
|
||||
"type": "field",
|
||||
"inboundTag": []string{inTag},
|
||||
"outboundTag": it.tag,
|
||||
}
|
||||
}
|
||||
routingJSON, _ := json.Marshal(map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": rules,
|
||||
})
|
||||
|
||||
logJSON, _ := json.Marshal(map[string]any{
|
||||
"loglevel": "warning",
|
||||
"access": "none",
|
||||
"error": "",
|
||||
"dnsLog": false,
|
||||
})
|
||||
|
||||
return &xray.Config{
|
||||
LogConfig: json_util.RawMessage(logJSON),
|
||||
InboundConfigs: inbounds,
|
||||
OutboundConfigs: json_util.RawMessage(outboundsJSON),
|
||||
RouterConfig: json_util.RawMessage(routingJSON),
|
||||
Policy: json_util.RawMessage(`{}`),
|
||||
Stats: json_util.RawMessage(`{}`),
|
||||
}
|
||||
}
|
||||
|
||||
// outboundsContainTag reports whether any outbound in the slice has the given tag.
|
||||
func outboundsContainTag(outbounds []any, tag string) bool {
|
||||
for _, ob := range outbounds {
|
||||
if m, ok := ob.(map[string]any); ok {
|
||||
if t, _ := m["tag"].(string); t == tag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// probeThroughSocks probes the local SOCKS inbound at the given port and
|
||||
// fills result. A first, cold GET proves reachability and carries the
|
||||
// httptrace breakdown: any HTTP response — including 4xx/5xx and unfollowed
|
||||
// redirects — counts as reachable; only transport-level failures (refused,
|
||||
// reset, timeout, proxy errors) are failures. Delay is then re-measured on a
|
||||
// warm request over the kept-alive connection — the real round-trip through
|
||||
// the established tunnel — falling back to the cold total if the warm request
|
||||
// fails. The test URL's hostname is resolved by xray (Go's SOCKS5 client
|
||||
// sends the domain to the proxy), so DNS goes through the outbound too.
|
||||
func probeThroughSocks(port int, testURL string, timeout time.Duration, realDelay bool, result *TestOutboundResult) {
|
||||
proxyURL := &url.URL{Scheme: "socks5", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(port))}
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
MaxIdleConns: 1,
|
||||
MaxIdleConnsPerHost: 1,
|
||||
IdleConnTimeout: timeout,
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: timeout,
|
||||
// A redirect would re-dial through the proxy and skew the timing;
|
||||
// the 3xx itself already proves the outbound works.
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
}
|
||||
|
||||
// Timing breakdown. ConnectStart/Done wrap the TCP dial to the local
|
||||
// inbound (the SOCKS handshake isn't traced, and xray ACKs CONNECT
|
||||
// before dialing upstream — so the real outbound establishment lands in
|
||||
// the TLS phase for https URLs, or inside TTFB for plain http).
|
||||
var (
|
||||
connStart, tlsStart time.Time
|
||||
connDur, tlsDur, ttfbDur time.Duration
|
||||
connDone, tlsDone, gotFirstRB bool
|
||||
)
|
||||
start := time.Now()
|
||||
trace := &httptrace.ClientTrace{
|
||||
ConnectStart: func(network, addr string) {
|
||||
if connStart.IsZero() {
|
||||
connStart = time.Now()
|
||||
}
|
||||
},
|
||||
ConnectDone: func(network, addr string, err error) {
|
||||
if err == nil && !connDone && !connStart.IsZero() {
|
||||
connDone = true
|
||||
connDur = time.Since(connStart)
|
||||
}
|
||||
},
|
||||
TLSHandshakeStart: func() {
|
||||
if tlsStart.IsZero() {
|
||||
tlsStart = time.Now()
|
||||
}
|
||||
},
|
||||
TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
|
||||
if err == nil && !tlsDone && !tlsStart.IsZero() {
|
||||
tlsDone = true
|
||||
tlsDur = time.Since(tlsStart)
|
||||
}
|
||||
},
|
||||
GotFirstResponseByte: func() {
|
||||
if !gotFirstRB {
|
||||
gotFirstRB = true
|
||||
ttfbDur = time.Since(start)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
coldDelay := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
result.Error = err.Error()
|
||||
return
|
||||
}
|
||||
drainAndClose(resp)
|
||||
|
||||
result.Success = true
|
||||
result.HTTPStatus = resp.StatusCode
|
||||
if connDone {
|
||||
result.ConnectMs = max(connDur.Milliseconds(), 1)
|
||||
}
|
||||
if tlsDone {
|
||||
result.TLSMs = max(tlsDur.Milliseconds(), 1)
|
||||
}
|
||||
if gotFirstRB {
|
||||
result.TTFBMs = max(ttfbDur.Milliseconds(), 1)
|
||||
}
|
||||
|
||||
delay := coldDelay
|
||||
if !realDelay {
|
||||
if warmDelay, ok := timedWarmGet(client, testURL); ok {
|
||||
delay = warmDelay
|
||||
}
|
||||
}
|
||||
result.Delay = max(delay, 1)
|
||||
if !realDelay {
|
||||
result.Egress = egressTraceProbe(proxyURL)
|
||||
}
|
||||
}
|
||||
|
||||
// probeEgressTrace fetches Cloudflare's plain-text trace endpoint through the
|
||||
// same SOCKS route used by the HTTP probe. It asks one IPv4 and one IPv6
|
||||
// Cloudflare address directly, when available, while keeping the TLS SNI as
|
||||
// cloudflare.com. Failures are intentionally ignored by the caller: egress
|
||||
// metadata is diagnostic, not reachability.
|
||||
func probeEgressTrace(proxyURL *url.URL) *TestEgressResult {
|
||||
ipv4, ipv6 := cloudflareTraceTargets()
|
||||
if ipv4 == nil && ipv6 == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: egressTraceHost,
|
||||
},
|
||||
}
|
||||
defer tr.CloseIdleConnections()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: egressTraceTimeout,
|
||||
}
|
||||
|
||||
egress := &TestEgressResult{}
|
||||
targets := make([]net.IP, 0, 2)
|
||||
if ipv4 != nil {
|
||||
targets = append(targets, ipv4)
|
||||
}
|
||||
if ipv6 != nil {
|
||||
targets = append(targets, ipv6)
|
||||
}
|
||||
results := make(chan map[string]string, len(targets))
|
||||
for _, target := range targets {
|
||||
go func(ip net.IP) {
|
||||
results <- fetchCloudflareTrace(client, ip)
|
||||
}(target)
|
||||
}
|
||||
for range targets {
|
||||
applyEgressTrace(egress, <-results)
|
||||
}
|
||||
if egress.IPv4 == "" && egress.IPv6 == "" && egress.Country == "" && egress.Warp == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return egress
|
||||
}
|
||||
|
||||
func cloudflareTraceTargets() (net.IP, net.IP) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), egressTraceTimeout)
|
||||
defer cancel()
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, egressTraceHost)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var ipv4, ipv6 net.IP
|
||||
for _, addr := range addrs {
|
||||
ip := addr.IP
|
||||
if ipv4 == nil {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
ipv4 = v4
|
||||
continue
|
||||
}
|
||||
}
|
||||
if ipv6 == nil && ip.To4() == nil && ip.To16() != nil {
|
||||
ipv6 = ip
|
||||
}
|
||||
if ipv4 != nil && ipv6 != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return ipv4, ipv6
|
||||
}
|
||||
|
||||
func fetchCloudflareTrace(client *http.Client, ip net.IP) map[string]string {
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
traceURL := (&url.URL{
|
||||
Scheme: "https",
|
||||
Host: net.JoinHostPort(ip.String(), "443"),
|
||||
Path: egressTracePath,
|
||||
}).String()
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, traceURL, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Host = egressTraceHost
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<10))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parseCloudflareTrace(string(body))
|
||||
}
|
||||
|
||||
func applyEgressTrace(egress *TestEgressResult, values map[string]string) {
|
||||
if len(values) == 0 {
|
||||
return
|
||||
}
|
||||
if ip := net.ParseIP(values["ip"]); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
if egress.IPv4 == "" {
|
||||
egress.IPv4 = values["ip"]
|
||||
}
|
||||
} else if egress.IPv6 == "" {
|
||||
egress.IPv6 = values["ip"]
|
||||
}
|
||||
}
|
||||
if egress.Country == "" {
|
||||
egress.Country = values["loc"]
|
||||
}
|
||||
if values["warp"] == "on" || egress.Warp == "" {
|
||||
egress.Warp = values["warp"]
|
||||
}
|
||||
}
|
||||
|
||||
func parseCloudflareTrace(body string) map[string]string {
|
||||
values := make(map[string]string)
|
||||
for line := range strings.SplitSeq(body, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[strings.TrimSpace(key)] = strings.TrimSpace(value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// timedWarmGet re-issues the probe request over the transport's kept-alive
|
||||
// connection and returns its duration — the tunnel's per-request round-trip.
|
||||
func timedWarmGet(client *http.Client, testURL string) (int64, bool) {
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
delay := time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
drainAndClose(resp)
|
||||
return delay, true
|
||||
}
|
||||
|
||||
// drainAndClose consumes the body (bounded by probeDrainLimit) so the
|
||||
// connection returns to the keep-alive pool for the warm request.
|
||||
func drainAndClose(resp *http.Response) {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, probeDrainLimit))
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// reserveLoopbackPorts grabs n free loopback ports and keeps the listeners
|
||||
// open so nothing else claims them; release() frees them (idempotent — the
|
||||
// caller releases right before starting xray and again via defer).
|
||||
func reserveLoopbackPorts(n int) ([]int, func(), error) {
|
||||
listeners := make([]net.Listener, 0, n)
|
||||
release := func() {
|
||||
for _, l := range listeners {
|
||||
l.Close()
|
||||
}
|
||||
}
|
||||
ports := make([]int, 0, n)
|
||||
for range n {
|
||||
l, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
release()
|
||||
return nil, nil, err
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
ports = append(ports, l.Addr().(*net.TCPAddr).Port)
|
||||
}
|
||||
return ports, release, nil
|
||||
}
|
||||
|
||||
// createTestConfigPath returns a unique path for a temporary xray config file in the bin folder.
|
||||
// The temp file is created and closed so the path is reserved; Start() will overwrite it.
|
||||
func createTestConfigPath() (string, error) {
|
||||
tmpFile, err := os.CreateTemp(config.GetBinFolderPath(), "xray_test_*.json")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := tmpFile.Name()
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
os.Remove(path)
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,597 +0,0 @@
|
||||
package outbound
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// stubProcess implements batchProcess without an xray binary. When serveSocks
|
||||
// is set, Start opens a minimal SOCKS5 server on every inbound port from the
|
||||
// config, so probes run against a real tunnel.
|
||||
type stubProcess struct {
|
||||
cfg *xray.Config
|
||||
startErr error
|
||||
result string
|
||||
serveSocks bool
|
||||
|
||||
running bool
|
||||
listeners []net.Listener
|
||||
}
|
||||
|
||||
func (p *stubProcess) Start() error {
|
||||
if p.startErr != nil {
|
||||
return p.startErr
|
||||
}
|
||||
for _, in := range p.cfg.InboundConfigs {
|
||||
l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", in.Port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.listeners = append(p.listeners, l)
|
||||
if p.serveSocks {
|
||||
go serveStubSocks(l)
|
||||
}
|
||||
}
|
||||
p.running = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *stubProcess) Stop() error {
|
||||
for _, l := range p.listeners {
|
||||
l.Close()
|
||||
}
|
||||
p.running = false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *stubProcess) IsRunning() bool { return p.running }
|
||||
func (p *stubProcess) GetResult() string {
|
||||
if p.result != "" {
|
||||
return p.result
|
||||
}
|
||||
return "stub exited"
|
||||
}
|
||||
|
||||
// serveStubSocks answers SOCKS5 no-auth CONNECTs and pipes to the requested
|
||||
// target — just enough protocol for net/http's socks5 client.
|
||||
func serveStubSocks(l net.Listener) {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
hello := make([]byte, 2)
|
||||
if _, err := io.ReadFull(c, hello); err != nil {
|
||||
return
|
||||
}
|
||||
methods := make([]byte, hello[1])
|
||||
if _, err := io.ReadFull(c, methods); err != nil {
|
||||
return
|
||||
}
|
||||
c.Write([]byte{0x05, 0x00})
|
||||
hdr := make([]byte, 4)
|
||||
if _, err := io.ReadFull(c, hdr); err != nil {
|
||||
return
|
||||
}
|
||||
var host string
|
||||
switch hdr[3] {
|
||||
case 0x01:
|
||||
b := make([]byte, 4)
|
||||
io.ReadFull(c, b)
|
||||
host = net.IP(b).String()
|
||||
case 0x03:
|
||||
lb := make([]byte, 1)
|
||||
io.ReadFull(c, lb)
|
||||
b := make([]byte, lb[0])
|
||||
io.ReadFull(c, b)
|
||||
host = string(b)
|
||||
case 0x04:
|
||||
b := make([]byte, 16)
|
||||
io.ReadFull(c, b)
|
||||
host = net.IP(b).String()
|
||||
default:
|
||||
return
|
||||
}
|
||||
pb := make([]byte, 2)
|
||||
if _, err := io.ReadFull(c, pb); err != nil {
|
||||
return
|
||||
}
|
||||
port := int(pb[0])<<8 | int(pb[1])
|
||||
upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
|
||||
if err != nil {
|
||||
c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
defer upstream.Close()
|
||||
c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
go io.Copy(upstream, c)
|
||||
io.Copy(c, upstream)
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func withStubProcess(t *testing.T, factory func(cfg *xray.Config, configPath string) batchProcess) {
|
||||
t.Helper()
|
||||
// createTestConfigPath writes into the bin folder, which doesn't exist
|
||||
// when running tests from the package directory.
|
||||
t.Setenv("XUI_BIN_FOLDER", t.TempDir())
|
||||
orig := newBatchProcess
|
||||
newBatchProcess = factory
|
||||
t.Cleanup(func() { newBatchProcess = orig })
|
||||
}
|
||||
|
||||
func withEgressTraceProbe(t *testing.T, probe func(*url.URL) *TestEgressResult) {
|
||||
t.Helper()
|
||||
orig := egressTraceProbe
|
||||
egressTraceProbe = probe
|
||||
t.Cleanup(func() { egressTraceProbe = orig })
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestBuildBatchTestConfig(t *testing.T) {
|
||||
items := []*httpBatchItem{
|
||||
{tag: "wg-sub", outbound: map[string]any{"tag": "wg-sub", "protocol": "wireguard"}},
|
||||
{tag: "proxy-a", outbound: map[string]any{"tag": "proxy-a", "protocol": "vless"}},
|
||||
}
|
||||
allOutbounds := []any{
|
||||
map[string]any{"tag": "direct", "protocol": "freedom", "settings": map[string]any{}},
|
||||
map[string]any{"tag": "proxy-a", "protocol": "vless", "settings": map[string]any{"address": "a.example.com"}},
|
||||
}
|
||||
ports := []int{61001, 61002}
|
||||
|
||||
cfg := buildBatchTestConfig(items, allOutbounds, ports)
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal config: %v", err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("unmarshal config: %v", err)
|
||||
}
|
||||
|
||||
inbounds, _ := m["inbounds"].([]any)
|
||||
if len(inbounds) != 2 {
|
||||
t.Fatalf("expected 2 inbounds, got %d", len(inbounds))
|
||||
}
|
||||
for i, raw := range inbounds {
|
||||
in := raw.(map[string]any)
|
||||
if got := in["tag"]; got != fmt.Sprintf("test-in-%d", i) {
|
||||
t.Errorf("inbound %d tag = %v", i, got)
|
||||
}
|
||||
if got := int(in["port"].(float64)); got != ports[i] {
|
||||
t.Errorf("inbound %d port = %d, want %d", i, got, ports[i])
|
||||
}
|
||||
if got := in["protocol"]; got != "socks" {
|
||||
t.Errorf("inbound %d protocol = %v", i, got)
|
||||
}
|
||||
if got := in["listen"]; got != "127.0.0.1" {
|
||||
t.Errorf("inbound %d listen = %v", i, got)
|
||||
}
|
||||
settings := in["settings"].(map[string]any)
|
||||
if settings["auth"] != "noauth" || settings["udp"] != false {
|
||||
t.Errorf("inbound %d settings = %v", i, settings)
|
||||
}
|
||||
}
|
||||
|
||||
routing := m["routing"].(map[string]any)
|
||||
rules, _ := routing["rules"].([]any)
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("expected 2 routing rules, got %d", len(rules))
|
||||
}
|
||||
wantTags := []string{"wg-sub", "proxy-a"}
|
||||
for i, raw := range rules {
|
||||
rule := raw.(map[string]any)
|
||||
inTags := rule["inboundTag"].([]any)
|
||||
if len(inTags) != 1 || inTags[0] != fmt.Sprintf("test-in-%d", i) {
|
||||
t.Errorf("rule %d inboundTag = %v", i, inTags)
|
||||
}
|
||||
if rule["outboundTag"] != wantTags[i] {
|
||||
t.Errorf("rule %d outboundTag = %v, want %s", i, rule["outboundTag"], wantTags[i])
|
||||
}
|
||||
}
|
||||
|
||||
outbounds, _ := m["outbounds"].([]any)
|
||||
if len(outbounds) != 3 {
|
||||
t.Fatalf("expected 3 outbounds (wg-sub appended once, proxy-a deduped), got %d", len(outbounds))
|
||||
}
|
||||
var wg map[string]any
|
||||
for _, raw := range outbounds {
|
||||
ob := raw.(map[string]any)
|
||||
if ob["tag"] == "wg-sub" {
|
||||
wg = ob
|
||||
}
|
||||
}
|
||||
if wg == nil {
|
||||
t.Fatal("wg-sub not appended to outbounds")
|
||||
}
|
||||
if settings, _ := wg["settings"].(map[string]any); settings == nil || settings["noKernelTun"] != true {
|
||||
t.Errorf("wireguard settings missing noKernelTun: %v", wg["settings"])
|
||||
}
|
||||
|
||||
if m["burstObservatory"] != nil {
|
||||
t.Errorf("burstObservatory should not be set, got %v", m["burstObservatory"])
|
||||
}
|
||||
if m["metrics"] != nil {
|
||||
t.Errorf("metrics should not be set, got %v", m["metrics"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsPrevalidationAndOrdering(t *testing.T) {
|
||||
calls := 0
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
calls++
|
||||
return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"protocol": "vless"}, // no tag
|
||||
map[string]any{"tag": "bh", "protocol": "blackhole"}, // blackhole
|
||||
map[string]any{"tag": "loop", "protocol": "loopback"}, // loopback
|
||||
map[string]any{"tag": "a", "protocol": "socks"}, // valid
|
||||
map[string]any{"tag": "a", "protocol": "vless"}, // duplicate
|
||||
})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
if len(results) != 5 {
|
||||
t.Fatalf("expected 5 results, got %d", len(results))
|
||||
}
|
||||
wantErrs := []string{
|
||||
"Outbound has no tag",
|
||||
"Blocked/blackhole outbound cannot be tested",
|
||||
"Loopback outbound cannot be tested",
|
||||
"Failed to start test xray instance: boom",
|
||||
"Duplicate outbound tag in batch: a",
|
||||
}
|
||||
for i, want := range wantErrs {
|
||||
if results[i].Success {
|
||||
t.Errorf("result %d unexpectedly succeeded", i)
|
||||
}
|
||||
if results[i].Error != want {
|
||||
t.Errorf("result %d error = %q, want %q", i, results[i].Error, want)
|
||||
}
|
||||
}
|
||||
if results[3].Tag != "a" || results[4].Tag != "a" || results[1].Tag != "bh" {
|
||||
t.Errorf("tags not propagated: %+v", results)
|
||||
}
|
||||
// Single valid item → no per-item fallback round.
|
||||
if calls != 1 {
|
||||
t.Errorf("process spawned %d times, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsFallbackOnStartFailure(t *testing.T) {
|
||||
calls := 0
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
calls++
|
||||
return &stubProcess{cfg: cfg, startErr: errors.New("boom")}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"tag": "a", "protocol": "socks"},
|
||||
map[string]any{"tag": "b", "protocol": "vless"},
|
||||
})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
for i, r := range results {
|
||||
if r.Success || r.Error != "Failed to start test xray instance: boom" {
|
||||
t.Errorf("result %d = %+v, want start failure", i, r)
|
||||
}
|
||||
}
|
||||
// 1 shared attempt + 2 isolated fallback attempts.
|
||||
if calls != 3 {
|
||||
t.Errorf("process spawned %d times, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsNoFallbackWhenBinaryMissing(t *testing.T) {
|
||||
calls := 0
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
calls++
|
||||
return &stubProcess{cfg: cfg, startErr: &fs.PathError{Op: "exec", Path: "xray", Err: fs.ErrNotExist}}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"tag": "a", "protocol": "socks"},
|
||||
map[string]any{"tag": "b", "protocol": "vless"},
|
||||
})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, "http://example.invalid/gen", "", "http")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
for i, r := range results {
|
||||
if r.Success || !strings.HasPrefix(r.Error, "Failed to start test xray instance:") {
|
||||
t.Errorf("result %d = %+v, want start failure", i, r)
|
||||
}
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Errorf("process spawned %d times, want 1 (no fallback for missing binary)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsSemaphoreBusy(t *testing.T) {
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
t.Fatal("process must not be spawned while semaphore is held")
|
||||
return nil
|
||||
})
|
||||
|
||||
httpTestSemaphore.Lock()
|
||||
defer httpTestSemaphore.Unlock()
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{"tag": "a", "protocol": "socks"}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "http")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
if results[0].Success || results[0].Error != "Another outbound test is already running, please wait" {
|
||||
t.Errorf("result = %+v, want busy error", results[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsInputValidation(t *testing.T) {
|
||||
s := &OutboundService{}
|
||||
if _, err := s.TestOutbounds("not json", "", "", "tcp"); err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
|
||||
big := make([]any, maxBatchItems+1)
|
||||
for i := range big {
|
||||
big[i] = map[string]any{"tag": fmt.Sprintf("t%d", i), "protocol": "socks"}
|
||||
}
|
||||
if _, err := s.TestOutbounds(mustJSON(t, big), "", "", "tcp"); err == nil {
|
||||
t.Error("expected error for oversized batch")
|
||||
}
|
||||
|
||||
results, err := s.TestOutbounds("[]", "", "", "tcp")
|
||||
if err != nil || len(results) != 0 {
|
||||
t.Errorf("empty batch: results=%v err=%v", results, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsTCPLane(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
port := l.Addr().(*net.TCPAddr).Port
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{
|
||||
"tag": "t1",
|
||||
"protocol": "socks",
|
||||
"settings": map[string]any{"servers": []any{map[string]any{"address": "127.0.0.1", "port": port}}},
|
||||
}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, "", "", "tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
r := results[0]
|
||||
if !r.Success || r.Mode != "tcp" || r.Tag != "t1" || len(r.Endpoints) != 1 {
|
||||
t.Errorf("unexpected tcp result: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsHTTPBatchThroughStubSocks(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
requestsPerConn := make(map[string]int)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
requestsPerConn[r.RemoteAddr]++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var proc *stubProcess
|
||||
calls := 0
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
calls++
|
||||
proc = &stubProcess{cfg: cfg, serveSocks: true}
|
||||
return proc
|
||||
})
|
||||
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
|
||||
return &TestEgressResult{IPv4: "198.51.100.1", Country: "ZZ", Warp: "off"}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"tag": "a", "protocol": "vless"},
|
||||
map[string]any{"tag": "b", "protocol": "trojan"},
|
||||
})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "http")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("process spawned %d times, want 1", calls)
|
||||
}
|
||||
for i, r := range results {
|
||||
if !r.Success {
|
||||
t.Fatalf("result %d failed: %+v", i, r)
|
||||
}
|
||||
if r.HTTPStatus != http.StatusNoContent {
|
||||
t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
|
||||
}
|
||||
if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
|
||||
t.Errorf("result %d timing not populated: %+v", i, r)
|
||||
}
|
||||
if r.TLSMs != 0 {
|
||||
t.Errorf("result %d TLSMs = %d, want 0 for plain http", i, r.TLSMs)
|
||||
}
|
||||
if r.Mode != "http" {
|
||||
t.Errorf("result %d mode = %q", i, r.Mode)
|
||||
}
|
||||
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.1" {
|
||||
t.Errorf("result %d egress = %+v", i, r.Egress)
|
||||
}
|
||||
}
|
||||
if proc.IsRunning() {
|
||||
t.Error("temp process not stopped after batch")
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
totalRequests := 0
|
||||
for addr, n := range requestsPerConn {
|
||||
totalRequests += n
|
||||
if n != 2 {
|
||||
t.Errorf("connection %s served %d requests, want 2 (warm delay request must reuse the cold request's connection)", addr, n)
|
||||
}
|
||||
}
|
||||
if totalRequests != 4 {
|
||||
t.Errorf("test URL served %d requests, want 4 (cold + warm per probe)", totalRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsRealDelayBatchThroughStubSocks(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
requestsPerConn := make(map[string]int)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
requestsPerConn[r.RemoteAddr]++
|
||||
mu.Unlock()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{
|
||||
map[string]any{"tag": "a", "protocol": "vless"},
|
||||
map[string]any{"tag": "wg", "protocol": "wireguard"},
|
||||
})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "real")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
for i, r := range results {
|
||||
if !r.Success {
|
||||
t.Fatalf("result %d failed: %+v", i, r)
|
||||
}
|
||||
if r.Mode != "real" {
|
||||
t.Errorf("result %d mode = %q, want %q", i, r.Mode, "real")
|
||||
}
|
||||
if r.HTTPStatus != http.StatusNoContent {
|
||||
t.Errorf("result %d status = %d, want 204", i, r.HTTPStatus)
|
||||
}
|
||||
if r.Delay < 1 || r.ConnectMs < 1 || r.TTFBMs < 1 {
|
||||
t.Errorf("result %d timing not populated: %+v", i, r)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
totalRequests := 0
|
||||
for addr, n := range requestsPerConn {
|
||||
totalRequests += n
|
||||
if n != 1 {
|
||||
t.Errorf("connection %s served %d requests, want 1 (real mode must skip the warm request)", addr, n)
|
||||
}
|
||||
}
|
||||
if totalRequests != 2 {
|
||||
t.Errorf("test URL served %d requests, want 2 (one cold request per probe)", totalRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
|
||||
return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{"tag": "wg", "protocol": "wireguard"}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
r := results[0]
|
||||
if !r.Success || r.Mode != "http" {
|
||||
t.Errorf("UDP outbound in tcp mode = %+v, want success with mode %q", r, "http")
|
||||
}
|
||||
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
|
||||
t.Errorf("UDP outbound egress = %+v", r.Egress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeModeLabel(t *testing.T) {
|
||||
cases := []struct{ mode, want string }{
|
||||
{"tcp", "tcp"},
|
||||
{"real", "real"},
|
||||
{"http", "http"},
|
||||
{"", "http"},
|
||||
{"bogus", "http"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := probeModeLabel(c.mode); got != c.want {
|
||||
t.Errorf("probeModeLabel(%q) = %q, want %q", c.mode, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeThroughSocksTransportFailure(t *testing.T) {
|
||||
// A listener that accepts and immediately closes — SOCKS handshake dies.
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
go func() {
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
var result TestOutboundResult
|
||||
probeThroughSocks(l.Addr().(*net.TCPAddr).Port, "http://127.0.0.1:9/", 2*time.Second, false, &result)
|
||||
if result.Success || result.Error == "" {
|
||||
t.Errorf("expected transport failure, got %+v", result)
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,6 @@ var defaultValueMap = map[string]string{
|
||||
"nord": "",
|
||||
"pia": "",
|
||||
"restartXrayOnClientDisable": "true",
|
||||
"xrayOutboundTestUrl": "https://www.google.com/generate_204",
|
||||
"panelOutbound": "",
|
||||
"devChannelEnable": "false",
|
||||
|
||||
@@ -352,18 +351,6 @@ func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
||||
return s.getString("xrayTemplateConfig")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetXrayOutboundTestUrl() (string, error) {
|
||||
return s.getString("xrayOutboundTestUrl")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetXrayOutboundTestUrl(url string) error {
|
||||
clean, err := SanitizeHTTPURL(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.setString("xrayOutboundTestUrl", clean)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetListen() (string, error) {
|
||||
return s.getString("webListen")
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "احفظ",
|
||||
"restartSuccess": "تم إعادة تشغيل Xray بنجاح",
|
||||
"stopSuccess": "تم إيقاف Xray بنجاح",
|
||||
"restartError": "حدث خطأ أثناء إعادة تشغيل Xray.",
|
||||
"stopError": "حدث خطأ أثناء إيقاف Xray.",
|
||||
"basicTemplate": "أساسي",
|
||||
"advancedTemplate": "متقدم",
|
||||
"generalConfigs": "إعدادات عامة",
|
||||
@@ -1198,8 +1194,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "عدد المللي ثانية قبل تجربة عائلة العناوين البديلة. 150–250 مللي ثانية نقطة بداية جيدة.",
|
||||
"RoutingStrategy": "استراتيجية التوجيه العامة",
|
||||
"RoutingStrategyDesc": "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات.",
|
||||
"outboundTestUrl": "رابط اختبار المخرج",
|
||||
"outboundTestUrlDesc": "الرابط المستخدم عند اختبار اتصال المخرج",
|
||||
"Torrent": "حظر بروتوكول التورنت",
|
||||
"Inbounds": "الواردات",
|
||||
"Outbounds": "الصادرات",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "الوسم",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "حالة المخرج",
|
||||
"sendThrough": "أرسل من خلال",
|
||||
"targetStrategy": "استراتيجية الوجهة",
|
||||
"modeRealDelay": "التأخير الفعلي",
|
||||
"testModeTooltip": "TCP: فحص dial سريع. HTTP: طلب كامل عبر xray. التأخير الفعلي: الوقت الكامل شاملاً إنشاء الاتصال.",
|
||||
"testAll": "اختبار الكل",
|
||||
"httpStatus": "حالة HTTP",
|
||||
"breakdownConnect": "اتصال البروكسي",
|
||||
"breakdownTls": "TLS عبر الصادر",
|
||||
"breakdownTtfb": "أول بايت",
|
||||
"country": "الدولة",
|
||||
"server": "الخادم",
|
||||
"city": "المدينة",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ لا يوجد سجل IP!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1290,10 +1290,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "Save",
|
||||
"restartSuccess": "Xray has been successfully relaunched.",
|
||||
"stopSuccess": "Xray has been successfully stopped.",
|
||||
"restartError": "There was an error when rebooting the Xray.",
|
||||
"stopError": "There was an error when stopping the Xray.",
|
||||
"basicTemplate": "Basics",
|
||||
"advancedTemplate": "Advanced",
|
||||
"generalConfigs": "General",
|
||||
@@ -1316,8 +1312,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Milliseconds before trying the alternate address family. 150–250 ms is a good starting point.",
|
||||
"RoutingStrategy": "Overall Routing Strategy",
|
||||
"RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
|
||||
"outboundTestUrl": "Outbound Test URL",
|
||||
"outboundTestUrlDesc": "URL used when testing outbound connectivity.",
|
||||
"Torrent": "Block BitTorrent Protocol",
|
||||
"Inbounds": "Inbounds",
|
||||
"Outbounds": "Outbounds",
|
||||
@@ -1473,18 +1467,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Tag",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "Outbound Status",
|
||||
"sendThrough": "Send Through",
|
||||
"targetStrategy": "Target Strategy",
|
||||
"modeRealDelay": "Real delay",
|
||||
"testModeTooltip": "TCP: fast dial-only probe. HTTP: full request through xray. Real delay: total time including connection setup.",
|
||||
"testAll": "Test all",
|
||||
"httpStatus": "HTTP status",
|
||||
"breakdownConnect": "Proxy connect",
|
||||
"breakdownTls": "TLS via outbound",
|
||||
"breakdownTtfb": "First byte",
|
||||
"country": "Country",
|
||||
"server": "Server",
|
||||
"city": "City",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ No IP Record!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "Guardar configuración",
|
||||
"restartSuccess": "Xray se ha reiniciado correctamente",
|
||||
"stopSuccess": "Xray se ha detenido correctamente",
|
||||
"restartError": "Ocurrió un error al reiniciar Xray.",
|
||||
"stopError": "Ocurrió un error al detener Xray.",
|
||||
"basicTemplate": "Perfil Básico",
|
||||
"advancedTemplate": "Perfil Avanzado",
|
||||
"generalConfigs": "Configuraciones Generales",
|
||||
@@ -1198,8 +1194,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Milisegundos antes de probar la otra familia de direcciones. 150–250 ms es un buen punto de partida.",
|
||||
"RoutingStrategy": "Configurar Estrategia de Enrutamiento de Dominios",
|
||||
"RoutingStrategyDesc": "Establece la estrategia general de enrutamiento para la resolución de DNS.",
|
||||
"outboundTestUrl": "URL de prueba de outbound",
|
||||
"outboundTestUrlDesc": "URL usada al probar la conectividad del outbound",
|
||||
"Torrent": "Prohibir Uso de BitTorrent",
|
||||
"Inbounds": "Entradas",
|
||||
"Outbounds": "Salidas",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Etiqueta",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "Estado de Salida",
|
||||
"sendThrough": "Enviar a través de",
|
||||
"targetStrategy": "Estrategia de destino",
|
||||
"modeRealDelay": "Retardo real",
|
||||
"testModeTooltip": "TCP: sonda rápida solo de dial. HTTP: petición completa a través de xray. Retardo real: tiempo total incluyendo el establecimiento de la conexión.",
|
||||
"testAll": "Probar todo",
|
||||
"httpStatus": "Estado HTTP",
|
||||
"breakdownConnect": "Conexión al proxy",
|
||||
"breakdownTls": "TLS vía salida",
|
||||
"breakdownTtfb": "Primer byte",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"city": "Ciudad",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ ¡No hay registro de IP!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "ذخیره",
|
||||
"restartSuccess": "Xray با موفقیت راهاندازی مجدد شد",
|
||||
"stopSuccess": "Xray با موفقیت متوقف شد",
|
||||
"restartError": "خطا در راهاندازی مجدد Xray.",
|
||||
"stopError": "خطا در توقف Xray.",
|
||||
"basicTemplate": "پایه",
|
||||
"advancedTemplate": "پیشرفته",
|
||||
"generalConfigs": "استراتژی کلی",
|
||||
@@ -1198,8 +1194,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "تعداد میلیثانیه پیش از امتحان خانوادهٔ آدرس دیگر. مقدار 150 تا 250 میلیثانیه نقطهٔ شروع خوبی است.",
|
||||
"RoutingStrategy": "استراتژی کلی مسیریابی",
|
||||
"RoutingStrategyDesc": "استراتژی کلی مسیریابی برای حل تمام درخواستها را تعیین میکند",
|
||||
"outboundTestUrl": "آدرس تست خروجی",
|
||||
"outboundTestUrlDesc": "آدرسی که برای تست اتصال خروجی استفاده میشود.",
|
||||
"Torrent": "مسدودسازی پروتکل بیتتورنت",
|
||||
"Inbounds": "ورودیها",
|
||||
"Outbounds": "خروجیها",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "تگ",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "وضعیت خروجی",
|
||||
"sendThrough": "ارسال با",
|
||||
"targetStrategy": "استراتژی مقصد",
|
||||
"modeRealDelay": "تأخیر واقعی",
|
||||
"testModeTooltip": "TCP: فقط dial سریع. HTTP: درخواست کامل از طریق xray. تأخیر واقعی: کل زمان همراه با برقراری اتصال.",
|
||||
"testAll": "تست همه",
|
||||
"httpStatus": "وضعیت HTTP",
|
||||
"breakdownConnect": "اتصال پروکسی",
|
||||
"breakdownTls": "TLS از طریق خروجی",
|
||||
"breakdownTtfb": "اولین بایت",
|
||||
"country": "کشور",
|
||||
"server": "سرور",
|
||||
"city": "شهر",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ رکورد آی پی وجود ندارد!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "Simpan",
|
||||
"restartSuccess": "Xray berhasil diluncurkan ulang",
|
||||
"stopSuccess": "Xray telah berhasil dihentikan",
|
||||
"restartError": "Terjadi kesalahan saat memulai ulang Xray.",
|
||||
"stopError": "Terjadi kesalahan saat menghentikan Xray.",
|
||||
"basicTemplate": "Dasar",
|
||||
"advancedTemplate": "Lanjutan",
|
||||
"generalConfigs": "Strategi Umum",
|
||||
@@ -1198,8 +1194,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Milidetik sebelum mencoba keluarga alamat lainnya. 150–250 ms adalah titik awal yang baik.",
|
||||
"RoutingStrategy": "Strategi Pengalihan Keseluruhan",
|
||||
"RoutingStrategyDesc": "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan.",
|
||||
"outboundTestUrl": "URL tes outbound",
|
||||
"outboundTestUrlDesc": "URL yang digunakan saat menguji konektivitas outbound",
|
||||
"Torrent": "Blokir Protokol BitTorrent",
|
||||
"Inbounds": "Inbound",
|
||||
"Outbounds": "Outbound",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Tag",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "Status Keluar",
|
||||
"sendThrough": "Kirim Melalui",
|
||||
"targetStrategy": "Strategi Target",
|
||||
"modeRealDelay": "Delay nyata",
|
||||
"testModeTooltip": "TCP: probe dial-only cepat. HTTP: permintaan penuh via xray. Delay nyata: total waktu termasuk pembentukan koneksi.",
|
||||
"testAll": "Tes semua",
|
||||
"httpStatus": "Status HTTP",
|
||||
"breakdownConnect": "Koneksi proxy",
|
||||
"breakdownTls": "TLS melalui outbound",
|
||||
"breakdownTtfb": "Byte pertama",
|
||||
"country": "Negara",
|
||||
"server": "Server",
|
||||
"city": "Kota",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ Tidak ada Catatan IP!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,10 +1180,6 @@
|
||||
"metricsListenDesc": "この アドレス:ポート で Xray の Prometheus 形式メトリクスを公開します(例: 127.0.0.1:11111)。空欄にすると無効になります。認証されないため、localhost にバインドしてリバースプロキシ経由で公開してください。",
|
||||
"metricsTag": "メトリクスタグ",
|
||||
"save": "保存",
|
||||
"restartSuccess": "Xrayの再起動に成功しました",
|
||||
"stopSuccess": "Xrayが正常に停止しました",
|
||||
"restartError": "Xrayの再起動中にエラーが発生しました。",
|
||||
"stopError": "Xrayの停止中にエラーが発生しました。",
|
||||
"basicTemplate": "基本設定",
|
||||
"advancedTemplate": "高度な設定",
|
||||
"generalConfigs": "一般設定",
|
||||
@@ -1206,8 +1202,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "別のアドレスファミリを試すまでのミリ秒。150〜250 ms が目安です。",
|
||||
"RoutingStrategy": "ルーティングドメイン戦略設定",
|
||||
"RoutingStrategyDesc": "DNS解決の全体的なルーティング戦略を設定する",
|
||||
"outboundTestUrl": "アウトバウンドテスト URL",
|
||||
"outboundTestUrlDesc": "アウトバウンド接続テストに使用する URL。既定値",
|
||||
"Torrent": "BitTorrent プロトコルをブロック",
|
||||
"Inbounds": "インバウンド",
|
||||
"Outbounds": "アウトバウンド",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "タグ",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "アウトバウンドステータス",
|
||||
"sendThrough": "送信経路",
|
||||
"targetStrategy": "ターゲット解決戦略",
|
||||
"modeRealDelay": "実際の遅延",
|
||||
"testModeTooltip": "TCP: 高速 dial-only プローブ。HTTP: xray を経由した完全リクエスト。実際の遅延: 接続確立を含む合計時間。",
|
||||
"testAll": "すべてテスト",
|
||||
"httpStatus": "HTTPステータス",
|
||||
"breakdownConnect": "プロキシ接続",
|
||||
"breakdownTls": "アウトバウンド経由のTLS",
|
||||
"breakdownTtfb": "最初のバイト",
|
||||
"country": "国",
|
||||
"server": "サーバー",
|
||||
"city": "都市",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ IPレコードがありません!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,10 +1180,6 @@
|
||||
"metricsListenDesc": "Expõe as métricas no estilo Prometheus do Xray neste endereço:porta (por exemplo, 127.0.0.1:11111). Deixe vazio para desativar. Vincule ao localhost e use um proxy reverso — ele não é autenticado.",
|
||||
"metricsTag": "Tag de métricas",
|
||||
"save": "Salvar",
|
||||
"restartSuccess": "Xray foi reiniciado com sucesso",
|
||||
"stopSuccess": "Xray foi interrompido com sucesso",
|
||||
"restartError": "Ocorreu um erro ao reiniciar o Xray.",
|
||||
"stopError": "Ocorreu um erro ao parar o Xray.",
|
||||
"basicTemplate": "Básico",
|
||||
"advancedTemplate": "Avançado",
|
||||
"generalConfigs": "Geral",
|
||||
@@ -1206,8 +1202,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Milissegundos antes de tentar a outra família de endereços. 150–250 ms é um bom ponto de partida.",
|
||||
"RoutingStrategy": "Estratégia Geral de Roteamento",
|
||||
"RoutingStrategyDesc": "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações.",
|
||||
"outboundTestUrl": "URL de teste de outbound",
|
||||
"outboundTestUrlDesc": "URL usada ao testar conectividade do outbound",
|
||||
"Torrent": "Bloquear Protocolo BitTorrent",
|
||||
"Inbounds": "Entradas",
|
||||
"Outbounds": "Saídas",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Tag",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "Status de Saída",
|
||||
"sendThrough": "Enviar Através de",
|
||||
"targetStrategy": "Estratégia de destino",
|
||||
"modeRealDelay": "Latência real",
|
||||
"testModeTooltip": "TCP: sondagem rápida apenas de dial. HTTP: requisição completa pelo xray. Latência real: tempo total incluindo o estabelecimento da conexão.",
|
||||
"testAll": "Testar todos",
|
||||
"httpStatus": "Status HTTP",
|
||||
"breakdownConnect": "Conexão do proxy",
|
||||
"breakdownTls": "TLS via saída",
|
||||
"breakdownTtfb": "Primeiro byte",
|
||||
"country": "País",
|
||||
"server": "Servidor",
|
||||
"city": "Cidade",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ Nenhum registro de IP!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,10 +1180,6 @@
|
||||
"metricsListenDesc": "Публикует метрики Xray в стиле Prometheus по этому адресу:порту (например, 127.0.0.1:11111). Оставьте пустым, чтобы отключить. Привяжите к localhost и проксируйте через reverse-proxy — он без аутентификации.",
|
||||
"metricsTag": "Тег метрик",
|
||||
"save": "Сохранить",
|
||||
"restartSuccess": "Xray успешно перезапущен",
|
||||
"stopSuccess": "Xray успешно остановлен",
|
||||
"restartError": "Произошла ошибка при перезапуске Xray.",
|
||||
"stopError": "Произошла ошибка при остановке Xray.",
|
||||
"basicTemplate": "Основное",
|
||||
"advancedTemplate": "Расширенный шаблон",
|
||||
"generalConfigs": "Основные настройки",
|
||||
@@ -1206,8 +1202,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Миллисекунды перед попыткой другого семейства адресов. 150–250 мс — хорошая отправная точка.",
|
||||
"RoutingStrategy": "Настройка маршрутизации доменов",
|
||||
"RoutingStrategyDesc": "Установка общей стратегии маршрутизации разрешения DNS",
|
||||
"outboundTestUrl": "URL для теста исходящего",
|
||||
"outboundTestUrlDesc": "URL для проверки подключения исходящего",
|
||||
"Torrent": "Заблокировать BitTorrent",
|
||||
"Inbounds": "Входящие",
|
||||
"Outbounds": "Исходящие",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Тег",
|
||||
"egress": "Выход",
|
||||
"egressHint": "Запустите HTTP-тест, чтобы показать выходной IP и страну.",
|
||||
"outboundStatus": "Статус исходящего подключения",
|
||||
"sendThrough": "Отправить через",
|
||||
"targetStrategy": "Стратегия назначения",
|
||||
"modeRealDelay": "Реальная задержка",
|
||||
"testModeTooltip": "TCP: быстрый dial-only probe. HTTP: полный запрос через xray. Реальная задержка: полное время с установлением соединения.",
|
||||
"testAll": "Тестировать все",
|
||||
"httpStatus": "HTTP-статус",
|
||||
"breakdownConnect": "Подключение к прокси",
|
||||
"breakdownTls": "TLS через исходящий",
|
||||
"breakdownTtfb": "Первый байт",
|
||||
"country": "Страна",
|
||||
"server": "Сервер",
|
||||
"city": "Город",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ Нет записей об IP-адресе."
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "Kaydet",
|
||||
"restartSuccess": "Xray başarıyla yeniden başlatıldı.",
|
||||
"stopSuccess": "Xray başarıyla durduruldu.",
|
||||
"restartError": "Xray yeniden başlatılırken bir hata oluştu.",
|
||||
"stopError": "Xray durdurulurken bir hata oluştu.",
|
||||
"basicTemplate": "Temeller",
|
||||
"advancedTemplate": "Gelişmiş",
|
||||
"generalConfigs": "Genel",
|
||||
@@ -1198,8 +1194,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Diğer adres ailesini denemeden önceki milisaniye. 150–250 ms iyi bir başlangıç noktasıdır.",
|
||||
"RoutingStrategy": "Genel Yönlendirme Stratejisi",
|
||||
"RoutingStrategyDesc": "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın.",
|
||||
"outboundTestUrl": "Giden Bağlantı Test URL'si",
|
||||
"outboundTestUrlDesc": "Giden bağlantı bağlantı testinde kullanılan URL.",
|
||||
"importRules": "Kuralları İçe Aktar",
|
||||
"exportRules": "Kuralları Dışa Aktar",
|
||||
"importOutbounds": "Giden Bağlantıları İçe Aktar",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Etiket",
|
||||
"egress": "Çıkış",
|
||||
"egressHint": "Çıkış IP adresini ve ülkeyi göstermek için bir HTTP testi gerçekleştirin.",
|
||||
"outboundStatus": "Giden Bağlantı Durumu",
|
||||
"sendThrough": "Üzerinden Gönder",
|
||||
"targetStrategy": "Hedef Stratejisi",
|
||||
"modeRealDelay": "Gerçek gecikme",
|
||||
"testModeTooltip": "TCP: hızlı sadece arama (dial-only) testi. HTTP: Xray üzerinden tam istek. Gerçek gecikme: bağlantı kurulumu dahil toplam süre.",
|
||||
"testAll": "Tümünü Test Et",
|
||||
"httpStatus": "HTTP durumu",
|
||||
"breakdownConnect": "Proxy bağlantısı",
|
||||
"breakdownTls": "Giden üzerinden TLS",
|
||||
"breakdownTtfb": "İlk bayt",
|
||||
"country": "Ülke",
|
||||
"server": "Sunucu",
|
||||
"city": "Şehir",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ IP Kaydı Yok!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "Зберегти",
|
||||
"restartSuccess": "Xray успішно перезапущено",
|
||||
"stopSuccess": "Xray успішно зупинено",
|
||||
"restartError": "Виникла помилка під час перезапуску Xray.",
|
||||
"stopError": "Виникла помилка під час зупинки Xray.",
|
||||
"basicTemplate": "Базовий шаблон",
|
||||
"advancedTemplate": "Додатково",
|
||||
"generalConfigs": "Загальні конфігурації",
|
||||
@@ -1198,8 +1194,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Мілісекунди перед спробою іншої родини адрес. 150–250 мс — добра початкова точка.",
|
||||
"RoutingStrategy": "Загальна стратегія маршрутизації",
|
||||
"RoutingStrategyDesc": "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів.",
|
||||
"outboundTestUrl": "URL тесту outbound",
|
||||
"outboundTestUrlDesc": "URL для перевірки з'єднання outbound",
|
||||
"Torrent": "Блокувати протокол BitTorrent",
|
||||
"Inbounds": "Вхідні",
|
||||
"Outbounds": "Вихідні",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Тег",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "Статус виходу",
|
||||
"sendThrough": "Надіслати через",
|
||||
"targetStrategy": "Стратегія призначення",
|
||||
"modeRealDelay": "Реальна затримка",
|
||||
"testModeTooltip": "TCP: швидкий dial-only probe. HTTP: повний запит через xray. Реальна затримка: повний час із встановленням з'єднання.",
|
||||
"testAll": "Тестувати всі",
|
||||
"httpStatus": "HTTP-статус",
|
||||
"breakdownConnect": "Підключення до проксі",
|
||||
"breakdownTls": "TLS через вихідний",
|
||||
"breakdownTtfb": "Перший байт",
|
||||
"country": "Країна",
|
||||
"server": "Сервер",
|
||||
"city": "Місто",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ Немає запису IP!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,10 +1180,6 @@
|
||||
"metricsListenDesc": "Hiển thị các chỉ số kiểu Prometheus của Xray tại địa chỉ:cổng này (ví dụ 127.0.0.1:11111). Để trống để tắt. Hãy gắn vào localhost và reverse-proxy nó — vì nó không có xác thực.",
|
||||
"metricsTag": "Metrics Tag",
|
||||
"save": "Lưu cài đặt",
|
||||
"restartSuccess": "Đã khởi động lại Xray thành công",
|
||||
"stopSuccess": "Xray đã được dừng thành công",
|
||||
"restartError": "Đã xảy ra lỗi khi khởi động lại Xray.",
|
||||
"stopError": "Đã xảy ra lỗi khi dừng Xray.",
|
||||
"basicTemplate": "Mẫu Cơ bản",
|
||||
"advancedTemplate": "Mẫu Nâng cao",
|
||||
"generalConfigs": "Cấu hình Chung",
|
||||
@@ -1206,8 +1202,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "Số mili-giây trước khi thử họ địa chỉ còn lại. 150–250 ms là điểm khởi đầu tốt.",
|
||||
"RoutingStrategy": "Cấu hình Chiến lược Định tuyến Tên miền",
|
||||
"RoutingStrategyDesc": "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS.",
|
||||
"outboundTestUrl": "URL kiểm tra outbound",
|
||||
"outboundTestUrlDesc": "URL dùng khi kiểm tra kết nối outbound",
|
||||
"Torrent": "Cấu hình sử dụng BitTorrent",
|
||||
"Inbounds": "Inbound",
|
||||
"Outbounds": "Outbound",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "Tag",
|
||||
"egress": "Egress",
|
||||
"egressHint": "Run an HTTP test to show egress IP and country.",
|
||||
"outboundStatus": "Trạng thái đầu ra",
|
||||
"sendThrough": "Gửi qua",
|
||||
"targetStrategy": "Chiến lược đích",
|
||||
"modeRealDelay": "Độ trễ thực",
|
||||
"testModeTooltip": "TCP: probe dial nhanh. HTTP: yêu cầu đầy đủ qua xray. Độ trễ thực: tổng thời gian gồm cả thiết lập kết nối.",
|
||||
"testAll": "Kiểm tra tất cả",
|
||||
"httpStatus": "Trạng thái HTTP",
|
||||
"breakdownConnect": "Kết nối proxy",
|
||||
"breakdownTls": "TLS qua outbound",
|
||||
"breakdownTtfb": "Byte đầu tiên",
|
||||
"country": "Quốc gia",
|
||||
"server": "Máy chủ",
|
||||
"city": "Thành phố",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ Không có bản ghi IP!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,10 +1180,6 @@
|
||||
"metricsListenDesc": "在此 address:port 上暴露 Xray 的 Prometheus 风格指标(例如 127.0.0.1:11111)。留空则禁用。请绑定到本地回环并通过反向代理转发——它没有身份验证。",
|
||||
"metricsTag": "指标标签",
|
||||
"save": "保存",
|
||||
"restartSuccess": "Xray 已成功重新启动",
|
||||
"stopSuccess": "Xray 已成功停止",
|
||||
"restartError": "重启 Xray 时发生错误。",
|
||||
"stopError": "停止 Xray 时发生错误。",
|
||||
"basicTemplate": "基础配置",
|
||||
"advancedTemplate": "高级配置",
|
||||
"generalConfigs": "常规配置",
|
||||
@@ -1206,8 +1202,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "尝试备用地址族之前等待的毫秒数。150–250 毫秒是不错的起点。",
|
||||
"RoutingStrategy": "配置路由域策略",
|
||||
"RoutingStrategyDesc": "设置 DNS 解析的整体路由策略",
|
||||
"outboundTestUrl": "出站测试 URL",
|
||||
"outboundTestUrlDesc": "测试出站连接时使用的 URL",
|
||||
"Torrent": "屏蔽 BitTorrent 协议",
|
||||
"Inbounds": "入站",
|
||||
"Outbounds": "出站",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "标签",
|
||||
"egress": "出口",
|
||||
"egressHint": "执行 HTTP 测试以显示出口 IP 和所在国家/地区。",
|
||||
"outboundStatus": "出站状态",
|
||||
"sendThrough": "发送通过",
|
||||
"targetStrategy": "目标解析策略",
|
||||
"modeRealDelay": "真实延迟",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探测。HTTP: 通过 xray 的完整请求。真实延迟: 含建立连接的总耗时。",
|
||||
"testAll": "全部测试",
|
||||
"httpStatus": "HTTP 状态",
|
||||
"breakdownConnect": "代理连接",
|
||||
"breakdownTls": "经由出站的 TLS",
|
||||
"breakdownTtfb": "首字节",
|
||||
"country": "国家",
|
||||
"server": "服务器",
|
||||
"city": "城市",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ 没有 IP 记录!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1172,6 @@
|
||||
},
|
||||
"xray": {
|
||||
"save": "儲存",
|
||||
"restartSuccess": "Xray 已成功重新啟動",
|
||||
"stopSuccess": "Xray 已成功停止",
|
||||
"restartError": "重新啟動 Xray 時發生錯誤。",
|
||||
"stopError": "停止 Xray 時發生錯誤。",
|
||||
"importRules": "匯入規則",
|
||||
"exportRules": "匯出規則",
|
||||
"importOutbounds": "匯入出站",
|
||||
@@ -1206,8 +1202,6 @@
|
||||
"FreedomHappyEyeballsTryDelayDesc": "嘗試備用位址族之前等待的毫秒數。150–250 毫秒是不錯的起點。",
|
||||
"RoutingStrategy": "配置路由域策略",
|
||||
"RoutingStrategyDesc": "設定 DNS 解析的整體路由策略",
|
||||
"outboundTestUrl": "出站測試 URL",
|
||||
"outboundTestUrlDesc": "測試出站連線時使用的 URL",
|
||||
"Torrent": "遮蔽 BitTorrent 協議",
|
||||
"Inbounds": "入站",
|
||||
"Outbounds": "出站",
|
||||
@@ -1355,18 +1349,9 @@
|
||||
},
|
||||
"outbound": {
|
||||
"tag": "標籤",
|
||||
"egress": "出口",
|
||||
"egressHint": "執行 HTTP 測試以顯示出口 IP 與所在國家/地區。",
|
||||
"outboundStatus": "出站狀態",
|
||||
"sendThrough": "傳送通過",
|
||||
"targetStrategy": "目標解析策略",
|
||||
"modeRealDelay": "真實延遲",
|
||||
"testModeTooltip": "TCP: 快速 dial-only 探測。HTTP: 透過 xray 的完整請求。真實延遲: 含建立連線的總耗時。",
|
||||
"testAll": "全部測試",
|
||||
"httpStatus": "HTTP 狀態",
|
||||
"breakdownConnect": "代理連線",
|
||||
"breakdownTls": "經由出站的 TLS",
|
||||
"breakdownTtfb": "首位元組",
|
||||
"country": "國家",
|
||||
"server": "伺服器",
|
||||
"city": "城市",
|
||||
@@ -1675,4 +1660,4 @@
|
||||
"noIpRecord": "❗ 沒有 IP 記錄!"
|
||||
},
|
||||
"email": {}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user