This commit is contained in:
/SNESE_AR 2025-11-23 18:41:58 +03:00
parent 784ed39930
commit fd9a62a91b
12 changed files with 275 additions and 3 deletions

View file

@ -53,6 +53,7 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"` StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"` Tag string `json:"tag" form:"tag" gorm:"unique"`
Sniffing string `json:"sniffing" form:"sniffing"` Sniffing string `json:"sniffing" form:"sniffing"`
VlessDomain string `json:"vlessDomain" form:"vlessDomain"` // Domain for VLESS protocol in subscriptions
} }
// OutboundTraffics tracks traffic statistics for Xray outbound connections. // OutboundTraffics tracks traffic statistics for Xray outbound connections.

View file

@ -3,7 +3,9 @@ package sub
import ( import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io"
"net" "net"
"net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
@ -50,9 +52,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
return nil, 0, traffic, err return nil, 0, traffic, err
} }
if len(inbounds) == 0 { // Allow empty inbounds if we have remote servers configured
return nil, 0, traffic, common.NewError("No inbounds found with ", subId) hasLocalInbounds := len(inbounds) > 0
}
s.datepicker, err = s.settingService.GetDatepicker() s.datepicker, err = s.settingService.GetDatepicker()
if err != nil { if err != nil {
@ -109,9 +110,139 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
} }
} }
} }
// Get subscriptions from remote servers
remoteSubs, err := s.getRemoteSubscriptions(subId)
if err != nil {
logger.Warning("Failed to get remote subscriptions:", err)
} else {
result = append(result, remoteSubs...)
}
// If no local inbounds and no remote subscriptions, return error
if !hasLocalInbounds && len(remoteSubs) == 0 {
return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
}
return result, lastOnline, traffic, nil return result, lastOnline, traffic, nil
} }
// getRemoteSubscriptions fetches subscription links from configured remote servers.
func (s *SubService) getRemoteSubscriptions(subId string) ([]string, error) {
remoteServers, err := s.settingService.GetSubRemoteServers()
if err != nil || remoteServers == "" {
return nil, nil
}
var serverURLs []string
if err := json.Unmarshal([]byte(remoteServers), &serverURLs); err != nil {
logger.Warning("Failed to parse remote servers JSON:", err)
return nil, err
}
if len(serverURLs) == 0 {
return nil, nil
}
var allSubs []string
subPath, err := s.settingService.GetSubPath()
if err != nil {
subPath = "/sub/"
}
// Ensure subPath ends with /
if !strings.HasSuffix(subPath, "/") {
subPath += "/"
}
client := &http.Client{
Timeout: 10 * time.Second,
}
for _, serverURL := range serverURLs {
if serverURL == "" {
continue
}
// Parse URL to check if it contains a path
parsedURL, err := url.Parse(serverURL)
if err != nil {
logger.Warningf("Failed to parse server URL %s: %v", serverURL, err)
continue
}
var subURL string
// If URL already has a path, use it and append subId
if parsedURL.Path != "" && parsedURL.Path != "/" {
// URL has a path, use it as base and append subId
basePath := strings.TrimRight(parsedURL.Path, "/")
// Preserve query and fragment if present
subURL = fmt.Sprintf("%s://%s%s/%s", parsedURL.Scheme, parsedURL.Host, basePath, subId)
if parsedURL.RawQuery != "" {
subURL += "?" + parsedURL.RawQuery
}
if parsedURL.Fragment != "" {
subURL += "#" + parsedURL.Fragment
}
} else {
// No path specified, use default subPath
serverURL = strings.TrimRight(serverURL, "/")
subURL = serverURL + subPath + subId
}
req, err := http.NewRequest("GET", subURL, nil)
if err != nil {
logger.Warningf("Failed to create request for %s: %v", subURL, err)
continue
}
// Set User-Agent to identify as subscription client
req.Header.Set("User-Agent", "3x-ui-subscription-client/1.0")
resp, err := client.Do(req)
if err != nil {
logger.Warningf("Failed to fetch subscription from %s: %v", subURL, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Warningf("Remote server %s returned status %d", subURL, resp.StatusCode)
continue
}
body, err := io.ReadAll(resp.Body)
if err != nil {
logger.Warningf("Failed to read response from %s: %v", subURL, err)
continue
}
// Check if response is base64 encoded (check Subscription-Userinfo header)
content := string(body)
if resp.Header.Get("Subscription-Userinfo") != "" {
// Try to decode base64
decoded, err := base64.StdEncoding.DecodeString(content)
if err == nil {
content = string(decoded)
}
}
// Split by newlines and add non-empty lines
lines := strings.Split(content, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" && (strings.HasPrefix(line, "vless://") ||
strings.HasPrefix(line, "vmess://") ||
strings.HasPrefix(line, "trojan://") ||
strings.HasPrefix(line, "ss://")) {
allSubs = append(allSubs, line)
}
}
}
return allSubs, nil
}
func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) { func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) {
db := database.GetDB() db := database.GetDB()
var inbounds []*model.Inbound var inbounds []*model.Inbound
@ -318,6 +449,12 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
address := s.address address := s.address
// Priority: 1) inbound-specific domain, 2) global VLESS domain setting, 3) default address
if inbound.VlessDomain != "" {
address = inbound.VlessDomain
} else if vlessDomain, err := s.settingService.GetSubVlessDomain(); err == nil && vlessDomain != "" {
address = vlessDomain
}
if inbound.Protocol != model.VLESS { if inbound.Protocol != model.VLESS {
return "" return ""
} }

View file

@ -20,6 +20,7 @@ class DBInbound {
this.streamSettings = ""; this.streamSettings = "";
this.tag = ""; this.tag = "";
this.sniffing = ""; this.sniffing = "";
this.vlessDomain = "";
this.clientStats = "" this.clientStats = ""
if (data == null) { if (data == null) {
return; return;

View file

@ -34,6 +34,7 @@ class AllSetting {
this.subPath = "/sub/"; this.subPath = "/sub/";
this.subJsonPath = "/json/"; this.subJsonPath = "/json/";
this.subDomain = ""; this.subDomain = "";
this.subVlessDomain = "";
this.externalTrafficInformEnable = false; this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = ""; this.externalTrafficInformURI = "";
this.subCertFile = ""; this.subCertFile = "";
@ -47,6 +48,7 @@ class AllSetting {
this.subJsonNoises = ""; this.subJsonNoises = "";
this.subJsonMux = ""; this.subJsonMux = "";
this.subJsonRules = ""; this.subJsonRules = "";
this.subRemoteServers = "";
this.timeLocation = "Local"; this.timeLocation = "Local";

View file

@ -61,6 +61,7 @@ type AllSetting struct {
SubPort int `json:"subPort" form:"subPort"` // Subscription server port SubPort int `json:"subPort" form:"subPort"` // Subscription server port
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation
SubVlessDomain string `json:"subVlessDomain" form:"subVlessDomain"` // Domain for VLESS protocol in subscriptions
SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server
SubUpdates int `json:"subUpdates" form:"subUpdates"` // Subscription update interval in minutes SubUpdates int `json:"subUpdates" form:"subUpdates"` // Subscription update interval in minutes
@ -75,6 +76,7 @@ type AllSetting struct {
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubRemoteServers string `json:"subRemoteServers" form:"subRemoteServers"` // JSON array of remote server URLs for subscription aggregation
// LDAP settings // LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`

