feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
Этот коммит содержится в:
429
checks/cbssl/cbssl.go
Обычный файл
429
checks/cbssl/cbssl.go
Обычный файл
@@ -0,0 +1,429 @@
|
||||
// Package cbssl provides browser-based SSL certificate chain validation.
|
||||
// It validates certificates against actual browser CA root stores (Chrome, Firefox).
|
||||
package cbssl
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"rsgit.ru/rsmon/rsmon/app/models"
|
||||
)
|
||||
|
||||
const stateOK = "OK"
|
||||
|
||||
// BrowserType represents the type of browser to use for CA roots
|
||||
type BrowserType string
|
||||
|
||||
// Browser type constants for CA root selection.
|
||||
const (
|
||||
BrowserChrome BrowserType = "chrome"
|
||||
BrowserFirefox BrowserType = "firefox"
|
||||
BrowserAll BrowserType = "all"
|
||||
)
|
||||
|
||||
var (
|
||||
// Certificate pools for different browsers
|
||||
chromeRoots *x509.CertPool
|
||||
firefoxRoots *x509.CertPool
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// BrowserCertPools holds the certificate pools for different browsers
|
||||
type BrowserCertPools struct {
|
||||
Chrome *x509.CertPool
|
||||
Firefox *x509.CertPool
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Initialize certificate pools
|
||||
once.Do(func() {
|
||||
var err error
|
||||
chromeRoots, firefoxRoots, err = LoadBrowserCARoots()
|
||||
if err != nil {
|
||||
log.Printf("Warning: failed to load browser CA roots: %v", err)
|
||||
// Fall back to system roots
|
||||
chromeRoots = x509.NewCertPool()
|
||||
firefoxRoots = x509.NewCertPool()
|
||||
systemRoots, err := x509.SystemCertPool()
|
||||
if err == nil {
|
||||
chromeRoots = systemRoots
|
||||
firefoxRoots = systemRoots
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Perform executes the browser-based SSL certificate check
|
||||
func Perform(c *models.Check) *Result {
|
||||
result := &Result{}
|
||||
result.State = stateOK
|
||||
|
||||
// Get URL to check
|
||||
checkURL, err := getCheckURL(c)
|
||||
if err != nil {
|
||||
result.State = "FAIL"
|
||||
result.Error = errors.Wrap(err, "invalid url")
|
||||
return result
|
||||
}
|
||||
log.Println("CBSSL URL:", checkURL)
|
||||
|
||||
// Parse hostname from URL
|
||||
parsedURL, err := url.Parse(checkURL)
|
||||
if err != nil {
|
||||
result.State = "FAIL"
|
||||
result.Error = errors.Wrap(err, "failed to parse url")
|
||||
return result
|
||||
}
|
||||
hostname := parsedURL.Hostname()
|
||||
|
||||
// Get browser type - default to checking all browsers
|
||||
// TODO: Add Browser field to CheckSettings to allow per-check configuration
|
||||
browserType := BrowserAll
|
||||
|
||||
// Create custom TLS client that validates against browser roots
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: hostname,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// We'll set RootCAs per request to test against different browsers
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Test against Chrome roots
|
||||
if browserType == BrowserChrome || browserType == BrowserAll {
|
||||
chromeResult := testWithBrowser(client, checkURL, chromeRoots, "Chrome")
|
||||
result.ChromeValid = chromeResult.Valid
|
||||
result.ChromeError = chromeResult.ErrorMsg
|
||||
result.ChromeChain = chromeResult.ChainInfo
|
||||
if !chromeResult.Valid {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New("certificate validation failed against Chrome roots")
|
||||
if chromeResult.ErrorMsg != "" {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Chrome: %s", chromeResult.ErrorMsg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test against Firefox roots
|
||||
if browserType == BrowserFirefox || browserType == BrowserAll {
|
||||
firefoxResult := testWithBrowser(client, checkURL, firefoxRoots, "Firefox")
|
||||
result.FirefoxValid = firefoxResult.Valid
|
||||
result.FirefoxError = firefoxResult.ErrorMsg
|
||||
result.FirefoxChain = firefoxResult.ChainInfo
|
||||
if !firefoxResult.Valid && result.State == stateOK {
|
||||
result.State = "ERR"
|
||||
result.Error = errors.New("certificate validation failed against Firefox roots")
|
||||
}
|
||||
if firefoxResult.ErrorMsg != "" {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Firefox: %s", firefoxResult.ErrorMsg))
|
||||
}
|
||||
}
|
||||
|
||||
// Get certificate info for expiration check
|
||||
certInfo, err := getCertificateInfo(checkURL)
|
||||
if err != nil {
|
||||
if result.State == stateOK {
|
||||
result.State = "WARN"
|
||||
}
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Failed to get certificate info: %v", err))
|
||||
} else {
|
||||
result.Expires = &certInfo.NotAfter
|
||||
result.Subject = certInfo.Subject.CommonName
|
||||
result.Issuer = certInfo.Issuer.CommonName
|
||||
result.DNSNames = certInfo.DNSNames
|
||||
|
||||
// Check expiration
|
||||
exp := time.Until(certInfo.NotAfter).Hours() / 24
|
||||
if exp < 2 {
|
||||
if result.State == stateOK {
|
||||
result.State = "WARN"
|
||||
}
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.1f days", exp))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// testWithBrowser tests the certificate against a specific browser's CA roots
|
||||
func testWithBrowser(client *http.Client, checkURL string, roots *x509.CertPool, browserName string) BrowserTestResult {
|
||||
result := BrowserTestResult{Valid: false}
|
||||
|
||||
// Clone transport and set custom root CAs
|
||||
transport, ok := client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
result.ErrorMsg = fmt.Sprintf("failed to get transport for %s", browserName)
|
||||
return result
|
||||
}
|
||||
|
||||
customTransport := transport.Clone()
|
||||
customTransport.TLSClientConfig.RootCAs = roots
|
||||
|
||||
// Create new client with custom transport
|
||||
customClient := &http.Client{
|
||||
Timeout: client.Timeout,
|
||||
CheckRedirect: client.CheckRedirect,
|
||||
Transport: customTransport,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", checkURL, http.NoBody)
|
||||
if err != nil {
|
||||
result.ErrorMsg = fmt.Sprintf("failed to create request for %s: %v", browserName, err)
|
||||
return result
|
||||
}
|
||||
|
||||
req.Header.Set("Cache-Control", "max-age=0")
|
||||
req.Header.Set("Connection", "close")
|
||||
req.Header.Set("User-Agent", getUserAgent(browserName))
|
||||
|
||||
resp, err := customClient.Do(req)
|
||||
if err != nil {
|
||||
// Check if it's a certificate verification error
|
||||
if strings.Contains(err.Error(), "certificate") || strings.Contains(err.Error(), "x509") {
|
||||
result.ErrorMsg = fmt.Sprintf("certificate verification failed: %v", err)
|
||||
} else {
|
||||
result.ErrorMsg = fmt.Sprintf("connection failed: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck // accepted lint exception
|
||||
|
||||
result.Valid = true
|
||||
|
||||
// Get chain info
|
||||
if resp.TLS != nil && len(resp.TLS.VerifiedChains) > 0 {
|
||||
chain := resp.TLS.VerifiedChains[0]
|
||||
result.ChainInfo = buildChainInfo(chain)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// BrowserTestResult represents the result of testing against a specific browser
|
||||
type BrowserTestResult struct {
|
||||
Valid bool
|
||||
ErrorMsg string
|
||||
ChainInfo []CertInfo
|
||||
}
|
||||
|
||||
func buildChainInfo(chain []*x509.Certificate) []CertInfo {
|
||||
info := make([]CertInfo, 0, len(chain))
|
||||
for _, cert := range chain {
|
||||
info = append(info, CertInfo{
|
||||
Subject: cert.Subject.CommonName,
|
||||
Issuer: cert.Issuer.CommonName,
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
IsCA: cert.IsCA,
|
||||
PublicKey: cert.PublicKeyAlgorithm.String(),
|
||||
Signature: cert.SignatureAlgorithm.String(),
|
||||
})
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func getUserAgent(browserName string) string {
|
||||
switch browserName {
|
||||
case "Chrome":
|
||||
return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
case "Firefox":
|
||||
return "Mozilla/5.0 (X11; Linux x86_64; rv:134.0) Gecko/20100101 Firefox/134.0"
|
||||
default:
|
||||
return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
}
|
||||
}
|
||||
|
||||
// getCertificateInfo retrieves certificate information without validation
|
||||
func getCertificateInfo(checkURL string) (*x509.Certificate, error) {
|
||||
parsedURL, err := url.Parse(checkURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hostname := parsedURL.Hostname()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: time.Second * 30,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: hostname,
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Get(checkURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close() //nolint:errcheck // accepted lint exception
|
||||
|
||||
if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 {
|
||||
return nil, fmt.Errorf("no TLS certificates found")
|
||||
}
|
||||
|
||||
return resp.TLS.PeerCertificates[0], nil
|
||||
}
|
||||
|
||||
// getCheckURL constructs the URL to check from the check configuration
|
||||
func getCheckURL(c *models.Check) (string, error) {
|
||||
if c.URL != nil && *c.URL != "" {
|
||||
ur := *c.URL
|
||||
u, err := url.Parse(ur)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "bad url")
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
return "https://" + c.Monitor.Host, nil
|
||||
}
|
||||
|
||||
// LoadBrowserCARoots loads CA root certificates from browser installations
|
||||
func LoadBrowserCARoots() (chrome, firefox *x509.CertPool, err error) {
|
||||
chrome = x509.NewCertPool()
|
||||
firefox = x509.NewCertPool()
|
||||
|
||||
// Load Chrome/Chromium CA roots (uses system certs on Linux)
|
||||
// On Alpine Linux, this is typically /etc/ssl/certs/ca-certificates.crt
|
||||
chromeLoaded := false
|
||||
|
||||
// Try common certificate bundle locations
|
||||
certPaths := []string{
|
||||
"/etc/ssl/certs/ca-certificates.crt", // Alpine/Debian
|
||||
"/etc/ssl/cert.pem", // macOS
|
||||
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS
|
||||
"/usr/local/share/ca-certificates/", // Custom certs
|
||||
"/data/rsmon/docker/cert-bundles/output/", // Our custom bundled certs
|
||||
}
|
||||
|
||||
for _, certPath := range certPaths {
|
||||
if loadCertsFromPath(chrome, certPath) {
|
||||
chromeLoaded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Load Firefox CA roots (NSS database)
|
||||
// Firefox uses its own NSS database at ~/.pki/nssdb/ or uses system roots
|
||||
firefoxLoaded := loadFirefoxCARoots(firefox)
|
||||
|
||||
// If Firefox failed to load, use Chrome/system roots as fallback
|
||||
if !firefoxLoaded && chromeLoaded {
|
||||
firefox = chrome
|
||||
}
|
||||
|
||||
// If both failed, return error
|
||||
if !chromeLoaded {
|
||||
return nil, nil, fmt.Errorf("failed to load any CA certificates")
|
||||
}
|
||||
|
||||
return chrome, firefox, nil
|
||||
}
|
||||
|
||||
// loadCertsFromPath loads certificates from a file or directory
|
||||
func loadCertsFromPath(pool *x509.CertPool, path string) bool {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Load all .crt and .pem files from directory
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
loaded := false
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
ext := filepath.Ext(file.Name())
|
||||
if ext == ".crt" || ext == ".pem" {
|
||||
fullPath := filepath.Join(path, file.Name())
|
||||
if pool.AppendCertsFromPEM(readFile(fullPath)) {
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
// Load single file
|
||||
return pool.AppendCertsFromPEM(readFile(path))
|
||||
}
|
||||
|
||||
func readFile(path string) []byte {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// loadFirefoxCARoots loads Firefox CA roots from NSS database
|
||||
// On Linux, Firefox typically uses the system's CA certificates via libnssckbi.so
|
||||
func loadFirefoxCARoots(pool *x509.CertPool) bool {
|
||||
// Firefox on Linux usually uses the system certificate database
|
||||
// Try to load from Mozilla's built-in certificate bundle if available
|
||||
paths := []string{
|
||||
"/usr/lib/x86_64-linux-gnu/libnssckbi.so", // Debian/Ubuntu NSS module
|
||||
"/usr/lib/libnssckbi.so", // Generic path
|
||||
"/data/rsmon/docker/cert-bundles/output/mozilla/", // Our bundled Mozilla certs
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
if loadCertsFromPath(pool, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// ParsePEMCerts parses PEM-encoded certificates from data
|
||||
func ParsePEMCerts(data []byte) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
var block *pem.Block
|
||||
|
||||
rest := data
|
||||
for {
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type == "CERTIFICATE" {
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found")
|
||||
}
|
||||
|
||||
return certs, nil
|
||||
}
|
||||
Ссылка в новой задаче
Block a user