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 удалений

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
}