Все проверки выполнены успешно
CI / test (push) Successful in 2m24s
Docker / Build and publish worker image (push) Successful in 13m24s
428 строки
11 KiB
Go
428 строки
11 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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|