diff --git a/sub/subClashService.go b/sub/subClashService.go index ea095919..f0a8ca8a 100644 --- a/sub/subClashService.go +++ b/sub/subClashService.go @@ -160,10 +160,10 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any { proxy := map[string]any{ - "name": s.SubService.genRemark(inbound, client.Email, extraRemark), + "name": s.SubService.genRemark(inbound, client.Email, extraRemark), "server": inbound.Listen, - "port": inbound.Port, - "udp": true, + "port": inbound.Port, + "udp": true, } network, _ := stream["network"].(string) diff --git a/web/controller/custom_geo.go b/web/controller/custom_geo.go index b0542581..677bda99 100644 --- a/web/controller/custom_geo.go +++ b/web/controller/custom_geo.go @@ -62,6 +62,12 @@ func mapCustomGeoErr(c *gin.Context, err error) error { case errors.Is(err, service.ErrCustomGeoDownload): logger.Warning("custom geo download:", err) return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload")) + case errors.Is(err, service.ErrCustomGeoSSRFBlocked): + logger.Warning("custom geo SSRF blocked:", err) + return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost")) + case errors.Is(err, service.ErrCustomGeoPathTraversal): + logger.Warning("custom geo path traversal blocked:", err) + return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload")) default: return err } diff --git a/web/entity/entity.go b/web/entity/entity.go index 14353cf0..7f37f564 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -76,9 +76,9 @@ type AllSetting struct { SubURI string `json:"subURI" form:"subURI"` // Subscription server URI SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI - SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint - SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint - SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI + SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint + SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint + SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration diff --git a/web/service/custom_geo.go b/web/service/custom_geo.go index a8b7456b..cb84c4d7 100644 --- a/web/service/custom_geo.go +++ b/web/service/custom_geo.go @@ -1,9 +1,11 @@ package service import ( + "context" "errors" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -43,6 +45,8 @@ var ( ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias") ErrCustomGeoNotFound = errors.New("custom_geo_not_found") ErrCustomGeoDownload = errors.New("custom_geo_download") + ErrCustomGeoSSRFBlocked = errors.New("custom_geo_ssrf_blocked") + ErrCustomGeoPathTraversal = errors.New("custom_geo_path_traversal") ) type CustomGeoUpdateAllItem struct { @@ -111,25 +115,41 @@ func (s *CustomGeoService) validateAlias(alias string) error { return nil } -func (s *CustomGeoService) validateURL(raw string) error { +func (s *CustomGeoService) sanitizeURL(raw string) (string, error) { if raw == "" { - return ErrCustomGeoURLRequired + return "", ErrCustomGeoURLRequired } u, err := url.Parse(raw) if err != nil { - return ErrCustomGeoInvalidURL + return "", ErrCustomGeoInvalidURL } if u.Scheme != "http" && u.Scheme != "https" { - return ErrCustomGeoURLScheme + return "", ErrCustomGeoURLScheme } if u.Host == "" { - return ErrCustomGeoURLHost + return "", ErrCustomGeoURLHost } - return nil + if err := checkSSRF(context.Background(), u.Hostname()); err != nil { + return "", err + } + // Reconstruct URL from parsed components to break taint propagation. + clean := &url.URL{ + Scheme: u.Scheme, + Host: u.Host, + Path: u.Path, + RawPath: u.RawPath, + RawQuery: u.RawQuery, + Fragment: u.Fragment, + } + return clean.String(), nil } func localDatFileNeedsRepair(path string) bool { - fi, err := os.Stat(path) + safePath, err := sanitizeDestPath(path) + if err != nil { + return true + } + fi, err := os.Stat(safePath) if err != nil { return true } @@ -143,9 +163,56 @@ func CustomGeoLocalFileNeedsRepair(path string) bool { return localDatFileNeedsRepair(path) } +func isBlockedIP(ip net.IP) bool { + return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() +} + +// checkSSRFDefault validates that the given host does not resolve to a private/internal IP. +// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution. +func checkSSRFDefault(ctx context.Context, hostname string) error { + ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname) + if err != nil { + return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname) + } + for _, ipAddr := range ips { + if isBlockedIP(ipAddr.IP) { + return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP) + } + } + return nil +} + +// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers. +var checkSSRF = checkSSRFDefault + +func ssrfSafeTransport() http.RoundTripper { + base, ok := http.DefaultTransport.(*http.Transport) + if !ok { + base = &http.Transport{} + } + cloned := base.Clone() + cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err) + } + if err := checkSSRF(ctx, host); err != nil { + return nil, err + } + var dialer net.Dialer + return dialer.DialContext(ctx, network, addr) + } + return cloned +} + func probeCustomGeoURLWithGET(rawURL string) error { - client := &http.Client{Timeout: customGeoProbeTimeout} - req, err := http.NewRequest(http.MethodGet, rawURL, nil) + sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL) + if err != nil { + return err + } + client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()} + req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil) if err != nil { return err } @@ -165,8 +232,12 @@ func probeCustomGeoURLWithGET(rawURL string) error { } func probeCustomGeoURL(rawURL string) error { - client := &http.Client{Timeout: customGeoProbeTimeout} - req, err := http.NewRequest(http.MethodHead, rawURL, nil) + sanitizedURL, err := (&CustomGeoService{}).sanitizeURL(rawURL) + if err != nil { + return err + } + client := &http.Client{Timeout: customGeoProbeTimeout, Transport: ssrfSafeTransport()} + req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil) if err != nil { return err } @@ -199,10 +270,12 @@ func (s *CustomGeoService) EnsureOnStartup() { logger.Infof("custom geo startup: checking %d custom geofile(s)", n) for i := range list { r := &list[i] - if err := s.validateURL(r.Url); err != nil { + sanitizedURL, err := s.sanitizeURL(r.Url) + if err != nil { logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err) continue } + r.Url = sanitizedURL s.syncLocalPath(r) localPath := r.LocalPath if !localDatFileNeedsRepair(localPath) { @@ -218,28 +291,71 @@ func (s *CustomGeoService) EnsureOnStartup() { } func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) { - skipped, lm, err := s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, false) + safeDestPath, err := sanitizeDestPath(destPath) + if err != nil { + return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) + } + + skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false) if err != nil { return false, "", err } if skipped { - if _, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) { + if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) { return true, lm, nil } - return s.downloadToPathOnce(resourceURL, destPath, lastModifiedHeader, true) + return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true) } return false, lm, nil } +// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal. +// It resolves symlinks to prevent symlink-based escapes. +// Returns the cleaned absolute path that is safe to use in file operations. +func sanitizeDestPath(destPath string) (string, error) { + baseDirAbs, err := filepath.Abs(config.GetBinFolderPath()) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err) + } + // Resolve symlinks in base directory to get the real path. + if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil { + baseDirAbs = resolved + } + destPathAbs, err := filepath.Abs(destPath) + if err != nil { + return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err) + } + // Resolve symlinks for the parent directory of the destination path. + destDir := filepath.Dir(destPathAbs) + if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil { + destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs)) + } + // Verify the resolved path is within the safe base directory using prefix check. + safeDirPrefix := baseDirAbs + string(filepath.Separator) + if !strings.HasPrefix(destPathAbs, safeDirPrefix) { + return "", ErrCustomGeoPathTraversal + } + return destPathAbs, nil +} + func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) { + safeDestPath, err := sanitizeDestPath(destPath) + if err != nil { + return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) + } + sanitizedURL, err := s.sanitizeURL(resourceURL) + if err != nil { + return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) + } + var req *http.Request - req, err = http.NewRequest(http.MethodGet, resourceURL, nil) + req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil) if err != nil { return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) } if !forceFull { - if fi, statErr := os.Stat(destPath); statErr == nil && !localDatFileNeedsRepair(destPath) { + if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) { if !fi.ModTime().IsZero() { req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat)) } else if lastModifiedHeader != "" { @@ -250,7 +366,7 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last } } - client := &http.Client{Timeout: 10 * time.Minute} + client := &http.Client{Timeout: 10 * time.Minute, Transport: ssrfSafeTransport()} resp, err := client.Do(req) if err != nil { return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) @@ -267,7 +383,7 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last updateModTime := func() { if !serverModTime.IsZero() { - _ = os.Chtimes(destPath, serverModTime, serverModTime) + _ = os.Chtimes(safeDestPath, serverModTime, serverModTime) } } @@ -282,33 +398,36 @@ func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, last return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode) } - binDir := filepath.Dir(destPath) + binDir := filepath.Dir(safeDestPath) if err = os.MkdirAll(binDir, 0o755); err != nil { return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) } - tmpPath := destPath + ".tmp" - out, err := os.Create(tmpPath) + safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp") + if err != nil { + return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) + } + out, err := os.Create(safeTmpPath) if err != nil { return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) } n, err := io.Copy(out, resp.Body) closeErr := out.Close() if err != nil { - _ = os.Remove(tmpPath) + _ = os.Remove(safeTmpPath) return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) } if closeErr != nil { - _ = os.Remove(tmpPath) + _ = os.Remove(safeTmpPath) return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr) } if n < minDatBytes { - _ = os.Remove(tmpPath) + _ = os.Remove(safeTmpPath) return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload) } - if err = os.Rename(tmpPath, destPath); err != nil { - _ = os.Remove(tmpPath) + if err = os.Rename(safeTmpPath, safeDestPath); err != nil { + _ = os.Remove(safeTmpPath) return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err) } @@ -331,6 +450,29 @@ func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) { r.LocalPath = p } +func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error { + s.syncLocalPath(r) + safePath, err := sanitizeDestPath(r.LocalPath) + if err != nil { + return err + } + r.LocalPath = safePath + return nil +} + +func removeSafePathIfExists(path string) error { + safePath, err := sanitizeDestPath(path) + if err != nil { + return err + } + if _, err := os.Stat(safePath); err == nil { + if err := os.Remove(safePath); err != nil { + return err + } + } + return nil +} + func (s *CustomGeoService) Create(r *model.CustomGeoResource) error { if err := s.validateType(r.Type); err != nil { return err @@ -338,16 +480,20 @@ func (s *CustomGeoService) Create(r *model.CustomGeoResource) error { if err := s.validateAlias(r.Alias); err != nil { return err } - if err := s.validateURL(r.Url); err != nil { + sanitizedURL, err := s.sanitizeURL(r.Url) + if err != nil { return err } + r.Url = sanitizedURL var existing int64 database.GetDB().Model(&model.CustomGeoResource{}). Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing) if existing > 0 { return ErrCustomGeoDuplicateAlias } - s.syncLocalPath(r) + if err := s.syncAndSanitizeLocalPath(r); err != nil { + return err + } skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified) if err != nil { return err @@ -356,7 +502,7 @@ func (s *CustomGeoService) Create(r *model.CustomGeoResource) error { r.LastUpdatedAt = now r.LastModified = lm if err = database.GetDB().Create(r).Error; err != nil { - _ = os.Remove(r.LocalPath) + _ = removeSafePathIfExists(r.LocalPath) return err } logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped) @@ -380,9 +526,11 @@ func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error { if err := s.validateAlias(r.Alias); err != nil { return err } - if err := s.validateURL(r.Url); err != nil { + sanitizedURL, err := s.sanitizeURL(r.Url) + if err != nil { return err } + r.Url = sanitizedURL if cur.Type != r.Type || cur.Alias != r.Alias { var cnt int64 database.GetDB().Model(&model.CustomGeoResource{}). @@ -393,12 +541,13 @@ func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error { } } oldPath := s.resolveDestPath(&cur) - s.syncLocalPath(r) r.Id = id - r.LocalPath = filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias)) + if err := s.syncAndSanitizeLocalPath(r); err != nil { + return err + } if oldPath != r.LocalPath && oldPath != "" { - if _, err := os.Stat(oldPath); err == nil { - _ = os.Remove(oldPath) + if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) { + logger.Warningf("custom geo remove old path %s: %v", oldPath, err) } } _, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified) @@ -435,14 +584,15 @@ func (s *CustomGeoService) Delete(id int) (displayName string, err error) { } displayName = s.fileNameFor(r.Type, r.Alias) p := s.resolveDestPath(&r) + if _, err := sanitizeDestPath(p); err != nil { + return displayName, err + } if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil { return displayName, err } if p != "" { - if _, err := os.Stat(p); err == nil { - if rmErr := os.Remove(p); rmErr != nil { - logger.Warningf("custom geo delete file %s: %v", p, rmErr) - } + if err := removeSafePathIfExists(p); err != nil { + logger.Warningf("custom geo delete file %s: %v", p, err) } } logger.Infof("custom geo deleted id=%d", id) @@ -467,8 +617,14 @@ func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (disp return "", err } displayName = s.fileNameFor(r.Type, r.Alias) - s.syncLocalPath(&r) - skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified) + if err := s.syncAndSanitizeLocalPath(&r); err != nil { + return displayName, err + } + sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url) + if sanitizeErr != nil { + return displayName, sanitizeErr + } + skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified) if err != nil { if onStartup { logger.Warningf("custom geo startup download id=%d: %v", id, err) diff --git a/web/service/custom_geo_test.go b/web/service/custom_geo_test.go index 811a0f62..c935b86a 100644 --- a/web/service/custom_geo_test.go +++ b/web/service/custom_geo_test.go @@ -1,6 +1,7 @@ package service import ( + "context" "errors" "fmt" "net/http" @@ -12,6 +13,15 @@ import ( "github.com/mhsanaei/3x-ui/v2/database/model" ) +// disableSSRFCheck disables the SSRF guard for the duration of a test, +// allowing httptest servers on localhost. It restores the original on cleanup. +func disableSSRFCheck(t *testing.T) { + t.Helper() + orig := checkSSRF + checkSSRF = func(_ context.Context, _ string) error { return nil } + t.Cleanup(func() { checkSSRF = orig }) +} + func TestNormalizeAliasKey(t *testing.T) { if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" { t.Fatalf("got %q", got) @@ -139,14 +149,16 @@ func TestCustomGeoValidateAlias(t *testing.T) { func TestCustomGeoValidateURL(t *testing.T) { s := CustomGeoService{} - if err := s.validateURL(""); !errors.Is(err, ErrCustomGeoURLRequired) { + if _, err := s.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) { t.Fatal("empty") } - if err := s.validateURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) { + if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) { t.Fatal("ftp") } - if err := s.validateURL("https://example.com/a.dat"); err != nil { + if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil { t.Fatal(err) + } else if sanitized != "https://example.com/a.dat" { + t.Fatalf("unexpected sanitized URL: %s", sanitized) } } @@ -161,6 +173,7 @@ func TestCustomGeoValidateType(t *testing.T) { } func TestCustomGeoDownloadToPath(t *testing.T) { + disableSSRFCheck(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Test", "1") if r.Header.Get("If-Modified-Since") != "" { @@ -193,6 +206,7 @@ func TestCustomGeoDownloadToPath(t *testing.T) { } func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) { + disableSSRFCheck(t) lm := "Wed, 21 Oct 2015 07:28:00 GMT" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("If-Modified-Since") != "" { @@ -221,6 +235,7 @@ func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) { } func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) { + disableSSRFCheck(t) lm := "Wed, 21 Oct 2015 07:28:00 GMT" ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("If-Modified-Since") != "" { @@ -264,6 +279,7 @@ func TestCustomGeoFileNameFor(t *testing.T) { func TestLocalDatFileNeedsRepair(t *testing.T) { dir := t.TempDir() + t.Setenv("XUI_BIN_FOLDER", dir) if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) { t.Fatal("missing") } @@ -297,6 +313,7 @@ func TestLocalDatFileNeedsRepair(t *testing.T) { } func TestProbeCustomGeoURL_HEADOK(t *testing.T) { + disableSSRFCheck(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusOK) @@ -311,6 +328,7 @@ func TestProbeCustomGeoURL_HEADOK(t *testing.T) { } func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) { + disableSSRFCheck(t) ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodHead { w.WriteHeader(http.StatusMethodNotAllowed) diff --git a/web/service/setting.go b/web/service/setting.go index 7027d466..468a7960 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -758,13 +758,13 @@ func extractHostname(host string) string { func (s *SettingService) GetDefaultSettings(host string) (any, error) { type settingFunc func() (any, error) settings := map[string]settingFunc{ - "expireDiff": func() (any, error) { return s.GetExpireDiff() }, - "trafficDiff": func() (any, error) { return s.GetTrafficDiff() }, - "pageSize": func() (any, error) { return s.GetPageSize() }, - "defaultCert": func() (any, error) { return s.GetCertFile() }, - "defaultKey": func() (any, error) { return s.GetKeyFile() }, - "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, - "subEnable": func() (any, error) { return s.GetSubEnable() }, + "expireDiff": func() (any, error) { return s.GetExpireDiff() }, + "trafficDiff": func() (any, error) { return s.GetTrafficDiff() }, + "pageSize": func() (any, error) { return s.GetPageSize() }, + "defaultCert": func() (any, error) { return s.GetCertFile() }, + "defaultKey": func() (any, error) { return s.GetKeyFile() }, + "tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() }, + "subEnable": func() (any, error) { return s.GetSubEnable() }, "subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() }, "subClashEnable": func() (any, error) { return s.GetSubClashEnable() }, "subTitle": func() (any, error) { return s.GetSubTitle() }, @@ -772,8 +772,8 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { "subJsonURI": func() (any, error) { return s.GetSubJsonURI() }, "subClashURI": func() (any, error) { return s.GetSubClashURI() }, "remarkModel": func() (any, error) { return s.GetRemarkModel() }, - "datepicker": func() (any, error) { return s.GetDatepicker() }, - "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, + "datepicker": func() (any, error) { return s.GetDatepicker() }, + "ipLimitEnable": func() (any, error) { return s.GetIpLimitEnable() }, } result := make(map[string]any)