enhanced_checker.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414
  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. return &SiteInfo{
  77. Status: StatusError,
  78. Error: fmt.Sprintf("Failed to create request: %v", err),
  79. }, err
  80. }
  81. // Add custom headers
  82. for key, value := range config.Headers {
  83. req.Header.Set(key, value)
  84. }
  85. // Set User-Agent if not provided
  86. if req.Header.Get("User-Agent") == "" {
  87. req.Header.Set("User-Agent", "Nginx-UI Enhanced Checker/2.0")
  88. }
  89. // Add request body for POST/PUT methods
  90. if config.Body != "" && (config.Method == "POST" || config.Method == "PUT") {
  91. req.Body = io.NopCloser(strings.NewReader(config.Body))
  92. if req.Header.Get("Content-Type") == "" {
  93. req.Header.Set("Content-Type", "application/json")
  94. }
  95. }
  96. // Create custom client if needed
  97. client := ec.defaultClient
  98. if config.ValidateSSL || config.VerifyHostname {
  99. transport := &http.Transport{
  100. Dial: (&net.Dialer{
  101. Timeout: 10 * time.Second,
  102. }).Dial,
  103. TLSHandshakeTimeout: 10 * time.Second,
  104. TLSClientConfig: &tls.Config{
  105. InsecureSkipVerify: !config.ValidateSSL,
  106. },
  107. }
  108. // Load client certificate if provided
  109. if config.ClientCert != "" && config.ClientKey != "" {
  110. cert, err := tls.LoadX509KeyPair(config.ClientCert, config.ClientKey)
  111. if err != nil {
  112. logger.Warnf("Failed to load client certificate: %v", err)
  113. } else {
  114. transport.TLSClientConfig.Certificates = []tls.Certificate{cert}
  115. }
  116. }
  117. client = &http.Client{
  118. Transport: transport,
  119. Timeout: 30 * time.Second,
  120. }
  121. }
  122. // Make request
  123. resp, err := client.Do(req)
  124. if err != nil {
  125. return &SiteInfo{
  126. Status: StatusError,
  127. ResponseTime: time.Since(startTime).Milliseconds(),
  128. Error: err.Error(),
  129. }, err
  130. }
  131. defer resp.Body.Close()
  132. responseTime := time.Since(startTime).Milliseconds()
  133. // Read response body
  134. body, err := io.ReadAll(resp.Body)
  135. if err != nil {
  136. logger.Warnf("Failed to read response body: %v", err)
  137. body = []byte{}
  138. }
  139. // Validate status code
  140. statusValid := false
  141. if len(config.ExpectedStatus) > 0 {
  142. statusValid = slices.Contains(config.ExpectedStatus, resp.StatusCode)
  143. } else {
  144. statusValid = resp.StatusCode >= 200 && resp.StatusCode < 400
  145. }
  146. // Validate response text
  147. bodyText := string(body)
  148. textValid := true
  149. if config.ExpectedText != "" {
  150. textValid = strings.Contains(bodyText, config.ExpectedText)
  151. }
  152. if config.NotExpectedText != "" {
  153. textValid = textValid && !strings.Contains(bodyText, config.NotExpectedText)
  154. }
  155. // Determine final status
  156. status := StatusOffline
  157. var errorMsg string
  158. if statusValid && textValid {
  159. status = StatusOnline
  160. } else {
  161. if !statusValid {
  162. errorMsg = fmt.Sprintf("Unexpected status code: %d", resp.StatusCode)
  163. } else {
  164. errorMsg = "Response content validation failed"
  165. }
  166. }
  167. // Get or create site config to get ID
  168. siteConfig := getOrCreateSiteConfigForURL(siteURL)
  169. return &SiteInfo{
  170. SiteConfig: *siteConfig,
  171. Status: status,
  172. StatusCode: resp.StatusCode,
  173. ResponseTime: responseTime,
  174. Error: errorMsg,
  175. }, nil
  176. }
  177. // checkHTTPS performs HTTPS health check with SSL validation
  178. func (ec *EnhancedSiteChecker) checkHTTPS(ctx context.Context, siteURL string, config *model.HealthCheckConfig) (*SiteInfo, error) {
  179. // Force HTTPS protocol
  180. httpsConfig := *config
  181. httpsConfig.Protocol = "https"
  182. httpsConfig.ValidateSSL = true
  183. return ec.checkHTTP(ctx, siteURL, &httpsConfig)
  184. }
  185. // checkGRPC performs gRPC health check
  186. func (ec *EnhancedSiteChecker) checkGRPC(ctx context.Context, siteURL string, config *model.HealthCheckConfig) (*SiteInfo, error) {
  187. startTime := time.Now()
  188. // Parse URL to get host and port
  189. parsedURL, err := parseGRPCURL(siteURL)
  190. if err != nil {
  191. return &SiteInfo{
  192. Status: StatusError,
  193. Error: fmt.Sprintf("Invalid gRPC URL: %v", err),
  194. }, err
  195. }
  196. // Set up connection options
  197. var opts []grpc.DialOption
  198. // TLS configuration based on protocol setting, not URL scheme
  199. if config.Protocol == "grpcs" || config.ValidateSSL {
  200. tlsConfig := &tls.Config{
  201. InsecureSkipVerify: !config.ValidateSSL,
  202. }
  203. // For GRPCS, default to skip verification unless explicitly enabled
  204. if config.Protocol == "grpcs" && !config.ValidateSSL {
  205. tlsConfig.InsecureSkipVerify = settings.HTTPSettings.InsecureSkipVerify
  206. }
  207. // Load client certificate if provided
  208. if config.ClientCert != "" && config.ClientKey != "" {
  209. cert, err := tls.LoadX509KeyPair(config.ClientCert, config.ClientKey)
  210. if err != nil {
  211. logger.Warnf("Failed to load client certificate: %v", err)
  212. } else {
  213. tlsConfig.Certificates = []tls.Certificate{cert}
  214. }
  215. }
  216. opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)))
  217. } else {
  218. opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
  219. }
  220. // Create gRPC client (connection established lazily on first RPC call)
  221. conn, err := grpc.NewClient(parsedURL.Host, opts...)
  222. if err != nil {
  223. errorMsg := fmt.Sprintf("Failed to connect to gRPC server: %v", err)
  224. // Provide more specific error messages
  225. if strings.Contains(err.Error(), "connection refused") {
  226. errorMsg = fmt.Sprintf("Connection refused - server may not be running on %s", parsedURL.Host)
  227. } else if strings.Contains(err.Error(), "context deadline exceeded") {
  228. errorMsg = fmt.Sprintf("Connection timeout - server at %s did not respond within 5 seconds", parsedURL.Host)
  229. } else if strings.Contains(err.Error(), "EOF") {
  230. errorMsg = fmt.Sprintf("Protocol mismatch - %s may not be a gRPC server or wrong TLS configuration", parsedURL.Host)
  231. }
  232. return &SiteInfo{
  233. Status: StatusError,
  234. ResponseTime: time.Since(startTime).Milliseconds(),
  235. Error: errorMsg,
  236. }, err
  237. }
  238. defer conn.Close()
  239. // Use health check service
  240. client := grpc_health_v1.NewHealthClient(conn)
  241. // Determine service name
  242. serviceName := ""
  243. if config.GRPCService != "" {
  244. serviceName = config.GRPCService
  245. }
  246. // Make health check request with shorter timeout
  247. checkCtx, checkCancel := context.WithTimeout(ctx, 3*time.Second)
  248. defer checkCancel()
  249. resp, err := client.Check(checkCtx, &grpc_health_v1.HealthCheckRequest{
  250. Service: serviceName,
  251. })
  252. responseTime := time.Since(startTime).Milliseconds()
  253. if err != nil {
  254. errorMsg := fmt.Sprintf("Health check failed: %v", err)
  255. // Provide more specific error messages for gRPC health check failures
  256. if strings.Contains(err.Error(), "Unimplemented") {
  257. errorMsg = "Server does not implement gRPC health check service"
  258. } else if strings.Contains(err.Error(), "context deadline exceeded") {
  259. errorMsg = "Health check timeout - server did not respond within 3 seconds"
  260. } else if strings.Contains(err.Error(), "EOF") {
  261. errorMsg = "Connection lost during health check"
  262. }
  263. return &SiteInfo{
  264. Status: StatusError,
  265. ResponseTime: responseTime,
  266. Error: errorMsg,
  267. }, err
  268. }
  269. // Check response status
  270. status := StatusOffline
  271. if resp.Status == grpc_health_v1.HealthCheckResponse_SERVING {
  272. status = StatusOnline
  273. }
  274. return &SiteInfo{
  275. Status: status,
  276. ResponseTime: responseTime,
  277. }, nil
  278. }
  279. // parseGRPCURL parses a URL and extracts host:port for gRPC connection
  280. func parseGRPCURL(rawURL string) (*url.URL, error) {
  281. // Parse the original URL to extract host and port
  282. parsedURL, err := url.Parse(rawURL)
  283. if err != nil {
  284. return nil, err
  285. }
  286. // Create a new URL structure for gRPC connection
  287. grpcURL := &url.URL{
  288. Scheme: "grpc", // Default to grpc, will be overridden by config.Protocol
  289. Host: parsedURL.Host,
  290. }
  291. // If no port is specified, use default ports based on original scheme
  292. if parsedURL.Port() == "" {
  293. switch parsedURL.Scheme {
  294. case "https":
  295. grpcURL.Host = parsedURL.Hostname() + ":443"
  296. case "http":
  297. grpcURL.Host = parsedURL.Hostname() + ":80"
  298. case "grpcs":
  299. grpcURL.Host = parsedURL.Hostname() + ":443"
  300. case "grpc":
  301. grpcURL.Host = parsedURL.Hostname() + ":80"
  302. default:
  303. // For URLs without scheme, default to port 80
  304. grpcURL.Host = parsedURL.Host + ":80"
  305. }
  306. }
  307. return grpcURL, nil
  308. }
  309. // LoadSiteConfig loads health check configuration for a site using cache
  310. func LoadSiteConfig(siteURL string) (*model.SiteConfig, error) {
  311. // Parse URL to get host:port
  312. tempConfig := &model.SiteConfig{}
  313. tempConfig.SetFromURL(siteURL)
  314. // Try to get from cache first
  315. if config, found := getCachedSiteConfig(tempConfig.Host); found {
  316. // Set default health check config if nil
  317. if config.HealthCheckConfig == nil {
  318. config.HealthCheckConfig = &model.HealthCheckConfig{
  319. Protocol: "http",
  320. Method: "GET",
  321. Path: "/",
  322. ExpectedStatus: []int{200},
  323. }
  324. }
  325. return config, nil
  326. }
  327. // Not in cache, query database
  328. sc := query.SiteConfig
  329. config, err := sc.Where(sc.Host.Eq(tempConfig.Host)).First()
  330. if err != nil {
  331. // Return default config if not found
  332. defaultConfig := &model.SiteConfig{
  333. HealthCheckEnabled: true,
  334. CheckInterval: 300,
  335. Timeout: 10,
  336. HealthCheckConfig: &model.HealthCheckConfig{
  337. Protocol: "http",
  338. Method: "GET",
  339. Path: "/",
  340. ExpectedStatus: []int{200},
  341. },
  342. }
  343. defaultConfig.SetFromURL(siteURL)
  344. return defaultConfig, nil
  345. }
  346. // Set default health check config if nil
  347. if config.HealthCheckConfig == nil {
  348. config.HealthCheckConfig = &model.HealthCheckConfig{
  349. Protocol: "http",
  350. Method: "GET",
  351. Path: "/",
  352. ExpectedStatus: []int{200},
  353. }
  354. }
  355. // Cache the config
  356. setCachedSiteConfig(tempConfig.Host, config)
  357. return config, nil
  358. }