Compare commits

..

4 Commits

Author SHA1 Message Date
1d32a64dc0 add cert-revcheck 2025-11-14 22:56:10 -08:00
d70ca5ee87 adding golangci-lint 2025-11-14 22:54:06 -08:00
eca3a229a4 config: golangci-lint cleanups. 2025-11-14 22:53:02 -08:00
4c1eb03671 Cleaning up golangci-lint warnings. 2025-11-14 22:49:54 -08:00
8 changed files with 431 additions and 102 deletions

87
.golangci.yml Normal file
View File

@@ -0,0 +1,87 @@
run:
timeout: 5m
tests: true
build-tags: []
modules-download-mode: readonly
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gofmt
- goimports
- misspell
- unparam
- unconvert
- goconst
- gocyclo
- gosec
- prealloc
- copyloopvar
- revive
- typecheck
linters-settings:
gocyclo:
min-complexity: 15
goconst:
min-len: 3
min-occurrences: 3
misspell:
locale: US
revive:
rules:
- name: exported
disabled: false
- name: error-return
- name: error-naming
- name: if-return
- name: var-naming
- name: package-comments
disabled: true
- name: indent-error-flow
- name: context-as-argument
gosec:
excludes:
- G304 # File path from variable (common in file utilities)
- G404 # Use of weak random (acceptable for non-crypto use)
issues:
exclude-rules:
# Exclude some linters from running on tests files
- path: _test\.go
linters:
- gocyclo
- errcheck
- gosec
# Exclude embedded content from checks
- path: ".*\\.txt$"
linters:
- all
# Ignore deprecation warnings in legacy code if needed
- linters:
- staticcheck
text: "SA1019"
# Maximum issues count per one linter
max-issues-per-linter: 0
# Maximum count of issues with the same text
max-same-issues: 0
output:
formats:
- format: colored-line-number
path: stdout
print-issued-lines: true
print-linter-name: true

View File

