enhanced_checker.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. package sitecheck
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "fmt"
  6. "io"
  7. "net"
  8. "net/http"
  9. "net/url"
  10. "slices"
  11. "strings"
  12. "time"
  13. "github.com/0xJacky/Nginx-UI/model"
  14. "github.com/0xJacky/Nginx-UI/query"
  15. "github.com/0xJacky/Nginx-UI/settings"
  16. "github.com/uozi-tech/cosy/logger"
  17. "google.golang.org/grpc"
  18. "google.golang.org/grpc/credentials"
  19. "google.golang.org/grpc/credentials/insecure"
  20. "google.golang.org/grpc/health/grpc_health_v1"
  21. )
  22. // EnhancedSiteChecker provides advanced health checking capabilities
  23. type EnhancedSiteChecker struct {
  24. defaultClient *http.Client
  25. }
  26. // NewEnhancedSiteChecker creates a new enhanced site checker
  27. func NewEnhancedSiteChecker() *EnhancedSiteChecker {
  28. transport := &http.Transport{
  29. Dial: (&net.Dialer{
  30. Timeout: 10 * time.Second,
  31. }).Dial,
  32. TLSHandshakeTimeout: 10 * time.Second,
  33. TLSClientConfig: &tls.Config{
  34. InsecureSkipVerify: settings.HTTPSettings.InsecureSkipVerify,
  35. },
  36. }
  37. client := &http.Client{
  38. Transport: transport,
  39. Timeout: 30 * time.Second,
  40. }
  41. return &EnhancedSiteChecker{
  42. defaultClient: client,
  43. }
  44. }
  45. // CheckSiteWithConfig performs enhanced health check using custom configuration
  46. func (ec *EnhancedSiteChecker) CheckSiteWithConfig(ctx context.Context, siteURL string, config *model.HealthCheckConfig) (*SiteInfo, error) {
  47. if config == nil {
  48. // Fallback to basic HTTP check
  49. return ec.checkHTTP(ctx, siteURL, &model.HealthCheckConfig{
  50. Protocol: "http",
  51. Method: "GET",
  52. Path: "/",
  53. ExpectedStatus: []int{200},
  54. })
  55. }
  56. switch config.Protocol {
  57. case "grpc", "grpcs":
  58. return ec.checkGRPC(ctx, siteURL, config)
  59. case "https":
  60. return ec.checkHTTPS(ctx, siteURL, config)
  61. default: // http
  62. return ec.checkHTTP(ctx, siteURL, config)
  63. }
  64. }
  65. // checkHTTP performs HTTP health check
  66. func (ec *EnhancedSiteChecker) checkHTTP(ctx context.Context, siteURL string, config *model.HealthCheckConfig) (*SiteInfo, error) {
  67. startTime := time.Now()
  68. // Build request URL
  69. checkURL := siteURL
  70. if config.Path != "" && config.Path != "/" {
  71. checkURL = strings.TrimRight(siteURL, "/") + "/" + strings.TrimLeft(config.Path, "/")
  72. }
  73. // Create request
  74. req, err := http.NewRequestWithContext(ctx, config.Method, checkURL, nil)
  75. if err != nil {
  76. // Parse URL components for error case
  77. scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
  78. return &SiteInfo{
  79. URL: siteURL,
  80. Status: StatusError,
  81. Error: fmt.Sprintf("Failed to create request: %v", err),
  82. HealthCheckProtocol: config.Protocol,
  83. Scheme: scheme,
  84. HostPort: hostPort,
  85. }, err
  86. }
  87. // Add custom headers
  88. for key, value := range config.Headers {
  89. req.Header.Set(key, value)
  90. }
  91. // Set User-Agent if not provided
  92. if req.Header.Get("User-Agent") == "" {
  93. req.Header.Set("User-Agent", "Nginx-UI Enhanced Checker/2.0")
  94. }
  95. // Add request body for POST/PUT methods
  96. if config.Body != "" && (config.Method == "POST" || config.Method == "PUT") {
  97. req.Body = io.NopCloser(strings.NewReader(config.Body))
  98. if req.Header.Get("Content-Type") == "" {
  99. req.Header.Set("Content-Type", "application/json")
  100. }
  101. }
  102. // Create custom client if needed
  103. client := ec.defaultClient
  104. if config.ValidateSSL || config.VerifyHostname {
  105. transport := &http.Transport{
  106. Dial: (&net.Dialer{
  107. Timeout: 10 * time.Second,
  108. }).Dial,
  109. TLSHandshakeTimeout: 10 * time.Second,
  110. TLSClientConfig: &tls.Config{
  111. InsecureSkipVerify: !config.ValidateSSL,
  112. },
  113. }
  114. // Load client certificate if provided
  115. if config.ClientCert != "" && config.ClientKey != "" {
  116. cert, err := tls.LoadX509KeyPair(config.ClientCert, config.ClientKey)
  117. if err != nil {
  118. logger.Warnf("Failed to load client certificate: %v", err)
  119. } else {
  120. transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
  121. }
  122. }
  123. client = &http.Client{
  124. Transport: transport,
  125. Timeout: 30 * time.Second,
  126. }
  127. }
  128. // Make request
  129. resp, err := client.Do(req)
  130. if err != nil {
  131. // Parse URL components for error case
  132. scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
  133. return &SiteInfo{
  134. URL: siteURL,
  135. Status: StatusError,
  136. ResponseTime: time.Since(startTime).Milliseconds(),
  137. Error: err.Error(),
  138. HealthCheckProtocol: config.Protocol,
  139. Scheme: scheme,
  140. HostPort: hostPort,
  141. }, err
  142. }
  143. defer resp.Body.Close()
  144. responseTime := time.Since(startTime).Milliseconds()
  145. // Read response body
  146. body, err := io.ReadAll(resp.Body)
  147. if err != nil {
  148. logger.Warnf("Failed to read response body: %v", err)
  149. body = []byte{}
  150. }
  151. // Validate status code
  152. statusValid := false
  153. if len(config.ExpectedStatus) > 0 {
  154. statusValid = slices.Contains(config.ExpectedStatus, resp.StatusCode)
  155. } else {
  156. statusValid = resp.StatusCode >= 200 && resp.StatusCode < 400
  157. }
  158. // Validate response text
  159. bodyText := string(body)
  160. textValid := true
  161. if config.ExpectedText != "" {
  162. textValid = strings.Contains(bodyText, config.ExpectedText)
  163. }
  164. if config.NotExpectedText != "" {
  165. textValid = textValid && !strings.Contains(bodyText, config.NotExpectedText)
  166. }
  167. // Determine final status
  168. status := StatusOffline
  169. var errorMsg string
  170. if statusValid && textValid {
  171. status = StatusOnline
  172. } else {
  173. if !statusValid {
  174. errorMsg = fmt.Sprintf("Unexpected status code: %d", resp.StatusCode)
  175. } else {
  176. errorMsg = "Response content validation failed"
  177. }
  178. }
  179. // Parse URL components for legacy fields
  180. _, hostPort := parseURLComponents(siteURL, config.Protocol)
  181. // Get or create site config to get ID
  182. siteConfig := getOrCreateSiteConfigForURL(siteURL)
  183. return &SiteInfo{
  184. ID: siteConfig.ID,
  185. Host: siteConfig.Host,
  186. Port: siteConfig.Port,
  187. Scheme: siteConfig.Scheme,
  188. DisplayURL: siteConfig.GetURL(),
  189. Status: status,
  190. StatusCode: resp.StatusCode,
  191. ResponseTime: responseTime,
  192. Error: errorMsg,
  193. // Legacy fields for backward compatibility
  194. URL: siteURL,
  195. HealthCheckProtocol: config.Protocol,
  196. HostPort: hostPort,
  197. }, nil
  198. }
  199. // checkHTTPS performs HTTPS health check with SSL validation
  200. func (ec *EnhancedSiteChecker) checkHTTPS(ctx context.Context, siteURL string, config *model.HealthCheckConfig) (*SiteInfo, error) {
  201. // Force HTTPS protocol
  202. httpsConfig := *config
  203. httpsConfig.Protocol = "https"
  204. httpsConfig.ValidateSSL = true
  205. return ec.checkHTTP(ctx, siteURL, &httpsConfig)
  206. }
  207. // checkGRPC performs gRPC health check
  208. func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, config *model.HealthCheckConfig) (*SiteInfo, error) {
  209. startTime := time.Now()
  210. // Parse URL to get host and port
  211. parsedURL, err := parseGRPCURL(siteURL)
  212. if err != nil {
  213. // Parse URL components for error case
  214. scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
  215. return &SiteInfo{
  216. URL: siteURL,
  217. Status: StatusError,
  218. Error: fmt.Sprintf("Invalid gRPC URL: %v", err),
  219. HealthCheckProtocol: config.Protocol,
  220. Scheme: scheme,
  221. HostPort: hostPort,
  222. }, err
  223. }
  224. // Set up connection options
  225. var opts []grpc.DialOption
  226. // TLS configuration based on protocol setting, not URL scheme
  227. if config.Protocol == "grpcs" || config.ValidateSSL {
  228. tlsConfig := &tls.Config{
  229. InsecureSkipVerify: !config.ValidateSSL,
  230. }
  231. // For GRPCS, default to skip verification unless explicitly enabled
  232. if config.Protocol == "grpcs" && !config.ValidateSSL {
  233. tlsConfig.InsecureSkipVerify = settings.HTTPSettings.InsecureSkipVerify
  234. }
  235. // Load client certificate if provided
  236. if config.ClientCert != "" && config.ClientKey != "" {
  237. cert, err := tls.LoadX509KeyPair(config.ClientCert, config.ClientKey)
  238. if err != nil {
  239. logger.Warnf("Failed to load client certificate: %v", err)
  240. } else {
  241. tlsConfig.Certificates = []tls.Certificate{cert}
  242. }
  243. }
  244. opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
  245. } else {
  246. opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
  247. }
  248. // Create connection with shorter timeout for faster failure detection
  249. dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
  250. defer cancel()
  251. conn, err := grpc.DialContext(dialCtx, parsedURL.Host, opts...)
  252. if err != nil {
  253. errorMsg := fmt.Sprintf("Failed to connect to gRPC server: %v", err)
  254. // Provide more specific error messages
  255. if strings.Contains(err.Error(), "connection refused") {
  256. errorMsg = fmt.Sprintf("Connection refused - server may not be running on %s", parsedURL.Host)
  257. } else if strings.Contains(err.Error(), "context deadline exceeded") {
  258. errorMsg = fmt.Sprintf("Connection timeout - server at %s did not respond within 5 seconds", parsedURL.Host)
  259. } else if strings.Contains(err.Error(), "EOF") {
  260. errorMsg = fmt.Sprintf("Protocol mismatch - %s may not be a gRPC server or wrong TLS configuration", parsedURL.Host)
  261. }
  262. // Parse URL components for error case
  263. scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
  264. return &SiteInfo{
  265. URL: siteURL,
  266. Status: StatusError,
  267. ResponseTime: time.Since(startTime).Milliseconds(),
  268. Error: errorMsg,
  269. HealthCheckProtocol: config.Protocol,
  270. Scheme: scheme,
  271. HostPort: hostPort,
  272. }, err
  273. }
  274. defer conn.Close()
  275. // Use health check service
  276. client := grpc_health_v1.NewHealthClient(conn)
  277. // Determine service name
  278. serviceName := ""
  279. if config.GRPCService != "" {
  280. serviceName = config.GRPCService
  281. }
  282. // Make health check request with shorter timeout
  283. checkCtx, checkCancel := context.WithTimeout(ctx, 3*time.Second)
  284. defer checkCancel()
  285. resp, err := client.Check(checkCtx, &grpc_health_v1.HealthCheckRequest{
  286. Service: serviceName,
  287. })
  288. responseTime := time.Since(startTime).Milliseconds()
  289. if err != nil {
  290. errorMsg := fmt.Sprintf("Health check failed: %v", err)
  291. // Provide more specific error messages for gRPC health check failures
  292. if strings.Contains(err.Error(), "Unimplemented") {
  293. errorMsg = "Server does not implement gRPC health check service"
  294. } else if strings.Contains(err.Error(), "context deadline exceeded") {
  295. errorMsg = "Health check timeout - server did not respond within 3 seconds"
  296. } else if strings.Contains(err.Error(), "EOF") {
  297. errorMsg = "Connection lost during health check"
  298. }
  299. // Parse URL components for error case
  300. scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
  301. return &SiteInfo{
  302. URL: siteURL,
  303. Status: StatusError,
  304. ResponseTime: responseTime,
  305. Error: errorMsg,
  306. HealthCheckProtocol: config.Protocol,
  307. Scheme: scheme,
  308. HostPort: hostPort,
  309. }, err
  310. }
  311. // Check response status
  312. status := StatusOffline
  313. if resp.Status == grpc_health_v1.HealthCheckResponse_SERVING {
  314. status = StatusOnline
  315. }
  316. // Parse URL components
  317. scheme, hostPort := parseURLComponents(siteURL, config.Protocol)
  318. return &SiteInfo{
  319. URL: siteURL,
  320. Status: status,
  321. ResponseTime: responseTime,
  322. HealthCheckProtocol: config.Protocol,
  323. Scheme: scheme,
  324. HostPort: hostPort,
  325. }, nil
  326. }
  327. // parseGRPCURL parses a URL and extracts host:port for gRPC connection
  328. func parseGRPCURL(rawURL string) (*url.URL, error) {
  329. // Parse the original URL to extract host and port
  330. parsedURL, err := url.Parse(rawURL)
  331. if err != nil {
  332. return nil, err
  333. }
  334. // Create a new URL structure for gRPC connection
  335. grpcURL := &url.URL{
  336. Scheme: "grpc", // Default to grpc, will be overridden by config.Protocol
  337. Host: parsedURL.Host,
  338. }
  339. // If no port is specified, use default ports based on original scheme
  340. if parsedURL.Port() == "" {
  341. switch parsedURL.Scheme {
  342. case "https":
  343. grpcURL.Host = parsedURL.Hostname() + ":443"
  344. case "http":
  345. grpcURL.Host = parsedURL.Hostname() + ":80"
  346. case "grpcs":
  347. grpcURL.Host = parsedURL.Hostname() + ":443"
  348. case "grpc":
  349. grpcURL.Host = parsedURL.Hostname() + ":80"
  350. default:
  351. // For URLs without scheme, default to port 80
  352. grpcURL.Host = parsedURL.Host + ":80"
  353. }
  354. }
  355. return grpcURL, nil
  356. }
  357. // LoadSiteConfig loads health check configuration for a site using cache
  358. func LoadSiteConfig(siteURL string) (*model.SiteConfig, error) {
  359. // Parse URL to get host:port
  360. tempConfig := &model.SiteConfig{}
  361. tempConfig.SetFromURL(siteURL)
  362. // Try to get from cache first
  363. if config, found := getCachedSiteConfig(tempConfig.Host); found {
  364. // Set default health check config if nil
  365. if config.HealthCheckConfig == nil {
  366. config.HealthCheckConfig = &model.HealthCheckConfig{
  367. Protocol: "http",
  368. Method: "GET",
  369. Path: "/",
  370. ExpectedStatus: []int{200},
  371. }
  372. }
  373. return config, nil
  374. }
  375. // Not in cache, query database
  376. sc := query.SiteConfig
  377. config, err := sc.Where(sc.Host.Eq(tempConfig.Host)).First()
  378. if err != nil {
  379. // Return default config if not found
  380. defaultConfig := &model.SiteConfig{
  381. HealthCheckEnabled: true,
  382. CheckInterval: 300,
  383. Timeout: 10,
  384. HealthCheckConfig: &model.HealthCheckConfig{
  385. Protocol: "http",
  386. Method: "GET",
  387. Path: "/",
  388. ExpectedStatus: []int{200},
  389. },
  390. }
  391. defaultConfig.SetFromURL(siteURL)
  392. return defaultConfig, nil
  393. }
  394. // Set default health check config if nil
  395. if config.HealthCheckConfig == nil {
  396. config.HealthCheckConfig = &model.HealthCheckConfig{
  397. Protocol: "http",
  398. Method: "GET",
  399. Path: "/",
  400. ExpectedStatus: []int{200},
  401. }
  402. }
  403. // Cache the config
  404. setCachedSiteConfig(tempConfig.Host, config)
  405. return config, nil
  406. }