1
\$\begingroup\$

I previously submitted this CLI tool to this site for review and have made changes based on comments by the reviewer. I have refactored the code, fixed a bug (the tool will no longer output certificate expiry dates for certificates that are not valid until the future) and added the following features:

  • Accepting URLs from a file or text that is piped into the program
  • Explicit handling of OS signals (like when CTRL-C is pressed in terminal)
  • An independent timeout context for each URL (there is no longer a single global timeout)

I chose to implement the file/pipe feature by refactoring the function processURLs (previously named processUrlsParallel) to accept a channel of job arguments (using a job struct to logically combine the previous URL, timeout, and verifySSL arguments) instead of a slice of URLs and to use a select statement (this is my first usage of this language feature). I also chose to use a separate channel to for each input source (CLI arguments and file/STDIN-pipe) and merge them into a single channel (which is given as an argument to processURLs). My intent with the job struct (HTTPSCertCheckJob) is to make it easier to implement the feature of accepting separate options for each URL (like the timeout or SSL verification options) and the intent of using separate channels for the input sources is to make it easier to support additional input sources in the future (like multiple files or a SQLite3 database).

The following is my updated code:

package main

import (
    "bufio"
    "context"
    "crypto/tls"
    "encoding/json"
    "errors"
    "flag"
    "fmt"
    "log"
    "net"
    "os"
    "os/signal"
    "runtime"
    "sort"
    "strconv"
    "strings"
    "sync"
    "syscall"
    "time"
)

const programVersion = `HTTPS Certificate Checker (https_certificate_checker) 1.1.0
Copyright (C) 2022 Preetpal Sohal.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Preetpal Sohal.`

type HTTPSCertCheckJob struct {
    URL       string
    Timeout   time.Duration
    VerifySSL bool
}

type SuccessResponse struct {
    URL       string    `json:"url"`
    NotAfter  time.Time `json:"notAfter"`
    NotBefore time.Time `json:"notBefore"`
}

type FailureResponse struct {
    URL   string `json:"url"`
    Error string `json:"error"`
}

type JSONResponse struct {
    Data   []SuccessResponse `json:"data"`
    Errors []FailureResponse `json:"errors"`
}

func newJsonReponse(data <-chan SuccessResponse, failures <-chan FailureResponse) JSONResponse {
    toReturn := JSONResponse{}
    for successResponse := range data {
        toReturn.Data = append(toReturn.Data, successResponse)
    }
    sort.SliceStable(toReturn.Data, func(i, j int) bool { return toReturn.Data[i].URL < toReturn.Data[j].URL })
    sort.SliceStable(toReturn.Data, func(i, j int) bool { return toReturn.Data[i].NotAfter.Before(toReturn.Data[j].NotAfter) })
    for failureResponse := range failures {
        toReturn.Errors = append(toReturn.Errors, failureResponse)
    }
    sort.SliceStable(toReturn.Errors, func(i, j int) bool { return toReturn.Errors[i].URL < toReturn.Errors[j].URL })
    return toReturn
}

func outputResult(jsonResponse JSONResponse, outputJSON bool) error {
    if outputJSON {
        mJSON, err := json.Marshal(jsonResponse)
        if err != nil {
            return err
        }
        fmt.Println(string(mJSON))
        return nil
    }
    now := time.Now()

    for _, successResponse := range jsonResponse.Data {
        fmt.Println("Checking", successResponse.URL, "cert expiration dates against:", now)

        startInHours := int64(now.Sub(successResponse.NotBefore).Hours())
        expiryInHours := int64(successResponse.NotAfter.Sub(now).Hours())
        if startInHours < 0 {
            fmt.Println("Not valid until ", successResponse.NotBefore)
        } else if expiryInHours <= 0 {
            fmt.Println("Expired on ", successResponse.NotAfter)
        } else {
            if expiryInHours < 24 {
                fmt.Println("\tWill expire on", successResponse.NotAfter, "which is in", expiryInHours, "hours.")
            } else {
                fmt.Println("\tWill expire on", successResponse.NotAfter, "which is in", expiryInHours/24, "days and", expiryInHours%24, "hours.")
            }
        }
    }

    for _, failureResponse := range jsonResponse.Errors {
        fmt.Println("Failed to check HTTPS certification for", failureResponse.URL, "with error:", failureResponse.Error)
    }
    return nil
}

func checkHTTPSCert(parentCtxt context.Context, job HTTPSCertCheckJob) (*SuccessResponse, error) {
    var toReturn SuccessResponse
    if !strings.ContainsAny(job.URL, ":") {
        job.URL = job.URL + ":443"
    }
    toReturn.URL = job.URL

    dialer := tls.Dialer{
        Config: &tls.Config{InsecureSkipVerify: !job.VerifySSL},
    }
    ctxt, cancel := context.WithTimeout(parentCtxt, job.Timeout)
    defer cancel()
    connection, err := dialer.DialContext(ctxt, "tcp", job.URL)
    if err != nil {
        if terr, ok := err.(net.Error); ok && terr.Timeout() {
            return nil, fmt.Errorf("connection timed out in %s to URL %s", job.Timeout, job.URL)
        }
        return nil, fmt.Errorf("certificate error: %s", err)
    }
    defer connection.Close()

    tlsConnection := connection.(*tls.Conn)
    connectionState := tlsConnection.ConnectionState()

    if len(connectionState.PeerCertificates) > 0 {
        // Relies on ordering: "The first element is the leaf certificate that the connection is verified against."
        firstCertificate := connectionState.PeerCertificates[0]

        toReturn.NotAfter = firstCertificate.NotAfter
        toReturn.NotBefore = firstCertificate.NotBefore
        return &toReturn, nil
    }
    return nil, errors.New("encountered HTTPS certificate with no peer certificates (internal invariant broken; GUID: a9f6135d-93fc-4319-891d-c67bf5a149fc) for URL: " + job.URL)
}