@@ -101,7 +101,12 @@ func main() {
} }
// Process each chain group // Process each chain group
createdFiles := []string{} // Pre-allocate createdFiles based on total number of formats across all groups
totalFormats := 0
for _, group := range cfg.Chains {
totalFormats += len(group.Outputs.Formats)
}
createdFiles := make([]string, 0, totalFormats)
for groupName, group := range cfg.Chains { for groupName, group := range cfg.Chains {
files, err := processChainGroup(groupName, group, expiryDuration) files, err := processChainGroup(groupName, group, expiryDuration)
if err != nil { if err != nil {
@@ -168,68 +173,79 @@ func parseDuration(s string) (time.Duration, error) {
} }
func processChainGroup(groupName string, group ChainGroup, expiryDuration time.Duration) ([]string, error) { func processChainGroup(groupName string, group ChainGroup, expiryDuration time.Duration) ([]string, error) {
var createdFiles []string
// Default encoding to "pem" if not specified // Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding encoding := group.Outputs.Encoding
if encoding == "" { if encoding == "" {
encoding = "pem" encoding = "pem"
} }
// Collect data from all chains in the group // Collect certificates from all chains in the group
singleFileCerts, individualCerts, err := loadAndCollectCerts(group.Certs, group.Outputs, expiryDuration)
if err != nil {
return nil, err
}
// Prepare files for inclusion in archives
archiveFiles, err := prepareArchiveFiles(singleFileCerts, individualCerts, group.Outputs, encoding)
if err != nil {
return nil, err
}
// Create archives for the entire group
createdFiles, err := createArchiveFiles(groupName, group.Outputs.Formats, archiveFiles)
if err != nil {
return nil, err
}
return createdFiles, nil
}
// loadAndCollectCerts loads all certificates from chains and collects them for processing
func loadAndCollectCerts(chains []CertChain, outputs Outputs, expiryDuration time.Duration) ([]*x509.Certificate, []certWithPath, error) {
var singleFileCerts []*x509.Certificate var singleFileCerts []*x509.Certificate
var individualCerts []certWithPath var individualCerts []certWithPath
for _, chain := range group.Certs { for _, chain := range chains {
// Step 1: Load all certificates for this chain
allCerts := make(map[string]*x509.Certificate)
// Load root certificate // Load root certificate
rootCert, err := certlib.LoadCertificate(chain.Root) rootCert, err := certlib.LoadCertificate(chain.Root)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load root certificate %s: %v", chain.Root, err) return nil, nil, fmt.Errorf("failed to load root certificate %s: %v", chain.Root, err)
} }
allCerts[chain.Root] = rootCert
// Check expiry for root // Check expiry for root
checkExpiry(chain.Root, rootCert, expiryDuration) checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to single file if needed // Add root to collections if needed
if group.Outputs.IncludeSingle { if outputs.IncludeSingle {
singleFileCerts = append(singleFileCerts, rootCert) singleFileCerts = append(singleFileCerts, rootCert)
} }
if outputs.IncludeIndividual {
// Add root to individual files if needed
if group.Outputs.IncludeIndividual {
individualCerts = append(individualCerts, certWithPath{ individualCerts = append(individualCerts, certWithPath{
cert: rootCert, cert: rootCert,
path: chain.Root, path: chain.Root,
}) })
} }
// Step 2: Load and validate intermediates // Load and validate intermediates
for _, intPath := range chain.Intermediates { for _, intPath := range chain.Intermediates {
intCert, err := certlib.LoadCertificate(intPath) intCert, err := certlib.LoadCertificate(intPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to load intermediate certificate %s: %v", intPath, err) return nil, nil, fmt.Errorf("failed to load intermediate certificate %s: %v", intPath, err)
} }
allCerts[intPath] = intCert
// Validate that intermediate is signed by root // Validate that intermediate is signed by root
if err := intCert.CheckSignatureFrom(rootCert); err != nil { if err := intCert.CheckSignatureFrom(rootCert); err != nil {
return nil, fmt.Errorf("intermediate %s is not properly signed by root %s: %v", intPath, chain.Root, err) return nil, nil, fmt.Errorf("intermediate %s is not properly signed by root %s: %v", intPath, chain.Root, err)
} }
// Check expiry for intermediate // Check expiry for intermediate
checkExpiry(intPath, intCert, expiryDuration) checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to a single file if needed // Add intermediate to collections if needed
if group.Outputs.IncludeSingle { if outputs.IncludeSingle {
singleFileCerts = append(singleFileCerts, intCert) singleFileCerts = append(singleFileCerts, intCert)
} }
if outputs.IncludeIndividual {
// Add intermediate to individual files if needed
if group.Outputs.IncludeIndividual {
individualCerts = append(individualCerts, certWithPath{ individualCerts = append(individualCerts, certWithPath{
cert: intCert, cert: intCert,
path: intPath, path: intPath,
@@ -238,11 +254,15 @@ func processChainGroup(groupName string, group ChainGroup, expiryDuration time.D
} }
} }
// Prepare files for inclusion in archives for the entire group. return singleFileCerts, individualCerts, nil
}
// prepareArchiveFiles prepares all files to be included in archives
func prepareArchiveFiles(singleFileCerts []*x509.Certificate, individualCerts []certWithPath, outputs Outputs, encoding string) ([]fileEntry, error) {
var archiveFiles []fileEntry var archiveFiles []fileEntry
// Handle a single bundle file. // Handle a single bundle file
if group.Outputs.IncludeSingle && len(singleFileCerts) > 0 { if outputs.IncludeSingle && len(singleFileCerts) > 0 {
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true) files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encode single bundle: %v", err) return nil, fmt.Errorf("failed to encode single bundle: %v", err)
@@ -251,7 +271,7 @@ func processChainGroup(groupName string, group ChainGroup, expiryDuration time.D
} }
// Handle individual files // Handle individual files
if group.Outputs.IncludeIndividual { if outputs.IncludeIndividual {
for _, cp := range individualCerts { for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path)) baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false) files, err := encodeCertsToFiles([]*x509.Certificate{cp.cert}, baseName, encoding, false)
@@ -263,7 +283,7 @@ func processChainGroup(groupName string, group ChainGroup, expiryDuration time.D
} }
// Generate manifest if requested // Generate manifest if requested
if group.Outputs.Manifest { if outputs.Manifest {
manifestContent := generateManifest(archiveFiles) manifestContent := generateManifest(archiveFiles)
archiveFiles = append(archiveFiles, fileEntry{ archiveFiles = append(archiveFiles, fileEntry{
name: "MANIFEST", name: "MANIFEST",
@@ -271,8 +291,14 @@ func processChainGroup(groupName string, group ChainGroup, expiryDuration time.D
}) })
} }
// Create archives for the entire group return archiveFiles, nil
for _, format := range group.Outputs.Formats { }
// createArchiveFiles creates archive files in the specified formats
func createArchiveFiles(groupName string, formats []string, archiveFiles []fileEntry) ([]string, error) {
createdFiles := make([]string, 0, len(formats))
for _, format := range formats {
ext, ok := formatExtensions[format] ext, ok := formatExtensions[format]
if !ok { if !ok {
return nil, fmt.Errorf("unsupported format: %s", format) return nil, fmt.Errorf("unsupported format: %s", format)
@@ -327,10 +353,7 @@ func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding str
switch encoding { switch encoding {
case "pem": case "pem":
pemContent, err := encodeCertsToPEM(certs, isSingle) pemContent := encodeCertsToPEM(certs)
if err != nil {
return nil, err
}
files = append(files, fileEntry{ files = append(files, fileEntry{
name: baseName + ".pem", name: baseName + ".pem",
content: pemContent, content: pemContent,
@@ -357,10 +380,7 @@ func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding str
} }
case "both": case "both":
// Add PEM version // Add PEM version
pemContent, err := encodeCertsToPEM(certs, isSingle) pemContent := encodeCertsToPEM(certs)
if err != nil {
return nil, err
}
files = append(files, fileEntry{ files = append(files, fileEntry{
name: baseName + ".pem", name: baseName + ".pem",
content: pemContent, content: pemContent,
@@ -391,7 +411,7 @@ func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding str
} }
// encodeCertsToPEM encodes certificates to PEM format // encodeCertsToPEM encodes certificates to PEM format
func encodeCertsToPEM(certs []*x509.Certificate, concatenate bool) ([]byte, error) { func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte var pemContent []byte
for _, cert := range certs { for _, cert := range certs {
pemBlock := &pem.Block{ pemBlock := &pem.Block{
@@ -400,7 +420,7 @@ func encodeCertsToPEM(certs []*x509.Certificate, concatenate bool) ([]byte, erro
} }
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...) pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
} }
return pemContent, nil return pemContent
} }
func generateManifest(files []fileEntry) []byte { func generateManifest(files []fileEntry) []byte {
@@ -420,22 +440,29 @@ func createZipArchive(path string, files []fileEntry) error {
if err != nil { if err != nil {
return err return err
} }
defer f.Close()
w := zip.NewWriter(f) w := zip.NewWriter(f)
defer w.Close()
for _, file := range files { for _, file := range files {
fw, err := w.Create(file.name) fw, err := w.Create(file.name)
if err != nil { if err != nil {
w.Close()
f.Close()
return err return err
} }
if _, err := fw.Write(file.content); err != nil { if _, err := fw.Write(file.content); err != nil {
w.Close()
f.Close()
return err return err
} }
} }
return nil // Check errors on close operations
if err := w.Close(); err != nil {
f.Close()
return err
}
return f.Close()
} }
func createTarGzArchive(path string, files []fileEntry) error { func createTarGzArchive(path string, files []fileEntry) error {
@@ -443,13 +470,9 @@ func createTarGzArchive(path string, files []fileEntry) error {
if err != nil { if err != nil {
return err return err
} }
defer f.Close()
gw := gzip.NewWriter(f) gw := gzip.NewWriter(f)
defer gw.Close()
tw := tar.NewWriter(gw) tw := tar.NewWriter(gw)
defer tw.Close()
for _, file := range files { for _, file := range files {
hdr := &tar.Header{ hdr := &tar.Header{
@@ -458,14 +481,30 @@ func createTarGzArchive(path string, files []fileEntry) error {
Size: int64(len(file.content)), Size: int64(len(file.content)),
} }
if err := tw.WriteHeader(hdr); err != nil { if err := tw.WriteHeader(hdr); err != nil {
tw.Close()
gw.Close()
f.Close()
return err return err
} }
if _, err := tw.Write(file.content); err != nil { if _, err := tw.Write(file.content); err != nil {
tw.Close()
gw.Close()
f.Close()
return err return err
} }
} }
return nil // Check errors on close operations in the correct order
if err := tw.Close(); err != nil {
gw.Close()
f.Close()
return err
}
if err := gw.Close(); err != nil {
f.Close()
return err
}
return f.Close()
} }
func generateHashFile(path string, files []string) error { func generateHashFile(path string, files []string) error {

View File

@@ -186,4 +186,9 @@ to provide the same detailed information.
It may be easier to embed the README.txt in the program on build. It may be easier to embed the README.txt in the program on build.
-----
For the archive (tar.gz and zip) writers, make sure errors are
checked at the end, and don't just defer the close operations.

View File

@@ -0,0 +1,36 @@
cert-revcheck: check certificate expiry and revocation
-----------------------------------------------------
Description
cert-revcheck accepts a list of certificate files (PEM or DER) or
site addresses (host[:port]) and checks whether the leaf certificate
is expired or revoked. Revocation checks use CRL and OCSP via the
certlib/revoke package.
Usage
cert-revcheck [options] <target> [<target>...]
Options
-hardfail treat revocation check failures as fatal (default: false)
-timeout dur HTTP/OCSP/CRL timeout for network operations (default: 10s)
-v verbose output
Targets
- File paths to certificates in PEM or DER format.
- Site addresses in the form host or host:port. If no port is
provided, 443 is assumed.
Examples
# Check a PEM file
cert-revcheck ./server.pem
# Check a DER (single) certificate
cert-revcheck ./server.der
# Check a live site (leaf certificate)
cert-revcheck example.com:443
Notes
- For sites, only the leaf certificate is checked.
- When -hardfail is set, network issues during OCSP/CRL fetch will
cause the check to fail (treated as revoked).

139
cmd/cert-revcheck/main.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"crypto/tls"
"crypto/x509"
"flag"
"fmt"
"io/ioutil"
"net"
"os"
"time"
"git.wntrmute.dev/kyle/goutils/certlib"
hosts "git.wntrmute.dev/kyle/goutils/certlib/hosts"
"git.wntrmute.dev/kyle/goutils/certlib/revoke"
"git.wntrmute.dev/kyle/goutils/fileutil"
)
var (
hardfail bool
timeout time.Duration
verbose bool
)
func main() {
flag.BoolVar(&hardfail, "hardfail", false, "treat revocation check failures as fatal")
flag.DurationVar(&timeout, "timeout", 10*time.Second, "network timeout for OCSP/CRL fetches and TLS site connects")
flag.BoolVar(&verbose, "v", false, "verbose output")
flag.Parse()
revoke.HardFail = hardfail
// Set HTTP client timeout for revocation library
revoke.HTTPClient.Timeout = timeout
if flag.NArg() == 0 {
fmt.Fprintf(os.Stderr, "Usage: %s [options] <target> [<target>...]\n", os.Args[0])
os.Exit(2)
}
exitCode := 0
for _, target := range flag.Args() {
status, err := processTarget(target)
switch status {
case "OK":
fmt.Printf("%s: OK\n", target)
case "EXPIRED":
fmt.Printf("%s: EXPIRED: %v\n", target, err)
exitCode = 1
case "REVOKED":
fmt.Printf("%s: REVOKED\n", target)
exitCode = 1
case "UNKNOWN":
fmt.Printf("%s: UNKNOWN: %v\n", target, err)
if hardfail {
// In hardfail, treat unknown as failure
exitCode = 1
}
}
}
os.Exit(exitCode)
}
func processTarget(target string) (string, error) {
if fileutil.FileDoesExist(target) {
return checkFile(target)
}
// Not a file; treat as site
return checkSite(target)
}
func checkFile(path string) (string, error) {
in, err := ioutil.ReadFile(path)
if err != nil {
return "UNKNOWN", err
}
// Try PEM first; if that fails, try single DER cert
certs, err := certlib.ReadCertificates(in)
if err != nil || len(certs) == 0 {
cert, _, derr := certlib.ReadCertificate(in)
if derr != nil || cert == nil {
if err == nil {
err = derr
}
return "UNKNOWN", err
}
return evaluateCert(cert)
}
// Evaluate the first certificate (leaf) by default
return evaluateCert(certs[0])
}
func checkSite(hostport string) (string, error) {
// Use certlib/hosts to parse host/port (supports https URLs and host:port)
target, err := hosts.ParseHost(hostport)
if err != nil {
return "UNKNOWN", err
}
d := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(d, "tcp", target.String(), &tls.Config{InsecureSkipVerify: true, ServerName: target.Host})
if err != nil {
return "UNKNOWN", err
}
defer conn.Close()
state := conn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return "UNKNOWN", fmt.Errorf("no peer certificates presented")
}
return evaluateCert(state.PeerCertificates[0])
}
func evaluateCert(cert *x509.Certificate) (string, error) {
// Expiry check
now := time.Now()
if !now.Before(cert.NotAfter) {
return "EXPIRED", fmt.Errorf("expired at %s", cert.NotAfter)
}
if !now.After(cert.NotBefore) {
return "EXPIRED", fmt.Errorf("not valid until %s", cert.NotBefore)
}
// Revocation check using certlib/revoke
revoked, ok, err := revoke.VerifyCertificateError(cert)
if revoked {
// If revoked is true, ok will be true per implementation, err may describe why
return "REVOKED", err
}
if !ok {
// Revocation status could not be determined
return "UNKNOWN", err
}
return "OK", nil
}

View File

@@ -64,11 +64,7 @@ func LoadFile(path string) error {
addLine(line) addLine(line)
} }
if err = scanner.Err(); err != nil { return scanner.Err()
return err
}
return nil
} }
// LoadFileFor scans the ini file at path, loading the default section // LoadFileFor scans the ini file at path, loading the default section

View File

@@ -42,67 +42,94 @@ func ParseReader(r io.Reader) (cfg ConfigMap, err error) {
line string line string
longLine bool longLine bool
currentSection string currentSection string
lineBytes []byte
isPrefix bool
) )
for { for {
err = nil line, longLine, err = readConfigLine(buf, line, longLine)
lineBytes, isPrefix, err = buf.ReadLine() if err == io.EOF {
if io.EOF == err {
err = nil err = nil
break break
} else if err != nil { } else if err != nil {
break break
} else if isPrefix {
line += string(lineBytes)
longLine = true
continue
} else if longLine {
line += string(lineBytes)
longLine = false
} else {
line = string(lineBytes)
} }
if commentLine.MatchString(line) { if line == "" {
continue continue
} else if blankLine.MatchString(line) { }
continue
} else if configSection.MatchString(line) { currentSection, err = processConfigLine(cfg, line, currentSection)
section := configSection.ReplaceAllString(line, if err != nil {
"$1")
if section == "" {
err = fmt.Errorf("invalid structure in file")
break break
} else if !cfg.SectionInConfig(section) { }
}
return
}
// readConfigLine reads and assembles a complete configuration line, handling long lines.
func readConfigLine(buf *bufio.Reader, currentLine string, longLine bool) (line string, stillLong bool, err error) {
lineBytes, isPrefix, err := buf.ReadLine()
if err != nil {
return "", false, err
}
if isPrefix {
return currentLine + string(lineBytes), true, nil
} else if longLine {
return currentLine + string(lineBytes), false, nil
}
return string(lineBytes), false, nil
}
// processConfigLine processes a single line and updates the configuration map.
func processConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
if commentLine.MatchString(line) || blankLine.MatchString(line) {
return currentSection, nil
}
if configSection.MatchString(line) {
return handleSectionLine(cfg, line)
}
if configLine.MatchString(line) {
return handleConfigLine(cfg, line, currentSection)
}
return currentSection, fmt.Errorf("invalid config file")
}
// handleSectionLine processes a section header line.
func handleSectionLine(cfg ConfigMap, line string) (string, error) {
section := configSection.ReplaceAllString(line, "$1")
if section == "" {
return "", fmt.Errorf("invalid structure in file")
}
if !cfg.SectionInConfig(section) {
cfg[section] = make(map[string]string, 0) cfg[section] = make(map[string]string, 0)
} }
currentSection = section return section, nil
} else if configLine.MatchString(line) { }
// handleConfigLine processes a key=value configuration line.
func handleConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
regex := configLine regex := configLine
if quotedConfigLine.MatchString(line) { if quotedConfigLine.MatchString(line) {
regex = quotedConfigLine regex = quotedConfigLine
} }
if currentSection == "" { if currentSection == "" {
currentSection = DefaultSection currentSection = DefaultSection
if !cfg.SectionInConfig(currentSection) { if !cfg.SectionInConfig(currentSection) {
cfg[currentSection] = map[string]string{} cfg[currentSection] = map[string]string{}
} }
} }
key := regex.ReplaceAllString(line, "$1") key := regex.ReplaceAllString(line, "$1")
val := regex.ReplaceAllString(line, "$2") val := regex.ReplaceAllString(line, "$2")
if key == "" { if key != "" {
continue
}
cfg[currentSection][key] = val cfg[currentSection][key] = val
} else {
err = fmt.Errorf("invalid config file")
break
} }
}
return return currentSection, nil
} }
// SectionInConfig determines whether a section is in the configuration. // SectionInConfig determines whether a section is in the configuration.

2
go.mod
View File

@@ -1,6 +1,6 @@
module git.wntrmute.dev/kyle/goutils module git.wntrmute.dev/kyle/goutils
go 1.20 go 1.22
require ( require (
github.com/hashicorp/go-syslog v1.0.0 github.com/hashicorp/go-syslog v1.0.0