enhanced_checker.go 13 KB

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