checker.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. package sitecheck
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "encoding/base64"
  6. "fmt"
  7. "io"
  8. "maps"
  9. "net"
  10. "net/http"
  11. "net/url"
  12. "regexp"
  13. "strings"
  14. "sync"
  15. "time"
  16. "github.com/0xJacky/Nginx-UI/internal/site"
  17. "github.com/0xJacky/Nginx-UI/model"
  18. "github.com/0xJacky/Nginx-UI/query"
  19. "github.com/0xJacky/Nginx-UI/settings"
  20. "github.com/uozi-tech/cosy/logger"
  21. )
  22. // Site config cache with expiration
  23. var (
  24. siteConfigCache = make(map[string]*siteConfigCacheEntry)
  25. siteConfigMutex sync.RWMutex
  26. cacheExpiry = 5 * time.Minute // Cache entries expire after 5 minutes
  27. lastBatchLoad time.Time
  28. )
  29. type siteConfigCacheEntry struct {
  30. config *model.SiteConfig
  31. expiresAt time.Time
  32. }
  33. type SiteChecker struct {
  34. sites map[string]*SiteInfo
  35. mu sync.RWMutex
  36. options CheckOptions
  37. client *http.Client
  38. onUpdateCallback func([]*SiteInfo) // Callback for notifying updates
  39. }
  40. // NewSiteChecker creates a new site checker
  41. func NewSiteChecker(options CheckOptions) *SiteChecker {
  42. transport := &http.Transport{
  43. Dial: (&net.Dialer{
  44. Timeout: 5 * time.Second,
  45. }).Dial,
  46. TLSHandshakeTimeout: 5 * time.Second,
  47. TLSClientConfig: &tls.Config{
  48. InsecureSkipVerify: settings.HTTPSettings.InsecureSkipVerify,
  49. },
  50. }
  51. client := &http.Client{
  52. Transport: transport,
  53. Timeout: options.Timeout,
  54. }
  55. if !options.FollowRedirects {
  56. client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  57. return http.ErrUseLastResponse
  58. }
  59. } else if options.MaxRedirects > 0 {
  60. client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
  61. if len(via) >= options.MaxRedirects {
  62. return fmt.Errorf("stopped after %d redirects", options.MaxRedirects)
  63. }
  64. return nil
  65. }
  66. }
  67. return &SiteChecker{
  68. sites: make(map[string]*SiteInfo),
  69. options: options,
  70. client: client,
  71. }
  72. }
  73. // SetUpdateCallback sets the callback function for site updates
  74. func (sc *SiteChecker) SetUpdateCallback(callback func([]*SiteInfo)) {
  75. sc.onUpdateCallback = callback
  76. }
  77. // CollectSites collects URLs from enabled indexed sites only
  78. func (sc *SiteChecker) CollectSites() {
  79. sc.mu.Lock()
  80. defer sc.mu.Unlock()
  81. // Clear existing sites
  82. sc.sites = make(map[string]*SiteInfo)
  83. // Debug: log indexed sites count
  84. logger.Debugf("Found %d indexed sites", len(site.IndexedSites))
  85. // Collect URLs from indexed sites, but only from enabled sites
  86. for siteName, indexedSite := range site.IndexedSites {
  87. // Check site status - only collect from enabled sites
  88. siteStatus := site.GetSiteStatus(siteName)
  89. if siteStatus != site.StatusEnabled {
  90. // logger.Debugf("Skipping site %s (status: %s) - only collecting from enabled sites", siteName, siteStatus)
  91. continue
  92. }
  93. // logger.Debugf("Processing enabled site: %s with %d URLs", siteName, len(indexedSite.Urls))
  94. for _, url := range indexedSite.Urls {
  95. if url != "" {
  96. // logger.Debugf("Adding site URL: %s", url)
  97. // Load site config to determine display URL
  98. config, err := LoadSiteConfig(url)
  99. protocol := "http" // default protocol
  100. if err == nil && config != nil && config.HealthCheckConfig != nil && config.HealthCheckConfig.Protocol != "" {
  101. protocol = config.HealthCheckConfig.Protocol
  102. // logger.Debugf("Site %s using protocol: %s", url, protocol)
  103. } else {
  104. logger.Debugf("Site %s using default protocol: %s (config error: %v)", url, protocol, err)
  105. }
  106. // Parse URL components for legacy fields
  107. // Get or create site config to get ID
  108. siteConfig := getOrCreateSiteConfigForURL(url)
  109. siteInfo := &SiteInfo{
  110. SiteConfig: *siteConfig,
  111. Name: extractDomainName(url),
  112. Status: StatusChecking,
  113. LastChecked: time.Now().Unix(),
  114. }
  115. sc.sites[url] = siteInfo
  116. }
  117. }
  118. }
  119. logger.Debugf("Collected %d enabled sites for checking", len(sc.sites))
  120. }
  121. // loadAllSiteConfigs loads all site configs from database and caches them
  122. func loadAllSiteConfigs() error {
  123. siteConfigMutex.Lock()
  124. defer siteConfigMutex.Unlock()
  125. // Skip database operation if query.SiteConfig is nil (e.g., in tests)
  126. if query.SiteConfig == nil {
  127. logger.Debugf("Skipping site config batch load - query.SiteConfig is nil (likely in test environment)")
  128. lastBatchLoad = time.Now()
  129. return nil
  130. }
  131. sc := query.SiteConfig
  132. configs, err := sc.Find()
  133. if err != nil {
  134. return fmt.Errorf("failed to load site configs: %w", err)
  135. }
  136. now := time.Now()
  137. expiry := now.Add(cacheExpiry)
  138. // Clear existing cache
  139. siteConfigCache = make(map[string]*siteConfigCacheEntry)
  140. // Cache all configs
  141. for _, config := range configs {
  142. siteConfigCache[config.Host] = &siteConfigCacheEntry{
  143. config: config,
  144. expiresAt: expiry,
  145. }
  146. }
  147. lastBatchLoad = now
  148. logger.Debugf("Loaded %d site configs into cache", len(configs))
  149. return nil
  150. }
  151. // getCachedSiteConfig gets a site config from cache, loading all configs if needed
  152. func getCachedSiteConfig(host string) (*model.SiteConfig, bool) {
  153. siteConfigMutex.RLock()
  154. // Check if we need to refresh the cache
  155. needsRefresh := time.Since(lastBatchLoad) > cacheExpiry
  156. if needsRefresh {
  157. siteConfigMutex.RUnlock()
  158. // Reload all configs if cache is expired
  159. if err := loadAllSiteConfigs(); err != nil {
  160. logger.Errorf("Failed to reload site configs: %v", err)
  161. return nil, false
  162. }
  163. siteConfigMutex.RLock()
  164. }
  165. entry, exists := siteConfigCache[host]
  166. siteConfigMutex.RUnlock()
  167. if !exists || time.Now().After(entry.expiresAt) {
  168. return nil, false
  169. }
  170. return entry.config, true
  171. }
  172. // setCachedSiteConfig sets a site config in cache
  173. func setCachedSiteConfig(host string, config *model.SiteConfig) {
  174. siteConfigMutex.Lock()
  175. defer siteConfigMutex.Unlock()
  176. siteConfigCache[host] = &siteConfigCacheEntry{
  177. config: config,
  178. expiresAt: time.Now().Add(cacheExpiry),
  179. }
  180. }
  181. // InvalidateSiteConfigCache invalidates the entire site config cache
  182. func InvalidateSiteConfigCache() {
  183. siteConfigMutex.Lock()
  184. defer siteConfigMutex.Unlock()
  185. siteConfigCache = make(map[string]*siteConfigCacheEntry)
  186. lastBatchLoad = time.Time{} // Reset batch load time to force reload
  187. logger.Debugf("Site config cache invalidated")
  188. }
  189. // InvalidateSiteConfigCacheForHost invalidates cache for a specific host
  190. func InvalidateSiteConfigCacheForHost(host string) {
  191. siteConfigMutex.Lock()
  192. defer siteConfigMutex.Unlock()
  193. delete(siteConfigCache, host)
  194. logger.Debugf("Site config cache invalidated for host: %s", host)
  195. }
  196. // getOrCreateSiteConfigForURL gets or creates a site config for the given URL
  197. func getOrCreateSiteConfigForURL(url string) *model.SiteConfig {
  198. // Parse URL to get host:port
  199. tempConfig := &model.SiteConfig{}
  200. tempConfig.SetFromURL(url)
  201. // Try to get from cache first
  202. if config, found := getCachedSiteConfig(tempConfig.Host); found {
  203. return config
  204. }
  205. // Not in cache, query database
  206. sc := query.SiteConfig
  207. siteConfig, err := sc.Where(sc.Host.Eq(tempConfig.Host)).First()
  208. if err != nil {
  209. // Record doesn't exist, create a new one
  210. newConfig := &model.SiteConfig{
  211. Host: tempConfig.Host,
  212. Port: tempConfig.Port,
  213. Scheme: tempConfig.Scheme,
  214. DisplayURL: url,
  215. HealthCheckEnabled: true,
  216. CheckInterval: 300,
  217. Timeout: 10,
  218. UserAgent: "Nginx-UI Site Checker/1.0",
  219. MaxRedirects: 3,
  220. FollowRedirects: true,
  221. CheckFavicon: true,
  222. }
  223. // Create the record in database
  224. if err := sc.Create(newConfig); err != nil {
  225. logger.Errorf("Failed to create site config for %s: %v", url, err)
  226. // Return temp config with a fake ID to avoid crashes
  227. tempConfig.ID = 0
  228. return tempConfig
  229. }
  230. // Cache the new config
  231. setCachedSiteConfig(tempConfig.Host, newConfig)
  232. return newConfig
  233. }
  234. // Record exists, ensure it has the correct URL information
  235. if siteConfig.DisplayURL == "" {
  236. siteConfig.DisplayURL = url
  237. siteConfig.SetFromURL(url)
  238. // Try to save the updated config, but don't fail if it doesn't work
  239. sc.Save(siteConfig)
  240. }
  241. // Cache the config
  242. setCachedSiteConfig(tempConfig.Host, siteConfig)
  243. return siteConfig
  244. }
  245. // CheckSite checks a single site's availability
  246. func (sc *SiteChecker) CheckSite(ctx context.Context, siteURL string) (*SiteInfo, error) {
  247. // Try enhanced health check first if config exists
  248. config, err := LoadSiteConfig(siteURL)
  249. // If health check is disabled, return a SiteInfo without status
  250. if err == nil && config != nil && !config.HealthCheckEnabled {
  251. protocol := "http"
  252. if config.HealthCheckConfig != nil && config.HealthCheckConfig.Protocol != "" {
  253. protocol = config.HealthCheckConfig.Protocol
  254. }
  255. siteInfo := &SiteInfo{
  256. SiteConfig: *config,
  257. Name: extractDomainName(siteURL),
  258. Title: config.DisplayURL,
  259. }
  260. // Try to get favicon if enabled and not a gRPC check
  261. if sc.options.CheckFavicon && !isGRPCProtocol(protocol) {
  262. faviconURL, faviconData := sc.tryGetFavicon(ctx, siteURL)
  263. siteInfo.FaviconURL = faviconURL
  264. siteInfo.FaviconData = faviconData
  265. }
  266. return siteInfo, nil
  267. }
  268. if err == nil && config != nil && config.HealthCheckConfig != nil {
  269. enhancedChecker := NewEnhancedSiteChecker()
  270. siteInfo, err := enhancedChecker.CheckSiteWithConfig(ctx, siteURL, config.HealthCheckConfig)
  271. if err == nil && siteInfo != nil {
  272. // Fill in additional details
  273. siteInfo.ID = config.ID
  274. siteInfo.HealthCheckEnabled = config.HealthCheckEnabled
  275. siteInfo.Name = extractDomainName(siteURL)
  276. siteInfo.LastChecked = time.Now().Unix()
  277. // Set health check protocol and display URL
  278. siteInfo.DisplayURL = generateDisplayURL(siteURL, config.HealthCheckConfig.Protocol)
  279. // Try to get favicon if enabled and not a gRPC check
  280. if sc.options.CheckFavicon && !isGRPCProtocol(config.HealthCheckConfig.Protocol) {
  281. faviconURL, faviconData := sc.tryGetFavicon(ctx, siteURL)
  282. siteInfo.FaviconURL = faviconURL
  283. siteInfo.FaviconData = faviconData
  284. }
  285. return siteInfo, nil
  286. }
  287. }
  288. // Fallback to basic HTTP check, but preserve original protocol if available
  289. originalProtocol := "http" // default
  290. if config != nil && config.HealthCheckConfig != nil && config.HealthCheckConfig.Protocol != "" {
  291. originalProtocol = config.HealthCheckConfig.Protocol
  292. }
  293. return sc.checkSiteBasic(ctx, siteURL, originalProtocol)
  294. }
  295. // checkSiteBasic performs basic HTTP health check
  296. func (sc *SiteChecker) checkSiteBasic(ctx context.Context, siteURL string, originalProtocol string) (*SiteInfo, error) {
  297. start := time.Now()
  298. req, err := http.NewRequestWithContext(ctx, "GET", siteURL, nil)
  299. if err != nil {
  300. return nil, fmt.Errorf("failed to create request: %w", err)
  301. }
  302. req.Header.Set("User-Agent", sc.options.UserAgent)
  303. resp, err := sc.client.Do(req)
  304. if err != nil {
  305. // Get or create site config to get ID
  306. siteConfig := getOrCreateSiteConfigForURL(siteURL)
  307. return &SiteInfo{
  308. SiteConfig: *siteConfig,
  309. Name: extractDomainName(siteURL),
  310. Status: StatusOffline,
  311. ResponseTime: time.Since(start).Milliseconds(),
  312. LastChecked: time.Now().Unix(),
  313. Error: err.Error(),
  314. }, nil
  315. }
  316. defer resp.Body.Close()
  317. responseTime := time.Since(start).Milliseconds()
  318. // Get or create site config to get ID
  319. siteConfig := getOrCreateSiteConfigForURL(siteURL)
  320. siteInfo := &SiteInfo{
  321. SiteConfig: *siteConfig,
  322. Name: extractDomainName(siteURL),
  323. StatusCode: resp.StatusCode,
  324. ResponseTime: responseTime,
  325. LastChecked: time.Now().Unix(),
  326. }
  327. // Determine status based on status code
  328. if resp.StatusCode >= 200 && resp.StatusCode < 400 {
  329. siteInfo.Status = StatusOnline
  330. } else {
  331. siteInfo.Status = StatusError
  332. siteInfo.Error = fmt.Sprintf("HTTP %d", resp.StatusCode)
  333. }
  334. // Read response body for title and favicon extraction
  335. body, err := io.ReadAll(resp.Body)
  336. if err != nil {
  337. logger.Warnf("Failed to read response body for %s: %v", siteURL, err)
  338. return siteInfo, nil
  339. }
  340. // Extract title
  341. siteInfo.Title = extractTitle(string(body))
  342. // Extract favicon if enabled
  343. if sc.options.CheckFavicon {
  344. faviconURL, faviconData := sc.extractFavicon(ctx, siteURL, string(body))
  345. siteInfo.FaviconURL = faviconURL
  346. siteInfo.FaviconData = faviconData
  347. }
  348. return siteInfo, nil
  349. }
  350. // tryGetFavicon attempts to get favicon for enhanced checks
  351. func (sc *SiteChecker) tryGetFavicon(ctx context.Context, siteURL string) (string, string) {
  352. // Make a simple GET request to get the HTML
  353. req, err := http.NewRequestWithContext(ctx, "GET", siteURL, nil)
  354. if err != nil {
  355. return "", ""
  356. }
  357. req.Header.Set("User-Agent", sc.options.UserAgent)
  358. resp, err := sc.client.Do(req)
  359. if err != nil {
  360. return "", ""
  361. }
  362. defer resp.Body.Close()
  363. if resp.StatusCode < 200 || resp.StatusCode >= 400 {
  364. return "", ""
  365. }
  366. body, err := io.ReadAll(resp.Body)
  367. if err != nil {
  368. return "", ""
  369. }
  370. return sc.extractFavicon(ctx, siteURL, string(body))
  371. }
  372. // CheckAllSites checks all collected sites concurrently
  373. func (sc *SiteChecker) CheckAllSites(ctx context.Context) {
  374. sc.mu.RLock()
  375. urls := make([]string, 0, len(sc.sites))
  376. for url := range sc.sites {
  377. urls = append(urls, url)
  378. }
  379. sc.mu.RUnlock()
  380. // Use a semaphore to limit concurrent requests
  381. semaphore := make(chan struct{}, 10) // Max 10 concurrent requests
  382. var wg sync.WaitGroup
  383. for _, url := range urls {
  384. wg.Add(1)
  385. go func(siteURL string) {
  386. defer wg.Done()
  387. semaphore <- struct{}{} // Acquire semaphore
  388. defer func() { <-semaphore }() // Release semaphore
  389. siteInfo, err := sc.CheckSite(ctx, siteURL)
  390. if err != nil {
  391. logger.Errorf("Failed to check site %s: %v", siteURL, err)
  392. return
  393. }
  394. sc.mu.Lock()
  395. sc.sites[siteURL] = siteInfo
  396. sc.mu.Unlock()
  397. }(url)
  398. }
  399. wg.Wait()
  400. // logger.Infof("Completed checking %d sites", len(urls))
  401. // Notify WebSocket clients of the update
  402. if sc.onUpdateCallback != nil {
  403. sites := make([]*SiteInfo, 0, len(sc.sites))
  404. sc.mu.RLock()
  405. for _, site := range sc.sites {
  406. sites = append(sites, site)
  407. }
  408. sc.mu.RUnlock()
  409. sc.onUpdateCallback(sites)
  410. }
  411. }
  412. // GetSites returns all checked sites
  413. func (sc *SiteChecker) GetSites() map[string]*SiteInfo {
  414. sc.mu.RLock()
  415. defer sc.mu.RUnlock()
  416. // Create a copy to avoid race conditions
  417. result := make(map[string]*SiteInfo)
  418. maps.Copy(result, sc.sites)
  419. return result
  420. }
  421. // GetSiteCount returns the number of sites being monitored
  422. func (sc *SiteChecker) GetSiteCount() int {
  423. sc.mu.RLock()
  424. defer sc.mu.RUnlock()
  425. return len(sc.sites)
  426. }
  427. // GetSitesList returns sites as a slice
  428. func (sc *SiteChecker) GetSitesList() []*SiteInfo {
  429. sc.mu.RLock()
  430. defer sc.mu.RUnlock()
  431. result := make([]*SiteInfo, 0, len(sc.sites))
  432. for _, site := range sc.sites {
  433. result = append(result, site)
  434. }
  435. return result
  436. }
  437. // extractDomainName extracts domain name from URL
  438. func extractDomainName(siteURL string) string {
  439. parsed, err := url.Parse(siteURL)
  440. if err != nil {
  441. return siteURL
  442. }
  443. return parsed.Host
  444. }
  445. // extractTitle extracts title from HTML content
  446. func extractTitle(html string) string {
  447. titleRegex := regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
  448. matches := titleRegex.FindStringSubmatch(html)
  449. if len(matches) > 1 {
  450. return strings.TrimSpace(matches[1])
  451. }
  452. return ""
  453. }
  454. // extractFavicon extracts favicon URL and data from HTML
  455. func (sc *SiteChecker) extractFavicon(ctx context.Context, siteURL, html string) (string, string) {
  456. parsedURL, err := url.Parse(siteURL)
  457. if err != nil {
  458. return "", ""
  459. }
  460. // Look for favicon link in HTML
  461. faviconRegex := regexp.MustCompile(`(?i)<link[^>]*rel=["'](?:icon|shortcut icon)["'][^>]*href=["']([^"']+)["']`)
  462. matches := faviconRegex.FindStringSubmatch(html)
  463. var faviconURL string
  464. if len(matches) > 1 {
  465. faviconURL = matches[1]
  466. } else {
  467. // Default favicon location
  468. faviconURL = "/favicon.ico"
  469. }
  470. // Convert relative URL to absolute
  471. if !strings.HasPrefix(faviconURL, "http") {
  472. baseURL := fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host)
  473. if strings.HasPrefix(faviconURL, "/") {
  474. faviconURL = baseURL + faviconURL
  475. } else {
  476. faviconURL = baseURL + "/" + faviconURL
  477. }
  478. }
  479. // Download favicon
  480. faviconData := sc.downloadFavicon(ctx, faviconURL)
  481. return faviconURL, faviconData
  482. }
  483. // downloadFavicon downloads and encodes favicon as base64
  484. func (sc *SiteChecker) downloadFavicon(ctx context.Context, faviconURL string) string {
  485. req, err := http.NewRequestWithContext(ctx, "GET", faviconURL, nil)
  486. if err != nil {
  487. return ""
  488. }
  489. req.Header.Set("User-Agent", sc.options.UserAgent)
  490. resp, err := sc.client.Do(req)
  491. if err != nil {
  492. return ""
  493. }
  494. defer resp.Body.Close()
  495. if resp.StatusCode != http.StatusOK {
  496. return ""
  497. }
  498. // Limit favicon size to 1MB
  499. body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
  500. if err != nil {
  501. return ""
  502. }
  503. // Get content type
  504. contentType := resp.Header.Get("Content-Type")
  505. if contentType == "" {
  506. // Try to determine from URL extension
  507. if strings.HasSuffix(faviconURL, ".png") {
  508. contentType = "image/png"
  509. } else if strings.HasSuffix(faviconURL, ".ico") {
  510. contentType = "image/x-icon"
  511. } else {
  512. contentType = "image/x-icon" // default
  513. }
  514. }
  515. // Encode as data URL
  516. encoded := base64.StdEncoding.EncodeToString(body)
  517. return fmt.Sprintf("data:%s;base64,%s", contentType, encoded)
  518. }
  519. // generateDisplayURL generates the URL to display in UI based on health check protocol
  520. func generateDisplayURL(originalURL, protocol string) string {
  521. parsed, err := url.Parse(originalURL)
  522. if err != nil {
  523. logger.Errorf("Failed to parse URL %s: %v", originalURL, err)
  524. return originalURL
  525. }
  526. // Determine the optimal scheme (prefer HTTPS if available)
  527. scheme := determineOptimalScheme(parsed, protocol)
  528. hostname := parsed.Hostname()
  529. port := parsed.Port()
  530. // For HTTP/HTTPS, return clean URL without default ports
  531. if scheme == "http" || scheme == "https" {
  532. // Build URL without default ports
  533. var result string
  534. if port == "" || (port == "80" && scheme == "http") || (port == "443" && scheme == "https") {
  535. // No port or default port - don't show port
  536. result = fmt.Sprintf("%s://%s", scheme, hostname)
  537. } else {
  538. // Non-default port - show port
  539. result = fmt.Sprintf("%s://%s:%s", scheme, hostname, port)
  540. }
  541. return result
  542. }
  543. // For gRPC/gRPCS, show the connection address format without default ports
  544. if scheme == "grpc" || scheme == "grpcs" {
  545. if port == "" {
  546. // Determine default port based on scheme
  547. if scheme == "grpcs" {
  548. port = "443"
  549. } else {
  550. port = "80"
  551. }
  552. }
  553. // Don't show default ports for gRPC either
  554. var result string
  555. if (port == "80" && scheme == "grpc") || (port == "443" && scheme == "grpcs") {
  556. result = fmt.Sprintf("%s://%s", scheme, hostname)
  557. } else {
  558. result = fmt.Sprintf("%s://%s:%s", scheme, hostname, port)
  559. }
  560. return result
  561. }
  562. // Fallback to original URL
  563. return originalURL
  564. }
  565. // isGRPCProtocol checks if the protocol is gRPC-based
  566. func isGRPCProtocol(protocol string) bool {
  567. return protocol == "grpc" || protocol == "grpcs"
  568. }
  569. // parseURLComponents extracts scheme and host:port from URL based on health check protocol
  570. func parseURLComponents(originalURL, healthCheckProtocol string) (scheme, hostPort string) {
  571. parsed, err := url.Parse(originalURL)
  572. if err != nil {
  573. logger.Debugf("Failed to parse URL %s: %v", originalURL, err)
  574. return healthCheckProtocol, originalURL
  575. }
  576. // Determine the best scheme to use
  577. scheme = determineOptimalScheme(parsed, healthCheckProtocol)
  578. // Extract hostname and port
  579. hostname := parsed.Hostname()
  580. if hostname == "" {
  581. // Fallback to original URL if we can't parse hostname
  582. return scheme, originalURL
  583. }
  584. port := parsed.Port()
  585. if port == "" {
  586. // Use default port based on scheme, but don't include it in hostPort for default ports
  587. switch scheme {
  588. case "https", "grpcs":
  589. // Default HTTPS port 443 - don't show in hostPort
  590. hostPort = hostname
  591. case "http", "grpc":
  592. // Default HTTP port 80 - don't show in hostPort
  593. hostPort = hostname
  594. default:
  595. hostPort = hostname
  596. }
  597. } else {
  598. // Non-default port specified
  599. isDefaultPort := (port == "80" && (scheme == "http" || scheme == "grpc")) ||
  600. (port == "443" && (scheme == "https" || scheme == "grpcs"))
  601. if isDefaultPort {
  602. // Don't show default ports
  603. hostPort = hostname
  604. } else {
  605. // Show non-default ports
  606. hostPort = hostname + ":" + port
  607. }
  608. }
  609. return scheme, hostPort
  610. }
  611. // determineOptimalScheme determines the best scheme to use based on original URL and health check protocol
  612. func determineOptimalScheme(parsed *url.URL, healthCheckProtocol string) string {
  613. // If health check protocol is specified, use it, but with special handling for HTTP/HTTPS
  614. if healthCheckProtocol != "" {
  615. // Special case: Don't downgrade HTTPS to HTTP
  616. if healthCheckProtocol == "http" && parsed.Scheme == "https" {
  617. // logger.Debugf("Preserving HTTPS scheme instead of downgrading to HTTP")
  618. return "https"
  619. }
  620. // For gRPC protocols, always use the specified protocol
  621. if healthCheckProtocol == "grpc" || healthCheckProtocol == "grpcs" {
  622. return healthCheckProtocol
  623. }
  624. // For HTTPS health check protocol, always use HTTPS
  625. if healthCheckProtocol == "https" {
  626. return "https"
  627. }
  628. // For HTTP health check protocol, only use HTTP if original was also HTTP
  629. if healthCheckProtocol == "http" && parsed.Scheme == "http" {
  630. return "http"
  631. }
  632. }
  633. // If no health check protocol, or if we need to fall back, prefer HTTPS if the original URL is HTTPS
  634. if parsed.Scheme == "https" {
  635. return "https"
  636. }
  637. // Default to HTTP
  638. return "http"
  639. }