Files
worker/checks/cbssl/cbssl.go
Gleb Tv 2c884c5612
Некоторые проверки не удались
CI / test (push) Successful in 2m5s
Docker / Build and publish worker image (push) Failing after 31s
refactor: adopt worker module path
2026-07-13 17:56:12 +03:00

430 строки
12 KiB
Go

// 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"
"rocketgit.ru/rsmon/worker/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
}