feat: publish standalone worker

Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
Gleb Tv
2026-07-13 17:55:14 +03:00
Коммит 2c7a0236da
309 изменённых файлов: 44004 добавлений и 0 удалений

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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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 Обычный файл
Просмотреть файл

@@ -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)
}
})
}
}