diff --git a/app.example.ini b/app.example.ini index 713bb238..8014a2bb 100644 --- a/app.example.ini +++ b/app.example.ini @@ -98,6 +98,14 @@ APIType = EnableCodeCompletion = false CodeCompletionModel = gpt-4o-mini +[site_check] +; Enable or disable the periodic Site Checker that probes every server_name. +Enabled = true +; Maximum concurrent health checks per sweep. Clamped to [1, 20]. +Concurrency = 5 +; Interval between sweeps, in seconds. Minimum 30. +IntervalSeconds = 300 + [terminal] StartCmd = bash diff --git a/docs/.vitepress/config/en.ts b/docs/.vitepress/config/en.ts index 1e2d862d..f13cdfd4 100644 --- a/docs/.vitepress/config/en.ts +++ b/docs/.vitepress/config/en.ts @@ -63,6 +63,7 @@ export const enConfig: LocaleSpecificConfig = { { text: 'Node', link: '/guide/config-node' }, { text: 'Open AI', link: '/guide/config-openai' }, { text: 'Server', link: '/guide/config-server' }, + { text: 'Site Check', link: '/guide/config-sitecheck' }, { text: 'Terminal', link: '/guide/config-terminal' }, { text: 'Webauthn', link: '/guide/config-webauthn' } ] diff --git a/docs/.vitepress/config/zh_CN.ts b/docs/.vitepress/config/zh_CN.ts index f47ec75f..e2a8eaca 100644 --- a/docs/.vitepress/config/zh_CN.ts +++ b/docs/.vitepress/config/zh_CN.ts @@ -68,6 +68,7 @@ export const zhCNConfig: LocaleSpecificConfig = { { text: 'Node', link: '/zh_CN/guide/config-node' }, { text: 'Open AI', link: '/zh_CN/guide/config-openai' }, { text: 'Server', link: '/zh_CN/guide/config-server' }, + { text: 'Site Check', link: '/zh_CN/guide/config-sitecheck' }, { text: 'Terminal', link: '/zh_CN/guide/config-terminal' }, { text: 'Webauthn', link: '/zh_CN/guide/config-webauthn' } ] diff --git a/docs/.vitepress/config/zh_TW.ts b/docs/.vitepress/config/zh_TW.ts index 024abda7..c31fda16 100644 --- a/docs/.vitepress/config/zh_TW.ts +++ b/docs/.vitepress/config/zh_TW.ts @@ -68,6 +68,7 @@ export const zhTWConfig: LocaleSpecificConfig = { { text: 'Node', link: '/zh_TW/guide/config-node' }, { text: 'Open AI', link: '/zh_TW/guide/config-openai' }, { text: 'Server', link: '/zh_TW/guide/config-server' }, + { text: 'Site Check', link: '/zh_TW/guide/config-sitecheck' }, { text: 'Terminal', link: '/zh_TW/guide/config-terminal' }, { text: 'Webauthn', link: '/zh_TW/guide/config-webauthn' } ] diff --git a/docs/guide/config-sitecheck.md b/docs/guide/config-sitecheck.md new file mode 100644 index 00000000..910f95c3 --- /dev/null +++ b/docs/guide/config-sitecheck.md @@ -0,0 +1,55 @@ +# Site Check + +The Site Checker probes every `server_name` Nginx serves to keep the Dashboard +status indicators current. This section controls how aggressively it runs. + +If your `server_name` entries resolve to ingress services that return many A +records (ngrok, AWS load balancers, Cloudflare), the historical defaults could +open enough concurrent outbound TCP flows to exhaust conntrack tables on +consumer routers (UniFi etc.). See +[issue #1608](https://github.com/0xJacky/nginx-ui/issues/1608). + +## Enabled + +- Type: `bool` +- Default: `true` +- Version: `>= v2.3.6` + +When `false`, the Site Checker service does not start: no periodic sweeps run +and no outbound connections are opened on its behalf. The Dashboard will keep +showing the last known state (or empty state on first start). Disable this when +you do not need automated health checks, or when the checker is causing +upstream / network problems. + +## Concurrency + +- Type: `int` +- Default: `5` +- Range: `[1, 20]` +- Version: `>= v2.3.6` + +The maximum number of concurrent health checks during a single sweep. Lower +values reduce burstiness; higher values complete a full sweep faster. The +checker also bounds connections per host (`MaxConnsPerHost = 2`), so even +hostnames with many A records will not open more than two concurrent flows +each. + +## IntervalSeconds + +- Type: `int` +- Default: `300` +- Minimum: `30` +- Version: `>= v2.3.6` + +How often, in seconds, the Site Checker re-sweeps every collected site. The +default of 5 minutes balances freshness against load. Values below 30 are +clamped back to the default. + +## Example + +```ini +[site_check] +Enabled = true +Concurrency = 5 +IntervalSeconds = 300 +``` diff --git a/docs/zh_CN/guide/config-sitecheck.md b/docs/zh_CN/guide/config-sitecheck.md new file mode 100644 index 00000000..6869e70f --- /dev/null +++ b/docs/zh_CN/guide/config-sitecheck.md @@ -0,0 +1,49 @@ +# 站点检查 + +站点检查器(Site Checker)会定期探测 Nginx 提供服务的每一个 `server_name`, +以便仪表盘上的状态指示保持实时。这一节用于控制其行为强度。 + +如果你的 `server_name` 解析到会返回大量 A 记录的入口服务(如 ngrok、AWS +负载均衡、Cloudflare),早期版本的默认配置可能会瞬间打开足够多的出站 TCP +连接,从而把家用路由器(如 UniFi)的 conntrack 表耗尽。详见 +[issue #1608](https://github.com/0xJacky/nginx-ui/issues/1608)。 + +## Enabled + +- 类型:`bool` +- 默认值:`true` +- 版本:`>= v2.3.6` + +设为 `false` 时,站点检查服务不会启动:不会执行周期性扫描,也不会代为发起 +任何出站连接。仪表盘将继续显示上一次记录的状态(首次启动则为空)。当你不 +需要自动健康检查、或检查器对上游 / 网络造成问题时,可以将其关闭。 + +## Concurrency + +- 类型:`int` +- 默认值:`5` +- 取值范围:`[1, 20]` +- 版本:`>= v2.3.6` + +单次扫描中允许并发执行的健康检查数量。该值越小越平稳,越大则完整一轮扫描 +越快。除此之外,检查器还会限制每个主机的并发连接数(`MaxConnsPerHost = 2`), +因此即使某个主机名解析出多个 A 记录,也最多只会并发产生 2 条连接。 + +## IntervalSeconds + +- 类型:`int` +- 默认值:`300` +- 最小值:`30` +- 版本:`>= v2.3.6` + +站点检查器对所有已收集站点进行重新扫描的间隔(秒)。默认 5 分钟,在数据新鲜 +度与系统负载之间取得平衡。低于 30 的取值会被回退为默认值。 + +## 示例 + +```ini +[site_check] +Enabled = true +Concurrency = 5 +IntervalSeconds = 300 +``` diff --git a/docs/zh_TW/guide/config-sitecheck.md b/docs/zh_TW/guide/config-sitecheck.md new file mode 100644 index 00000000..e951e72e --- /dev/null +++ b/docs/zh_TW/guide/config-sitecheck.md @@ -0,0 +1,49 @@ +# 站點檢查 + +站點檢查器(Site Checker)會定期探測 Nginx 服務的每一個 `server_name`, +讓儀表板上的狀態指示保持即時。本節用於控制其行為強度。 + +如果你的 `server_name` 解析到會回傳大量 A 紀錄的入口服務(例如 ngrok、AWS +負載平衡、Cloudflare),舊版本的預設值可能瞬間開啟足夠多的對外 TCP 連線, +進而耗盡家用路由器(例如 UniFi)的 conntrack 表。詳見 +[issue #1608](https://github.com/0xJacky/nginx-ui/issues/1608)。 + +## Enabled + +- 型別:`bool` +- 預設值:`true` +- 版本:`>= v2.3.6` + +設為 `false` 時,站點檢查服務不會啟動:不會執行週期性掃描,也不會代為發起 +任何對外連線。儀表板會繼續顯示上一次的狀態(首次啟動則為空)。當你不需要 +自動健康檢查、或檢查器對上游 / 網路造成問題時,可以將它關閉。 + +## Concurrency + +- 型別:`int` +- 預設值:`5` +- 範圍:`[1, 20]` +- 版本:`>= v2.3.6` + +單次掃描中允許併發執行的健康檢查數量。值越小越平穩,越大則完整一輪掃描越 +快。此外,檢查器還會限制每個主機的併發連線數(`MaxConnsPerHost = 2`), +因此即便某個主機名解析出多個 A 紀錄,也最多只會併發產生 2 條連線。 + +## IntervalSeconds + +- 型別:`int` +- 預設值:`300` +- 最小值:`30` +- 版本:`>= v2.3.6` + +站點檢查器對所有已收集站點重新掃描的間隔(秒)。預設 5 分鐘,在資料新鮮度 +與系統負載之間取得平衡。低於 30 的數值會被回退為預設值。 + +## 範例 + +```ini +[site_check] +Enabled = true +Concurrency = 5 +IntervalSeconds = 300 +``` diff --git a/internal/sitecheck/checker.go b/internal/sitecheck/checker.go index 049fb8cb..114abdd0 100644 --- a/internal/sitecheck/checker.go +++ b/internal/sitecheck/checker.go @@ -2,12 +2,10 @@ package sitecheck import ( "context" - "crypto/tls" "encoding/base64" "fmt" "io" "maps" - "net" "net/http" "net/url" "regexp" @@ -40,25 +38,15 @@ type SiteChecker struct { mu sync.RWMutex options CheckOptions client *http.Client + enhanced *EnhancedSiteChecker onUpdateCallback func([]*SiteInfo) // Callback for notifying updates } -// NewSiteChecker creates a new site checker +// NewSiteChecker creates a new site checker that reuses the package-level +// shared HTTP transport. The shared transport bounds connections per host so +// the checker cannot exhaust router conntrack tables (#1608). func NewSiteChecker(options CheckOptions) *SiteChecker { - transport := &http.Transport{ - Dial: (&net.Dialer{ - Timeout: 5 * time.Second, - }).Dial, - TLSHandshakeTimeout: 5 * time.Second, - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: settings.HTTPSettings.InsecureSkipVerify, - }, - } - - client := &http.Client{ - Transport: transport, - Timeout: options.Timeout, - } + client := SharedClient(options.Timeout) if !options.FollowRedirects { client.CheckRedirect = func(req *http.Request, via []*http.Request) error { @@ -74,9 +62,10 @@ func NewSiteChecker(options CheckOptions) *SiteChecker { } return &SiteChecker{ - sites: make(map[string]*SiteInfo), - options: options, - client: client, + sites: make(map[string]*SiteInfo), + options: options, + client: client, + enhanced: NewEnhancedSiteChecker(), } } @@ -318,9 +307,9 @@ func (sc *SiteChecker) CheckSite(ctx context.Context, siteURL string) (*SiteInfo } if err == nil && config != nil && config.HealthCheckConfig != nil { - enhancedChecker := NewEnhancedSiteChecker() - siteInfo, err := enhancedChecker.CheckSiteWithConfig(ctx, siteURL, config.HealthCheckConfig) - if err == nil && siteInfo != nil { + result, err := sc.enhanced.CheckSiteWithConfig(ctx, siteURL, config.HealthCheckConfig) + if err == nil && result != nil && result.Info != nil { + siteInfo := result.Info // Fill in additional details siteInfo.ID = config.ID siteInfo.HealthCheckEnabled = config.HealthCheckEnabled @@ -330,9 +319,11 @@ func (sc *SiteChecker) CheckSite(ctx context.Context, siteURL string) (*SiteInfo // Set health check protocol and display URL siteInfo.DisplayURL = generateDisplayURL(siteURL, config.HealthCheckConfig.Protocol) - // Try to get favicon if enabled and not a gRPC check + // Try to get favicon if enabled and not a gRPC check. + // Reuse the body fetched by the health check whenever possible + // to avoid issuing a second GET to the same host (#1608). if sc.options.CheckFavicon && !isGRPCProtocol(config.HealthCheckConfig.Protocol) { - faviconURL, faviconData := sc.tryGetFavicon(ctx, siteURL) + faviconURL, faviconData := sc.faviconFromBody(ctx, siteURL, result.Body) siteInfo.FaviconURL = faviconURL siteInfo.FaviconData = faviconData } @@ -417,9 +408,21 @@ func (sc *SiteChecker) checkSiteBasic(ctx context.Context, siteURL string, origi return siteInfo, nil } -// tryGetFavicon attempts to get favicon for enhanced checks +// faviconFromBody extracts the favicon using a body that has already been +// fetched by the health checker. If the body is empty (e.g. the path probed +// wasn't the homepage), it falls back to a single GET via the shared client. +func (sc *SiteChecker) faviconFromBody(ctx context.Context, siteURL string, body []byte) (string, string) { + if len(body) > 0 { + return sc.extractFavicon(ctx, siteURL, string(body)) + } + return sc.tryGetFavicon(ctx, siteURL) +} + +// tryGetFavicon attempts to get favicon when no health-check body is +// available (e.g. gRPC checks, or when the enhanced check failed and we are +// falling back). It uses the shared transport so it shares the per-host +// connection budget with the health check itself. func (sc *SiteChecker) tryGetFavicon(ctx context.Context, siteURL string) (string, string) { - // Make a simple GET request to get the HTML req, err := http.NewRequestWithContext(ctx, "GET", siteURL, nil) if err != nil { return "", "" @@ -445,8 +448,16 @@ func (sc *SiteChecker) tryGetFavicon(ctx context.Context, siteURL string) (strin return sc.extractFavicon(ctx, siteURL, string(body)) } -// CheckAllSites checks all collected sites concurrently +// CheckAllSites checks all collected sites concurrently. URLs sharing the +// same host:port are coalesced into a single network probe whose result is +// fanned out to every alias, so multi-server_name configs do not multiply +// outbound connections (#1608). func (sc *SiteChecker) CheckAllSites(ctx context.Context) { + if !settings.SiteCheckSettings.Enabled { + logger.Debug("Site check is disabled; skipping CheckAllSites") + return + } + sc.mu.RLock() urls := make([]string, 0, len(sc.sites)) for url := range sc.sites { @@ -454,37 +465,51 @@ func (sc *SiteChecker) CheckAllSites(ctx context.Context) { } sc.mu.RUnlock() - // Use a semaphore to limit concurrent requests - semaphore := make(chan struct{}, 10) // Max 10 concurrent requests + // Group URLs by host:port so duplicate aliases share one HTTP probe. + groups := make(map[string][]string, len(urls)) + for _, raw := range urls { + key := dedupeKey(raw) + groups[key] = append(groups[key], raw) + } + + concurrency := settings.SiteCheckSettings.GetConcurrency() + semaphore := make(chan struct{}, concurrency) var wg sync.WaitGroup - for _, url := range urls { + for _, aliases := range groups { wg.Add(1) - go func(siteURL string) { + go func(aliases []string) { defer wg.Done() - semaphore <- struct{}{} // Acquire semaphore - defer func() { <-semaphore }() // Release semaphore + semaphore <- struct{}{} + defer func() { <-semaphore }() - siteInfo, err := sc.CheckSite(ctx, siteURL) + primary := aliases[0] + siteInfo, err := sc.CheckSite(ctx, primary) if err != nil { - logger.Errorf("Failed to check site %s: %v", siteURL, err) + logger.Errorf("Failed to check site %s: %v", primary, err) return } sc.mu.Lock() - sc.sites[siteURL] = siteInfo + for _, alias := range aliases { + if alias == primary { + sc.sites[alias] = siteInfo + continue + } + clone := *siteInfo + sc.sites[alias] = &clone + } sc.mu.Unlock() - }(url) + }(aliases) } wg.Wait() - // logger.Infof("Completed checking %d sites", len(urls)) // Notify WebSocket clients of the update if sc.onUpdateCallback != nil { - sites := make([]*SiteInfo, 0, len(sc.sites)) sc.mu.RLock() + sites := make([]*SiteInfo, 0, len(sc.sites)) for _, site := range sc.sites { sites = append(sites, site) } @@ -493,6 +518,25 @@ func (sc *SiteChecker) CheckAllSites(ctx context.Context) { } } +// dedupeKey builds a stable key for an indexed site URL so aliases that +// resolve to the same host:port share a single network probe. +func dedupeKey(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return rawURL + } + host := strings.ToLower(parsed.Host) + if !strings.Contains(host, ":") { + switch parsed.Scheme { + case "https", "grpcs": + host += ":443" + default: + host += ":80" + } + } + return parsed.Scheme + "://" + host +} + // GetSites returns all checked sites func (sc *SiteChecker) GetSites() map[string]*SiteInfo { sc.mu.RLock() diff --git a/internal/sitecheck/httpclient.go b/internal/sitecheck/httpclient.go new file mode 100644 index 00000000..527679e8 --- /dev/null +++ b/internal/sitecheck/httpclient.go @@ -0,0 +1,111 @@ +package sitecheck + +import ( + "crypto/tls" + "net" + "net/http" + "sync" + "time" + + "github.com/0xJacky/Nginx-UI/model" + "github.com/0xJacky/Nginx-UI/settings" +) + +// Connection-pool sizing. Kept small on purpose: the Site Checker probes +// hosts that may resolve to ingress services with multiple A records, and we +// must not exhaust conntrack tables on consumer routers. See issue #1608. +const ( + siteCheckMaxIdleConns = 50 + siteCheckMaxIdleConnsPerHost = 2 + siteCheckMaxConnsPerHost = 2 + siteCheckIdleConnTimeout = 90 * time.Second + siteCheckTLSHandshakeTimeout = 10 * time.Second + siteCheckResponseHdrTimeout = 15 * time.Second + siteCheckDialTimeout = 5 * time.Second + siteCheckDialKeepAlive = 30 * time.Second +) + +var ( + sharedDialer = &net.Dialer{ + Timeout: siteCheckDialTimeout, + KeepAlive: siteCheckDialKeepAlive, + FallbackDelay: -1, // disable Happy Eyeballs IPv6 race that storms TIME_WAIT + } + + sharedTransport *http.Transport + sharedTransportOnce sync.Once +) + +// SharedTransport returns the package-level http.Transport used by every +// Site Checker request. Centralising it ensures connection reuse across +// goroutines and across sweep cycles. +func SharedTransport() *http.Transport { + sharedTransportOnce.Do(func() { + sharedTransport = newPooledTransport(&tls.Config{ + InsecureSkipVerify: settings.HTTPSettings.InsecureSkipVerify, + }) + }) + return sharedTransport +} + +// SharedClient returns an http.Client backed by the shared transport with the +// given per-request timeout. The client is cheap to construct; only the +// transport must be reused. +func SharedClient(timeout time.Duration) *http.Client { + return &http.Client{ + Transport: SharedTransport(), + Timeout: timeout, + } +} + +// ClientForHealthCheck returns the right client for a per-site health check. +// It reuses the shared transport whenever possible. A dedicated transport is +// only built when the per-site TLS configuration genuinely diverges from the +// global default (custom validation, hostname check, or client certificate), +// and it still uses the shared dialer + pool sizing. +func ClientForHealthCheck(cfg *model.HealthCheckConfig, timeout time.Duration) *http.Client { + if cfg == nil || !needsCustomTLS(cfg) { + return SharedClient(timeout) + } + + tlsConfig := &tls.Config{ + InsecureSkipVerify: !cfg.ValidateSSL, + } + if cfg.ClientCert != "" && cfg.ClientKey != "" { + if cert, err := tls.LoadX509KeyPair(cfg.ClientCert, cfg.ClientKey); err == nil { + tlsConfig.Certificates = []tls.Certificate{cert} + } + } + + return &http.Client{ + Transport: newPooledTransport(tlsConfig), + Timeout: timeout, + } +} + +func needsCustomTLS(cfg *model.HealthCheckConfig) bool { + if cfg == nil { + return false + } + if cfg.ValidateSSL || cfg.VerifyHostname { + return true + } + if cfg.ClientCert != "" && cfg.ClientKey != "" { + return true + } + return false +} + +func newPooledTransport(tlsConfig *tls.Config) *http.Transport { + return &http.Transport{ + DialContext: sharedDialer.DialContext, + TLSHandshakeTimeout: siteCheckTLSHandshakeTimeout, + ResponseHeaderTimeout: siteCheckResponseHdrTimeout, + IdleConnTimeout: siteCheckIdleConnTimeout, + MaxIdleConns: siteCheckMaxIdleConns, + MaxIdleConnsPerHost: siteCheckMaxIdleConnsPerHost, + MaxConnsPerHost: siteCheckMaxConnsPerHost, + ForceAttemptHTTP2: true, + TLSClientConfig: tlsConfig, + } +} diff --git a/internal/sitecheck/service.go b/internal/sitecheck/service.go index 500373e0..6711dd1e 100644 --- a/internal/sitecheck/service.go +++ b/internal/sitecheck/service.go @@ -6,6 +6,7 @@ import ( "time" "github.com/0xJacky/Nginx-UI/internal/cache" + "github.com/0xJacky/Nginx-UI/settings" "github.com/uozi-tech/cosy/kernel" "github.com/uozi-tech/cosy/logger" ) @@ -125,7 +126,9 @@ func (s *Service) SetUpdateCallback(callback func([]*SiteInfo)) { s.checker.SetUpdateCallback(callback) } -// Start begins the site checking service +// Start begins the site checking service. When the feature is globally +// disabled via settings.SiteCheckSettings.Enabled, no collection or checking +// goroutines are started (#1608). func (s *Service) Start() { s.mu.Lock() defer s.mu.Unlock() @@ -134,6 +137,11 @@ func (s *Service) Start() { return } + if !settings.SiteCheckSettings.Enabled { + logger.Debug("Site check is disabled; service will not start") + return + } + s.running = true // Initial collection and check with delay to allow cache scanner to complete @@ -149,8 +157,8 @@ func (s *Service) Start() { sl.Debug("Sitecheck initial collection goroutine completed") }) - // Start periodic checking (every 5 minutes) - s.ticker = time.NewTicker(5 * time.Minute) + // Start periodic checking using the configured interval. + s.ticker = time.NewTicker(settings.SiteCheckSettings.GetInterval()) go kernel.Run(s.ctx, "sitecheck periodic check goroutine", func(ctx context.Context) { sl := logger.NewSessionLogger(ctx) sl.Debug("Started sitecheck periodicCheck goroutine") diff --git a/internal/sitecheck/types.go b/internal/sitecheck/types.go index 871f130e..eeca3a34 100644 --- a/internal/sitecheck/types.go +++ b/internal/sitecheck/types.go @@ -17,15 +17,15 @@ const ( // SiteInfo represents the information about a site type SiteInfo struct { model.SiteConfig - Name string `json:"name"` - Status string `json:"status"` // StatusOnline, StatusOffline, StatusError, StatusChecking - StatusCode int `json:"status_code"` - ResponseTime int64 `json:"response_time"` // in milliseconds - FaviconURL string `json:"favicon_url"` - FaviconData string `json:"favicon_data"` // base64 encoded favicon - Title string `json:"title"` - LastChecked int64 `json:"last_checked"` // Unix timestamp in seconds - Error string `json:"error,omitempty"` + Name string `json:"name"` + Status string `json:"status"` // StatusOnline, StatusOffline, StatusError, StatusChecking + StatusCode int `json:"status_code"` + ResponseTime int64 `json:"response_time"` // in milliseconds + FaviconURL string `json:"favicon_url"` + FaviconData string `json:"favicon_data"` // base64 encoded favicon + Title string `json:"title"` + LastChecked int64 `json:"last_checked"` // Unix timestamp in seconds + Error string `json:"error,omitempty"` } // CheckOptions represents options for site checking diff --git a/settings/settings.go b/settings/settings.go index e1336f44..d9c669a0 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -29,22 +29,23 @@ var envPrefixMap = map[string]interface{}{ "APP": settings.AppSettings, "SERVER": settings.ServerSettings, // Nginx UI - "DB": DatabaseSettings, - "AUTH": AuthSettings, - "CASDOOR": CasdoorSettings, - "CERT": CertSettings, - "CLUSTER": ClusterSettings, - "CRYPTO": CryptoSettings, - "HTTP": HTTPSettings, - "LOGROTATE": LogrotateSettings, - "NGINX": NginxSettings, - "NGINX_LOG": NginxLogSettings, - "NODE": NodeSettings, - "OPENAI": OpenAISettings, - "TERMINAL": TerminalSettings, - "WEBAUTHN": WebAuthnSettings, - "BACKUP": BackupSettings, - "OIDC": OIDCSettings, + "DB": DatabaseSettings, + "AUTH": AuthSettings, + "CASDOOR": CasdoorSettings, + "CERT": CertSettings, + "CLUSTER": ClusterSettings, + "CRYPTO": CryptoSettings, + "HTTP": HTTPSettings, + "LOGROTATE": LogrotateSettings, + "NGINX": NginxSettings, + "NGINX_LOG": NginxLogSettings, + "NODE": NodeSettings, + "OPENAI": OpenAISettings, + "SITE_CHECK": SiteCheckSettings, + "TERMINAL": TerminalSettings, + "WEBAUTHN": WebAuthnSettings, + "BACKUP": BackupSettings, + "OIDC": OIDCSettings, } func init() { @@ -65,6 +66,7 @@ func init() { sections.Set("nginx_log", NginxLogSettings) sections.Set("node", NodeSettings) sections.Set("openai", OpenAISettings) + sections.Set("site_check", SiteCheckSettings) sections.Set("terminal", TerminalSettings) sections.Set("webauthn", WebAuthnSettings) diff --git a/settings/sitecheck.go b/settings/sitecheck.go new file mode 100644 index 00000000..5dd47ebc --- /dev/null +++ b/settings/sitecheck.go @@ -0,0 +1,42 @@ +package settings + +import "time" + +const ( + defaultSiteCheckConcurrency = 5 + defaultSiteCheckIntervalSeconds = 300 + maxSiteCheckConcurrency = 20 + minSiteCheckIntervalSeconds = 30 +) + +type SiteCheck struct { + Enabled bool `json:"enabled"` + Concurrency int `json:"concurrency" binding:"omitempty,min=1,max=20"` + IntervalSeconds int `json:"interval_seconds" binding:"omitempty,min=30"` +} + +var SiteCheckSettings = &SiteCheck{ + Enabled: true, + Concurrency: defaultSiteCheckConcurrency, + IntervalSeconds: defaultSiteCheckIntervalSeconds, +} + +// GetConcurrency returns the configured concurrency, clamped to a safe range. +func (s SiteCheck) GetConcurrency() int { + if s.Concurrency < 1 { + return defaultSiteCheckConcurrency + } + if s.Concurrency > maxSiteCheckConcurrency { + return maxSiteCheckConcurrency + } + return s.Concurrency +} + +// GetInterval returns the periodic sweep interval, clamped to a safe minimum. +func (s SiteCheck) GetInterval() time.Duration { + seconds := s.IntervalSeconds + if seconds < minSiteCheckIntervalSeconds { + seconds = defaultSiteCheckIntervalSeconds + } + return time.Duration(seconds) * time.Second +}