// 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 }