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