View file

@ -18,6 +18,20 @@
</table> </table>
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item>
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.inbounds.vlessDomainDesc" }}</span>
</template>
{{ i18n "pages.inbounds.vlessDomain" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="dbInbound.vlessDomain" placeholder='{{ i18n "pages.inbounds.vlessDomainPlaceholder" }}'></a-input>
</a-form-item>
</a-form>
<template v-if="!inbound.stream.isTLS || !inbound.stream.isReality"> <template v-if="!inbound.stream.isTLS || !inbound.stream.isReality">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"> <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Authentication"> <a-form-item label="Authentication">

View file

@ -1076,6 +1076,7 @@
port: inbound.port, port: inbound.port,
protocol: inbound.protocol, protocol: inbound.protocol,
settings: inbound.settings.toString(), settings: inbound.settings.toString(),
vlessDomain: dbInbound.vlessDomain || "",
}; };
if (inbound.canEnableStream()) { if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString(); data.streamSettings = inbound.stream.toString();
@ -1101,6 +1102,7 @@
port: inbound.port, port: inbound.port,
protocol: inbound.protocol, protocol: inbound.protocol,
settings: inbound.settings.toString(), settings: inbound.settings.toString(),
vlessDomain: dbInbound.vlessDomain || "",
}; };
if (inbound.canEnableStream()) { if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString(); data.streamSettings = inbound.stream.toString();

View file

@ -123,6 +123,7 @@
user: {}, user: {},
lang: LanguageManager.getLanguage(), lang: LanguageManager.getLanguage(),
inboundOptions: [], inboundOptions: [],
subRemoteServersInput: '',
remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' }, remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' },
remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'], remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'],
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }], datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
@ -243,10 +244,61 @@
this.oldAllSetting = new AllSetting(msg.obj); this.oldAllSetting = new AllSetting(msg.obj);
this.allSetting = new AllSetting(msg.obj); this.allSetting = new AllSetting(msg.obj);
// Initialize subRemoteServersInput from JSON
this.updateSubRemoteServersInput();
app.changeRemarkSample(); app.changeRemarkSample();
this.saveBtnDisable = true; this.saveBtnDisable = true;
} }
}, },
updateSubRemoteServersInput() {
const remoteServers = this.allSetting.subRemoteServers;
if (!remoteServers || typeof remoteServers !== 'string' || remoteServers.trim() === '') {
this.subRemoteServersInput = '';
return;
}
try {
const servers = JSON.parse(remoteServers);
if (Array.isArray(servers) && servers.length > 0) {
this.subRemoteServersInput = servers.join(', ');
} else {
this.subRemoteServersInput = '';
}
} catch (e) {
// If not valid JSON, treat as comma-separated string (backward compatibility)
this.subRemoteServersInput = remoteServers;
}
},
convertRemoteServersToJson() {
const input = this.subRemoteServersInput;
if (!input || input.trim() === '') {
this.allSetting.subRemoteServers = '';
return;
}
// Split by comma (handle both ", " and ","), filter empty strings, trim each
const servers = input
.split(/\s*,\s*/)
.map(s => s.trim())
.filter(s => s.length > 0)
.map(server => {
server = server.trim();
// Only add https:// if no protocol specified
if (!server.startsWith('http://') && !server.startsWith('https://')) {
server = 'https://' + server;
}
return server;
});
// Convert to JSON array
try {
this.allSetting.subRemoteServers = JSON.stringify(servers);
console.log('Converted remote servers to JSON:', this.allSetting.subRemoteServers);
} catch (e) {
console.error('Failed to convert remote servers to JSON:', e);
this.allSetting.subRemoteServers = JSON.stringify([]);
}
},
async loadInboundTags() { async loadInboundTags() {
const msg = await HttpUtil.get("/panel/api/inbounds/list"); const msg = await HttpUtil.get("/panel/api/inbounds/list");
if (msg && msg.success && Array.isArray(msg.obj)) { if (msg && msg.success && Array.isArray(msg.obj)) {
@ -259,6 +311,8 @@
} }
}, },
async updateAllSetting() { async updateAllSetting() {
// Convert remote servers input to JSON before saving
this.convertRemoteServersToJson();
this.loading(true); this.loading(true);
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting); const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
this.loading(false); this.loading(false);
@ -567,6 +621,17 @@
} }
} }
}, },
watch: {
'allSetting.subRemoteServers': {
handler(newVal, oldVal) {
// Only update if value actually changed
if (newVal !== oldVal) {
this.updateSubRemoteServersInput();
}
},
immediate: true
}
},
async mounted() { async mounted() {
await this.getAllSetting(); await this.getAllSetting();
await this.loadInboundTags(); await this.loadInboundTags();

View file

@ -36,6 +36,13 @@
<a-input type="text" v-model="allSetting.subDomain"></a-input> <a-input type="text" v-model="allSetting.subDomain"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subVlessDomain"}}</template>
<template #description>{{ i18n "pages.settings.subVlessDomainDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subVlessDomain"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subPort"}}</template> <template #title>{{ i18n "pages.settings.subPort"}}</template>
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template> <template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
@ -62,6 +69,15 @@
v-model="allSetting.subURI"></a-input> v-model="allSetting.subURI"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.subRemoteServers"}}</template>
<template #description>{{ i18n "pages.settings.subRemoteServersDesc"}}</template>
<template #control>
<a-input type="text" placeholder='server1.com, http://serv2.com/config/sub, https://server3.com'
v-model="subRemoteServersInput"
@blur="convertRemoteServersToJson"></a-input>
</template>
</a-setting-list-item>
</a-collapse-panel> </a-collapse-panel>
<a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'> <a-collapse-panel key="2" header='{{ i18n "pages.settings.information" }}'>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">

View file

@ -57,6 +57,7 @@ var defaultValueMap = map[string]string{
"subPort": "2096", "subPort": "2096",
"subPath": "/sub/", "subPath": "/sub/",
"subDomain": "", "subDomain": "",
"subVlessDomain": "",
"subCertFile": "", "subCertFile": "",
"subKeyFile": "", "subKeyFile": "",
"subUpdates": "12", "subUpdates": "12",
@ -69,6 +70,7 @@ var defaultValueMap = map[string]string{
"subJsonNoises": "", "subJsonNoises": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subRemoteServers": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
"externalTrafficInformEnable": "false", "externalTrafficInformEnable": "false",
@ -479,6 +481,14 @@ func (s *SettingService) GetSubDomain() (string, error) {
return s.getString("subDomain") return s.getString("subDomain")
} }
func (s *SettingService) GetSubVlessDomain() (string, error) {
return s.getString("subVlessDomain")
}
func (s *SettingService) SetSubVlessDomain(subVlessDomain string) error {
return s.setString("subVlessDomain", subVlessDomain)
}
func (s *SettingService) SetSubCertFile(subCertFile string) error { func (s *SettingService) SetSubCertFile(subCertFile string) error {
return s.setString("subCertFile", subCertFile) return s.setString("subCertFile", subCertFile)
} }
@ -535,6 +545,14 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
return s.getString("subJsonRules") return s.getString("subJsonRules")
} }
func (s *SettingService) GetSubRemoteServers() (string, error) {
return s.getString("subRemoteServers")
}
func (s *SettingService) SetSubRemoteServers(servers string) error {
return s.setString("subRemoteServers", servers)
}
func (s *SettingService) GetDatepicker() (string, error) { func (s *SettingService) GetDatepicker() (string, error) {
return s.getString("datepicker") return s.getString("datepicker")
} }

View file

@ -211,6 +211,9 @@
"privatekey" = "Private Key" "privatekey" = "Private Key"
"clickOnQRcode" = "Click on QR Code to Copy" "clickOnQRcode" = "Click on QR Code to Copy"
"client" = "Client" "client" = "Client"
"vlessDomain" = "VLESS Domain"
"vlessDomainDesc" = "Domain name to use in VLESS subscription links for this inbound. (leave blank to use global setting or default)"
"vlessDomainPlaceholder" = "e.g., vless.example.com"
"export" = "Export All URLs" "export" = "Export All URLs"
"clone" = "Clone" "clone" = "Clone"
"cloneInbound" = "Clone" "cloneInbound" = "Clone"
@ -386,6 +389,8 @@
"subPathDesc" = "The URI path for the subscription service. (begins with / and concludes with /)" "subPathDesc" = "The URI path for the subscription service. (begins with / and concludes with /)"
"subDomain" = "Listen Domain" "subDomain" = "Listen Domain"
"subDomainDesc" = "The domain name for the subscription service. (leave blank to listen on all domains and IPs)" "subDomainDesc" = "The domain name for the subscription service. (leave blank to listen on all domains and IPs)"
"subVlessDomain" = "VLESS Domain"
"subVlessDomainDesc" = "Domain name to use in VLESS subscription links. (leave blank to use the default subscription domain)"
"subUpdates" = "Update Intervals" "subUpdates" = "Update Intervals"
"subUpdatesDesc" = "The update intervals of the subscription URL in the client apps. (unit: hour)" "subUpdatesDesc" = "The update intervals of the subscription URL in the client apps. (unit: hour)"
"subEncrypt" = "Encode" "subEncrypt" = "Encode"
@ -394,6 +399,8 @@
"subShowInfoDesc" = "The remaining traffic and date will be displayed in the client apps." "subShowInfoDesc" = "The remaining traffic and date will be displayed in the client apps."
"subURI" = "Reverse Proxy URI" "subURI" = "Reverse Proxy URI"
"subURIDesc" = "The URI path of the subscription URL for use behind proxies." "subURIDesc" = "The URI path of the subscription URL for use behind proxies."
"subRemoteServers" = "Remote Subscription Servers"
"subRemoteServersDesc" = "Comma-separated list of remote server URLs. Can be full paths (e.g., http://serv1.com/config/sub) or domains (e.g., server1.com). SubId will be appended automatically."
"externalTrafficInformEnable" = "External Traffic Inform" "externalTrafficInformEnable" = "External Traffic Inform"
"externalTrafficInformEnableDesc" = "Inform external API on every traffic update." "externalTrafficInformEnableDesc" = "Inform external API on every traffic update."
"externalTrafficInformURI" = "External Traffic Inform URI" "externalTrafficInformURI" = "External Traffic Inform URI"

View file

@ -211,6 +211,9 @@
"privatekey" = "Приватный ключ" "privatekey" = "Приватный ключ"
"clickOnQRcode" = "Нажмите на QR-код, чтобы скопировать" "clickOnQRcode" = "Нажмите на QR-код, чтобы скопировать"
"client" = "Клиент" "client" = "Клиент"
"vlessDomain" = "Домен для VLESS"
"vlessDomainDesc" = "Домен для использования в ссылках подписки VLESS для этого inbound. (оставьте пустым для использования глобальной настройки или значения по умолчанию)"
"vlessDomainPlaceholder" = "например, vless.example.com"
"export" = "Экспорт ссылок" "export" = "Экспорт ссылок"
"clone" = "Клонировать" "clone" = "Клонировать"
"cloneInbound" = "Клонировать" "cloneInbound" = "Клонировать"
@ -386,6 +389,8 @@
"subPathDesc" = "Должен начинаться с '/' и заканчиваться на '/'" "subPathDesc" = "Должен начинаться с '/' и заканчиваться на '/'"
"subDomain" = "Домен прослушивания" "subDomain" = "Домен прослушивания"
"subDomainDesc" = "Оставьте пустым по умолчанию, чтобы слушать все домены и IP-адреса" "subDomainDesc" = "Оставьте пустым по умолчанию, чтобы слушать все домены и IP-адреса"
"subVlessDomain" = "Домен для VLESS"
"subVlessDomainDesc" = "Домен для использования в ссылках подписки VLESS. (оставьте пустым для использования домена подписки по умолчанию)"
"subUpdates" = "Интервалы обновления подписки" "subUpdates" = "Интервалы обновления подписки"
"subUpdatesDesc" = "Интервал между обновлениями в клиентском приложении (в часах)" "subUpdatesDesc" = "Интервал между обновлениями в клиентском приложении (в часах)"
"subEncrypt" = "Шифровать конфиги" "subEncrypt" = "Шифровать конфиги"
@ -394,6 +399,8 @@
"subShowInfoDesc" = "Отображать остаток трафика и дату окончания после имени конфигурации" "subShowInfoDesc" = "Отображать остаток трафика и дату окончания после имени конфигурации"
"subURI" = "URI обратного прокси" "subURI" = "URI обратного прокси"
"subURIDesc" = "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами" "subURIDesc" = "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами"
"subRemoteServers" = "Удаленные серверы подписок"
"subRemoteServersDesc" = "Список URL-ов удаленных серверов через запятую. Можно указать полный путь (например, http://serv1.com/config/sub) или домен (например, server1.com). SubId будет добавлен автоматически."
"externalTrafficInformEnable" = "Информация о внешнем трафике" "externalTrafficInformEnable" = "Информация о внешнем трафике"
"externalTrafficInformEnableDesc" = "Информировать внешний API о каждом обновлении трафика" "externalTrafficInformEnableDesc" = "Информировать внешний API о каждом обновлении трафика"
"externalTrafficInformURI" = "URI информации о внешнем трафике" "externalTrafficInformURI" = "URI информации о внешнем трафике"