From fd9a62a91b1e438e46334e55c553c499193f183a Mon Sep 17 00:00:00 2001 From: /SNESE_AR Date: Sun, 23 Nov 2025 18:41:58 +0300 Subject: [PATCH] update --- database/model/model.go | 1 + sub/subService.go | 143 +++++++++++++++++- web/assets/js/model/dbinbound.js | 1 + web/assets/js/model/setting.js | 2 + web/entity/entity.go | 2 + web/html/form/protocol/vless.html | 14 ++ web/html/inbounds.html | 2 + web/html/settings.html | 65 ++++++++ .../settings/panel/subscription/general.html | 16 ++ web/service/setting.go | 18 +++ web/translation/translate.en_US.toml | 7 + web/translation/translate.ru_RU.toml | 7 + 12 files changed, 275 insertions(+), 3 deletions(-) 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 @@ + + + + + + + + + + + @@ -62,6 +69,15 @@ v-model="allSetting.subURI"> + + + + + 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 информации о внешнем трафике"