mirror of
https://github.com/0xJacky/nginx-ui.git
synced 2026-05-07 06:23:39 +08:00
fix(sitecheck): bound outbound connections and add global controls (#1608)
The site checker created a fresh http.Transport per request and per EnhancedSiteChecker, with Go's default Happy-Eyeballs dialer. When server_name entries resolved to ingress services returning many A records (ngrok, AWS ALB, Cloudflare), each sweep opened enough flows to exhaust conntrack tables on consumer routers (UniFi). Introduce a package-level shared http.Transport with MaxConnsPerHost=2, MaxIdleConnsPerHost=2 and FallbackDelay=-1 (disables IPv6 dial races), plumb it through SiteChecker and EnhancedSiteChecker, and only build a custom client when the per-site HealthCheckConfig truly diverges on TLS. Reuse the response body fetched by the health check for favicon extraction so each site is hit at most once per sweep, and dedupe sites sharing the same host:port before fan-out. Add a [site_check] settings section (Enabled, Concurrency, Interval- Seconds) so operators can disable the checker entirely or tune the sweep cadence; clamp Concurrency to [1, 20] and IntervalSeconds to >=30. Document the new section in en, zh_CN and zh_TW guides and add sidebar entries. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export const enConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
@@ -68,6 +68,7 @@ export const zhCNConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
@@ -68,6 +68,7 @@ export const zhTWConfig: LocaleSpecificConfig<DefaultTheme.Config> = {
|
||||
{ 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' }
|
||||
]
|
||||
|
||||
55
docs/guide/config-sitecheck.md
Normal file
55
docs/guide/config-sitecheck.md
Normal file
@@ -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
|
||||
```
|
||||
49
docs/zh_CN/guide/config-sitecheck.md
Normal file
49
docs/zh_CN/guide/config-sitecheck.md
Normal file
@@ -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
|
||||
```
|
||||
49
docs/zh_TW/guide/config-sitecheck.md
Normal file
49
docs/zh_TW/guide/config-sitecheck.md
Normal file
@@ -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
|
||||
```
|
||||
@@ -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()
|
||||
|
||||
111
internal/sitecheck/httpclient.go
Normal file
111
internal/sitecheck/httpclient.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
42
settings/sitecheck.go
Normal file
42
settings/sitecheck.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user