diff --git a/database/model/model.go b/database/model/model.go
index 4ca39d87..ca129c43 100644
--- a/database/model/model.go
+++ b/database/model/model.go
@@ -53,6 +53,7 @@ type Inbound struct {
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
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.
diff --git a/sub/subService.go b/sub/subService.go
index 55bddf7f..3eeadb6b 100644
--- a/sub/subService.go
+++ b/sub/subService.go
@@ -3,7 +3,9 @@ package sub
import (
"encoding/base64"
"fmt"
+ "io"
"net"
+ "net/http"
"net/url"
"strings"
"time"
@@ -50,9 +52,8 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
return nil, 0, traffic, err
}
- if len(inbounds) == 0 {
- return nil, 0, traffic, common.NewError("No inbounds found with ", subId)
- }
+ // Allow empty inbounds if we have remote servers configured
+ hasLocalInbounds := len(inbounds) > 0
s.datepicker, err = s.settingService.GetDatepicker()
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
}
+// 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) {
db := database.GetDB()
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 {
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 {
return ""
}
diff --git a/web/assets/js/model/dbinbound.js b/web/assets/js/model/dbinbound.js
index befc618e..3fccbb63 100644
--- a/web/assets/js/model/dbinbound.js
+++ b/web/assets/js/model/dbinbound.js
@@ -20,6 +20,7 @@ class DBInbound {
this.streamSettings = "";
this.tag = "";
this.sniffing = "";
+ this.vlessDomain = "";
this.clientStats = ""
if (data == null) {
return;
diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js
index 53ffae1a..9713ed1c 100644
--- a/web/assets/js/model/setting.js
+++ b/web/assets/js/model/setting.js
@@ -34,6 +34,7 @@ class AllSetting {
this.subPath = "/sub/";
this.subJsonPath = "/json/";
this.subDomain = "";
+ this.subVlessDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
this.subCertFile = "";
@@ -47,6 +48,7 @@ class AllSetting {
this.subJsonNoises = "";
this.subJsonMux = "";
this.subJsonRules = "";
+ this.subRemoteServers = "";
this.timeLocation = "Local";
diff --git a/web/entity/entity.go b/web/entity/entity.go
index 42e2df85..0115fcf1 100644
--- a/web/entity/entity.go
+++ b/web/entity/entity.go
@@ -61,6 +61,7 @@ type AllSetting struct {
SubPort int `json:"subPort" form:"subPort"` // Subscription server port
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
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
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server
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
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
+ SubRemoteServers string `json:"subRemoteServers" form:"subRemoteServers"` // JSON array of remote server URLs for subscription aggregation
// LDAP settings
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html
index 140b9c1a..e9dd952d 100644
--- a/web/html/form/protocol/vless.html
+++ b/web/html/form/protocol/vless.html
@@ -18,6 +18,20 @@
+
+
+
+
+
+ {{ i18n "pages.inbounds.vlessDomainDesc" }}
+
+ {{ i18n "pages.inbounds.vlessDomain" }}
+
+
+
+
+
+
diff --git a/web/html/inbounds.html b/web/html/inbounds.html
index 8616dce5..6e3a0670 100644
--- a/web/html/inbounds.html
+++ b/web/html/inbounds.html
@@ -1076,6 +1076,7 @@
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
+ vlessDomain: dbInbound.vlessDomain || "",
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
@@ -1101,6 +1102,7 @@
port: inbound.port,
protocol: inbound.protocol,
settings: inbound.settings.toString(),
+ vlessDomain: dbInbound.vlessDomain || "",
};
if (inbound.canEnableStream()) {
data.streamSettings = inbound.stream.toString();
diff --git a/web/html/settings.html b/web/html/settings.html
index e664e6ec..ae2c6958 100644
--- a/web/html/settings.html
+++ b/web/html/settings.html
@@ -123,6 +123,7 @@
user: {},
lang: LanguageManager.getLanguage(),
inboundOptions: [],
+ subRemoteServersInput: '',
remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' },
remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'],
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
@@ -243,10 +244,61 @@
this.oldAllSetting = new AllSetting(msg.obj);
this.allSetting = new AllSetting(msg.obj);
+ // Initialize subRemoteServersInput from JSON
+ this.updateSubRemoteServersInput();
app.changeRemarkSample();
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() {
const msg = await HttpUtil.get("/panel/api/inbounds/list");
if (msg && msg.success && Array.isArray(msg.obj)) {
@@ -259,6 +311,8 @@
}
},
async updateAllSetting() {
+ // Convert remote servers input to JSON before saving
+ this.convertRemoteServersToJson();
this.loading(true);
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
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() {
await this.getAllSetting();
await this.loadInboundTags();
diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html
index e65b2738..5c018db5 100644
--- a/web/html/settings/panel/subscription/general.html
+++ b/web/html/settings/panel/subscription/general.html
@@ -36,6 +36,13 @@
+
+ {{ i18n "pages.settings.subVlessDomain"}}
+ {{ i18n "pages.settings.subVlessDomainDesc"}}
+
+
+
+
{{ i18n "pages.settings.subPort"}}
{{ i18n "pages.settings.subPortDesc"}}
@@ -62,6 +69,15 @@
v-model="allSetting.subURI">
+
+ {{ i18n "pages.settings.subRemoteServers"}}
+ {{ i18n "pages.settings.subRemoteServersDesc"}}
+
+
+
+
diff --git a/web/service/setting.go b/web/service/setting.go
index 56db346d..432788f6 100644
--- a/web/service/setting.go
+++ b/web/service/setting.go
@@ -57,6 +57,7 @@ var defaultValueMap = map[string]string{
"subPort": "2096",
"subPath": "/sub/",
"subDomain": "",
+ "subVlessDomain": "",
"subCertFile": "",
"subKeyFile": "",
"subUpdates": "12",
@@ -69,6 +70,7 @@ var defaultValueMap = map[string]string{
"subJsonNoises": "",
"subJsonMux": "",
"subJsonRules": "",
+ "subRemoteServers": "",
"datepicker": "gregorian",
"warp": "",
"externalTrafficInformEnable": "false",
@@ -479,6 +481,14 @@ func (s *SettingService) GetSubDomain() (string, error) {
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 {
return s.setString("subCertFile", subCertFile)
}
@@ -535,6 +545,14 @@ func (s *SettingService) GetSubJsonRules() (string, error) {
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) {
return s.getString("datepicker")
}
diff --git a/web/translation/translate.en_US.toml b/web/translation/translate.en_US.toml
index e56f5f89..69020091 100644
--- a/web/translation/translate.en_US.toml
+++ b/web/translation/translate.en_US.toml
@@ -211,6 +211,9 @@
"privatekey" = "Private Key"
"clickOnQRcode" = "Click on QR Code to Copy"
"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"
"clone" = "Clone"
"cloneInbound" = "Clone"
@@ -386,6 +389,8 @@
"subPathDesc" = "The URI path for the subscription service. (begins with ‘/‘ and concludes with ‘/‘)"
"subDomain" = "Listen Domain"
"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"
"subUpdatesDesc" = "The update intervals of the subscription URL in the client apps. (unit: hour)"
"subEncrypt" = "Encode"
@@ -394,6 +399,8 @@
"subShowInfoDesc" = "The remaining traffic and date will be displayed in the client apps."
"subURI" = "Reverse Proxy URI"
"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"
"externalTrafficInformEnableDesc" = "Inform external API on every traffic update."
"externalTrafficInformURI" = "External Traffic Inform URI"
diff --git a/web/translation/translate.ru_RU.toml b/web/translation/translate.ru_RU.toml
index 847b718e..c04f0b7e 100644
--- a/web/translation/translate.ru_RU.toml
+++ b/web/translation/translate.ru_RU.toml
@@ -211,6 +211,9 @@
"privatekey" = "Приватный ключ"
"clickOnQRcode" = "Нажмите на QR-код, чтобы скопировать"
"client" = "Клиент"
+"vlessDomain" = "Домен для VLESS"
+"vlessDomainDesc" = "Домен для использования в ссылках подписки VLESS для этого inbound. (оставьте пустым для использования глобальной настройки или значения по умолчанию)"
+"vlessDomainPlaceholder" = "например, vless.example.com"
"export" = "Экспорт ссылок"
"clone" = "Клонировать"
"cloneInbound" = "Клонировать"
@@ -386,6 +389,8 @@
"subPathDesc" = "Должен начинаться с '/' и заканчиваться на '/'"
"subDomain" = "Домен прослушивания"
"subDomainDesc" = "Оставьте пустым по умолчанию, чтобы слушать все домены и IP-адреса"
+"subVlessDomain" = "Домен для VLESS"
+"subVlessDomainDesc" = "Домен для использования в ссылках подписки VLESS. (оставьте пустым для использования домена подписки по умолчанию)"
"subUpdates" = "Интервалы обновления подписки"
"subUpdatesDesc" = "Интервал между обновлениями в клиентском приложении (в часах)"
"subEncrypt" = "Шифровать конфиги"
@@ -394,6 +399,8 @@
"subShowInfoDesc" = "Отображать остаток трафика и дату окончания после имени конфигурации"
"subURI" = "URI обратного прокси"
"subURIDesc" = "Изменить базовый URI URL-адреса подписки для использования за прокси-серверами"
+"subRemoteServers" = "Удаленные серверы подписок"
+"subRemoteServersDesc" = "Список URL-ов удаленных серверов через запятую. Можно указать полный путь (например, http://serv1.com/config/sub) или домен (например, server1.com). SubId будет добавлен автоматически."
"externalTrafficInformEnable" = "Информация о внешнем трафике"
"externalTrafficInformEnableDesc" = "Информировать внешний API о каждом обновлении трафика"
"externalTrafficInformURI" = "URI информации о внешнем трафике"