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
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 {
files, err := processChainGroup(groupName, group, expiryDuration)
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) {
var createdFiles []string
// Default encoding to "pem" if not specified
encoding := group.Outputs.Encoding
if encoding == "" {
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 individualCerts []certWithPath
for _, chain := range group.Certs {
// Step 1: Load all certificates for this chain
allCerts := make(map[string]*x509.Certificate)
for _, chain := range chains {
// Load root certificate
rootCert, err := certlib.LoadCertificate(chain.Root)
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
checkExpiry(chain.Root, rootCert, expiryDuration)
// Add root to single file if needed
if group.Outputs.IncludeSingle {
// Add root to collections if needed
if outputs.IncludeSingle {
singleFileCerts = append(singleFileCerts, rootCert)
}
// Add root to individual files if needed
if group.Outputs.IncludeIndividual {
if outputs.IncludeIndividual {
individualCerts = append(individualCerts, certWithPath{
cert: rootCert,
path: chain.Root,
})
}
// Step 2: Load and validate intermediates
// Load and validate intermediates
for _, intPath := range chain.Intermediates {
intCert, err := certlib.LoadCertificate(intPath)
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
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
checkExpiry(intPath, intCert, expiryDuration)
// Add intermediate to a single file if needed
if group.Outputs.IncludeSingle {
// Add intermediate to collections if needed
if outputs.IncludeSingle {
singleFileCerts = append(singleFileCerts, intCert)
}
// Add intermediate to individual files if needed
if group.Outputs.IncludeIndividual {
if outputs.IncludeIndividual {
individualCerts = append(individualCerts, certWithPath{
cert: intCert,
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
// Handle a single bundle file.
if group.Outputs.IncludeSingle && len(singleFileCerts) > 0 {
// Handle a single bundle file
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
if err != nil {
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
if group.Outputs.IncludeIndividual {
if outputs.IncludeIndividual {
for _, cp := range individualCerts {
baseName := strings.TrimSuffix(filepath.Base(cp.path), filepath.Ext(cp.path))
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
if group.Outputs.Manifest {
if outputs.Manifest {
manifestContent := generateManifest(archiveFiles)
archiveFiles = append(archiveFiles, fileEntry{
name: "MANIFEST",
@@ -271,8 +291,14 @@ func processChainGroup(groupName string, group ChainGroup, expiryDuration time.D
})
}
// Create archives for the entire group
for _, format := range group.Outputs.Formats {
return archiveFiles, nil
}
// 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]
if !ok {
return nil, fmt.Errorf("unsupported format: %s", format)
@@ -327,10 +353,7 @@ func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding str
switch encoding {
case "pem":
pemContent, err := encodeCertsToPEM(certs, isSingle)
if err != nil {
return nil, err
}
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
@@ -357,10 +380,7 @@ func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding str
}
case "both":
// Add PEM version
pemContent, err := encodeCertsToPEM(certs, isSingle)
if err != nil {
return nil, err
}
pemContent := encodeCertsToPEM(certs)
files = append(files, fileEntry{
name: baseName + ".pem",
content: pemContent,
@@ -391,7 +411,7 @@ func encodeCertsToFiles(certs []*x509.Certificate, baseName string, encoding str
}
// encodeCertsToPEM encodes certificates to PEM format
func encodeCertsToPEM(certs []*x509.Certificate, concatenate bool) ([]byte, error) {
func encodeCertsToPEM(certs []*x509.Certificate) []byte {
var pemContent []byte
for _, cert := range certs {
pemBlock := &pem.Block{
@@ -400,7 +420,7 @@ func encodeCertsToPEM(certs []*x509.Certificate, concatenate bool) ([]byte, erro
}
pemContent = append(pemContent, pem.EncodeToMemory(pemBlock)...)
}
return pemContent, nil
return pemContent
}
func generateManifest(files []fileEntry) []byte {
@@ -420,22 +440,29 @@ func createZipArchive(path string, files []fileEntry) error {
if err != nil {
return err
}
defer f.Close()
w := zip.NewWriter(f)
defer w.Close()
for _, file := range files {
fw, err := w.Create(file.name)
if err != nil {
w.Close()
f.Close()
return err
}
if _, err := fw.Write(file.content); err != nil {
w.Close()
f.Close()
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 {
@@ -443,13 +470,9 @@ func createTarGzArchive(path string, files []fileEntry) error {
if err != nil {
return err
}
defer f.Close()
gw := gzip.NewWriter(f)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
for _, file := range files {
hdr := &tar.Header{
@@ -458,14 +481,30 @@ func createTarGzArchive(path string, files []fileEntry) error {
Size: int64(len(file.content)),
}
if err := tw.WriteHeader(hdr); err != nil {
tw.Close()
gw.Close()
f.Close()
return err
}
if _, err := tw.Write(file.content); err != nil {
tw.Close()
gw.Close()
f.Close()
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 {

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.
-----
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)
}
if err = scanner.Err(); err != nil {
return err
}
return nil
return scanner.Err()
}
// LoadFileFor scans the ini file at path, loading the default section

View File

@@ -42,69 +42,96 @@ func ParseReader(r io.Reader) (cfg ConfigMap, err error) {
line string
longLine bool
currentSection string
lineBytes []byte
isPrefix bool
)
for {
err = nil
lineBytes, isPrefix, err = buf.ReadLine()
if io.EOF == err {
line, longLine, err = readConfigLine(buf, line, longLine)
if err == io.EOF {
err = nil
break
} else if err != nil {
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
} else if blankLine.MatchString(line) {
continue
} else if configSection.MatchString(line) {
section := configSection.ReplaceAllString(line,
"$1")
if section == "" {
err = fmt.Errorf("invalid structure in file")
break
} else if !cfg.SectionInConfig(section) {
cfg[section] = make(map[string]string, 0)
}
currentSection = section
} else if configLine.MatchString(line) {
regex := configLine
if quotedConfigLine.MatchString(line) {
regex = quotedConfigLine
}
if currentSection == "" {
currentSection = DefaultSection
if !cfg.SectionInConfig(currentSection) {
cfg[currentSection] = map[string]string{}
}
}
key := regex.ReplaceAllString(line, "$1")
val := regex.ReplaceAllString(line, "$2")
if key == "" {
continue
}
cfg[currentSection][key] = val
} else {
err = fmt.Errorf("invalid config file")
}
currentSection, err = processConfigLine(cfg, line, currentSection)
if err != nil {
break
}
}
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)
}
return section, nil
}
// handleConfigLine processes a key=value configuration line.
func handleConfigLine(cfg ConfigMap, line string, currentSection string) (string, error) {
regex := configLine
if quotedConfigLine.MatchString(line) {
regex = quotedConfigLine
}
if currentSection == "" {
currentSection = DefaultSection
if !cfg.SectionInConfig(currentSection) {
cfg[currentSection] = map[string]string{}
}
}
key := regex.ReplaceAllString(line, "$1")
val := regex.ReplaceAllString(line, "$2")
if key != "" {
cfg[currentSection][key] = val
}
return currentSection, nil
}
// SectionInConfig determines whether a section is in the configuration.
func (c ConfigMap) SectionInConfig(section string) bool {
_, ok := c[section]

2
go.mod
View File

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