func processURLs(ctxt context.Context, jobsCh <-chan HTTPSCertCheckJob) (<-chan SuccessResponse, <-chan FailureResponse) {
    wg := &sync.WaitGroup{}
    data := make(chan SuccessResponse, 8)
    failures := make(chan FailureResponse, 8)
    wg.Add(1)
    defer func() {
        wg.Wait()
        close(data)
        close(failures)
    }()

    go func() {
        defer wg.Done()
        for {
            select {
            case <-ctxt.Done():
                return
            case httpsCertCheckJob, ok := <-jobsCh:
                if ok {
                    jsonResponse, err := checkHTTPSCert(ctxt, httpsCertCheckJob)

                    if err != nil {
                        failures <- FailureResponse{
                            URL:   httpsCertCheckJob.URL,
                            Error: err.Error(),
                        }
                    } else {
                        data <- *jsonResponse
                    }
                } else {
                    jobsCh = nil
                }
            }
            if jobsCh == nil {
                break
            }
        }
    }()

    return data, failures
}

func main() {
    ctxt, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGKILL, syscall.SIGTERM)
    defer stop()

    var (
        outputJSON, showVersion, verifySSL bool
        timeoutInSeconds                   uint64
        file                               string
    )

    flag.BoolVar(&outputJSON, "json", false, "output json to STDOUT")
    flag.BoolVar(&showVersion, "v", false, "show program version")
    flag.BoolVar(&verifySSL, "verify-ssl", false, "verify SSL certificate")
    flag.Uint64Var(&timeoutInSeconds, "timeout", 5, "timeout in seconds")
    flag.StringVar(&file, "file", "", "read input from file (one URL per line), when file is -, read standard input")
    flag.Usage = func() {
        w := flag.CommandLine.Output()

        fmt.Fprintf(w, `Usage: %s [options] URL...

This application determines when the SSL certificate will expire for the provided URLs.

Notes:
- File extension arguments should be provided without a protocol (e.g., "example.com").
- This application assumes that the the TLS connection should be made over port 443 unless a port is already provided in the URL (e.g., "smtp.example.com:465").
- This application does not attempt to modify the provided URLs.

Program options:
`, os.Args[0])
        flag.PrintDefaults()
    }

    flag.Parse()

    maxThreads, convErr := strconv.ParseInt(os.Getenv("MAX_THREADS"), 10, 32)
    if int(maxThreads) > 0 && convErr == nil {
        runtime.GOMAXPROCS(int(maxThreads))
    }

    if showVersion {
        fmt.Println(programVersion)
        return
    }

    if timeoutInSeconds < 1 {
        log.Fatal("Provided timeout must be greater than zero.")
    }

    if len(flag.Args()) < 1 && file == "" {
        flag.Usage()
        log.Fatal("Please provide at least one URL argument.")
    }

    timeout := time.Duration(timeoutInSeconds) * time.Second
    argsInputChannel := make(chan HTTPSCertCheckJob, 8)
    fileInputChannel := make(chan HTTPSCertCheckJob, 8)

    go func() {
        for _, URL := range flag.Args() {
            argsInputChannel <- HTTPSCertCheckJob{
                URL:       URL,
                Timeout:   timeout,
                VerifySSL: verifySSL,
            }
        }
        close(argsInputChannel)
    }()

    if file != "" {
        go func() {
            defer close(fileInputChannel)
            readURLsFromFile := func(file *os.File) {
                inputScanner := bufio.NewScanner(file)

                for {
                    inputScanner.Scan()
                    URL := strings.TrimSpace(inputScanner.Text())

                    if len(URL) == 0 {
                        break
                    }

                    fileInputChannel <- HTTPSCertCheckJob{
                        URL:       URL,
                        Timeout:   timeout,
                        VerifySSL: verifySSL,
                    }
                }
            }
            if file == "-" {
                stat, err := os.Stdin.Stat()
                if err != nil {
                    log.Fatal("Failed to read from STDIN (GUID: 3628ed96-0ce9-478f-bab1-bafc2c760924).")
                }

                if (stat.Mode()&os.ModeCharDevice == os.ModeCharDevice) || stat.Size() < 0 {
                    log.Fatal("Failed to read from STDIN (GUID: 9908c4f5-60a4-41f8-bc94-766e04ca4d5c).")
                }

                readURLsFromFile(os.Stdin)
            } else {
                fileHandle, err := os.Open(file)
                if err != nil {
                    log.Fatal(err)
                }
                defer fileHandle.Close()
                readURLsFromFile(fileHandle)
            }
        }()
    } else {
        close(fileInputChannel)
    }

    data, failures := processURLs(ctxt, merge(argsInputChannel, fileInputChannel))

    jsonResponse := newJsonReponse(data, failures)
    err := outputResult(jsonResponse, outputJSON)
    if err != nil {
        log.Fatal("Internal Error (GUID: e6ad5934-0a7d-4100-9889-7274058e60db): Failed to marshall internal structure to JSON")
    }
}

// From https://go.dev/blog/pipelines but modified with generics.
func merge[T any](cs ...<-chan T) <-chan T {
    var wg sync.WaitGroup
    out := make(chan T)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c is closed, then calls wg.Done.
    output := func(c <-chan T) {
        for n := range c {
            out <- n
        }
        wg.Done()
    }
    wg.Add(len(cs))
    for _, c := range cs {
        go output(c)
    }

    // Start a goroutine to close out once all the output goroutines are
    // done.  This must start after the wg.Add call.
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Browse other questions tagged or ask your own question.