From 39aaacf13a53be888a41941851fcd74861822237 Mon Sep 17 00:00:00 2001 From: Massimiliano Adamo <maxadamo@gmail.com> Date: Wed, 22 Jan 2025 18:29:47 +0100 Subject: [PATCH] add certificate inspector functionality and update build command --- README.md | 2 +- certinspector/inspector.go | 154 +++++++++++++++++++++++++++++++++++++ main.go | 87 +++++++++++++-------- 3 files changed, 208 insertions(+), 35 deletions(-) create mode 100644 certinspector/inspector.go diff --git a/README.md b/README.md index 3633ce9..bfc000a 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ LATEST_TAG=$(git describe --tags $(git rev-list --tags --max-count=1)) PROG_VERSION=${LATEST_TAG:1} BUILD_TIME=$(date -u '+%Y-%m-%d_%H:%M:%S') git checkout $LATEST_TAG -go build -ldflags "-s -w -X main.appVersion=${PROG_VERSION} -X main.buildTime=${BUILDTIME}" -o acme-web +GOARCH=amd64 GOOS=linux go build -ldflags "-s -w -X main.appVersion=${PROG_VERSION} -X main.buildTime=${BUILDTIME}" -o acme-web ``` ## Setting up systemd diff --git a/certinspector/inspector.go b/certinspector/inspector.go new file mode 100644 index 0000000..f7047ec --- /dev/null +++ b/certinspector/inspector.go @@ -0,0 +1,154 @@ +package certinspector + +import ( + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +type CertificateData struct { + CertName string `json:"certname"` + SerialNumber string `json:"serial_number"` + Domains []string `json:"domains"` + ExpiryDate string `json:"expiry_date"` +} + +// inspect certificate and return CertificateData +func InspectCertificate(certDir string) (CertificateData, error) { + fullchainPath := filepath.Join(certDir, "fullchain.pem") + data, err := os.ReadFile(fullchainPath) + if err != nil { + return CertificateData{}, fmt.Errorf("failed to read certificate: %w", err) + } + + block, _ := pem.Decode(data) + if block == nil { + return CertificateData{}, errors.New("failed to parse PEM block") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return CertificateData{}, fmt.Errorf("failed to parse certificate: %w", err) + } + + certName := cert.Subject.CommonName + serial := strings.ToUpper(fmt.Sprintf("%X", cert.SerialNumber)) + domains := cert.DNSNames + daysLeft := int(time.Until(cert.NotAfter).Hours() / 24) + status := "VALID" + if daysLeft <= 0 { + status = "EXPIRED" + } + expiry := fmt.Sprintf("%s: %d DAYS", status, daysLeft) + + return CertificateData{ + CertName: certName, + SerialNumber: serial, + Domains: domains, + ExpiryDate: expiry, + }, nil +} + +// call writeJSON functio. Used by the API. +func ProcessCertificatesWrite(baseDir, provider string, outputDir string) error { + liveDir := filepath.Join(baseDir, provider, "live") + dirs, err := os.ReadDir(liveDir) + if err != nil { + return fmt.Errorf("failed to list directories: %w", err) + } + + var wg sync.WaitGroup + var mu sync.Mutex + results := []CertificateData{} + + for _, dir := range dirs { + if !dir.IsDir() || dir.Name() == "README" { + continue + } + + wg.Add(1) + go func(certDir string) { + defer wg.Done() + data, err := InspectCertificate(certDir) + if err == nil { + mu.Lock() + results = append(results, data) + mu.Unlock() + } + }(filepath.Join(liveDir, dir.Name())) + } + + wg.Wait() + + sort.Slice(results, func(i, j int) bool { + return results[i].CertName < results[j].CertName + }) + + outputFile := filepath.Join(outputDir, provider+".json") + return writeJSON(outputFile, results) +} + +// write JSON to file. Used by the API. +func writeJSON(filename string, data interface{}) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create JSON file: %w", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + return encoder.Encode(data) +} + +// process certificates and return JSON data +func ProcessCertificates(baseDir, provider string) ([]byte, error) { + liveDir := filepath.Join(baseDir, provider, "live") + dirs, err := os.ReadDir(liveDir) + if err != nil { + return nil, fmt.Errorf("failed to list directories: %w", err) + } + + var wg sync.WaitGroup + var mu sync.Mutex + results := []CertificateData{} + + for _, dir := range dirs { + if !dir.IsDir() || dir.Name() == "README" { + continue + } + + wg.Add(1) + go func(certDir string) { + defer wg.Done() + data, err := InspectCertificate(certDir) + if err == nil { + mu.Lock() + results = append(results, data) + mu.Unlock() + } + }(filepath.Join(liveDir, dir.Name())) + } + + wg.Wait() + + sort.Slice(results, func(i, j int) bool { + return results[i].CertName < results[j].CertName + }) + + // Encode results directly into JSON + jsonData, err := json.MarshalIndent(results, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to encode JSON: %w", err) + } + + return jsonData, nil +} diff --git a/main.go b/main.go index 2846fbd..7e04bb8 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "acme-web/certinspector" "fmt" "log" "net/http" @@ -16,13 +17,16 @@ import ( var ( appVersion string buildTime string + baseDir string webDir string - jsonConverter string bearerToken string WarningLogger *log.Logger InfoLogger *log.Logger ErrorLogger *log.Logger verboseBool bool + baseURLs []string + apiURLs []string + otherURLs []string ) func init() { @@ -33,37 +37,39 @@ func init() { // serve certificates JSON func renderJSON(w http.ResponseWriter, req *http.Request) { - // content-type currently not working provider := strings.Split(req.URL.Path, "/")[2] - serveFile := fmt.Sprintf("%v/%v/%v.json", webDir, provider, provider) - cmd := exec.Command(jsonConverter, "-p", provider) - err := cmd.Run() + jsonData, err := certinspector.ProcessCertificates(baseDir, provider) if err != nil { WarningLogger.Println(err) - w.WriteHeader(http.StatusServiceUnavailable) - } else { - if verboseBool { - InfoLogger.Printf("HTTP Status %v", http.StatusOK) - } - w.Header().Set("Content-Type", "application/json") + http.Error(w, "Failed to process certificates", http.StatusServiceUnavailable) + return + } + + // Write JSON response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err = w.Write(jsonData) + if err != nil { + WarningLogger.Printf("Failed to write response: %v", err) } - http.ServeFile(w, req, serveFile) } // serve certificates list func renderPage(w http.ResponseWriter, req *http.Request) { - provider := strings.Split(req.URL.Path, "/") + provider := strings.Split(req.URL.Path, "/")[1] + outputDir := filepath.Join(webDir, provider) serveFile := filepath.Join(webDir, req.URL.Path) - cmd := exec.Command(jsonConverter, "-p", provider[1]) - err := cmd.Run() + + err := certinspector.ProcessCertificatesWrite(baseDir, provider, outputDir) if err != nil { WarningLogger.Println(err) - w.WriteHeader(http.StatusServiceUnavailable) - } else { - if verboseBool { - InfoLogger.Printf("HTTP Status %v", http.StatusOK) - } - w.Header().Set("Content-Type", "text/html") + http.Error(w, "Failed to process certificates", http.StatusServiceUnavailable) + return + } + + w.Header().Set("Content-Type", "text/html") + if verboseBool { + InfoLogger.Printf("Serving file: %s", serveFile) } http.ServeFile(w, req, serveFile) } @@ -112,7 +118,7 @@ func main() { - serve ACME HTML pages, trigger Puppet, expose API Usage: - %v [--json-converter=JSONCONVERTER] [--listen-address=LISTENADDRESS] [--listen-port=LISTENPORT] [--verbose] + %v [--listen-address=LISTENADDRESS] [--listen-port=LISTENPORT] [--verbose] %v -h | --help %v -b | --build %v -v | --version @@ -121,7 +127,6 @@ Options: -h --help Show this screen -b --build Print version and build information and exit -v --version Print version information and exit - --json-converter=JSONCONVERTER Path to json converter script [default: /usr/bin/cert2json] --listen-address=LISTENADDRESS Web server address. Check Go net/http documentation [default: any] --listen-port=LISTENPORT Web server port [default: 8000] --verbose Log also successful connections @@ -140,22 +145,37 @@ Options: os.Exit(1) } bearerToken = cfg.Section("acme").Key("bearer_token").String() + // remove leading and trailing spaces and quotes and split by comma + acmeProvidersRaw := cfg.Section("acme").Key("acme_providers").String() + acmeProvidersRaw = strings.Trim(acmeProvidersRaw, "[] ") + acmeProviders := strings.Split(acmeProvidersRaw, ",") + for i := range acmeProviders { + acmeProviders[i] = strings.Trim(acmeProviders[i], "' ") + } + baseDir = "/etc" webDir = "/var/www/acme_web" - jsonConverter = arguments["--json-converter"].(string) verboseBool = arguments["--verbose"].(bool) listenAddress := arguments["--listen-address"].(string) listenPort := arguments["--listen-port"].(string) - baseURLs := [6]string{"/sectigo_ev", "/sectigo_ov", "/letsencrypt", "/sectigo_ev/", "/sectigo_ov/", "/letsencrypt/"} - apiURLs := [6]string{"/api/sectigo_ev", "/api/sectigo_ov", "/api/letsencrypt", - "/api/sectigo_ev/", "/api/sectigo_ov/", "/api/letsencrypt/"} - otherURLs := [12]string{"/letsencrypt/by_name.html", "/letsencrypt/by_date.html", - "/letsencrypt/letsencrypt.json", "/letsencrypt/letsencrypt_expired.json", - "/sectigo_ov/by_name.html", "/sectigo_ov/by_date.html", - "/sectigo_ov/sectigo_ov.json", "/sectigo_ov/sectigo_ov_expired.json", - "/sectigo_ev/by_name.html", "/sectigo_ev/by_date.html", - "/sectigo_ev/sectigo_ev.json", "/sectigo_ev/sectigo_ev_expired.json"} + for _, provider := range acmeProviders { + fmt.Printf("%v element\n", provider) + baseURLs = append(baseURLs, "/"+provider, "/"+provider+"/") + fmt.Printf("%v element\n", baseURLs) + } + + for _, provider := range acmeProviders { + apiURLs = append(apiURLs, "/api/"+provider, "/api/"+provider+"/") + } + + for _, provider := range acmeProviders { + otherURLs = append( + otherURLs, "/"+provider+"/by_name.html", "/"+provider+"/by_date.html", + "/"+provider+"/"+provider+".json", "/"+provider+"/"+provider+"_expired.json", + ) + } + puppetURL := "/puppet" fs := http.FileServer(http.Dir("/var/www/acme_web/static")) @@ -184,5 +204,4 @@ Options: } else { log.Fatal(http.ListenAndServe(fmt.Sprintf("%v:%v", listenAddress, listenPort), nil)) } - } -- GitLab