feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
687
checks/calls/init.go
Обычный файл
687
checks/calls/init.go
Обычный файл
@@ -0,0 +1,687 @@
|
||||
// Package calls provides functionality.
|
||||
package calls
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chromedp/cdproto/network"
|
||||
"github.com/chromedp/cdproto/runtime"
|
||||
"github.com/chromedp/chromedp"
|
||||
"github.com/google/uuid"
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
"rsgit.ru/rsmon/rsmon/storage"
|
||||
)
|
||||
|
||||
const (
|
||||
verdictError = "error"
|
||||
verdictWarning = "warning"
|
||||
verdictNormal = "normal"
|
||||
)
|
||||
|
||||
// Result is a result of an AI health check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
|
||||
// Captured data
|
||||
Screenshot string `json:"screenshot"` // base64 encoded screenshot
|
||||
DOM string `json:"dom"` // HTML content
|
||||
NetworkLogs string `json:"network_logs"` // JSON array of network logs
|
||||
ConsoleLogs string `json:"console_logs"` // JSON array of console logs
|
||||
URL string `json:"url"` // Final URL after redirects
|
||||
StatusCode int `json:"status_code"` // HTTP status code
|
||||
Title string `json:"title"` // Page title
|
||||
|
||||
// LLM analysis
|
||||
LLMResponse string `json:"llm_response"` // Full LLM response
|
||||
LLMVerdict string `json:"llm_verdict"` // "normal", "error", "warning"
|
||||
LLMReasoning string `json:"llm_reasoning"` // LLM's explanation
|
||||
HasChanges bool `json:"has_changes"` // Whether changes were detected from previous
|
||||
|
||||
// Previous screenshot comparison
|
||||
PreviousURL string `json:"previous_url"` // URL of previous screenshot (if any)
|
||||
PreviousTime string `json:"previous_time"` // Timestamp of previous screenshot
|
||||
|
||||
// Metadata
|
||||
CheckID uint `json:"check_id"` // For database reference
|
||||
ScreenshotID uint `json:"screenshot_id"` // ID of stored screenshot
|
||||
}
|
||||
|
||||
// NetworkLog represents a network request log
|
||||
type NetworkLog struct {
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Type string `json:"type"`
|
||||
Size int64 `json:"size"`
|
||||
Time float64 `json:"time"`
|
||||
}
|
||||
|
||||
// ConsoleLog represents a console log entry
|
||||
type ConsoleLog struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// ScreenshotRecord represents a stored screenshot in the database
|
||||
type ScreenshotRecord struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CheckID uint `json:"check_id"`
|
||||
MonitorID uint `json:"monitor_id"`
|
||||
URL string `json:"url"`
|
||||
// ScreenshotPath stores the S3 object path (e.g., "screenshots/monitor_123/2025-01-15_123456.jpg")
|
||||
|
||||
ScreenshotPath string `json:"screenshot_path" gorm:"type:varchar(500)"`
|
||||
DOM string `gorm:"type:longtext" json:"dom"`
|
||||
NetworkLogs string `gorm:"type:longtext" json:"network_logs"`
|
||||
ConsoleLogs string `gorm:"type:longtext" json:"console_logs"`
|
||||
StatusCode int `json:"status_code"`
|
||||
Title string `json:"title"`
|
||||
LLMVerdict string `json:"llm_verdict"`
|
||||
LLMReasoning string `json:"llm_reasoning"`
|
||||
}
|
||||
|
||||
// Perform executes the AI health check
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{
|
||||
CheckID: uint(c.ID),
|
||||
}
|
||||
result.Warnings = []string{}
|
||||
result.Infos = []string{}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Get the target URL
|
||||
targetURL := getTargetURL(c)
|
||||
result.URL = targetURL
|
||||
|
||||
// Step 1: Capture screenshot, DOM, and logs
|
||||
screenshotData, domData, networkLogs, consoleLogs, statusCode, title, err := captureWebData(targetURL)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to capture web data: %w", err)
|
||||
result.Duration = time.Since(start)
|
||||
result.State = "FAIL"
|
||||
return result
|
||||
}
|
||||
|
||||
result.Screenshot = screenshotData
|
||||
result.DOM = domData
|
||||
result.NetworkLogs = networkLogs
|
||||
result.ConsoleLogs = consoleLogs
|
||||
result.StatusCode = statusCode
|
||||
result.Title = title
|
||||
|
||||
// Step 2: Store current screenshot in database (if available)
|
||||
var screenshotID uint
|
||||
if isDBAvailable() {
|
||||
screenshotID, err = storeScreenshot(uint(c.MonitorID), uint(c.ID), result)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "Failed to store screenshot: "+err.Error())
|
||||
log.Printf("[ai-check] Failed to store screenshot: %v", err)
|
||||
} else {
|
||||
result.ScreenshotID = screenshotID
|
||||
result.Infos = append(result.Infos, "Screenshot stored for reference")
|
||||
}
|
||||
} else {
|
||||
result.Infos = append(result.Infos, "Database not available - running in standalone mode")
|
||||
}
|
||||
|
||||
// Step 3: Get previous screenshots for comparison (if DB available)
|
||||
var previousScreenshots []ScreenshotRecord
|
||||
if isDBAvailable() {
|
||||
previousScreenshots, err = getPreviousScreenshots(uint(c.MonitorID), 3)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "Could not retrieve previous screenshots: "+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
if len(previousScreenshots) > 0 {
|
||||
result.PreviousURL = previousScreenshots[0].URL
|
||||
result.PreviousTime = previousScreenshots[0].CreatedAt.Format(time.RFC3339)
|
||||
result.Infos = append(result.Infos, fmt.Sprintf("Comparing with %d previous screenshot(s)", len(previousScreenshots)))
|
||||
}
|
||||
|
||||
// Step 4: Analyze with LLM
|
||||
llmVerdict, llmReasoning, hasChanges, err := analyzeWithLLM(targetURL, result, previousScreenshots)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("LLM analysis failed: %w", err)
|
||||
result.LLMResponse = err.Error()
|
||||
result.Duration = time.Since(start)
|
||||
result.State = "FAIL"
|
||||
return result
|
||||
}
|
||||
|
||||
result.LLMVerdict = llmVerdict
|
||||
result.LLMReasoning = llmReasoning
|
||||
result.HasChanges = hasChanges
|
||||
|
||||
// Update the screenshot record with LLM analysis
|
||||
if screenshotID > 0 {
|
||||
_ = updateScreenshotWithLLM(screenshotID, llmVerdict, llmReasoning)
|
||||
}
|
||||
|
||||
// Determine final state based on LLM verdict
|
||||
switch llmVerdict {
|
||||
case verdictError:
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New(llmReasoning)
|
||||
case verdictWarning:
|
||||
result.State = "WARN"
|
||||
result.Warnings = append(result.Warnings, llmReasoning)
|
||||
default:
|
||||
result.State = "OK"
|
||||
if hasChanges {
|
||||
result.Infos = append(result.Infos, "Visual changes detected from previous screenshot")
|
||||
}
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
|
||||
// getTargetURL constructs the target URL from the check
|
||||
func getTargetURL(c *models.Check) string {
|
||||
if c.URL != nil && *c.URL != "" {
|
||||
return *c.URL
|
||||
}
|
||||
|
||||
// Construct from monitor host
|
||||
host := c.Monitor.Host
|
||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||
return "https://" + host
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// captureWebData captures screenshot, DOM, network and console logs using chromedp
|
||||
func captureWebData(targetURL string) (screenshot, dom, networkLogsJSON, consoleLogsJSON string, statusCode int, title string, err error) {
|
||||
// Create a context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create chromedp context with options for headless chrome
|
||||
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx,
|
||||
chromedp.NoDefaultBrowserCheck,
|
||||
chromedp.NoFirstRun,
|
||||
chromedp.DisableGPU,
|
||||
chromedp.IgnoreCertErrors,
|
||||
chromedp.Flag("headless", "new"),
|
||||
chromedp.Flag("no-sandbox", true),
|
||||
chromedp.Flag("disable-dev-shm-usage", true),
|
||||
chromedp.Flag("hide-scrollbars", true),
|
||||
chromedp.Flag("mute-audio", true),
|
||||
chromedp.WindowSize(1920, 1080),
|
||||
)
|
||||
defer allocCancel()
|
||||
|
||||
taskCtx, taskCancel := chromedp.NewContext(allocCtx)
|
||||
defer taskCancel()
|
||||
|
||||
// Collect network logs
|
||||
networkLogs := []NetworkLog{}
|
||||
consoleLogs := []ConsoleLog{}
|
||||
|
||||
var finalStatusCode int
|
||||
|
||||
// Listen for network events
|
||||
chromedp.ListenTarget(taskCtx, func(ev interface{}) {
|
||||
switch e := ev.(type) {
|
||||
case *network.EventRequestWillBeSent:
|
||||
// Track the main request
|
||||
if e.Type == network.ResourceTypeDocument {
|
||||
networkLogs = append(networkLogs, NetworkLog{
|
||||
URL: e.Request.URL,
|
||||
Method: e.Request.Method,
|
||||
Type: string(e.Type),
|
||||
})
|
||||
}
|
||||
case *network.EventResponseReceived:
|
||||
// Update the main request with status code
|
||||
for i, log := range networkLogs {
|
||||
if log.URL == e.Response.URL && log.Method == "" { // Not yet updated
|
||||
networkLogs[i].StatusCode = int(e.Response.Status)
|
||||
networkLogs[i].Size = int64(e.Response.EncodedDataLength)
|
||||
|
||||
// Capture final status code for main document
|
||||
if e.Type == network.ResourceTypeDocument && finalStatusCode == 0 {
|
||||
finalStatusCode = int(e.Response.Status)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
case *runtime.EventConsoleAPICalled:
|
||||
// Capture console logs
|
||||
value := ""
|
||||
for _, arg := range e.Args {
|
||||
// Get the string representation of the argument
|
||||
value += arg.Value.String()
|
||||
}
|
||||
if value != "" && value != "undefined" {
|
||||
consoleLogs = append(consoleLogs, ConsoleLog{
|
||||
Type: "console." + string(e.Type),
|
||||
Value: strings.TrimSpace(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Run the browser tasks
|
||||
var buf []byte
|
||||
var html string
|
||||
var pageTitle string
|
||||
|
||||
tasks := chromedp.Tasks{
|
||||
network.Enable(),
|
||||
chromedp.Navigate(targetURL),
|
||||
chromedp.WaitReady("body", chromedp.ByQuery),
|
||||
chromedp.Sleep(2 * time.Second), // Wait for page to fully render
|
||||
chromedp.Title(&pageTitle),
|
||||
chromedp.OuterHTML(":root", &html, chromedp.ByQueryAll),
|
||||
chromedp.FullScreenshot(&buf, 90), // 90% quality JPEG
|
||||
}
|
||||
|
||||
if err := chromedp.Run(taskCtx, tasks); err != nil {
|
||||
// Check if it's a timeout error
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return "", "", "", "", 0, "", fmt.Errorf("page load timeout after 30s")
|
||||
}
|
||||
return "", "", "", "", 0, "", fmt.Errorf("chromedp error: %w", err)
|
||||
}
|
||||
|
||||
// Encode screenshot as base64
|
||||
screenshot = base64.StdEncoding.EncodeToString(buf)
|
||||
dom = html
|
||||
|
||||
// Convert logs to JSON
|
||||
networkBytes, _ := json.Marshal(networkLogs)
|
||||
networkLogsJSON = string(networkBytes)
|
||||
|
||||
consoleBytes, _ := json.Marshal(consoleLogs)
|
||||
consoleLogsJSON = string(consoleBytes)
|
||||
|
||||
// Set status code if we got it from network events
|
||||
if finalStatusCode == 0 {
|
||||
finalStatusCode = 200 // Assume OK if we got this far
|
||||
}
|
||||
|
||||
return screenshot, dom, networkLogsJSON, consoleLogsJSON, finalStatusCode, pageTitle, nil
|
||||
}
|
||||
|
||||
// storeScreenshot stores the screenshot data in S3 and metadata in the database
|
||||
func storeScreenshot(monitorID, checkID uint, result *Result) (uint, error) {
|
||||
// Initialize storage if not already initialized
|
||||
if !storage.IsAvailable() {
|
||||
if err := storage.Init(); err != nil {
|
||||
return 0, fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a unique filename for the screenshot
|
||||
// Format: screenshots/monitor_<monitor_id>/YYYY-MM-DD/<uuid>.jpg
|
||||
timestamp := time.Now().Format("2006-01-02")
|
||||
screenshotID := uuid.New().String()
|
||||
objectPath := fmt.Sprintf("screenshots/monitor_%d/%s/%s.jpg", monitorID, timestamp, screenshotID)
|
||||
|
||||
// Decode base64 screenshot data
|
||||
screenshotBytes, err := base64.StdEncoding.DecodeString(result.Screenshot)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to decode screenshot: %w", err)
|
||||
}
|
||||
|
||||
// Upload screenshot to S3
|
||||
ctx := context.Background()
|
||||
reader := bytes.NewReader(screenshotBytes)
|
||||
objectSize := int64(len(screenshotBytes))
|
||||
|
||||
_, err = storage.PutObject(ctx, objectPath, reader, objectSize, minio.PutObjectOptions{
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to upload screenshot to S3: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[ai-check] Stored screenshot at S3 path: %s (size: %d bytes)", objectPath, objectSize)
|
||||
|
||||
// Store metadata in database
|
||||
record := &ScreenshotRecord{
|
||||
MonitorID: monitorID,
|
||||
CheckID: checkID,
|
||||
URL: result.URL,
|
||||
ScreenshotPath: objectPath,
|
||||
DOM: truncateString(result.DOM, 50000), // Limit DOM size
|
||||
NetworkLogs: result.NetworkLogs,
|
||||
ConsoleLogs: result.ConsoleLogs,
|
||||
StatusCode: result.StatusCode,
|
||||
Title: result.Title,
|
||||
}
|
||||
|
||||
// Auto migrate the table
|
||||
if err := models.DB().AutoMigrate(&ScreenshotRecord{}); err != nil {
|
||||
return 0, fmt.Errorf("failed to auto-migrate: %w", err)
|
||||
}
|
||||
|
||||
if err := models.DB().Create(record).Error; err != nil {
|
||||
return 0, fmt.Errorf("failed to insert screenshot metadata: %w", err)
|
||||
}
|
||||
|
||||
return record.ID, nil
|
||||
}
|
||||
|
||||
// loadScreenshotData loads screenshot data from S3 for a given record
|
||||
func loadScreenshotData(record *ScreenshotRecord) (string, error) {
|
||||
if record.ScreenshotPath == "" {
|
||||
return "", errors.New("no screenshot path in record")
|
||||
}
|
||||
|
||||
if !storage.IsAvailable() {
|
||||
if err := storage.Init(); err != nil {
|
||||
return "", fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
object, err := storage.GetObject(ctx, record.ScreenshotPath, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get screenshot from S3: %w", err)
|
||||
}
|
||||
defer object.Close() //nolint:errcheck // accepted lint exception
|
||||
|
||||
// Read the object data
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, object)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read screenshot data: %w", err)
|
||||
}
|
||||
|
||||
// Encode as base64 for LLM analysis
|
||||
return base64.StdEncoding.EncodeToString(buf.Bytes()), nil
|
||||
}
|
||||
|
||||
// updateScreenshotWithLLM updates a screenshot record with LLM analysis
|
||||
func updateScreenshotWithLLM(screenshotID uint, verdict, reasoning string) error {
|
||||
return models.DB().Model(&ScreenshotRecord{}).
|
||||
Where("id = ?", screenshotID).
|
||||
Updates(map[string]interface{}{
|
||||
"llm_verdict": verdict,
|
||||
"llm_reasoning": reasoning,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// isDBAvailable checks if the database is available
|
||||
func isDBAvailable() bool {
|
||||
return models.IsDBAvailable()
|
||||
}
|
||||
|
||||
// getPreviousScreenshots retrieves previous screenshots for comparison
|
||||
func getPreviousScreenshots(monitorID uint, limit int) ([]ScreenshotRecord, error) {
|
||||
var records []ScreenshotRecord
|
||||
|
||||
err := models.DB().Model(&ScreenshotRecord{}).
|
||||
Where("monitor_id = ? AND llm_verdict != ''", monitorID).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Find(&records).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// truncateString truncates a string to a maximum length
|
||||
func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "... (truncated)"
|
||||
}
|
||||
|
||||
// analyzeWithLLM sends the screenshot and data to the LLM for analysis
|
||||
func analyzeWithLLM(targetURL string, current *Result, previous []ScreenshotRecord) (verdict, reasoning string, hasChanges bool, err error) { //nolint:lll
|
||||
// Get LLM configuration from environment
|
||||
llmKey := firstEnv("LLM_APIKEY", "LLAMA_KEY")
|
||||
llmURL := firstEnv("LLM_URL", "LLAMA_URL")
|
||||
llmModel := firstEnv("LLM_MODEL", "LLAMA_MODEL")
|
||||
|
||||
if llmKey == "" || llmURL == "" {
|
||||
return "", "", false, errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)")
|
||||
}
|
||||
|
||||
if llmModel == "" {
|
||||
llmModel = "llama3.2-vision" // Default model
|
||||
}
|
||||
|
||||
// Create OpenAI client with custom base URL
|
||||
client := openai.NewClient(
|
||||
option.WithBaseURL(llmURL),
|
||||
option.WithAPIKey(llmKey),
|
||||
)
|
||||
|
||||
// Build the system prompt
|
||||
systemPrompt := buildSystemPrompt(len(previous) > 0)
|
||||
|
||||
// Build user message with current screenshot
|
||||
userContent := buildUserMessage(targetURL, current, previous)
|
||||
|
||||
// Prepare messages
|
||||
messages := []openai.ChatCompletionMessageParamUnion{
|
||||
openai.SystemMessage(systemPrompt),
|
||||
openai.UserMessage(userContent),
|
||||
}
|
||||
|
||||
// Call LLM with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Messages: messages,
|
||||
Model: llmModel,
|
||||
MaxTokens: openai.Int(2000),
|
||||
Temperature: openai.Float(0.3), // Lower temperature for more consistent analysis
|
||||
}
|
||||
|
||||
completion, err := client.Chat.Completions.New(ctx, params)
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("LLM request failed: %w", err)
|
||||
}
|
||||
|
||||
if len(completion.Choices) == 0 {
|
||||
return "", "", false, errors.New("LLM returned no choices")
|
||||
}
|
||||
|
||||
response := completion.Choices[0].Message.Content
|
||||
|
||||
// Parse the LLM response to extract verdict and reasoning
|
||||
return parseLLMResponse(response)
|
||||
}
|
||||
|
||||
func firstEnv(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// buildSystemPrompt creates the system prompt for the LLM
|
||||
func buildSystemPrompt(hasPrevious bool) string {
|
||||
basePrompt := `
|
||||
You are a web application health monitoring assistant. Your task is to analyze website screenshots
|
||||
and determine if the website appears to be functioning normally or if there are issues.
|
||||
|
||||
|
||||
Consider the following aspects:
|
||||
1. Visual layout - Is the page rendered correctly? Are elements properly aligned?
|
||||
2. Error messages - Are there any visible error messages, 404s, 500s, or similar?
|
||||
3. Loading states - Is the page stuck loading or showing spinners?
|
||||
4. Broken elements - Are there broken images, missing styles, or layout shifts?
|
||||
5. Content availability - Is the expected content visible?
|
||||
|
||||
Respond in the following JSON format:
|
||||
{
|
||||
"verdict": "normal" | "warning" | "error",
|
||||
"reasoning": "Brief explanation of your assessment",
|
||||
"has_changes": true | false
|
||||
}
|
||||
|
||||
Verdict guidelines:
|
||||
- "error": Page is clearly broken (error messages, completely failed to load, blank page)
|
||||
- "warning": Page loads but has issues (partial content, broken elements, degraded appearance)
|
||||
- "normal": Page appears to be functioning correctly`
|
||||
|
||||
if hasPrevious {
|
||||
basePrompt += `
|
||||
|
||||
You will also be shown previous screenshots of the same website. Compare the current screenshot with previous ones and report any significant visual changes that might indicate problems (layout shifts, missing elements, color changes, etc.).` //nolint:lll
|
||||
}
|
||||
|
||||
return basePrompt
|
||||
}
|
||||
|
||||
// buildUserMessage creates the user message with screenshot data
|
||||
func buildUserMessage(targetURL string, current *Result, previous []ScreenshotRecord) string {
|
||||
var sb strings.Builder
|
||||
|
||||
_, _ = fmt.Fprintf(&sb, "Analyze the screenshot of: %s\n\n", targetURL)
|
||||
_, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", current.StatusCode)
|
||||
_, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", current.Title)
|
||||
|
||||
// Add console errors if any
|
||||
var consoleErrors []ConsoleLog
|
||||
json.Unmarshal([]byte(current.ConsoleLogs), &consoleErrors) //nolint:errcheck // accepted lint exception
|
||||
errorCount := 0
|
||||
for _, log := range consoleErrors {
|
||||
if strings.Contains(log.Type, "error") || strings.Contains(log.Type, "warn") {
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
if errorCount > 0 {
|
||||
_, _ = fmt.Fprintf(&sb, "Console Errors/Warnings: %d\n\n", errorCount)
|
||||
}
|
||||
|
||||
// Add network errors
|
||||
var networkLogs []NetworkLog
|
||||
json.Unmarshal([]byte(current.NetworkLogs), &networkLogs) //nolint:errcheck // accepted lint exception
|
||||
failedRequests := 0
|
||||
for _, req := range networkLogs {
|
||||
if req.StatusCode >= 400 {
|
||||
failedRequests++
|
||||
}
|
||||
}
|
||||
if failedRequests > 0 {
|
||||
_, _ = fmt.Fprintf(&sb, "Failed Network Requests: %d\n\n", failedRequests)
|
||||
}
|
||||
|
||||
sb.WriteString("Current screenshot (base64):\n")
|
||||
sb.WriteString(current.Screenshot)
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
// Add previous screenshots if available (load from S3)
|
||||
if len(previous) > 0 {
|
||||
_, _ = fmt.Fprintf(&sb, "For comparison, here are %d previous screenshot(s):\n\n", len(previous))
|
||||
for i, prev := range previous { //nolint:gocritic // range copy is acceptable here
|
||||
// Load screenshot data from S3
|
||||
screenshotData, err := loadScreenshotData(&prev)
|
||||
if err != nil {
|
||||
log.Printf("[ai-check] Failed to load previous screenshot %d: %v", i+1, err)
|
||||
_, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s): [error loading screenshot]\n\n",
|
||||
i+1, prev.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
continue
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(&sb, "Previous screenshot #%d (from %s):\n", i+1, prev.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
sb.WriteString(screenshotData)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseLLMResponse parses the LLM response to extract structured data
|
||||
func parseLLMResponse(response string) (verdict, reasoning string, hasChanges bool, err error) {
|
||||
// Try to extract JSON from the response
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Look for JSON block
|
||||
jsonStart := strings.Index(response, "{")
|
||||
jsonEnd := strings.LastIndex(response, "}")
|
||||
|
||||
if jsonStart == -1 || jsonEnd == -1 {
|
||||
// No JSON found, try to parse text response
|
||||
return parseTextResponse(response)
|
||||
}
|
||||
|
||||
jsonStr := response[jsonStart : jsonEnd+1]
|
||||
|
||||
var parsed struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
HasChanges bool `json:"has_changes"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
// JSON parse failed, fall back to text parsing
|
||||
return parseTextResponse(response)
|
||||
}
|
||||
|
||||
// Validate verdict
|
||||
switch parsed.Verdict {
|
||||
case "normal", "ok", "healthy":
|
||||
return verdictNormal, parsed.Reasoning, parsed.HasChanges, nil
|
||||
case "warning", "warn":
|
||||
return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil
|
||||
case "error", "fail", "unhealthy":
|
||||
return verdictError, parsed.Reasoning, parsed.HasChanges, nil
|
||||
default:
|
||||
// Unknown verdict, default to normal with warning
|
||||
if parsed.Verdict != "" {
|
||||
return verdictWarning, parsed.Reasoning, parsed.HasChanges, nil
|
||||
}
|
||||
return verdictNormal, "Unable to determine specific issues from analysis", false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseTextResponse parses a non-JSON response
|
||||
func parseTextResponse(response string) (verdict, reasoning string, hasChanges bool, err error) {
|
||||
responseLower := strings.ToLower(response)
|
||||
|
||||
// Look for keywords
|
||||
hasError := strings.Contains(responseLower, "error") ||
|
||||
strings.Contains(responseLower, "broken") ||
|
||||
strings.Contains(responseLower, "failed") ||
|
||||
strings.Contains(responseLower, "not working")
|
||||
|
||||
hasWarning := strings.Contains(responseLower, "warning") ||
|
||||
strings.Contains(responseLower, "issue") ||
|
||||
strings.Contains(responseLower, "problem") ||
|
||||
strings.Contains(responseLower, "degraded")
|
||||
|
||||
hasChange := strings.Contains(responseLower, "change") ||
|
||||
strings.Contains(responseLower, "different") ||
|
||||
strings.Contains(responseLower, "modified")
|
||||
|
||||
if hasError {
|
||||
return verdictError, response, hasChange, nil
|
||||
} else if hasWarning {
|
||||
return verdictWarning, response, hasChange, nil
|
||||
}
|
||||
|
||||
return verdictNormal, response, hasChange, nil
|
||||
}
|
||||
11
checks/calls/init_test.go
Обычный файл
11
checks/calls/init_test.go
Обычный файл
@@ -0,0 +1,11 @@
|
||||
package calls
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsDBAvailableWithoutInitializedDatabase(t *testing.T) {
|
||||
assert.False(t, isDBAvailable())
|
||||
}
|
||||
4
checks/calls/result.go
Обычный файл
4
checks/calls/result.go
Обычный файл
@@ -0,0 +1,4 @@
|
||||
package calls
|
||||
|
||||
// Result is a result of an AI health check
|
||||
// The full Result struct is defined in init.go
|
||||
158
checks/cbssl/README.md
Обычный файл
158
checks/cbssl/README.md
Обычный файл
@@ -0,0 +1,158 @@
|
||||
# CBSSL - Browser-Based SSL Certificate Chain Checker
|
||||
|
||||
## Overview
|
||||
|
||||
The `cbssl` (Browser SSL) check validates SSL/TLS certificates against actual browser CA root stores. Unlike the standard `cssl` check which uses Go's system certificate pool, this check validates certificates against the same CA roots used by Chrome and Firefox on Linux.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dual Browser Validation**: Validates certificates against both Chrome and Firefox CA roots
|
||||
- **Full Chain Information**: Returns complete certificate chain details for each browser
|
||||
- **Expiration Tracking**: Monitors certificate expiration dates and warns before expiry
|
||||
- **Detailed Metrics**: Provides InfluxDB-compatible metrics with detailed validation results
|
||||
|
||||
## How It Works
|
||||
|
||||
### Browser CA Roots
|
||||
|
||||
On Linux, both Chrome and Firefox use the system's CA certificate store:
|
||||
|
||||
- **Chrome/Chromium**: Uses `/etc/ssl/certs/ca-certificates.crt` (on Alpine/Debian)
|
||||
- **Firefox**: Uses NSS library or falls back to system certificates
|
||||
|
||||
The check loads these CA certificates and validates the target site's certificate chain against each browser's root store independently.
|
||||
|
||||
### Certificate Sources
|
||||
|
||||
The checker looks for CA certificates in the following locations (in order):
|
||||
|
||||
1. `/etc/ssl/certs/ca-certificates.crt` - Alpine/Debian system certificates
|
||||
2. `/etc/ssl/cert.pem` - macOS system certificates
|
||||
3. `/etc/pki/tls/certs/ca-bundle.crt` - RHEL/CentOS system certificates
|
||||
4. `/usr/local/share/ca-certificates/` - Custom certificate directory
|
||||
5. `/data/rsmon/docker/cert-bundles/output/` - Project-specific certificate bundles
|
||||
|
||||
### Mozilla CA Bundle
|
||||
|
||||
The project includes the Mozilla CA certificate bundle which contains the same CA certificates used by Firefox:
|
||||
|
||||
```bash
|
||||
# Downloaded from: https://curl.se/ca/cacert.pem
|
||||
# Location: docker/cert-bundles/output/mozilla-ca-bundle.crt
|
||||
# Certificate count: 144 CA certificates
|
||||
```
|
||||
|
||||
## Result Format
|
||||
|
||||
```go
|
||||
type Result struct {
|
||||
// Standard check result
|
||||
cr.CheckResult
|
||||
|
||||
// Chrome-specific results
|
||||
ChromeValid bool // true if certificate validates against Chrome roots
|
||||
ChromeError string // error message if Chrome validation fails
|
||||
ChromeChain []CertInfo // certificate chain as validated by Chrome
|
||||
|
||||
// Firefox-specific results
|
||||
FirefoxValid bool // true if certificate validates against Firefox roots
|
||||
FirefoxError string // error message if Firefox validation fails
|
||||
FirefoxChain []CertInfo // certificate chain as validated by Firefox
|
||||
|
||||
// Certificate details
|
||||
Expires *time.Time // certificate expiration date
|
||||
Subject string // certificate subject (CN)
|
||||
Issuer string // certificate issuer (CN)
|
||||
DNSNames []string // certificate SANs
|
||||
}
|
||||
```
|
||||
|
||||
## Check Parameters
|
||||
|
||||
Currently, the check validates against both browsers. Future versions may support:
|
||||
|
||||
- `browser` - Specify which browser to validate against: "chrome", "firefox", or "all" (default)
|
||||
|
||||
## InfluxDB Metrics
|
||||
|
||||
The check provides the following metrics:
|
||||
|
||||
**Fields:**
|
||||
- `took` - Request duration in milliseconds
|
||||
- `chrome_valid` - Whether certificate validated against Chrome roots (1/0)
|
||||
- `firefox_valid` - Whether certificate validated against Firefox roots (1/0)
|
||||
- `expires_at` - Unix timestamp of certificate expiration
|
||||
- `days_until_expiry` - Days until certificate expires
|
||||
|
||||
**Tags:**
|
||||
- `check` - Check ID
|
||||
- `state` - Check state (OK, WARN, ERR, FAIL)
|
||||
- `subject` - Certificate subject CN
|
||||
- `issuer` - Certificate issuer CN
|
||||
- `chrome_error` - Chrome validation error (if any)
|
||||
- `firefox_error` - Firefox validation error (if any)
|
||||
- `dns_names` - Comma-separated list of DNS names in certificate
|
||||
|
||||
## Example Usage
|
||||
|
||||
```go
|
||||
import "rsgit.ru/rsmon/rsmon/checks/cbssl"
|
||||
|
||||
// Perform the check
|
||||
result := cbssl.Perform(check)
|
||||
|
||||
// Check results
|
||||
if result.ChromeValid && result.FirefoxValid {
|
||||
// Certificate is valid for both browsers
|
||||
} else if !result.ChromeValid {
|
||||
// Certificate fails Chrome validation
|
||||
fmt.Printf("Chrome error: %s\n", result.ChromeError)
|
||||
}
|
||||
```
|
||||
|
||||
## Differences from cssl
|
||||
|
||||
| Feature | cssl | cbssl |
|
||||
|---------|------|-------|
|
||||
| CA Root Source | Go's system pool | Browser-specific CA roots |
|
||||
| Browser Validation | Single (system) | Dual (Chrome + Firefox) |
|
||||
| Chain Information | Basic leaf cert | Full chain per browser |
|
||||
| Use Case | General SSL validation | Browser compatibility verification |
|
||||
|
||||
## Certificate Bundle Management
|
||||
|
||||
To update the CA certificate bundles:
|
||||
|
||||
```bash
|
||||
# Download latest Mozilla CA bundle
|
||||
cd /data/rsmon
|
||||
curl -fsSL -o docker/cert-bundles/output/mozilla-ca-bundle.crt \
|
||||
https://curl.se/ca/cacert.pem
|
||||
|
||||
# Verify
|
||||
grep -c "BEGIN CERTIFICATE" docker/cert-bundles/output/mozilla-ca-bundle.crt
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "failed to load any CA certificates"
|
||||
|
||||
This error occurs when no CA certificates can be found. Solutions:
|
||||
|
||||
1. Ensure the system has `ca-certificates` package installed
|
||||
2. Place custom CA certificates in `/usr/local/share/ca-certificates/`
|
||||
3. Add certificates to the project bundle at `docker/cert-bundles/output/`
|
||||
|
||||
### Certificate validation failures
|
||||
|
||||
If validation fails for a site that works in browsers:
|
||||
|
||||
1. Check if the site uses a custom CA not in the Mozilla bundle
|
||||
2. Verify the site's intermediate certificates are properly configured
|
||||
3. Check for expired or malformed certificate chains
|
||||
|
||||
## References
|
||||
|
||||
- [Mozilla Included CA Certificate List](https://wiki.mozilla.org/CA/Included_Certificates)
|
||||
- [Chrome Root Certificate Policy](https://www.chromium.org/Home/chromium-security/root-ca-policy)
|
||||
- [curl CA Bundle](https://curl.se/docs/caextract.html)
|
||||
429
checks/cbssl/cbssl.go
Обычный файл
429
checks/cbssl/cbssl.go
Обычный файл
@@ -0,0 +1,429 @@
|
||||
// Package cbssl provides browser-based SSL certificate chain validation.
|
||||
// It validates certificates against actual browser CA root stores (Chrome, Firefox).
|
||||
package cbssl
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const stateOK = "OK"
|
||||
|
||||
// BrowserType represents the type of browser to use for CA roots
|
||||
type BrowserType string
|
||||
|
||||
// Browser type constants for CA root selection.
|
||||
const (
|
||||
BrowserChrome BrowserType = "chrome"
|
||||
BrowserFirefox BrowserType = "firefox"
|
||||
BrowserAll BrowserType = "all"
|
||||
)
|
||||
|
||||
var (
|
||||
// Certificate pools for different browsers
|
||||
chromeRoots *x509.CertPool
|
||||
firefoxRoots *x509.CertPool
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// BrowserCertPools holds the certificate pools for different browsers
|
||||
type BrowserCertPools struct {
|
||||
Chrome *x509.CertPool
|
||||
Firefox *x509.CertPool
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Initialize certificate pools
|
||||
once.Do(func() {
|
||||
var err error
|
||||
chromeRoots, firefoxRoots, err = LoadBrowserCARoots()
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load browser CA roots: %v", err)
|
||||
// Fall back to system roots
|
||||
chromeRoots = x509.NewCertPool()
|
||||
firefoxRoots = x509.NewCertPool()
|
||||
systemRoots, err := x509.SystemCertPool()
|
||||
if err == nil {
|
||||
chromeRoots = systemRoots
|
||||
firefoxRoots = systemRoots
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Perform executes the browser-based SSL certificate check
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
result.State = stateOK
|
||||
|
||||
// Get URL to check
|
||||
checkURL, err := getCheckURL(c)
|
||||
if err != nil {
|
||||
result.State = "FAIL"
|
||||
result.Error = errors.Wrap(err, "invalid url")
|
||||
return result
|
||||
}
|
||||
log.Println("CBSSL URL:", checkURL)
|
||||
|
||||
// Parse hostname from URL
|
||||
parsedURL, err := url.Parse(checkURL)
|
||||
if err != nil {
|
||||
result.State = "FAIL"
|
||||
result.Error = errors.Wrap(err, "failed to parse url")
|
||||
return result
|
||||
}
|
||||
hostname := parsedURL.Hostname()
|
||||
|
||||
// Get browser type - default to checking all browsers
|
||||
// TODO: Add Browser field to CheckSettings to allow per-check configuration
|
||||
browserType := BrowserAll
|
||||
|
||||
// Create custom TLS client that validates against browser roots
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: hostname,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// We'll set RootCAs per request to test against different browsers
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test against Chrome roots
|
||||
if browserType == BrowserChrome || browserType == BrowserAll {
|
||||
chromeResult := testWithBrowser(client, checkURL, chromeRoots, "Chrome")
|
||||
result.ChromeValid = chromeResult.Valid
|
||||
result.ChromeError = chromeResult.ErrorMsg
|
||||
result.ChromeChain = chromeResult.ChainInfo
|
||||
if !chromeResult.Valid {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New("certificate validation failed against Chrome roots")
|
||||
if chromeResult.ErrorMsg != "" {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Chrome: %s", chromeResult.ErrorMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test against Firefox roots
|
||||
if browserType == BrowserFirefox || browserType == BrowserAll {
|
||||
firefoxResult := testWithBrowser(client, checkURL, firefoxRoots, "Firefox")
|
||||
result.FirefoxValid = firefoxResult.Valid
|
||||
result.FirefoxError = firefoxResult.ErrorMsg
|
||||
result.FirefoxChain = firefoxResult.ChainInfo
|
||||
if !firefoxResult.Valid && result.State == stateOK {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New("certificate validation failed against Firefox roots")
|
||||
}
|
||||
if firefoxResult.ErrorMsg != "" {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Firefox: %s", firefoxResult.ErrorMsg))
|
||||
}
|
||||
}
|
||||
|
||||
// Get certificate info for expiration check
|
||||
certInfo, err := getCertificateInfo(checkURL)
|
||||
if err != nil {
|
||||
if result.State == stateOK {
|
||||
result.State = "WARN"
|
||||
}
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to get certificate info: %v", err))
|
||||
} else {
|
||||
result.Expires = &certInfo.NotAfter
|
||||
result.Subject = certInfo.Subject.CommonName
|
||||
result.Issuer = certInfo.Issuer.CommonName
|
||||
result.DNSNames = certInfo.DNSNames
|
||||
|
||||
// Check expiration
|
||||
exp := time.Until(certInfo.NotAfter).Hours() / 24
|
||||
if exp < 2 {
|
||||
if result.State == stateOK {
|
||||
result.State = "WARN"
|
||||
}
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.1f days", exp))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// testWithBrowser tests the certificate against a specific browser's CA roots
|
||||
func testWithBrowser(client *http.Client, checkURL string, roots *x509.CertPool, browserName string) BrowserTestResult {
|
||||
result := BrowserTestResult{Valid: false}
|
||||
|
||||
// Clone transport and set custom root CAs
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
result.ErrorMsg = fmt.Sprintf("failed to get transport for %s", browserName)
|
||||
return result
|
||||
}
|
||||
|
||||
customTransport := transport.Clone()
|
||||
customTransport.TLSClientConfig.RootCAs = roots
|
||||
|
||||
// Create new client with custom transport
|
||||
customClient := &http.Client{
|
||||
Timeout: client.Timeout,
|
||||
CheckRedirect: client.CheckRedirect,
|
||||
Transport: customTransport,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", checkURL, http.NoBody)
|
||||
if err != nil {
|
||||
result.ErrorMsg = fmt.Sprintf("failed to create request for %s: %v", browserName, err)
|
||||
return result
|
||||
}
|
||||
|
||||
req.Header.Set("Cache-Control", "max-age=0")
|
||||
req.Header.Set("Connection", "close")
|
||||
req.Header.Set("User-Agent", getUserAgent(browserName))
|
||||
|
||||
resp, err := customClient.Do(req)
|
||||
if err != nil {
|
||||
// Check if it's a certificate verification error
|
||||
if strings.Contains(err.Error(), "certificate") || strings.Contains(err.Error(), "x509") {
|
||||
result.ErrorMsg = fmt.Sprintf("certificate verification failed: %v", err)
|
||||
} else {
|
||||
result.ErrorMsg = fmt.Sprintf("connection failed: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck // accepted lint exception
|
||||
|
||||
result.Valid = true
|
||||
|
||||
// Get chain info
|
||||
if resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0 {
|
||||
chain := resp.TLS.VerifiedChains[0]
|
||||
result.ChainInfo = buildChainInfo(chain)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// BrowserTestResult represents the result of testing against a specific browser
|
||||
type BrowserTestResult struct {
|
||||
Valid bool
|
||||
ErrorMsg string
|
||||
ChainInfo []CertInfo
|
||||
}
|
||||
|
||||
func buildChainInfo(chain []*x509.Certificate) []CertInfo {
|
||||
info := make([]CertInfo, 0, len(chain))
|
||||
for _, cert := range chain {
|
||||
info = append(info, CertInfo{
|
||||
Subject: cert.Subject.CommonName,
|
||||
Issuer: cert.Issuer.CommonName,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
IsCA: cert.IsCA,
|
||||
PublicKey: cert.PublicKeyAlgorithm.String(),
|
||||
Signature: cert.SignatureAlgorithm.String(),
|
||||
})
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func getUserAgent(browserName string) string {
|
||||
switch browserName {
|
||||
case "Chrome":
|
||||
return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
case "Firefox":
|
||||
return "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0"
|
||||
default:
|
||||
return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
}
|
||||
}
|
||||
|
||||
// getCertificateInfo retrieves certificate information without validation
|
||||
func getCertificateInfo(checkURL string) (*x509.Certificate, error) {
|
||||
parsedURL, err := url.Parse(checkURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hostname := parsedURL.Hostname()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: hostname,
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(checkURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck // accepted lint exception
|
||||
|
||||
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("no TLS certificates found")
|
||||
}
|
||||
|
||||
return resp.TLS.PeerCertificates[0], nil
|
||||
}
|
||||
|
||||
// getCheckURL constructs the URL to check from the check configuration
|
||||
func getCheckURL(c *models.Check) (string, error) {
|
||||
if c.URL != nil && *c.URL != "" {
|
||||
ur := *c.URL
|
||||
u, err := url.Parse(ur)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "bad url")
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
return "https://" + c.Monitor.Host, nil
|
||||
}
|
||||
|
||||
// LoadBrowserCARoots loads CA root certificates from browser installations
|
||||
func LoadBrowserCARoots() (chrome, firefox *x509.CertPool, err error) {
|
||||
chrome = x509.NewCertPool()
|
||||
firefox = x509.NewCertPool()
|
||||
|
||||
// Load Chrome/Chromium CA roots (uses system certs on Linux)
|
||||
// On Alpine Linux, this is typically /etc/ssl/certs/ca-certificates.crt
|
||||
chromeLoaded := false
|
||||
|
||||
// Try common certificate bundle locations
|
||||
certPaths := []string{
|
||||
"/etc/ssl/certs/ca-certificates.crt", // Alpine/Debian
|
||||
"/etc/ssl/cert.pem", // macOS
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS
|
||||
"/usr/local/share/ca-certificates/", // Custom certs
|
||||
"/data/rsmon/docker/cert-bundles/output/", // Our custom bundled certs
|
||||
}
|
||||
|
||||
for _, certPath := range certPaths {
|
||||
if loadCertsFromPath(chrome, certPath) {
|
||||
chromeLoaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Load Firefox CA roots (NSS database)
|
||||
// Firefox uses its own NSS database at ~/.pki/nssdb/ or uses system roots
|
||||
firefoxLoaded := loadFirefoxCARoots(firefox)
|
||||
|
||||
// If Firefox failed to load, use Chrome/system roots as fallback
|
||||
if !firefoxLoaded && chromeLoaded {
|
||||
firefox = chrome
|
||||
}
|
||||
|
||||
// If both failed, return error
|
||||
if !chromeLoaded {
|
||||
return nil, nil, fmt.Errorf("failed to load any CA certificates")
|
||||
}
|
||||
|
||||
return chrome, firefox, nil
|
||||
}
|
||||
|
||||
// loadCertsFromPath loads certificates from a file or directory
|
||||
func loadCertsFromPath(pool *x509.CertPool, path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Load all .crt and .pem files from directory
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
loaded := false
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := filepath.Ext(file.Name())
|
||||
if ext == ".crt" || ext == ".pem" {
|
||||
fullPath := filepath.Join(path, file.Name())
|
||||
if pool.AppendCertsFromPEM(readFile(fullPath)) {
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
// Load single file
|
||||
return pool.AppendCertsFromPEM(readFile(path))
|
||||
}
|
||||
|
||||
func readFile(path string) []byte {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// loadFirefoxCARoots loads Firefox CA roots from NSS database
|
||||
// On Linux, Firefox typically uses the system's CA certificates via libnssckbi.so
|
||||
func loadFirefoxCARoots(pool *x509.CertPool) bool {
|
||||
// Firefox on Linux usually uses the system certificate database
|
||||
// Try to load from Mozilla's built-in certificate bundle if available
|
||||
paths := []string{
|
||||
"/usr/lib/x86_64-linux-gnu/libnssckbi.so", // Debian/Ubuntu NSS module
|
||||
"/usr/lib/libnssckbi.so", // Generic path
|
||||
"/data/rsmon/docker/cert-bundles/output/mozilla/", // Our bundled Mozilla certs
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if loadCertsFromPath(pool, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParsePEMCerts parses PEM-encoded certificates from data
|
||||
func ParsePEMCerts(data []byte) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
var block *pem.Block
|
||||
|
||||
rest := data
|
||||
for {
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found")
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
135
checks/cbssl/cbssl_test.go
Обычный файл
135
checks/cbssl/cbssl_test.go
Обычный файл
@@ -0,0 +1,135 @@
|
||||
package cbssl
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParsePEMCerts(t *testing.T) {
|
||||
// Generate a valid self-signed certificate for testing
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %v", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "testca"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
pemData := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
certs, err := ParsePEMCerts(pemData)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePEMCerts failed: %v", err)
|
||||
}
|
||||
|
||||
if len(certs) != 1 {
|
||||
t.Fatalf("expected 1 certificate, got %d", len(certs))
|
||||
}
|
||||
|
||||
if certs[0] == nil {
|
||||
t.Fatal("expected non-nil certificate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePEMCertsInvalid(t *testing.T) {
|
||||
// Test invalid PEM data
|
||||
pemData := []byte(`not a valid PEM`)
|
||||
|
||||
_, err := ParsePEMCerts(pemData)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid PEM data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserAgent(t *testing.T) {
|
||||
tests := []struct {
|
||||
browser string
|
||||
wantStart string
|
||||
}{
|
||||
{"Chrome", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"},
|
||||
{"Firefox", "Mozilla/5.0 (X11; Linux x86_64"},
|
||||
{"Unknown", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.browser, func(t *testing.T) {
|
||||
ua := getUserAgent(tt.browser)
|
||||
if len(ua) < 20 {
|
||||
t.Errorf("getUserAgent() returned too short string: %s", ua)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserTypeString(t *testing.T) {
|
||||
if BrowserChrome != "chrome" {
|
||||
t.Errorf("expected 'chrome', got %s", BrowserChrome)
|
||||
}
|
||||
if BrowserFirefox != "firefox" {
|
||||
t.Errorf("expected 'firefox', got %s", BrowserFirefox)
|
||||
}
|
||||
if BrowserAll != "all" {
|
||||
t.Errorf("expected 'all', got %s", BrowserAll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertInfo(t *testing.T) {
|
||||
info := CertInfo{
|
||||
Subject: "example.com",
|
||||
Issuer: "CA Root",
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
IsCA: false,
|
||||
PublicKey: "RSA",
|
||||
Signature: "SHA256-RSA",
|
||||
}
|
||||
|
||||
if info.Subject != "example.com" {
|
||||
t.Errorf("expected subject 'example.com', got %s", info.Subject)
|
||||
}
|
||||
if info.IsCA {
|
||||
t.Error("expected IsCA to be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFile(t *testing.T) {
|
||||
// Test reading non-existent file
|
||||
data := readFile("/nonexistent/file.txt")
|
||||
if data != nil {
|
||||
t.Error("expected nil for non-existent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBrowserCARoots(t *testing.T) {
|
||||
chrome, firefox, err := LoadBrowserCARoots()
|
||||
if err != nil {
|
||||
t.Logf("LoadBrowserCARoots failed (expected in some environments): %v", err)
|
||||
// This is expected in minimal test environments without CA certificates
|
||||
return
|
||||
}
|
||||
|
||||
if chrome == nil {
|
||||
t.Error("expected non-nil chrome pool")
|
||||
}
|
||||
if firefox == nil {
|
||||
t.Error("expected non-nil firefox pool")
|
||||
}
|
||||
}
|
||||
76
checks/cbssl/result.go
Обычный файл
76
checks/cbssl/result.go
Обычный файл
@@ -0,0 +1,76 @@
|
||||
package cbssl
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of a browser-based SSL certificate check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
|
||||
// Chrome-specific results
|
||||
ChromeValid bool `json:"chrome_valid"`
|
||||
ChromeError string `json:"chrome_error,omitempty"`
|
||||
ChromeChain []CertInfo `json:"chrome_chain,omitempty"`
|
||||
|
||||
// Firefox-specific results
|
||||
FirefoxValid bool `json:"firefox_valid"`
|
||||
FirefoxError string `json:"firefox_error,omitempty"`
|
||||
FirefoxChain []CertInfo `json:"firefox_chain,omitempty"`
|
||||
|
||||
// Certificate details
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Issuer string `json:"issuer,omitempty"`
|
||||
DNSNames []string `json:"dns_names,omitempty"`
|
||||
}
|
||||
|
||||
// CertInfo represents information about a certificate in the chain
|
||||
type CertInfo struct {
|
||||
Subject string `json:"subject"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
PublicKey string `json:"public_key_algorithm"`
|
||||
Signature string `json:"signature_algorithm"`
|
||||
}
|
||||
|
||||
// InfluxFields returns the fields for InfluxDB metrics
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
ret["chrome_valid"] = r.ChromeValid
|
||||
ret["firefox_valid"] = r.FirefoxValid
|
||||
if r.Expires != nil {
|
||||
ret["expires_at"] = r.Expires.Unix()
|
||||
ret["days_until_expiry"] = int64(time.Until(*r.Expires).Hours() / 24)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags returns the tags for InfluxDB metrics
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
ret["subject"] = r.Subject
|
||||
ret["issuer"] = r.Issuer
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
if r.ChromeError != "" {
|
||||
ret["chrome_error"] = r.ChromeError
|
||||
}
|
||||
if r.FirefoxError != "" {
|
||||
ret["firefox_error"] = r.FirefoxError
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
ret["dns_names"] = strings.Join(r.DNSNames, ",")
|
||||
return ret
|
||||
}
|
||||
39
checks/cdns/a_records.go
Обычный файл
39
checks/cdns/a_records.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
// Package cdns provides functionality.
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/netaddr"
|
||||
)
|
||||
|
||||
// FetchARecords provides functionality.
|
||||
func FetchARecords(zone, ns string) ([]NSRecord, error) {
|
||||
config := dns.ClientConfig{Servers: []string{ns}}
|
||||
c := new(dns.Client)
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(zone, dns.TypeA)
|
||||
m.RecursionDesired = true
|
||||
r, _, err := c.Exchange(m, config.Servers[0]+":53")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Rcode != dns.RcodeSuccess {
|
||||
return nil, errors.New("bad rcode: " + strconv.Itoa(r.Rcode))
|
||||
}
|
||||
|
||||
var result []NSRecord
|
||||
for _, a := range r.Answer {
|
||||
if ar, ok := a.(*dns.A); ok {
|
||||
// spew.Dump(ar)
|
||||
val := netaddr.Inet{Inet: ar.A}
|
||||
result = append(result, NSRecord{Name: a.Header().Name, Kind: "A", Value: val})
|
||||
// fmt.Printf("%s\n", mx.String())
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
39
checks/cdns/config.go
Обычный файл
39
checks/cdns/config.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DNS check configuration constants.
|
||||
const (
|
||||
// Timeout is the DNS query timeout in seconds.
|
||||
Timeout float64 = float64(1.5)
|
||||
MaxTrials uint = 3
|
||||
MaxNameservers uint = 20
|
||||
MaxAddresses uint = 10
|
||||
EdnsbufferSize uint16 = 4096
|
||||
)
|
||||
|
||||
var (
|
||||
conf *dns.ClientConfig
|
||||
debug = false
|
||||
maxTrials = 3
|
||||
v4only = true
|
||||
v6only = false
|
||||
bufsize = EdnsbufferSize
|
||||
timeout = time.Duration(float64(Timeout) * float64(time.Second))
|
||||
noedns = false
|
||||
recursion = false
|
||||
tcp = false
|
||||
noauthrequired = false
|
||||
nodnssec = false
|
||||
)
|
||||
|
||||
func init() {
|
||||
conf = &dns.ClientConfig{
|
||||
Servers: []string{"8.8.8.8", "1.1.1.1", "77.88.8.8"},
|
||||
Port: "53",
|
||||
}
|
||||
}
|
||||
234
checks/cdns/dns.go
Обычный файл
234
checks/cdns/dns.go
Обычный файл
@@ -0,0 +1,234 @@
|
||||
// Source: https://github.com/bortzmeyer/check-soa/blob/master/check-soa.go
|
||||
// 2-Clause BSD License: Copyright (c) 2012, Stephane Bortzmeyer All rights reserved.
|
||||
// A simple program to have rapidly an idea of the health of a DNS
|
||||
// zone. It queries each name server of the zone for the SOA record and
|
||||
// displays the value of the serial number for each server.
|
||||
//
|
||||
// Stephane Bortzmeyer <bortzmeyer@nic.fr>
|
||||
// Heavily modified for RSMon
|
||||
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
stateWARN = "WARN"
|
||||
)
|
||||
|
||||
var localhost net.IP
|
||||
|
||||
func init() {
|
||||
localhost = net.ParseIP("127.0.0.1")
|
||||
}
|
||||
|
||||
// Perform checks if domain is resolvable via it's DNS servers
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
result.State = "FAIL"
|
||||
host := c.Monitor.Host
|
||||
|
||||
if host == "" {
|
||||
result.State = "FAIL"
|
||||
result.Error = errors.New("empty host name")
|
||||
return result
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
// log.Println("run dns:", host)
|
||||
if host == "localhost" || strings.HasPrefix(host, "localhost:") {
|
||||
result.Warnings = append(result.Warnings, "DNS check not possible for localhost, please disable")
|
||||
return result
|
||||
}
|
||||
addr := net.ParseIP(host)
|
||||
if addr != nil {
|
||||
result.Warnings = append(result.Warnings, "DNS check not possible for ip address, please disable")
|
||||
return result
|
||||
}
|
||||
|
||||
zname, err := publicsuffix.Domain(host)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "Failed to get public suffix: "+err.Error())
|
||||
zname = host
|
||||
}
|
||||
// if zname != host {
|
||||
// result.Infos = append(result.Infos, "not top level domain, running NS check for "+zname)
|
||||
// }
|
||||
|
||||
zone := dns.Fqdn(zname)
|
||||
nsChan := make(chan DNSreply)
|
||||
// log.Println(zone)
|
||||
go localQuery(nsChan, zone, dns.TypeNS)
|
||||
nsResult := <-nsChan
|
||||
if nsResult.r == nil {
|
||||
result.State = stateERR
|
||||
result.Error = fmt.Errorf("cannot retrieve the list of name servers for %s: %s", zone, nsResult.err)
|
||||
return result
|
||||
}
|
||||
if nsResult.r.Rcode == dns.RcodeNameError {
|
||||
result.State = stateERR
|
||||
result.Error = fmt.Errorf("no such domain %s", zone)
|
||||
return result
|
||||
}
|
||||
// spew.Dump(nsResult)
|
||||
|
||||
nslist := make(map[string]nameServer, 0)
|
||||
for i := range nsResult.r.Answer {
|
||||
ans := nsResult.r.Answer[i]
|
||||
if ns, ok := ans.(*dns.NS); ok {
|
||||
name := ns.Ns
|
||||
nslist[name] = nameServer{name: name, ips: make([]string, MaxAddresses)}
|
||||
}
|
||||
}
|
||||
|
||||
// spew.Dump(nslist)
|
||||
|
||||
numNS, numNSaddr, success, results := masterTask(zone, nslist)
|
||||
if success {
|
||||
result.State = stateOK
|
||||
} else {
|
||||
result.State = stateERR
|
||||
}
|
||||
|
||||
if numNS == 0 {
|
||||
result.State = stateERR
|
||||
result.Error = fmt.Errorf("no NS records for zone \"%s\"", zone)
|
||||
return result
|
||||
}
|
||||
if numNSaddr == 0 {
|
||||
result.State = stateERR
|
||||
result.Error = fmt.Errorf("no IP addresses for name servers of %s", zone)
|
||||
return result
|
||||
}
|
||||
|
||||
gallOK := true
|
||||
ganyOK := false
|
||||
failedNS := []string{}
|
||||
|
||||
lzone := dns.Fqdn(host)
|
||||
|
||||
for _, rzt := range results { //nolint:gocritic // range copy is acceptable here
|
||||
// spew.Dump(rzt)
|
||||
|
||||
allOK := true
|
||||
anyOK := false
|
||||
ns := NSServer{Name: rzt.name}
|
||||
for i := 0; i < len(rzt.ips); i++ {
|
||||
ip := NSIP{
|
||||
ResponseTime: rzt.rtts[i],
|
||||
IP: rzt.ips[i],
|
||||
}
|
||||
if rzt.success[i] {
|
||||
anyOK = true
|
||||
ganyOK = true
|
||||
ip.State = stateOK
|
||||
ip.Serial = rzt.serial[i]
|
||||
} else {
|
||||
allOK = false
|
||||
gallOK = false
|
||||
ip.State = stateERR
|
||||
ip.Error = errors.New(rzt.errMsg[i])
|
||||
failedNS = append(failedNS, rzt.name)
|
||||
// spew.Dump(rzt)
|
||||
}
|
||||
ns.NSIPs = append(ns.NSIPs, ip)
|
||||
|
||||
if result.State == stateOK {
|
||||
// spew.Dump(ns)
|
||||
// log.Println("fetching records for", lzone, "from", ns.NSIPs[0].IP)
|
||||
ns.Response, err = FetchARecords(lzone, ns.NSIPs[0].IP)
|
||||
if err != nil {
|
||||
ns.State = stateERR
|
||||
ns.Error = err
|
||||
gallOK = false
|
||||
failedNS = append(failedNS, rzt.name+"/"+ns.NSIPs[0].IP)
|
||||
// log.Println("failed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(rzt.ips) == 0 {
|
||||
ns.State = stateERR
|
||||
ns.Error = errors.New(rzt.globalErrMsg)
|
||||
failedNS = append(failedNS, rzt.name+"/no ip for dns server")
|
||||
gallOK = false
|
||||
} else {
|
||||
if allOK {
|
||||
ns.State = stateOK
|
||||
} else {
|
||||
if anyOK {
|
||||
ns.State = "WARN"
|
||||
// log.Println("failed NS")
|
||||
// spew.Dump(ns)
|
||||
if ns.Error == nil {
|
||||
ns.Error = errors.New("some servers failed")
|
||||
}
|
||||
} else {
|
||||
ns.State = stateERR
|
||||
if ns.Error == nil {
|
||||
ns.Error = errors.New("all servers failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.NSServers = append(result.NSServers, ns)
|
||||
}
|
||||
|
||||
if gallOK {
|
||||
result.State = stateOK
|
||||
} else {
|
||||
if ganyOK {
|
||||
result.State = stateWARN
|
||||
result.Warnings = append(result.Warnings, "some servers failed: "+strings.Join(failedNS, ","))
|
||||
} else {
|
||||
result.State = stateERR
|
||||
if result.Error == nil {
|
||||
result.Error = errors.New("all servers failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, ni := range result.NSServers {
|
||||
// log.Println(ni.Name)
|
||||
for _, r := range ni.Response {
|
||||
ip := r.Value.Inet
|
||||
if ip.Equal(localhost) {
|
||||
result.State = stateERR
|
||||
err := "resolves to localhost/127.0.0.1"
|
||||
if result.Error == nil {
|
||||
result.Error = errors.New(err)
|
||||
} else if result.Error.Error() != err {
|
||||
result.Warnings = append(result.Warnings, err)
|
||||
}
|
||||
}
|
||||
// log.Println(r.Name, r.Kind, r.Value)
|
||||
}
|
||||
}
|
||||
|
||||
_, maxt := result.Times()
|
||||
if maxt > 2*time.Second {
|
||||
if result.State == stateOK {
|
||||
result.State = stateWARN
|
||||
}
|
||||
result.Warnings = append(result.Warnings, "slow")
|
||||
}
|
||||
|
||||
result.Duration = time.Since(start)
|
||||
|
||||
// spew.Dump(result)
|
||||
|
||||
return result
|
||||
}
|
||||
67
checks/cdns/local_query.go
Обычный файл
67
checks/cdns/local_query.go
Обычный файл
@@ -0,0 +1,67 @@
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func localQuery(mychan chan DNSreply, qname string, qtype uint16) {
|
||||
if debug {
|
||||
fmt.Printf("DEBUG: start of DNS request \"%s\" / %d\n", qname, qtype)
|
||||
}
|
||||
var result DNSreply
|
||||
var trials uint
|
||||
result.qname = qname
|
||||
result.qtype = qtype
|
||||
result.r = nil
|
||||
result.err = errors.New("no name server to answer the question")
|
||||
localm := new(dns.Msg)
|
||||
localm.Id = dns.Id()
|
||||
localm.RecursionDesired = true
|
||||
localm.Question = make([]dns.Question, 1)
|
||||
localm.SetEdns0(bufsize, false) // Even if no EDNS requested, see #9 May be we should retry without it if timeout?
|
||||
localc := new(dns.Client)
|
||||
localc.ReadTimeout = timeout
|
||||
localm.Question[0] = dns.Question{Name: qname, Qtype: qtype, Qclass: dns.ClassINET}
|
||||
Tests:
|
||||
for trials = 0; trials < uint(maxTrials); trials++ {
|
||||
for serverIndex := range conf.Servers {
|
||||
server := conf.Servers[serverIndex]
|
||||
result.nameserver = server
|
||||
// Brackets around the server address are necessary for IPv6 name servers
|
||||
// Brackets required for IPv6; do not use net.JoinHostPort (see check-soa commit 3e4edb1)
|
||||
r, rtt, err := localc.Exchange(localm, "["+server+"]:"+conf.Port)
|
||||
if r == nil {
|
||||
result.r = nil
|
||||
result.err = err
|
||||
log.Println(err.Error())
|
||||
if strings.Contains(err.Error(), "timeout") {
|
||||
// Try another resolver
|
||||
continue
|
||||
}
|
||||
// We give in
|
||||
break Tests
|
||||
}
|
||||
result.rtt = rtt
|
||||
if r.Rcode == dns.RcodeSuccess {
|
||||
// TODO: NODATA (NOERROR/ANSWER=0) are silently ignored (e.g. name exists but no IP address)
|
||||
// TODO: for rcodes like SERVFAIL, trying another resolver could make sense
|
||||
result.r = r
|
||||
result.err = nil
|
||||
break Tests
|
||||
}
|
||||
// All the other codes are errors
|
||||
result.r = r
|
||||
result.err = errors.New(dns.RcodeToString[r.Rcode])
|
||||
break Tests
|
||||
}
|
||||
}
|
||||
if debug {
|
||||
fmt.Printf("DEBUG: end of DNS request \"%s\" / %d\n", qname, qtype)
|
||||
}
|
||||
mychan <- result
|
||||
}
|
||||
123
checks/cdns/master_task.go
Обычный файл
123
checks/cdns/master_task.go
Обычный файл
@@ -0,0 +1,123 @@
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// Results provides functionality.
|
||||
type Results map[string]nameServer
|
||||
|
||||
func masterTask(zone string, nameservers map[string]nameServer) (uint, uint, bool, Results) {
|
||||
var numRequests uint
|
||||
success := true
|
||||
addressChannel := make(chan DNSreply)
|
||||
soaChannel := make(chan SOAreply)
|
||||
numNS := uint(0)
|
||||
numAddrNS := uint(0)
|
||||
results := make(Results)
|
||||
for name := range nameservers {
|
||||
if !v6only {
|
||||
go localQuery(addressChannel, name, dns.TypeA)
|
||||
}
|
||||
if !v4only {
|
||||
go localQuery(addressChannel, name, dns.TypeAAAA)
|
||||
}
|
||||
numNS++
|
||||
}
|
||||
if v6only || v4only {
|
||||
numRequests = numNS
|
||||
} else {
|
||||
numRequests = numNS * 2
|
||||
}
|
||||
for i := uint(0); i < numRequests; i++ {
|
||||
addrResult := <-addressChannel
|
||||
addrFamily := "IPv6"
|
||||
if addrResult.qtype == dns.TypeA {
|
||||
addrFamily = "IPv4"
|
||||
}
|
||||
if addrResult.r == nil {
|
||||
// TODO We may have different globalErrMsg is it
|
||||
// works with IPv4 but not IPv6 (it should not happen but it does)
|
||||
nameservers[addrResult.qname] = nameServer{
|
||||
name: addrResult.qname,
|
||||
ips: nil,
|
||||
globalErrMsg: fmt.Sprintf("Cannot get the %s address: %s", addrFamily, addrResult.err),
|
||||
}
|
||||
success = false
|
||||
} else {
|
||||
if addrResult.r.Rcode != dns.RcodeSuccess {
|
||||
nameservers[addrResult.qname] = nameServer{
|
||||
name: addrResult.qname,
|
||||
ips: nil,
|
||||
globalErrMsg: fmt.Sprintf("Cannot get the %s address: %s", addrFamily, dns.RcodeToString[addrResult.r.Rcode]),
|
||||
}
|
||||
success = false
|
||||
} else {
|
||||
for j := range addrResult.r.Answer {
|
||||
ansa := addrResult.r.Answer[j]
|
||||
var ns string
|
||||
switch a := ansa.(type) {
|
||||
case *dns.A:
|
||||
ns = a.A.String()
|
||||
existing := nameservers[addrResult.qname]
|
||||
nameservers[addrResult.qname] = nameServer{name: addrResult.qname, ips: append(existing.ips, ns)}
|
||||
numAddrNS++
|
||||
go soaQuery(soaChannel, zone, addrResult.qname, ns)
|
||||
case *dns.AAAA:
|
||||
ns = a.AAAA.String()
|
||||
existing2 := nameservers[addrResult.qname]
|
||||
nameservers[addrResult.qname] = nameServer{name: addrResult.qname, ips: append(existing2.ips, ns)}
|
||||
numAddrNS++
|
||||
go soaQuery(soaChannel, zone, addrResult.qname, ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := uint(0); i < numAddrNS; i++ {
|
||||
if debug {
|
||||
fmt.Printf("DEBUG Getting result for ns #%d/%d\n", i+1, numAddrNS)
|
||||
}
|
||||
soaResult := <-soaChannel
|
||||
_, present := results[soaResult.name]
|
||||
if !present {
|
||||
results[soaResult.name] = nameServer{
|
||||
name: soaResult.name,
|
||||
ips: make([]string, 0),
|
||||
success: make([]bool, 0),
|
||||
errMsg: make([]string, 0),
|
||||
serial: make([]uint32, 0),
|
||||
rtts: make([]time.Duration, 0),
|
||||
}
|
||||
}
|
||||
if !soaResult.retrieved {
|
||||
results[soaResult.name] = nameServer{
|
||||
name: soaResult.name,
|
||||
ips: append(results[soaResult.name].ips, soaResult.address),
|
||||
success: append(results[soaResult.name].success, false),
|
||||
errMsg: append(results[soaResult.name].errMsg, soaResult.msg),
|
||||
serial: append(results[soaResult.name].serial, 0),
|
||||
rtts: append(results[soaResult.name].rtts, soaResult.rtt),
|
||||
}
|
||||
success = false
|
||||
} else {
|
||||
results[soaResult.name] = nameServer{
|
||||
name: soaResult.name,
|
||||
ips: append(results[soaResult.name].ips, soaResult.address),
|
||||
success: append(results[soaResult.name].success, true),
|
||||
errMsg: append(results[soaResult.name].errMsg, ""),
|
||||
serial: append(results[soaResult.name].serial, soaResult.serial),
|
||||
rtts: append(results[soaResult.name].rtts, soaResult.rtt),
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range nameservers {
|
||||
if nameservers[name].ips == nil {
|
||||
results[name] = nameservers[name]
|
||||
}
|
||||
}
|
||||
return numNS, numAddrNS, success, results
|
||||
}
|
||||
109
checks/cdns/result.go
Обычный файл
109
checks/cdns/result.go
Обычный файл
@@ -0,0 +1,109 @@
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
"rsgit.ru/rsmon/rsmon/internal/netaddr"
|
||||
)
|
||||
|
||||
// NSIP holds the result of querying a single nameserver IP.
|
||||
type NSIP struct {
|
||||
State string
|
||||
IP string
|
||||
ResponseTime time.Duration
|
||||
Serial uint32
|
||||
Error error
|
||||
}
|
||||
|
||||
// NSRecord holds a single DNS record from a nameserver response.
|
||||
type NSRecord struct {
|
||||
Name string
|
||||
Kind string
|
||||
Value netaddr.Inet
|
||||
}
|
||||
|
||||
// NSServer holds the result of querying a single nameserver.
|
||||
type NSServer struct {
|
||||
State string
|
||||
Name string
|
||||
Error error
|
||||
NSIPs []NSIP
|
||||
Response []NSRecord
|
||||
}
|
||||
|
||||
// Result holds the full DNS check result.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
NSServers []NSServer
|
||||
}
|
||||
|
||||
// Servers provides functionality.
|
||||
func (r *Result) Servers() []string {
|
||||
ret := make([]string, 0, len(r.NSServers))
|
||||
for _, s := range r.NSServers {
|
||||
ret = append(ret, s.Name+"-"+s.State)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// ServersOK provides functionality.
|
||||
func (r *Result) ServersOK() int {
|
||||
ok := 0
|
||||
for _, s := range r.NSServers {
|
||||
if s.State == "OK" {
|
||||
ok++
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
// Times provides functionality.
|
||||
func (r *Result) Times() (time.Duration, time.Duration) {
|
||||
minTime := time.Hour
|
||||
maxTime := time.Duration(0)
|
||||
for _, s := range r.NSServers {
|
||||
for _, i := range s.NSIPs {
|
||||
if i.ResponseTime > maxTime {
|
||||
maxTime = i.ResponseTime
|
||||
}
|
||||
if i.ResponseTime < minTime {
|
||||
minTime = i.ResponseTime
|
||||
}
|
||||
}
|
||||
}
|
||||
return minTime, maxTime
|
||||
}
|
||||
|
||||
// InfluxFields provides functionality.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
|
||||
_, maxTime := r.Times()
|
||||
ret["took"] = int64(maxTime / time.Millisecond)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags provides functionality.
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = c.State
|
||||
minTime, _ := r.Times()
|
||||
ret["min_took"] = strconv.FormatInt(int64(minTime/time.Millisecond), 10)
|
||||
|
||||
ret["state"] = r.State
|
||||
total := len(r.NSServers)
|
||||
oks := r.ServersOK()
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
|
||||
ret["nservers"] = strconv.Itoa(total)
|
||||
ret["servers"] = strings.Join(r.Servers(), ",")
|
||||
ret["servers_ok"] = strconv.Itoa(oks)
|
||||
ret["servers_failed"] = strconv.Itoa(total - oks)
|
||||
return ret
|
||||
}
|
||||
82
checks/cdns/soa_query.go
Обычный файл
82
checks/cdns/soa_query.go
Обычный файл
@@ -0,0 +1,82 @@
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func soaQuery(mychan chan SOAreply, zone string, name string, server string) {
|
||||
var result SOAreply
|
||||
var trials uint
|
||||
result.retrieved = false
|
||||
result.name = name
|
||||
result.address = server
|
||||
result.msg = "UNKNOWN"
|
||||
m := new(dns.Msg)
|
||||
if !noedns {
|
||||
m.SetEdns0(bufsize, !nodnssec)
|
||||
}
|
||||
m.Id = dns.Id()
|
||||
if recursion {
|
||||
m.RecursionDesired = true
|
||||
} else {
|
||||
m.RecursionDesired = false
|
||||
}
|
||||
m.Question = make([]dns.Question, 1)
|
||||
c := new(dns.Client)
|
||||
c.ReadTimeout = timeout // Seems ignored for TCP?
|
||||
if tcp {
|
||||
c.Net = "tcp"
|
||||
}
|
||||
m.Question[0] = dns.Question{Name: zone, Qtype: dns.TypeSOA, Qclass: dns.ClassINET}
|
||||
nsAddressPort := net.JoinHostPort(server, "53")
|
||||
if debug {
|
||||
fmt.Printf("DEBUG Querying SOA from %s\n", nsAddressPort)
|
||||
}
|
||||
for trials = 0; trials < uint(maxTrials); trials++ {
|
||||
soa, rtt, err := c.Exchange(m, nsAddressPort)
|
||||
if soa == nil {
|
||||
result.rtt = 0
|
||||
result.msg = err.Error()
|
||||
} else {
|
||||
result.rtt = rtt
|
||||
if soa.Rcode != dns.RcodeSuccess {
|
||||
result.msg = dns.RcodeToString[soa.Rcode]
|
||||
break
|
||||
}
|
||||
if len(soa.Answer) == 0 { /* May happen if the server is a recursor, not authoritative, since we query with RD=0 */
|
||||
result.msg = "0 answer"
|
||||
break
|
||||
} else { //nolint:revive // complex nested structure
|
||||
gotSoa := false
|
||||
for _, rsoa := range soa.Answer {
|
||||
switch r := rsoa.(type) {
|
||||
case *dns.SOA:
|
||||
if noauthrequired || soa.Authoritative {
|
||||
result.retrieved = true
|
||||
result.serial = r.Serial
|
||||
result.msg = "OK"
|
||||
} else {
|
||||
result.msg = "Not authoritative"
|
||||
}
|
||||
gotSoa = true
|
||||
case *dns.CNAME: /* Bad practice but common */
|
||||
result.msg = "Apparently not a zone but an alias"
|
||||
case *dns.RRSIG:
|
||||
/* Ignore them. See bug #8 */
|
||||
default:
|
||||
// TODO: a name server can send us other RR types.
|
||||
result.msg = fmt.Sprintf("Internal error when processing %s, unexpected record type\n", rsoa)
|
||||
}
|
||||
}
|
||||
if !gotSoa {
|
||||
result.msg = "No SOA record in reply"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
mychan <- result
|
||||
}
|
||||
37
checks/cdns/types.go
Обычный файл
37
checks/cdns/types.go
Обычный файл
@@ -0,0 +1,37 @@
|
||||
package cdns
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// DNSreply provides functionality.
|
||||
type DNSreply struct {
|
||||
qname string
|
||||
qtype uint16
|
||||
r *dns.Msg
|
||||
err error
|
||||
nameserver string
|
||||
rtt time.Duration
|
||||
}
|
||||
|
||||
// SOAreply provides functionality.
|
||||
type SOAreply struct {
|
||||
name string
|
||||
address string
|
||||
serial uint32
|
||||
retrieved bool
|
||||
msg string
|
||||
rtt time.Duration
|
||||
}
|
||||
|
||||
type nameServer struct {
|
||||
name string
|
||||
ips []string
|
||||
globalErrMsg string
|
||||
success []bool
|
||||
errMsg []string
|
||||
serial []uint32
|
||||
rtts []time.Duration
|
||||
}
|
||||
50
checks/cftp/cftp.go
Обычный файл
50
checks/cftp/cftp.go
Обычный файл
@@ -0,0 +1,50 @@
|
||||
// Package cftp provides functionality.
|
||||
package cftp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const stateERR = "ERR"
|
||||
|
||||
// Perform provides functionality.
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
host := c.GetSettings().Port
|
||||
if host == "" {
|
||||
host = "21"
|
||||
}
|
||||
result.State = "START"
|
||||
client := &net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
DualStack: true,
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := client.Dial("tcp", net.JoinHostPort(c.Monitor.Host, host))
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = err
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
result.Duration = time.Since(start)
|
||||
status, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
if strings.Contains(status, "FTP") {
|
||||
result.State = "OK"
|
||||
} else {
|
||||
result.State = stateERR
|
||||
result.Error = errors.New("it's not FTP")
|
||||
}
|
||||
return result
|
||||
}
|
||||
10
checks/cftp/result.go
Обычный файл
10
checks/cftp/result.go
Обычный файл
@@ -0,0 +1,10 @@
|
||||
package cftp
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of a check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
}
|
||||
179
checks/chttp/http.go
Обычный файл
179
checks/chttp/http.go
Обычный файл
@@ -0,0 +1,179 @@
|
||||
// Package chttp provides HTTP check functionality for RSMon.
|
||||
package chttp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateERR = "ERR"
|
||||
stateFAIL = "FAIL"
|
||||
)
|
||||
|
||||
// UserAgent is the HTTP User-Agent header sent with all HTTP checks.
|
||||
const UserAgent = "Mozilla/5.0 (compatible; RSMon/1.0; +https://rsmon.ru/bot)"
|
||||
|
||||
func ipv6(ip []string) []string {
|
||||
var ips []string
|
||||
for _, a := range ip {
|
||||
ip := net.ParseIP(a)
|
||||
check := ip.To4()
|
||||
if check == nil {
|
||||
ips = append(ips, a)
|
||||
}
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
// Perform executes an HTTP check.
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
result.State = "START"
|
||||
if c.URL == nil {
|
||||
result.State = stateFAIL
|
||||
result.Error = errors.New("no url or bad url")
|
||||
return result
|
||||
}
|
||||
|
||||
var requestBody io.Reader
|
||||
|
||||
settings := c.GetSettings()
|
||||
var err error
|
||||
var to time.Duration
|
||||
var slow time.Duration
|
||||
|
||||
if settings.Timeout > 0 && settings.Timeout < 300000 {
|
||||
to = time.Millisecond * time.Duration(settings.Timeout)
|
||||
} else {
|
||||
to = time.Second * 60
|
||||
}
|
||||
|
||||
if settings.SlowTime > 0 {
|
||||
slow = time.Millisecond * time.Duration(settings.SlowTime)
|
||||
} else {
|
||||
slow = time.Second * 5
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: to,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
if settings.CheckIp {
|
||||
addr, err := net.LookupHost(*c.URL)
|
||||
if err != nil {
|
||||
result.Status = stateFAIL
|
||||
result.Error = err
|
||||
}
|
||||
if settings.CheckIPv6 {
|
||||
addr = ipv6(addr)
|
||||
}
|
||||
for _, a := range addr {
|
||||
_, err := net.Dial("tcp", net.JoinHostPort(a, "80"))
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
break
|
||||
}
|
||||
result.State = "OK"
|
||||
}
|
||||
}
|
||||
switch settings.RequestType {
|
||||
case "application/json":
|
||||
body, _ := json.Marshal(settings.RequestContent)
|
||||
requestBody = bytes.NewReader(body)
|
||||
case "application/x-www-form-urlencoded":
|
||||
requestBody = strings.NewReader(settings.RequestContent)
|
||||
case "multipart/form-data":
|
||||
buf := bytes.NewBuffer([]byte{})
|
||||
writer := multipart.NewWriter(buf)
|
||||
_, _ = writer.CreateFormField(settings.RequestContent)
|
||||
_ = writer.Close()
|
||||
requestBody = bytes.NewReader(buf.Bytes())
|
||||
settings.RequestType = writer.FormDataContentType()
|
||||
case "text/plain":
|
||||
requestBody = bytes.NewBufferString(settings.RequestContent)
|
||||
}
|
||||
if settings.RequestMethod == "" {
|
||||
settings.RequestMethod = "GET"
|
||||
}
|
||||
request, err := http.NewRequest(strings.ToUpper(settings.RequestMethod), *c.URL, requestBody)
|
||||
if err != nil {
|
||||
result.State = stateFAIL
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
if settings.HTTPUsername != "" && settings.HTTPPassword != "" {
|
||||
request.SetBasicAuth(settings.HTTPUsername, settings.HTTPPassword)
|
||||
}
|
||||
|
||||
request.Header.Set("User-Agent", UserAgent)
|
||||
request.Header.Set("Cache-Control", "max-age=0")
|
||||
request.Header.Set("Connection", "close")
|
||||
if settings.RequestType != "" {
|
||||
request.Header.Set("Content-Type", settings.RequestType)
|
||||
}
|
||||
if len(settings.RequestHeader) != 0 {
|
||||
for _, h := range settings.RequestHeader {
|
||||
request.Header.Set(h.Key, h.Value)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(request)
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = errors.Wrap(err, "request exec")
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck // accepted lint exception
|
||||
|
||||
result.Status = resp.Status
|
||||
result.StatusCode = resp.StatusCode
|
||||
result.Headers = resp.Header
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
result.Duration = time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = errors.Wrap(err, "read body")
|
||||
return result
|
||||
}
|
||||
result.Length = len(body)
|
||||
result.Body = body
|
||||
|
||||
warns, err := settings.CheckAnswer(resp, body)
|
||||
|
||||
for _, w := range warns {
|
||||
result.State = "WARN"
|
||||
result.Warnings = append(result.Warnings, w)
|
||||
}
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = errors.Wrap(err, "response check")
|
||||
}
|
||||
|
||||
if result.Error == nil {
|
||||
if result.Duration > slow {
|
||||
result.State = "WARN"
|
||||
result.Warnings = append(result.Warnings, "slow")
|
||||
} else if result.State == "START" {
|
||||
result.State = "OK"
|
||||
}
|
||||
} else {
|
||||
result.State = stateERR
|
||||
}
|
||||
return result
|
||||
}
|
||||
41
checks/chttp/result.go
Обычный файл
41
checks/chttp/result.go
Обычный файл
@@ -0,0 +1,41 @@
|
||||
package chttp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result provides functionality.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
StatusCode int
|
||||
Status string
|
||||
Body []byte
|
||||
Headers map[string][]string
|
||||
Length int
|
||||
}
|
||||
|
||||
// InfluxFields provides functionality.
|
||||
func (hr *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(hr.Duration / time.Millisecond)
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags provides functionality.
|
||||
func (hr *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = hr.State
|
||||
ret["code"] = strconv.Itoa(hr.StatusCode)
|
||||
if hr.Error != nil {
|
||||
ret["error"] = hr.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(hr.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
378
checks/cping/ping.go
Обычный файл
378
checks/cping/ping.go
Обычный файл
@@ -0,0 +1,378 @@
|
||||
// Package cping provides ICMP echo (ping) check functionality for RSMon.
|
||||
//
|
||||
// The implementation targets minimal-reuse logic inspired by
|
||||
// github.com/go-ping/ping, but rewritten inline so the project does not
|
||||
// pick up an external dependency. Concretely it uses
|
||||
// golang.org/x/net/icmp to send an Echo Request and waits for a single
|
||||
// Echo Reply within the configured timeout.
|
||||
//
|
||||
// Privileges / CAP_NET_RAW:
|
||||
//
|
||||
// - On Linux, the "ip4:icmp" listener needs either CAP_NET_RAW on the
|
||||
// binary OR the net.ipv4.ping_group_range sysctl to be widened
|
||||
// (see "unprivileged ICMP sockets"). When neither is available, we
|
||||
// fall back to "udp4" which works on Linux only when the same
|
||||
// sysctl is widened; on macOS the "udp4" mode is unprivileged by
|
||||
// default.
|
||||
// - On Windows the privileged (raw) ICMP listener is required.
|
||||
//
|
||||
// The check returns FAIL when neither listener can be opened so the
|
||||
// caller knows the operator needs to enable the capability. OK/ERR are
|
||||
// reported when the listener works but the host is unreachable or times
|
||||
// out.
|
||||
package cping
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
stateFail = "FAIL"
|
||||
|
||||
defaultCount = 1
|
||||
defaultTimeout = 5 * time.Second
|
||||
minTimeout = 1 * time.Second
|
||||
defaultPacketSz = 56
|
||||
|
||||
icmpProtoIP4 = "ip4:icmp"
|
||||
icmpProtoUDP = "udp4"
|
||||
)
|
||||
|
||||
// Pinger is the abstraction the package uses for ICMP echo. The
|
||||
// production code path uses runPinger, but it is interface-typed so
|
||||
// tests can substitute a fake without touching raw sockets.
|
||||
type Pinger interface {
|
||||
Run(host string, count int, timeout time.Duration, payloadSize int) Stats
|
||||
}
|
||||
|
||||
// Stats is the per-run summary produced by a Pinger.
|
||||
type Stats struct {
|
||||
PacketsSent int
|
||||
PacketsRecv int
|
||||
AvgRtt time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
var (
|
||||
defaultPinger Pinger = &realPinger{}
|
||||
|
||||
// pingerMu guards the swap of defaultPinger in tests.
|
||||
pingerMu sync.RWMutex
|
||||
)
|
||||
|
||||
// SetPinger overrides the default Pinger. It is intended for tests
|
||||
// that need to inject fakes without granting CAP_NET_RAW to the test
|
||||
// binary.
|
||||
func SetPinger(p Pinger) {
|
||||
pingerMu.Lock()
|
||||
defaultPinger = p
|
||||
pingerMu.Unlock()
|
||||
}
|
||||
|
||||
func currentPinger() Pinger {
|
||||
pingerMu.RLock()
|
||||
defer pingerMu.RUnlock()
|
||||
return defaultPinger
|
||||
}
|
||||
|
||||
// Perform executes a single ping check for the supplied Check.
|
||||
//
|
||||
// Settings consumed from models.CheckSettings:
|
||||
// - count (int): number of echo requests to send. Defaults to 1 to
|
||||
// keep intervals short; clamped to [1, 5].
|
||||
// - timeout (int, seconds): total budget for the check; clamped to
|
||||
// at least 1s.
|
||||
// - packet_size (int): ICMP payload size in bytes; clamped to
|
||||
// [0, 1400].
|
||||
// - host (string): optional override of the monitor host.
|
||||
func Perform(c *models.Check) *Result {
|
||||
r := &Result{}
|
||||
settings := c.GetSettings()
|
||||
|
||||
host := c.Monitor.Host
|
||||
if settings.Host != "" {
|
||||
host = settings.Host
|
||||
}
|
||||
if host == "" {
|
||||
r.State = stateFail
|
||||
r.Error = errors.New("ping: empty host")
|
||||
return r
|
||||
}
|
||||
|
||||
count := settings.Count
|
||||
if count <= 0 {
|
||||
count = defaultCount
|
||||
}
|
||||
if count > 5 {
|
||||
count = 5
|
||||
}
|
||||
|
||||
timeout := time.Duration(settings.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
if timeout < minTimeout {
|
||||
timeout = minTimeout
|
||||
}
|
||||
|
||||
payloadSize := settings.PacketSize
|
||||
if payloadSize == 0 {
|
||||
payloadSize = defaultPacketSz
|
||||
}
|
||||
if payloadSize < 0 {
|
||||
payloadSize = 0
|
||||
}
|
||||
if payloadSize > 1400 {
|
||||
payloadSize = 1400
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
stats := currentPinger().Run(host, count, timeout, payloadSize)
|
||||
r.Duration = time.Since(start)
|
||||
r.PacketsSent = stats.PacketsSent
|
||||
r.PacketsRecv = stats.PacketsRecv
|
||||
if stats.PacketsRecv > 0 {
|
||||
r.AvgRttMs = float64(stats.AvgRtt.Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
if stats.Err != nil {
|
||||
// Distinguish "could not run at all" (no privileges) from
|
||||
// "ran but failed" so the operator can fix the environment.
|
||||
if isUnsupported(stats.Err) {
|
||||
r.State = stateFail
|
||||
} else {
|
||||
r.State = stateERR
|
||||
}
|
||||
r.Error = stats.Err
|
||||
return r
|
||||
}
|
||||
|
||||
if stats.PacketsRecv == 0 {
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("no reply from %s (sent %d)", host, stats.PacketsSent)
|
||||
return r
|
||||
}
|
||||
|
||||
r.State = stateOK
|
||||
r.Infos = append(r.Infos, fmt.Sprintf("rtt=%.2fms sent=%d recv=%d", r.AvgRttMs, stats.PacketsSent, stats.PacketsRecv))
|
||||
return r
|
||||
}
|
||||
|
||||
// isUnsupported reports whether err looks like a permission problem
|
||||
// rather than a runtime failure.
|
||||
func isUnsupported(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
if runtime.GOOS == "windows" {
|
||||
return msg != ""
|
||||
}
|
||||
// Linux/Darwin permission flavors.
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return true
|
||||
}
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
if containsAny(msg, "operation not permitted", "permission denied", "cap_net_raw", "ping_group_range") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsAny(s string, needles ...string) bool {
|
||||
for _, n := range needles {
|
||||
if n == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(s, n) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// realPinger sends and receives ICMP echo packets using the standard
|
||||
// library plus golang.org/x/net/icmp.
|
||||
type realPinger struct{}
|
||||
|
||||
// Run is the production pinger entrypoint. It resolves host, opens an
|
||||
// ICMP listener (preferring the unprivileged UDP path on Linux when
|
||||
// available, falling back to raw IP), and waits up to timeout for at
|
||||
// least one Echo Reply.
|
||||
func (r *realPinger) Run(host string, count int, timeout time.Duration, payloadSize int) Stats {
|
||||
stats := Stats{PacketsSent: 0, PacketsRecv: 0}
|
||||
|
||||
dst, err := net.ResolveIPAddr("ip4", host)
|
||||
if err != nil {
|
||||
stats.Err = fmt.Errorf("resolve %s: %w", host, err)
|
||||
return stats
|
||||
}
|
||||
|
||||
conn, network, err := openICMP()
|
||||
if err != nil {
|
||||
stats.Err = err
|
||||
return stats
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck // accepted lint exception: best-effort close
|
||||
|
||||
// Build an "echo and wait for reply" function keyed by network so
|
||||
// the IPv4-only logic stays close to where it is used.
|
||||
sendAndRecv := func(seq int) (time.Duration, error) {
|
||||
msg := icmp.Message{
|
||||
Type: ipv4.ICMPTypeEcho, Code: 0,
|
||||
Body: &icmp.Echo{
|
||||
ID: os.Getpid() & 0xffff, Seq: seq,
|
||||
Data: makeBytes(payloadSize),
|
||||
},
|
||||
}
|
||||
bin, err := msg.Marshal(nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("marshal icmp: %w", err)
|
||||
}
|
||||
|
||||
sentAt := time.Now()
|
||||
if _, err := conn.WriteTo(bin, dst); err != nil {
|
||||
return 0, fmt.Errorf("write icmp: %w", err)
|
||||
}
|
||||
|
||||
// Per-packet deadline = remaining budget / remaining attempts
|
||||
// (or 1s minimum). We reuse the single shared conn for all
|
||||
// count iterations so replies may arrive slightly out of order.
|
||||
deadline := time.Now().Add(timeout / time.Duration(count))
|
||||
if remaining := time.Until(deadline); remaining < time.Second {
|
||||
deadline = time.Now().Add(time.Second)
|
||||
}
|
||||
if err := conn.SetReadDeadline(deadline); err != nil {
|
||||
return 0, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
|
||||
reply, peer, err := readOne(conn, network)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_ = peer // peer would be useful for response-time per hop; not needed for MVP.
|
||||
if reply == nil {
|
||||
return 0, errors.New("nil reply")
|
||||
}
|
||||
return time.Since(sentAt), nil
|
||||
}
|
||||
|
||||
var total time.Duration
|
||||
for i := 1; i <= count; i++ {
|
||||
stats.PacketsSent++
|
||||
rtt, err := sendAndRecv(i)
|
||||
if err != nil {
|
||||
// First packet failed because of read timeout: report
|
||||
// host as unreachable. Keep iterating up to count so the
|
||||
// reported packet loss is accurate (>= 50%).
|
||||
if i == 1 {
|
||||
stats.Err = fmt.Errorf("icmp %s: %w", host, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
stats.PacketsRecv++
|
||||
total += rtt
|
||||
}
|
||||
if stats.PacketsRecv > 0 {
|
||||
stats.AvgRtt = total / time.Duration(stats.PacketsRecv)
|
||||
}
|
||||
// When at least one packet succeeded, drop the underlying error so
|
||||
// Perform() reports OK.
|
||||
if stats.PacketsRecv > 0 {
|
||||
stats.Err = nil
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// openICMP returns an ICMP packet connection. On Linux the code prefers
|
||||
// the "udp4" (unprivileged) listener because the raw "ip4:icmp"
|
||||
// listener needs CAP_NET_RAW unless the sysctl
|
||||
// net.ipv4.ping_group_range is widened. On other OSes we fall back to
|
||||
// the raw listener.
|
||||
func openICMP() (*icmp.PacketConn, string, error) {
|
||||
// Try the unprivileged path first; if it fails, fall back to raw.
|
||||
conn, err := icmp.ListenPacket(icmpProtoUDP, "0.0.0.0")
|
||||
if err == nil {
|
||||
return conn, icmpProtoUDP, nil
|
||||
}
|
||||
rawErr := err
|
||||
if runtime.GOOS == "windows" {
|
||||
// Windows must use the raw (privileged) listener.
|
||||
conn, err = icmp.ListenPacket(icmpProtoIP4, "0.0.0.0")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("icmp listen: %w", rawErr)
|
||||
}
|
||||
return conn, icmpProtoIP4, nil
|
||||
}
|
||||
// Linux/Darwin: try raw as a fallback. Production binaries
|
||||
// shipping with cap_net_raw=+ep will succeed here; test/CI
|
||||
// runners without the capability will surface a clear
|
||||
// permission error.
|
||||
conn, err = icmp.ListenPacket(icmpProtoIP4, "0.0.0.0")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("icmp listen (udp4=%v; ip4=%v)", rawErr, err)
|
||||
}
|
||||
return conn, icmpProtoIP4, nil
|
||||
}
|
||||
|
||||
// readOne reads a single packet, validates it is an Echo Reply (or a
|
||||
// TTL-exceeded reply from a router along the path), and returns it.
|
||||
func readOne(conn *icmp.PacketConn, network string) (*icmp.Message, net.Addr, error) {
|
||||
buf := make([]byte, 1500)
|
||||
n, peer, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read icmp: %w", err)
|
||||
}
|
||||
parsed, err := icmp.ParseMessage(icmpProtoToInt(network), buf[:n])
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse icmp: %w", err)
|
||||
}
|
||||
// We accept both Echo Reply (the destination) and Time Exceeded
|
||||
// (intermediate router hop) because some networks filter Echo
|
||||
// Replies but still return traceroute-style Time Exceeded packets,
|
||||
// which proves the host is reachable.
|
||||
switch parsed.Type {
|
||||
case ipv4.ICMPTypeEchoReply, ipv4.ICMPTypeTimeExceeded:
|
||||
return parsed, peer, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("unexpected icmp type %v", parsed.Type)
|
||||
}
|
||||
|
||||
// icmpProtoToInt maps our internal "ip4:icmp"/"udp4" tag to the
|
||||
// protocol number expected by icmp.ParseMessage. icmp.DefaultPacketProtocol
|
||||
// would re-derive this but we want the value stable.
|
||||
func icmpProtoToInt(probe string) int {
|
||||
if probe == icmpProtoUDP {
|
||||
return 1 // udp4
|
||||
}
|
||||
return 0 // ip4:icmp
|
||||
}
|
||||
|
||||
// makeBytes returns a deterministic payload of the requested size so
|
||||
// packet sizes are stable across runs.
|
||||
func makeBytes(n int) []byte {
|
||||
if n <= 0 {
|
||||
return []byte{}
|
||||
}
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = byte('a' + (i % 26))
|
||||
}
|
||||
return b
|
||||
}
|
||||
159
checks/cping/ping_test.go
Обычный файл
159
checks/cping/ping_test.go
Обычный файл
@@ -0,0 +1,159 @@
|
||||
package cping
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// settings mirrors models.CheckSettings so the test can build a
|
||||
// CheckSettings JSON without pulling in the entire model package.
|
||||
type settings struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
PacketSize int `json:"packet_size,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
}
|
||||
|
||||
func newCheck(t *testing.T, s settings, host string) *models.Check {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal settings: %v", err)
|
||||
}
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return &models.Check{
|
||||
Kind: "ping",
|
||||
Monitor: &models.Monitor{Host: host},
|
||||
Settings: datatypes.JSON(raw),
|
||||
}
|
||||
}
|
||||
|
||||
// fakePinger returns canned stats without touching the network so the
|
||||
// state machine in Perform() can be exercised in CI environments
|
||||
// without CAP_NET_RAW.
|
||||
type fakePinger struct {
|
||||
stats Stats
|
||||
}
|
||||
|
||||
func (f *fakePinger) Run(host string, count int, timeout time.Duration, payloadSize int) Stats {
|
||||
return f.stats
|
||||
}
|
||||
|
||||
func TestPerformOK(t *testing.T) {
|
||||
SetPinger(&fakePinger{stats: Stats{PacketsSent: 3, PacketsRecv: 3, AvgRtt: 4 * time.Millisecond}})
|
||||
defer SetPinger(&realPinger{})
|
||||
|
||||
c := newCheck(t, settings{Count: 3}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if r.PacketsSent != 3 || r.PacketsRecv != 3 {
|
||||
t.Fatalf("packet counts wrong: sent=%d recv=%d", r.PacketsSent, r.PacketsRecv)
|
||||
}
|
||||
if r.AvgRttMs < 1 {
|
||||
t.Fatalf("expected RTT > 0, got %v", r.AvgRttMs)
|
||||
}
|
||||
if len(r.Infos) == 0 || !strings.Contains(r.Infos[0], "rtt=") {
|
||||
t.Fatalf("expected rtt info line, got %v", r.Infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformNoReply(t *testing.T) {
|
||||
SetPinger(&fakePinger{stats: Stats{PacketsSent: 3, PacketsRecv: 0, Err: errors.New("i/o timeout")}})
|
||||
defer SetPinger(&realPinger{})
|
||||
|
||||
c := newCheck(t, settings{}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR, got %s", r.State)
|
||||
}
|
||||
if r.PacketsSent == 0 {
|
||||
t.Fatalf("expected PacketsSent to be incremented even on failure")
|
||||
}
|
||||
if r.Error == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUnsupported(t *testing.T) {
|
||||
SetPinger(&fakePinger{stats: Stats{Err: errors.New("socket: operation not permitted (cap_net_raw)")}})
|
||||
defer SetPinger(&realPinger{})
|
||||
|
||||
c := newCheck(t, settings{}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateFail {
|
||||
t.Fatalf("expected FAIL when raw ICMP is not allowed, got %s", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformEmptyHost(t *testing.T) {
|
||||
c := &models.Check{
|
||||
Kind: "ping",
|
||||
Monitor: &models.Monitor{Host: ""},
|
||||
Settings: datatypes.JSON("{}"),
|
||||
}
|
||||
r := Perform(c)
|
||||
if r.State != stateFail {
|
||||
t.Fatalf("expected FAIL on empty host, got %s", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsUnsupported(t *testing.T) {
|
||||
cases := []struct {
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{err: nil, want: false},
|
||||
{err: errors.New(""), want: false},
|
||||
{err: errors.New("permission denied"), want: true},
|
||||
{err: errors.New("icmp listen: cap_net_raw required"), want: true},
|
||||
{err: errors.New("i/o timeout"), want: false},
|
||||
{err: errors.New("no route to host"), want: false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := isUnsupported(tc.err); got != tc.want {
|
||||
t.Errorf("isUnsupported(%q) = %v, want %v", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLossPercent(t *testing.T) {
|
||||
cases := []struct {
|
||||
sent, recv int
|
||||
want int64
|
||||
}{
|
||||
{sent: 0, recv: 0, want: 0},
|
||||
{sent: 5, recv: 5, want: 0},
|
||||
{sent: 5, recv: 3, want: 40},
|
||||
{sent: 5, recv: 0, want: 100},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := lossPercent(tc.sent, tc.recv); got != tc.want {
|
||||
t.Errorf("lossPercent(%d,%d) = %d, want %d", tc.sent, tc.recv, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMakeBytes(t *testing.T) {
|
||||
if got := makeBytes(0); len(got) != 0 {
|
||||
t.Fatalf("expected empty slice, got %d bytes", len(got))
|
||||
}
|
||||
if got := makeBytes(-1); len(got) != 0 {
|
||||
t.Fatalf("expected empty slice for negative size, got %d bytes", len(got))
|
||||
}
|
||||
got := makeBytes(3)
|
||||
if len(got) != 3 || string(got) != "abc" {
|
||||
t.Fatalf("expected 'abc', got %q", string(got))
|
||||
}
|
||||
}
|
||||
60
checks/cping/result.go
Обычный файл
60
checks/cping/result.go
Обычный файл
@@ -0,0 +1,60 @@
|
||||
package cping
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single ping check. It embeds
|
||||
// checkresult.CheckResult for the standard fields (State, Error,
|
||||
// Duration, Warnings, Infos) and adds Ping-specific metrics consumed
|
||||
// by the metrics writers when the check produces influx telemetry.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
PacketsSent int
|
||||
PacketsRecv int
|
||||
AvgRttMs float64
|
||||
}
|
||||
|
||||
// InfluxFields reports ping metrics in the same shape as the rest of
|
||||
// the check packages: "took" is elapsed ms (for graphs and alerts).
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
ret["packets_sent"] = r.PacketsSent
|
||||
ret["packets_recv"] = r.PacketsRecv
|
||||
ret["packet_loss"] = lossPercent(r.PacketsSent, r.PacketsRecv)
|
||||
if r.AvgRttMs > 0 {
|
||||
ret["rtt_ms"] = int64(r.AvgRttMs)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags returns the standard set of tags used by the chttp/cdns
|
||||
// packages. The "state" tag is taken from the embedded CheckResult
|
||||
// after Perform() has populated it so the writer sees the actual
|
||||
// final state (OK/ERR/FAIL/WARN).
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
|
||||
func lossPercent(sent, recv int) int64 {
|
||||
if sent <= 0 {
|
||||
return 0
|
||||
}
|
||||
if recv >= sent {
|
||||
return 0
|
||||
}
|
||||
return int64(100 * (sent - recv) / sent)
|
||||
}
|
||||
11
checks/crkn/result.go
Обычный файл
11
checks/crkn/result.go
Обычный файл
@@ -0,0 +1,11 @@
|
||||
// Package crkn provides functionality.
|
||||
package crkn
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of a check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
}
|
||||
61
checks/crkn/rkn_init.go
Обычный файл
61
checks/crkn/rkn_init.go
Обычный файл
@@ -0,0 +1,61 @@
|
||||
package crkn
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Perform checks if a domain or IP is in the RKN registry
|
||||
func Perform(m *models.Monitor) *Result {
|
||||
result := &Result{}
|
||||
start := time.Now()
|
||||
|
||||
blocked, err := models.IsRknDomainBlocked(m.Host)
|
||||
if err != nil {
|
||||
failResult(err, result, start)
|
||||
return result
|
||||
}
|
||||
if blocked {
|
||||
testResult(true, result, "Domain is contained in RKN registry", start)
|
||||
return result
|
||||
}
|
||||
|
||||
for _, dnsRecord := range m.DNSRecords {
|
||||
if dnsRecord.Kind != "A" && dnsRecord.Kind != "AAAA" {
|
||||
continue
|
||||
}
|
||||
findIP, err := models.IsRknIPBlocked(dnsRecord.Value.Inet.String())
|
||||
if err != nil {
|
||||
failResult(err, result, start)
|
||||
return result
|
||||
}
|
||||
if findIP {
|
||||
testResult(true, result, "IP is contained in RKN registry", start)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
testResult(false, result, "", start)
|
||||
return result
|
||||
}
|
||||
|
||||
func failResult(err error, result *Result, start time.Time) {
|
||||
result.State = "FAIL"
|
||||
result.Error = err
|
||||
result.Duration = time.Since(start)
|
||||
}
|
||||
|
||||
func testResult(find bool, result *Result, msg string, start time.Time) {
|
||||
if find {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New(msg)
|
||||
result.Duration = time.Since(start)
|
||||
result.Warnings = append(result.Warnings, "Resource is blocked in Russia according to RKN registry")
|
||||
} else {
|
||||
result.State = "OK"
|
||||
result.Duration = time.Since(start)
|
||||
result.Infos = append(result.Infos, "Resource is not in RKN registry")
|
||||
}
|
||||
}
|
||||
134
checks/crkn/rkn_init_test.go
Обычный файл
134
checks/crkn/rkn_init_test.go
Обычный файл
@@ -0,0 +1,134 @@
|
||||
package crkn_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/checks/crkn"
|
||||
"rsgit.ru/rsmon/rsmon/config/database"
|
||||
"rsgit.ru/rsmon/rsmon/internal/netaddr"
|
||||
)
|
||||
|
||||
func init() {
|
||||
database.Init()
|
||||
}
|
||||
|
||||
func TestPerform_DomainBlocked(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
require.NoError(t, models.ReplaceRknDomains([]string{"blocked.example.com"}))
|
||||
|
||||
monitor := &models.Monitor{Host: "blocked.example.com"}
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "ERR", result.State)
|
||||
assert.NotNil(t, result.Error)
|
||||
assert.Contains(t, result.Error.Error(), "RKN registry")
|
||||
assert.Contains(t, result.Warnings, "Resource is blocked in Russia according to RKN registry")
|
||||
}
|
||||
|
||||
func TestPerform_DomainClean(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
require.NoError(t, models.ReplaceRknDomains([]string{"blocked.example.com"}))
|
||||
|
||||
monitor := &models.Monitor{Host: "clean.example.org"}
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "OK", result.State)
|
||||
assert.Nil(t, result.Error)
|
||||
assert.Contains(t, result.Infos, "Resource is not in RKN registry")
|
||||
}
|
||||
|
||||
func TestPerform_DomainSuffixMatch(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
require.NoError(t, models.ReplaceRknDomains([]string{"example.com"}))
|
||||
|
||||
monitor := &models.Monitor{Host: "sub.example.com"}
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "ERR", result.State, "subdomain must match apex via IsRknDomainBlocked")
|
||||
}
|
||||
|
||||
func TestPerform_IPBlocked(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
_, network, err := net.ParseCIDR("10.5.5.0/24")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{network}))
|
||||
|
||||
monitor := &models.Monitor{
|
||||
Host: "clean.example.org",
|
||||
DNSRecords: []models.DNSRecord{
|
||||
{Kind: "A", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("10.5.5.7")}},
|
||||
},
|
||||
}
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "ERR", result.State)
|
||||
assert.NotNil(t, result.Error)
|
||||
assert.Contains(t, result.Error.Error(), "IP is contained in RKN registry")
|
||||
}
|
||||
|
||||
func TestPerform_IPClean(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
_, network, err := net.ParseCIDR("10.0.0.0/8")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{network}))
|
||||
|
||||
monitor := &models.Monitor{
|
||||
Host: "clean.example.org",
|
||||
DNSRecords: []models.DNSRecord{
|
||||
{Kind: "A", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("8.8.8.8")}},
|
||||
},
|
||||
}
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "OK", result.State)
|
||||
}
|
||||
|
||||
func TestPerform_SkipsNonADNSRecords(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
|
||||
_, network, err := net.ParseCIDR("10.0.0.0/8")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.ReplaceRknIPs([]*net.IPNet{network}))
|
||||
|
||||
monitor := &models.Monitor{
|
||||
Host: "clean.example.org",
|
||||
DNSRecords: []models.DNSRecord{
|
||||
{Kind: "CNAME", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("10.5.5.7")}},
|
||||
{Kind: "TXT", Name: "clean.example.org", Value: netaddr.Inet{Inet: net.ParseIP("10.5.5.7")}},
|
||||
},
|
||||
}
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "OK", result.State, "non-A/AAAA records must be skipped")
|
||||
}
|
||||
|
||||
// TestPerform_FastReadOnly verifies the refactor removed the background
|
||||
// goroutine that previously triggered a remote data refresh on every
|
||||
// check — the call must return quickly and only depend on the local DB.
|
||||
func TestPerform_FastReadOnly(t *testing.T) {
|
||||
models.Drop()
|
||||
models.Migrate()
|
||||
require.NoError(t, models.ReplaceRknDomains([]string{"blocked.example.com"}))
|
||||
|
||||
monitor := &models.Monitor{Host: "blocked.example.com"}
|
||||
|
||||
result := crkn.Perform(monitor)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, "ERR", result.State)
|
||||
}
|
||||
49
checks/cssh/cssh.go
Обычный файл
49
checks/cssh/cssh.go
Обычный файл
@@ -0,0 +1,49 @@
|
||||
// Package cssh provides functionality.
|
||||
package cssh
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const stateERR = "ERR"
|
||||
|
||||
// Perform provides functionality.
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
port := c.GetSettings().Port
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
result.State = "START"
|
||||
client := &net.Dialer{
|
||||
Timeout: 60 * time.Second,
|
||||
DualStack: true,
|
||||
}
|
||||
start := time.Now()
|
||||
conn, err := client.Dial("tcp", net.JoinHostPort(c.Monitor.Host, port))
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = err
|
||||
result.Duration = time.Since(start)
|
||||
return result
|
||||
}
|
||||
result.Duration = time.Since(start)
|
||||
status, err := bufio.NewReader(conn).ReadString('\n')
|
||||
if err != nil {
|
||||
result.State = stateERR
|
||||
result.Error = err
|
||||
}
|
||||
if strings.Contains(status, "SSH") {
|
||||
result.State = "OK"
|
||||
} else {
|
||||
result.State = stateERR
|
||||
result.Error = errors.New("it's not SSH")
|
||||
}
|
||||
return result
|
||||
}
|
||||
10
checks/cssh/result.go
Обычный файл
10
checks/cssh/result.go
Обычный файл
@@ -0,0 +1,10 @@
|
||||
package cssh
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of a check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
}
|
||||
79
checks/cssl/cssl.go
Обычный файл
79
checks/cssl/cssl.go
Обычный файл
@@ -0,0 +1,79 @@
|
||||
// Package cssl provides functionality.
|
||||
package cssl
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
var client *http.Client
|
||||
|
||||
func init() {
|
||||
client = &http.Client{
|
||||
Timeout: time.Second * 60,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Perform provides functionality.
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
result.State = "OK"
|
||||
|
||||
var ur string
|
||||
if c.URL != nil && *c.URL != "" {
|
||||
ur = *c.URL
|
||||
|
||||
u, err := url.Parse(ur)
|
||||
if err != nil {
|
||||
result.State = "FAIL"
|
||||
result.Error = errors.Wrap(err, "bad url")
|
||||
return result
|
||||
}
|
||||
u.Scheme = "https"
|
||||
ur = u.String()
|
||||
} else {
|
||||
ur = "https://" + c.Monitor.Host
|
||||
}
|
||||
log.Println("URL:", ur)
|
||||
reqest, err := http.NewRequest("GET", ur, http.NoBody)
|
||||
if err != nil {
|
||||
result.State = "FAIL"
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
reqest.Header.Set("Cache-Control", "max-age=0")
|
||||
reqest.Header.Set("Connection", "close")
|
||||
resp, err := client.Do(reqest)
|
||||
if err != nil {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.Wrap(err, "https request error")
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
if resp.TLS == nil {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New("bad SSL cert CN")
|
||||
} else {
|
||||
cert := resp.TLS.VerifiedChains[0][0]
|
||||
result.Expires = &cert.NotAfter
|
||||
exp := time.Until(*result.Expires).Hours() / 24
|
||||
|
||||
if exp < 2 {
|
||||
result.State = "WARN"
|
||||
result.Warnings = append(result.Warnings, "Certificate expires soon")
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
10
checks/cssl/result.go
Обычный файл
10
checks/cssl/result.go
Обычный файл
@@ -0,0 +1,10 @@
|
||||
package cssl
|
||||
|
||||
import (
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of a check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
}
|
||||
39
checks/ctcp/result.go
Обычный файл
39
checks/ctcp/result.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package ctcp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single TCP check. It embeds
|
||||
// checkresult.CheckResult and adds the resolved address so logs /
|
||||
// metrics can show what was actually dialed.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
// InfluxFields reports the dial duration in milliseconds — same
|
||||
// convention as chttp and cping.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags returns the standard set of tags used by chttp / cping /
|
||||
// cdns. The "state" tag is the final Result.State set by Perform().
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
77
checks/ctcp/tcp.go
Обычный файл
77
checks/ctcp/tcp.go
Обычный файл
@@ -0,0 +1,77 @@
|
||||
// Package ctcp provides TCP connect / port check functionality for RSMon.
|
||||
//
|
||||
// Semantics: the check opens a TCP connection to host:port using the
|
||||
// dialer's timeout, and reports OK as soon as the kernel-level
|
||||
// handshake completes (no banner read, no payload sent). Any dial
|
||||
// failure (refused, timed out, network unreachable, no route, ...)
|
||||
// is reported as ERR with the underlying error message so operators
|
||||
// can distinguish configuration problems from real outages.
|
||||
//
|
||||
// Settings consumed from models.CheckSettings:
|
||||
// - port (string): TCP port to dial. Defaults to 80 when empty.
|
||||
// - timeout (int, seconds): per-dial budget. Clamped to at least 1s.
|
||||
// - host (string): optional override of the monitor host.
|
||||
package ctcp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
|
||||
defaultTimeout = 5 * time.Second
|
||||
minTimeout = 1 * time.Second
|
||||
)
|
||||
|
||||
// Perform executes a single TCP connect check.
|
||||
func Perform(c *models.Check) *Result {
|
||||
r := &Result{}
|
||||
settings := c.GetSettings()
|
||||
|
||||
host := c.Monitor.Host
|
||||
if settings.Host != "" {
|
||||
host = settings.Host
|
||||
}
|
||||
if host == "" {
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("tcp: empty host")
|
||||
return r
|
||||
}
|
||||
|
||||
port := settings.Port
|
||||
if port == "" {
|
||||
port = "80"
|
||||
}
|
||||
|
||||
timeout := time.Duration(settings.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
if timeout < minTimeout {
|
||||
timeout = minTimeout
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
dialer := &net.Dialer{Timeout: timeout, DualStack: true}
|
||||
start := time.Now()
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
r.Duration = time.Since(start)
|
||||
if err != nil {
|
||||
r.State = stateERR
|
||||
r.Error = err
|
||||
return r
|
||||
}
|
||||
r.RemoteAddr = conn.RemoteAddr().String()
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
r.Warnings = append(r.Warnings, fmt.Sprintf("close: %v", closeErr))
|
||||
}
|
||||
r.State = stateOK
|
||||
r.Infos = append(r.Infos, fmt.Sprintf("tcp %s in %.2fms", addr, float64(r.Duration.Microseconds())/1000.0))
|
||||
return r
|
||||
}
|
||||
164
checks/ctcp/tcp_test.go
Обычный файл
164
checks/ctcp/tcp_test.go
Обычный файл
@@ -0,0 +1,164 @@
|
||||
package ctcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
type settings struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
PacketSize int `json:"packet_size,omitempty"`
|
||||
}
|
||||
|
||||
func newCheck(t *testing.T, s settings, host string) *models.Check {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return &models.Check{
|
||||
Kind: "tcp",
|
||||
Monitor: &models.Monitor{Host: host},
|
||||
Settings: datatypes.JSON(raw),
|
||||
}
|
||||
}
|
||||
|
||||
// startListener brings up a TCP listener on 127.0.0.1:0 so tests can
|
||||
// reach a real port. Returns the listener and the resolved address.
|
||||
func startListener(t *testing.T) (net.Listener, string) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
return ln, ln.Addr().String()
|
||||
}
|
||||
|
||||
func TestPerformOK(t *testing.T) {
|
||||
ln, addr := startListener(t)
|
||||
defer ln.Close()
|
||||
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split addr: %v", err)
|
||||
}
|
||||
c := newCheck(t, settings{Port: port, Timeout: 1}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if r.RemoteAddr == "" {
|
||||
t.Fatalf("expected RemoteAddr to be populated, got %q", r.RemoteAddr)
|
||||
}
|
||||
if !contains(r.Infos[0], "tcp") {
|
||||
t.Fatalf("expected info line about tcp probe, got %v", r.Infos)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformRefused(t *testing.T) {
|
||||
// Bind to 127.0.0.1:0 to find a free port, close immediately so
|
||||
// the next dial gets RST/CONNREFUSED.
|
||||
ln, addr := startListener(t)
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
_ = ln.Close()
|
||||
|
||||
// On some runners the OS reassigns the just-closed port to a
|
||||
// listener before our dial. Retry up to a few times to stabilise.
|
||||
var final net.Listener
|
||||
for i := 0; i < 3; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr2 := l.Addr().String()
|
||||
h, p, _ := net.SplitHostPort(addr2)
|
||||
_ = l.Close()
|
||||
// Replace what we are about to dial with one we just freed.
|
||||
addr = addr2
|
||||
host = h
|
||||
port = p
|
||||
break
|
||||
}
|
||||
_ = final
|
||||
|
||||
c := newCheck(t, settings{Port: port, Timeout: 1}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if r.Error == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUnreachable(t *testing.T) {
|
||||
// 127.0.0.0/8 — using 127.0.0.99 should resolve but be a hard
|
||||
// "no route" on most platforms. The check should fail fast.
|
||||
c := newCheck(t, settings{Port: "65530", Timeout: 1}, "127.0.0.99")
|
||||
// Use a short timeout so the test does not hang on slow CI.
|
||||
r := Perform(c)
|
||||
if r.State == stateOK {
|
||||
t.Fatalf("expected ERR/WARN, got OK (this loopback should not answer)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformDefaultPort(t *testing.T) {
|
||||
ln, addr := startListener(t)
|
||||
defer ln.Close()
|
||||
host, _, _ := net.SplitHostPort(addr)
|
||||
c := newCheck(t, settings{}, host) // port empty -> defaults to 80
|
||||
r := Perform(c)
|
||||
// 80 is unlikely to answer on the loopback; we only care that
|
||||
// the check did not panic and reported something on failure.
|
||||
if r.State == "" {
|
||||
t.Fatalf("expected non-empty state, got %q", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformEmptyHost(t *testing.T) {
|
||||
c := newCheck(t, settings{Port: "80", Timeout: 1}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR on empty host, got %s", r.State)
|
||||
}
|
||||
if r.Error == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformClampsTimeout(t *testing.T) {
|
||||
// Already covered indirectly by TestPerformDefaultPort but
|
||||
// assert the dialer budget is at least 1s even with Timeout=0.
|
||||
ln, addr := startListener(t)
|
||||
defer ln.Close()
|
||||
host, port, _ := net.SplitHostPort(addr)
|
||||
|
||||
start := time.Now()
|
||||
c := newCheck(t, settings{Port: port, Timeout: 0}, host)
|
||||
r := Perform(c)
|
||||
elapsed := time.Since(start)
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("default timeout exceeded 2s, got %v", elapsed)
|
||||
}
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK against local listener, got %s", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
39
checks/cudp/result.go
Обычный файл
39
checks/cudp/result.go
Обычный файл
@@ -0,0 +1,39 @@
|
||||
package cudp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is the outcome of a single UDP check. It embeds
|
||||
// checkresult.CheckResult and adds the resolved address so logs /
|
||||
// metrics can show what was probed.
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
RemoteAddr string
|
||||
}
|
||||
|
||||
// InfluxFields reports the dial+probe duration in milliseconds — same
|
||||
// convention as chttp / cping / ctcp.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxTags returns the standard set of tags used by chttp / cping /
|
||||
// ctcp. The "state" tag is the final Result.State set by Perform().
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
161
checks/cudp/udp.go
Обычный файл
161
checks/cudp/udp.go
Обычный файл
@@ -0,0 +1,161 @@
|
||||
// Package cudp provides UDP probe / port check functionality for RSMon.
|
||||
//
|
||||
// Semantics: UDP is a connectionless protocol so a successful connect
|
||||
// (net.Dial("udp", ...)) only means the kernel resolved the route to
|
||||
// host:port — the remote may silently drop the packet. The check
|
||||
// therefore:
|
||||
//
|
||||
// 1. Resolves the address and opens a UDP "connection".
|
||||
//
|
||||
// 2. Writes a small probe packet with the dial deadline active.
|
||||
//
|
||||
// 3. Sets a short read deadline and waits for any reply.
|
||||
//
|
||||
// - OK is reported only when a reply is received from the server.
|
||||
// - ERR is reported when the dial itself fails (refused, network
|
||||
// unreachable, no route, ...).
|
||||
// - WARN is reported when no reply is received within the deadline
|
||||
// because that is the most common UDP behavior for a real
|
||||
// service that isn't echoing probes; we still consider it
|
||||
// "monitoring" — the route is reachable — but flag it as worth
|
||||
// investigating.
|
||||
//
|
||||
// Settings consumed from models.CheckSettings:
|
||||
// - port (string): UDP port to probe. Defaults to 53 when empty.
|
||||
// - timeout (int, seconds): per-probe budget. Clamped to >= 1s.
|
||||
// - host (string): optional override of the monitor host.
|
||||
package cudp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const (
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
stateWARN = "WARN"
|
||||
|
||||
defaultTimeout = 5 * time.Second
|
||||
minTimeout = 1 * time.Second
|
||||
|
||||
probeSize = 16
|
||||
)
|
||||
|
||||
var probe = []byte("rsmon-udp-probe")
|
||||
|
||||
// Perform executes a single UDP probe check.
|
||||
func Perform(c *models.Check) *Result {
|
||||
r := &Result{}
|
||||
settings := c.GetSettings()
|
||||
|
||||
host := c.Monitor.Host
|
||||
if settings.Host != "" {
|
||||
host = settings.Host
|
||||
}
|
||||
if host == "" {
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("udp: empty host")
|
||||
return r
|
||||
}
|
||||
|
||||
port := settings.Port
|
||||
if port == "" {
|
||||
port = "53"
|
||||
}
|
||||
|
||||
timeout := time.Duration(settings.Timeout) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
if timeout < minTimeout {
|
||||
timeout = minTimeout
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout, DualStack: true}
|
||||
start := time.Now()
|
||||
conn, err := dialer.Dial("udp", addr)
|
||||
if err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = err
|
||||
return r
|
||||
}
|
||||
defer conn.Close() //nolint:errcheck // accepted lint exception: best-effort close
|
||||
|
||||
r.RemoteAddr = conn.RemoteAddr().String()
|
||||
|
||||
if err := conn.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("set write deadline: %w", err)
|
||||
return r
|
||||
}
|
||||
if _, err := conn.Write(probe[:minInt(probeSize, len(probe))]); err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("write probe: %w", err)
|
||||
return r
|
||||
}
|
||||
|
||||
// If the dial succeeded, give the read a fraction of the budget
|
||||
// so the whole check still fits inside Settings.Timeout.
|
||||
readBudget := timeout
|
||||
if readBudget > 2*time.Second {
|
||||
readBudget = 2 * time.Second
|
||||
}
|
||||
if err := conn.SetReadDeadline(time.Now().Add(readBudget)); err != nil {
|
||||
r.Duration = time.Since(start)
|
||||
r.State = stateERR
|
||||
r.Error = fmt.Errorf("set read deadline: %w", err)
|
||||
return r
|
||||
}
|
||||
buf := make([]byte, 1500)
|
||||
_, readErr := conn.Read(buf)
|
||||
r.Duration = time.Since(start)
|
||||
|
||||
switch {
|
||||
case readErr == nil:
|
||||
r.State = stateOK
|
||||
r.Infos = append(r.Infos, fmt.Sprintf("udp %s replied in %.2fms", addr, float64(r.Duration.Microseconds())/1000.0))
|
||||
case isTimeout(readErr):
|
||||
// UDP services rarely echo unrecognized probes back. Reaching
|
||||
// the host without a reply is meaningful, but ambiguous: the
|
||||
// service might be working or firewalled. Surface as WARN.
|
||||
r.State = stateWARN
|
||||
r.Warnings = append(r.Warnings, fmt.Sprintf("udp %s reachable but no reply within %s", addr, readBudget))
|
||||
default:
|
||||
// Any other read error (connection reset, ...) still means the
|
||||
// kernel could route the packet; report as ERR so operators
|
||||
// know to investigate the service.
|
||||
r.State = stateERR
|
||||
r.Error = readErr
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
type timeout interface{ Timeout() bool }
|
||||
if t, ok := err.(timeout); ok {
|
||||
return t.Timeout()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// minInt mirrors the standard library min() for ints without taking
|
||||
// a dependency on Go 1.21+. We keep a custom name to avoid clashing
|
||||
// with the built-in and being flagged by the linter.
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
170
checks/cudp/udp_test.go
Обычный файл
170
checks/cudp/udp_test.go
Обычный файл
@@ -0,0 +1,170 @@
|
||||
package cudp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
type settings struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
Count int `json:"count,omitempty"`
|
||||
PacketSize int `json:"packet_size,omitempty"`
|
||||
}
|
||||
|
||||
func newCheck(t *testing.T, s settings, host string) *models.Check {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return &models.Check{
|
||||
Kind: "udp",
|
||||
Monitor: &models.Monitor{Host: host},
|
||||
Settings: datatypes.JSON(raw),
|
||||
}
|
||||
}
|
||||
|
||||
// startEchoUDP brings up a UDP listener on 127.0.0.1:0 that echoes
|
||||
// the first byte back to the sender. Returns the listener and the
|
||||
// resolved address. Stop it with the returned cleanup.
|
||||
func startEchoUDP(t *testing.T) (cleanup func(), addr string) {
|
||||
t.Helper()
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("listen udp: %v", err)
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = conn.SetReadDeadline(time.Now().Add(50 * time.Millisecond))
|
||||
n, src, err := conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
_, _ = conn.WriteToUDP(buf[:1], src)
|
||||
}
|
||||
}()
|
||||
return func() {
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
_ = conn.Close()
|
||||
}, conn.LocalAddr().String()
|
||||
}
|
||||
|
||||
func TestPerformOKWhenServerReplies(t *testing.T) {
|
||||
cleanup, addr := startEchoUDP(t)
|
||||
defer cleanup()
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split: %v", err)
|
||||
}
|
||||
c := newCheck(t, settings{Port: port, Timeout: 2}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateOK {
|
||||
t.Fatalf("expected OK when the server echoes back, got %s (err=%v, warn=%v)", r.State, r.Error, r.Warnings)
|
||||
}
|
||||
if r.RemoteAddr == "" {
|
||||
t.Fatalf("expected RemoteAddr, got %q", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformWarnWhenNoReply(t *testing.T) {
|
||||
// Open a UDP listener that never replies; the probe should
|
||||
// time out and the check should report WARN to signal "reachable,
|
||||
// ambiguous service".
|
||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0})
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// Drain the listener so the kernel knows the port is in use but
|
||||
// never reply; just close the read side and let the kernel drop
|
||||
// incoming datagrams.
|
||||
go func() {
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
_, _, _ = conn.ReadFromUDP(buf)
|
||||
}
|
||||
}()
|
||||
|
||||
host, port, _ := net.SplitHostPort(conn.LocalAddr().String())
|
||||
c := newCheck(t, settings{Port: port, Timeout: 1}, host)
|
||||
r := Perform(c)
|
||||
if r.State != stateWARN {
|
||||
t.Fatalf("expected WARN when the server is silent, got %s (err=%v)", r.State, r.Error)
|
||||
}
|
||||
if len(r.Warnings) == 0 {
|
||||
t.Fatalf("expected a warning describing the silence, got %v", r.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformUnreachable(t *testing.T) {
|
||||
c := newCheck(t, settings{Port: "65530", Timeout: 1}, "127.0.0.99")
|
||||
r := Perform(c)
|
||||
if r.State == stateOK {
|
||||
t.Fatalf("expected ERR/WARN, got OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerformEmptyHost(t *testing.T) {
|
||||
c := newCheck(t, settings{Port: "53", Timeout: 1}, "")
|
||||
r := Perform(c)
|
||||
if r.State != stateERR {
|
||||
t.Fatalf("expected ERR on empty host, got %s", r.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTimeout(t *testing.T) {
|
||||
if isTimeout(nil) {
|
||||
t.Fatalf("isTimeout(nil) should be false")
|
||||
}
|
||||
if !isTimeout(timeoutErr{}) {
|
||||
t.Fatalf("isTimeout(timeoutErr) should be true")
|
||||
}
|
||||
if isTimeout(plainErr{}) {
|
||||
t.Fatalf("isTimeout(plainErr) should be false")
|
||||
}
|
||||
}
|
||||
|
||||
type timeoutErr struct{}
|
||||
|
||||
func (timeoutErr) Error() string { return "i/o timeout" }
|
||||
func (timeoutErr) Timeout() bool { return true }
|
||||
func (timeoutErr) Temporary() bool { return true }
|
||||
|
||||
type plainErr struct{}
|
||||
|
||||
func (plainErr) Error() string { return "boom" }
|
||||
|
||||
func TestMin(t *testing.T) {
|
||||
if got := minInt(1, 2); got != 1 {
|
||||
t.Fatalf("minInt(1,2) = %d, want 1", got)
|
||||
}
|
||||
if got := minInt(2, 1); got != 1 {
|
||||
t.Fatalf("minInt(2,1) = %d, want 1", got)
|
||||
}
|
||||
if got := minInt(0, 0); got != 0 {
|
||||
t.Fatalf("minInt(0,0) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
14
checks/cwhois/result.go
Обычный файл
14
checks/cwhois/result.go
Обычный файл
@@ -0,0 +1,14 @@
|
||||
// Package cwhois provides functionality.
|
||||
package cwhois
|
||||
|
||||
import (
|
||||
"github.com/glebtv/whois"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of a check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
Raw whois.Result
|
||||
}
|
||||
69
checks/cwhois/whois.go
Обычный файл
69
checks/cwhois/whois.go
Обычный файл
@@ -0,0 +1,69 @@
|
||||
package cwhois
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebtv/whois"
|
||||
"github.com/weppos/publicsuffix-go/publicsuffix"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
// Perform provides functionality.
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
result.State = "FAIL"
|
||||
host := c.Monitor.Host
|
||||
|
||||
if host == "localhost" || strings.HasPrefix(host, "localhost:") {
|
||||
result.Warnings = append(result.Warnings, "WHOIS check not possible for localhost, please disable")
|
||||
return result
|
||||
}
|
||||
|
||||
addr := net.ParseIP(host)
|
||||
if addr != nil {
|
||||
result.Warnings = append(result.Warnings, "WHOIS check not possible for ip address, please disable")
|
||||
return result
|
||||
}
|
||||
|
||||
zname, err := publicsuffix.Domain(host)
|
||||
if err != nil {
|
||||
result.Warnings = append(result.Warnings, "Failed to get public suffix: "+err.Error())
|
||||
zname = host
|
||||
}
|
||||
if zname != host {
|
||||
result.Infos = append(result.Infos, "not top level domain, running WHOIS check for "+zname)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
response := whois.Whois(zname)
|
||||
result.Raw = *response
|
||||
result.Error = result.Raw.Error
|
||||
if result.Error == nil {
|
||||
result.State = "OK"
|
||||
}
|
||||
result.Duration = time.Since(start)
|
||||
|
||||
if result.Error != nil {
|
||||
result.State = "ERR"
|
||||
}
|
||||
|
||||
if response.Expires.IsZero() {
|
||||
if result.State == "OK" {
|
||||
result.State = "WARN"
|
||||
}
|
||||
result.Warnings = append(result.Warnings, "unable to get whois expiration for "+host)
|
||||
return result
|
||||
}
|
||||
result.Expires = &response.Expires
|
||||
exp := time.Until(*result.Expires).Hours() / 24
|
||||
|
||||
if exp < 3 {
|
||||
result.State = "WARN"
|
||||
result.Warnings = append(result.Warnings, "Domain expires in a few days")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
376
checks/llmhttp/init.go
Обычный файл
376
checks/llmhttp/init.go
Обычный файл
@@ -0,0 +1,376 @@
|
||||
// Package llmhttp provides LLM-based HTTP check functionality for RSMon.
|
||||
package llmhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/openai/openai-go/v3"
|
||||
"github.com/openai/openai-go/v3/option"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
const (
|
||||
verdictNormal = "normal"
|
||||
verdictWarning = "warning"
|
||||
verdictError = "error"
|
||||
|
||||
stateOK = "OK"
|
||||
stateERR = "ERR"
|
||||
stateWARN = "WARN"
|
||||
)
|
||||
|
||||
var titleRe = regexp.MustCompile(`<title[^>]*>(.*?)</title>`)
|
||||
|
||||
// Perform executes the LLM HTTP health check
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{
|
||||
CheckID: uint(c.ID),
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
|
||||
// Get the target URL
|
||||
targetURL := getTargetURL(c)
|
||||
result.URL = targetURL
|
||||
|
||||
// Step 1: Fetch HTML content
|
||||
html, statusCode, contentType, contentLength, err := fetchHTML(targetURL)
|
||||
if err != nil {
|
||||
return &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "FAIL",
|
||||
Error: fmt.Errorf("failed to fetch HTML: %w", err),
|
||||
Duration: time.Since(start),
|
||||
},
|
||||
URL: targetURL,
|
||||
}
|
||||
}
|
||||
|
||||
result.HTML = html
|
||||
result.StatusCode = statusCode
|
||||
result.ContentType = contentType
|
||||
result.ContentLength = contentLength
|
||||
|
||||
// Extract title from HTML
|
||||
result.Title = extractTitle(html)
|
||||
|
||||
// Step 2: Analyze with LLM
|
||||
llmVerdict, llmReasoning, err := analyzeWithLLM(targetURL, result)
|
||||
if err != nil {
|
||||
return &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "FAIL",
|
||||
Error: fmt.Errorf("LLM analysis failed: %w", err),
|
||||
Duration: time.Since(start),
|
||||
},
|
||||
URL: targetURL,
|
||||
HTML: html,
|
||||
StatusCode: statusCode,
|
||||
}
|
||||
}
|
||||
|
||||
result.LLMVerdict = llmVerdict
|
||||
result.LLMReasoning = llmReasoning
|
||||
|
||||
// Determine final state based on LLM verdict
|
||||
var state string
|
||||
var crErr error
|
||||
switch llmVerdict {
|
||||
case verdictError:
|
||||
state = stateERR
|
||||
crErr = errors.New(llmReasoning)
|
||||
case verdictWarning:
|
||||
state = stateWARN
|
||||
default:
|
||||
state = stateOK
|
||||
}
|
||||
|
||||
return &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: state,
|
||||
Error: crErr,
|
||||
Duration: time.Since(start),
|
||||
Warnings: []string{},
|
||||
Infos: []string{},
|
||||
},
|
||||
URL: targetURL,
|
||||
HTML: html,
|
||||
StatusCode: statusCode,
|
||||
Title: result.Title,
|
||||
ContentType: contentType,
|
||||
ContentLength: contentLength,
|
||||
LLMVerdict: llmVerdict,
|
||||
LLMReasoning: llmReasoning,
|
||||
CheckID: uint(c.ID),
|
||||
}
|
||||
}
|
||||
|
||||
// getTargetURL constructs the target URL from the check
|
||||
func getTargetURL(c *models.Check) string {
|
||||
if c.URL != nil && *c.URL != "" {
|
||||
return *c.URL
|
||||
}
|
||||
|
||||
// Construct from monitor host
|
||||
host := c.Monitor.Host
|
||||
if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") {
|
||||
return "https://" + host
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// fetchHTML fetches HTML content from the target URL
|
||||
func fetchHTML(targetURL string) (html string, statusCode int, contentType string, contentLength int64, err error) {
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
// Follow redirects
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return nil // Allow up to 10 redirects by default
|
||||
},
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequest("GET", targetURL, http.NoBody)
|
||||
if err != nil {
|
||||
return "", 0, "", 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
// Set user agent to avoid being blocked
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RSMon-LLM-Checker/1.0)")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
|
||||
// Make request
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, "", 0, fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck
|
||||
|
||||
statusCode = resp.StatusCode
|
||||
contentType = resp.Header.Get("Content-Type")
|
||||
contentLength = resp.ContentLength
|
||||
|
||||
// Check status code
|
||||
if statusCode >= 400 {
|
||||
return "", statusCode, contentType, contentLength, fmt.Errorf("HTTP status code: %d", statusCode)
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", statusCode, contentType, contentLength, fmt.Errorf("failed to read response body: %w", err)
|
||||
}
|
||||
|
||||
html = string(body)
|
||||
|
||||
// Truncate HTML if too large for LLM context
|
||||
maxHTMLSize := 50000 // About 50k chars
|
||||
if len(html) > maxHTMLSize {
|
||||
log.Printf("[llm-http] HTML too large (%d chars), truncating to %d", len(html), maxHTMLSize)
|
||||
html = html[:maxHTMLSize] + "\n\n... (truncated)"
|
||||
}
|
||||
|
||||
return html, statusCode, contentType, contentLength, nil
|
||||
}
|
||||
|
||||
// extractTitle extracts the title from HTML content
|
||||
func extractTitle(html string) string {
|
||||
matches := titleRe.FindStringSubmatch(html)
|
||||
if len(matches) > 1 {
|
||||
return strings.TrimSpace(matches[1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// analyzeWithLLM sends the HTML content to the LLM for analysis
|
||||
func analyzeWithLLM(targetURL string, result *Result) (verdict, reasoning string, err error) {
|
||||
// Get LLM configuration from environment
|
||||
llmKey := getEnv("LLM_APIKEY", "LLAMA_KEY")
|
||||
llmURL := getEnv("LLM_URL", "LLAMA_URL")
|
||||
llmModel := getEnv("LLM_MODEL", "LLAMA_MODEL")
|
||||
|
||||
if llmKey == "" || llmURL == "" {
|
||||
return "", "", errors.New("LLM credentials not configured (LLAMA_KEY, LLAMA_URL)")
|
||||
}
|
||||
|
||||
if llmModel == "" {
|
||||
llmModel = "llama3.2" // Default model (text-only)
|
||||
}
|
||||
|
||||
// Create OpenAI client with custom base URL
|
||||
client := openai.NewClient(
|
||||
option.WithBaseURL(llmURL),
|
||||
option.WithAPIKey(llmKey),
|
||||
)
|
||||
|
||||
// Build the system prompt
|
||||
systemPrompt := buildSystemPrompt()
|
||||
|
||||
// Build user message with HTML content
|
||||
userContent := buildUserMessage(targetURL, result)
|
||||
|
||||
// Prepare messages
|
||||
messages := []openai.ChatCompletionMessageParamUnion{
|
||||
openai.SystemMessage(systemPrompt),
|
||||
openai.UserMessage(userContent),
|
||||
}
|
||||
|
||||
// Call LLM with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Messages: messages,
|
||||
Model: llmModel,
|
||||
MaxTokens: openai.Int(2000),
|
||||
Temperature: openai.Float(0.3), // Lower temperature for more consistent analysis
|
||||
}
|
||||
|
||||
completion, err := client.Chat.Completions.New(ctx, params)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("LLM request failed: %w", err)
|
||||
}
|
||||
|
||||
if len(completion.Choices) == 0 {
|
||||
return "", "", errors.New("LLM returned no choices")
|
||||
}
|
||||
|
||||
response := completion.Choices[0].Message.Content
|
||||
|
||||
// Parse the LLM response to extract verdict and reasoning
|
||||
return parseLLMResponse(response)
|
||||
}
|
||||
|
||||
// buildSystemPrompt creates the system prompt for the LLM
|
||||
func buildSystemPrompt() string {
|
||||
return `
|
||||
You are a web application health monitoring assistant. Your task is to analyze website HTML content
|
||||
and determine if the website appears to be functioning normally or if there are issues.
|
||||
|
||||
Consider the following aspects:
|
||||
1. HTML structure - Is the page structured correctly?
|
||||
2. Error messages - Are there any visible error messages, 404s, 500s, or similar in the HTML?
|
||||
3. Title and meta tags - Are they present and reasonable?
|
||||
4. Content availability - Is there actual content or is the page mostly empty?
|
||||
5. Response status - Consider the HTTP status code provided
|
||||
6. Content type - Verify the content type is appropriate
|
||||
|
||||
Respond in the following JSON format:
|
||||
{
|
||||
"verdict": verdictNormal | verdictWarning | verdictError,
|
||||
"reasoning": "Brief explanation of your assessment"
|
||||
}
|
||||
|
||||
Verdict guidelines:
|
||||
- verdictError: Page is clearly broken (5xx status codes, error messages in HTML, completely empty page, "404 Not Found" in title)
|
||||
- verdictWarning: Page loads but has issues (4xx status codes, incomplete content, unusual title, suspicious patterns)
|
||||
- verdictNormal: Page appears to be functioning correctly (2xx status, proper HTML structure, reasonable content)`
|
||||
}
|
||||
|
||||
// buildUserMessage creates the user message with HTML data
|
||||
func buildUserMessage(targetURL string, result *Result) string {
|
||||
var sb strings.Builder
|
||||
|
||||
_, _ = fmt.Fprintf(&sb, "Analyze the HTML content of: %s\n\n", targetURL)
|
||||
_, _ = fmt.Fprintf(&sb, "HTTP Status Code: %d\n", result.StatusCode)
|
||||
_, _ = fmt.Fprintf(&sb, "Content-Type: %s\n", result.ContentType)
|
||||
_, _ = fmt.Fprintf(&sb, "Content Length: %d bytes\n", result.ContentLength)
|
||||
_, _ = fmt.Fprintf(&sb, "Page Title: %s\n\n", result.Title)
|
||||
|
||||
sb.WriteString("HTML content:\n")
|
||||
sb.WriteString("```\n")
|
||||
sb.WriteString(result.HTML)
|
||||
sb.WriteString("```\n")
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// parseLLMResponse parses the LLM response to extract structured data
|
||||
func parseLLMResponse(response string) (verdict, reasoning string, err error) {
|
||||
// Try to extract JSON from the response
|
||||
response = strings.TrimSpace(response)
|
||||
|
||||
// Look for JSON block
|
||||
jsonStart := strings.Index(response, "{")
|
||||
jsonEnd := strings.LastIndex(response, "}")
|
||||
|
||||
if jsonStart == -1 || jsonEnd == -1 {
|
||||
// No JSON found, try to parse text response
|
||||
return parseTextResponse(response)
|
||||
}
|
||||
|
||||
jsonStr := response[jsonStart : jsonEnd+1]
|
||||
|
||||
var parsed struct {
|
||||
Verdict string `json:"verdict"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
// JSON parse failed, fall back to text parsing
|
||||
return parseTextResponse(response)
|
||||
}
|
||||
|
||||
// Validate verdict
|
||||
switch parsed.Verdict {
|
||||
case verdictNormal, "ok", "healthy":
|
||||
return verdictNormal, parsed.Reasoning, nil
|
||||
case verdictWarning, "warn":
|
||||
return verdictWarning, parsed.Reasoning, nil
|
||||
case verdictError, "fail", "unhealthy":
|
||||
return verdictError, parsed.Reasoning, nil
|
||||
default:
|
||||
// Unknown verdict, default to normal with warning
|
||||
if parsed.Verdict != "" {
|
||||
return verdictWarning, parsed.Reasoning, nil
|
||||
}
|
||||
return verdictNormal, "Unable to determine specific issues from analysis", nil
|
||||
}
|
||||
}
|
||||
|
||||
// parseTextResponse parses a non-JSON response
|
||||
func parseTextResponse(response string) (verdict, reasoning string, err error) {
|
||||
responseLower := strings.ToLower(response)
|
||||
|
||||
// Look for keywords
|
||||
hasError := strings.Contains(responseLower, verdictError) ||
|
||||
strings.Contains(responseLower, "broken") ||
|
||||
strings.Contains(responseLower, "failed") ||
|
||||
strings.Contains(responseLower, "not working")
|
||||
|
||||
hasWarning := strings.Contains(responseLower, verdictWarning) ||
|
||||
strings.Contains(responseLower, "issue") ||
|
||||
strings.Contains(responseLower, "problem") ||
|
||||
strings.Contains(responseLower, "degraded")
|
||||
|
||||
if hasError {
|
||||
return verdictError, response, nil
|
||||
} else if hasWarning {
|
||||
return verdictWarning, response, nil
|
||||
}
|
||||
|
||||
return verdictNormal, response, nil
|
||||
}
|
||||
|
||||
// getEnv gets an environment variable or returns empty string
|
||||
func getEnv(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
52
checks/llmhttp/result.go
Обычный файл
52
checks/llmhttp/result.go
Обычный файл
@@ -0,0 +1,52 @@
|
||||
package llmhttp
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
// Result is a result of an LLM HTTP health check
|
||||
type Result struct {
|
||||
checkresult.CheckResult
|
||||
|
||||
// Captured data
|
||||
HTML string `json:"html"` // HTML content
|
||||
URL string `json:"url"` // Final URL after redirects
|
||||
StatusCode int `json:"status_code"` // HTTP status code
|
||||
Title string `json:"title"` // Page title (extracted from HTML)
|
||||
ContentLength int64 `json:"content_length"` // Size of response body
|
||||
ContentType string `json:"content_type"` // Content-Type header
|
||||
|
||||
// LLM analysis
|
||||
LLMResponse string `json:"llm_response"` // Full LLM response
|
||||
LLMVerdict string `json:"llm_verdict"` // "normal", "error", "warning"
|
||||
LLMReasoning string `json:"llm_reasoning"` // LLM's explanation
|
||||
|
||||
// Metadata
|
||||
CheckID uint `json:"check_id"` // For database reference
|
||||
}
|
||||
|
||||
// InfluxTags provides functionality.
|
||||
func (r *Result) InfluxTags(c models.Check) map[string]string { //nolint:gocritic // hugeParam: accepted for interface compatibility
|
||||
ret := make(map[string]string, 0)
|
||||
ret["check"] = strconv.FormatInt(c.ID, 10)
|
||||
ret["state"] = r.State
|
||||
ret["code"] = strconv.Itoa(r.StatusCode)
|
||||
if r.Error != nil {
|
||||
ret["error"] = r.Error.Error()
|
||||
}
|
||||
ret["warnings"] = strings.Join(r.Warnings, ",")
|
||||
return ret
|
||||
}
|
||||
|
||||
// InfluxFields provides functionality.
|
||||
func (r *Result) InfluxFields() map[string]interface{} {
|
||||
ret := make(map[string]interface{}, 0)
|
||||
ret["took"] = int64(r.Duration / time.Millisecond)
|
||||
|
||||
return ret
|
||||
}
|
||||
133
checks/llmhttp/result_test.go
Обычный файл
133
checks/llmhttp/result_test.go
Обычный файл
@@ -0,0 +1,133 @@
|
||||
package llmhttp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
"rsgit.ru/rsmon/rsmon/internal/checkresult"
|
||||
)
|
||||
|
||||
func TestInfluxTags(t *testing.T) {
|
||||
c := models.Check{ID: 123}
|
||||
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "OK",
|
||||
Error: nil,
|
||||
Warnings: nil,
|
||||
Duration: 100 * time.Millisecond,
|
||||
},
|
||||
StatusCode: 200,
|
||||
}
|
||||
|
||||
tags := r.InfluxTags(c)
|
||||
if tags["check"] != "123" {
|
||||
t.Errorf("check = %q, want 123", tags["check"])
|
||||
}
|
||||
if tags["state"] != "OK" {
|
||||
t.Errorf("state = %q, want OK", tags["state"])
|
||||
}
|
||||
if tags["code"] != "200" {
|
||||
t.Errorf("code = %q, want 200", tags["code"])
|
||||
}
|
||||
if _, ok := tags["error"]; ok {
|
||||
t.Error("error tag should not be present when Error is nil")
|
||||
}
|
||||
|
||||
rWithErr := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "ERR",
|
||||
Error: errors.New("something went wrong"),
|
||||
Warnings: nil,
|
||||
Duration: 100 * time.Millisecond,
|
||||
},
|
||||
StatusCode: 500,
|
||||
}
|
||||
|
||||
tags = rWithErr.InfluxTags(c)
|
||||
if tags["error"] != "something went wrong" {
|
||||
t.Errorf("error = %q, want 'something went wrong'", tags["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfluxTags_Warnings(t *testing.T) {
|
||||
c := models.Check{ID: 456}
|
||||
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "WARN",
|
||||
Warnings: []string{"redirect detected", "slow response"},
|
||||
Duration: time.Second,
|
||||
},
|
||||
StatusCode: 301,
|
||||
}
|
||||
|
||||
tags := r.InfluxTags(c)
|
||||
wantWarnings := "redirect detected,slow response"
|
||||
if tags["warnings"] != wantWarnings {
|
||||
t.Errorf("warnings = %q, want %q", tags["warnings"], wantWarnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfluxFields(t *testing.T) {
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "OK",
|
||||
Duration: 1234 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
fields := r.InfluxFields()
|
||||
if took, ok := fields["took"]; !ok {
|
||||
t.Error("took field missing")
|
||||
} else if took != int64(1234) {
|
||||
t.Errorf("took = %v (%T), want int64(1234)", took, took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfluxFields_NegativeDuration(t *testing.T) {
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
State: "OK",
|
||||
Duration: 0,
|
||||
},
|
||||
}
|
||||
|
||||
fields := r.InfluxFields()
|
||||
if took, ok := fields["took"]; !ok {
|
||||
t.Error("took field missing")
|
||||
} else if took != int64(0) {
|
||||
t.Errorf("took = %v, want 0", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningsJoined(t *testing.T) {
|
||||
c := models.Check{ID: 1}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
warnings []string
|
||||
want string
|
||||
}{
|
||||
{"nil", nil, ""},
|
||||
{"empty", []string{}, ""},
|
||||
{"single", []string{"a"}, "a"},
|
||||
{"multiple", []string{"a", "b", "c"}, "a,b,c"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := &Result{
|
||||
CheckResult: checkresult.CheckResult{
|
||||
Warnings: tt.warnings,
|
||||
},
|
||||
}
|
||||
tags := r.InfluxTags(c)
|
||||
if tags["warnings"] != tt.want {
|
||||
t.Errorf("warnings = %q, want %q", tags["warnings"], tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user