Files
certigo/cli/cli.go
T
Matthew McPherrin 26cd65d9bd refactor certigo package main
This splits the logic into a cli, which handles parsing command line args and
reading files, and a terminal abstraction for handling user input and ouput.

All uses of os.Exit are removed in favor of returning errors.

Overall this enables better testing and reuse of code.  Previously we had to
rely on external unit testing for CLI tests, which are harder to write tests.
2020-01-13 15:58:53 -08:00

232 lines
8.5 KiB
Go

package cli
import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"github.com/square/certigo/cli/terminal"
"github.com/square/certigo/lib"
"github.com/square/certigo/starttls"
"gopkg.in/alecthomas/kingpin.v2"
"os"
"strings"
)
var (
app = kingpin.New("certigo", "A command-line utility to examine and validate certificates to help with debugging SSL/TLS issues.")
verbose = app.Flag("verbose", "Print verbose").Short('v').Bool()
dump = app.Command("dump", "Display information about a certificate from a file or stdin.")
dumpFiles = dump.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFiles()
dumpType = dump.Flag("format", "Format of given input (PEM, DER, JCEKS, PKCS12; heuristic if missing).").Short('f').String()
dumpPassword = dump.Flag("password", "Password for PKCS12/JCEKS key stores (reads from TTY if missing).").Short('p').String()
dumpPem = dump.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool()
dumpJSON = dump.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool()
connect = app.Command("connect", "Connect to a server and print its certificate(s).")
connectTo = connect.Arg("server[:port]", "Hostname or IP to connect to, with optional port.").Required().String()
connectName = connect.Flag("name", "Override the server name used for Server Name Indication (SNI).").Short('n').String()
connectCaPath = connect.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile()
connectCert = connect.Flag("cert", "Client certificate chain for connecting to server (PEM).").ExistingFile()
connectKey = connect.Flag("key", "Private key for client certificate, if not in same file (PEM).").ExistingFile()
connectStartTLS = connect.Flag("start-tls", fmt.Sprintf("Enable StartTLS protocol; one of: %v.", starttls.Protocols)).Short('t').PlaceHolder("PROTOCOL").Enum(starttls.Protocols...)
connectIdentity = connect.Flag("identity", "With --start-tls, sets the DB user or SMTP EHLO name").Default("certigo").String()
connectProxy = connect.Flag("proxy", "Optional URI for HTTP(s) CONNECT proxy to dial connections with").URL()
connectTimeout = connect.Flag("timeout", "Timeout for connecting to remote server (can be '5m', '1s', etc).").Default("5s").Duration()
connectPem = connect.Flag("pem", "Write output as PEM blocks instead of human-readable format.").Short('m').Bool()
connectJSON = connect.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool()
connectVerify = connect.Flag("verify", "Verify certificate chain.").Bool()
verify = app.Command("verify", "Verify a certificate chain from file/stdin against a name.")
verifyFile = verify.Arg("file", "Certificate file to dump (or stdin if not specified).").ExistingFile()
verifyType = verify.Flag("format", "Format of given input (PEM, DER, JCEKS, PKCS12; heuristic if missing).").Short('f').String()
verifyPassword = verify.Flag("password", "Password for PKCS12/JCEKS key stores (reads from TTY if missing).").Short('p').String()
verifyName = verify.Flag("name", "Server name to verify certificate against.").Short('n').Required().String()
verifyCaPath = verify.Flag("ca", "Path to CA bundle (system default if unspecified).").ExistingFile()
verifyJSON = verify.Flag("json", "Write output as machine-readable JSON format.").Short('j').Bool()
)
func Run(args []string, tty terminal.Terminal) int {
terminalWidth := tty.DetermineWidth()
stdout := tty.Output()
errOut := tty.Error()
printErr := func(format string, args ...interface{}) int {
_, err := fmt.Fprintf(errOut, format, args...)
if err != nil {
// If we can't write the error, we bail with a different return code... not much good
// we can do at this point
return 3
}
return 2
}
app.Version("1.11.0")
// Alias starttls to start-tls
connect.Flag("starttls", "").Hidden().EnumVar(connectStartTLS, starttls.Protocols...)
// Use long help because many useful flags are under subcommands
app.UsageTemplate(kingpin.LongHelpTemplate)
result := lib.SimpleResult{}
command, err := app.Parse(args)
if err != nil {
return printErr("%s, try --help\n", err)
}
switch command {
case dump.FullCommand(): // Dump certificate
if dumpPassword != nil && *dumpPassword != "" {
tty.SetDefaultPassword(*dumpPassword)
}
files, err := inputFiles(*dumpFiles)
defer func() {
for _, file := range files {
file.Close()
}
}()
if *dumpPem {
err = lib.ReadAsPEMFromFiles(files, *dumpType, tty.ReadPassword, func(block *pem.Block) error {
block.Headers = nil
return pem.Encode(stdout, block)
})
} else {
err = lib.ReadAsX509FromFiles(files, *dumpType, tty.ReadPassword, func(cert *x509.Certificate, err error) error {
if err != nil {
return fmt.Errorf("error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n"))
} else {
result.Certificates = append(result.Certificates, cert)
}
return nil
})
if *dumpJSON {
blob, _ := json.Marshal(result)
fmt.Println(string(blob))
} else {
for i, cert := range result.Certificates {
fmt.Fprintf(stdout, "** CERTIFICATE %d **\n", i+1)
fmt.Fprintf(stdout, "%s\n\n", lib.EncodeX509ToText(cert, terminalWidth, *verbose))
}
}
}
if err != nil {
return printErr("error: %s\n", strings.TrimSuffix(err.Error(), "\n"))
} else if len(result.Certificates) == 0 && !*dumpPem {
printErr("warning: no certificates found in input\n")
}
case connect.FullCommand(): // Get certs by connecting to a server
if connectStartTLS == nil && connectIdentity != nil {
return printErr("error: --identity can only be used with --start-tls")
}
connState, cri, err := starttls.GetConnectionState(
*connectStartTLS, *connectName, *connectTo, *connectIdentity,
*connectCert, *connectKey, *connectProxy, *connectTimeout)
if err != nil {
return printErr("%s\n", strings.TrimSuffix(err.Error(), "\n"))
}
result.TLSConnectionState = connState
result.CertificateRequestInfo = cri
for _, cert := range connState.PeerCertificates {
if *connectPem {
pem.Encode(stdout, lib.EncodeX509ToPEM(cert, nil))
} else {
result.Certificates = append(result.Certificates, cert)
}
}
var hostname string
if *connectName != "" {
hostname = *connectName
} else {
hostname = strings.Split(*connectTo, ":")[0]
}
verifyResult := lib.VerifyChain(connState.PeerCertificates, connState.OCSPResponse, hostname, *connectCaPath)
result.VerifyResult = &verifyResult
if *connectJSON {
blob, _ := json.Marshal(result)
fmt.Println(string(blob))
} else if !*connectPem {
fmt.Fprintf(
stdout, "%s\n\n",
lib.EncodeTLSInfoToText(result.TLSConnectionState, result.CertificateRequestInfo))
for i, cert := range result.Certificates {
fmt.Fprintf(stdout, "** CERTIFICATE %d **\n", i+1)
fmt.Fprintf(stdout, "%s\n\n", lib.EncodeX509ToText(cert, terminalWidth, *verbose))
}
lib.PrintVerifyResult(stdout, *result.VerifyResult)
}
if *connectVerify && len(result.VerifyResult.Error) > 0 {
return 1
}
case verify.FullCommand():
if verifyPassword != nil && *verifyPassword != "" {
tty.SetDefaultPassword(*verifyPassword)
}
file, err := inputFile(*verifyFile)
if err != nil {
return printErr("%s\n", err.Error())
}
defer file.Close()
chain := []*x509.Certificate{}
err = lib.ReadAsX509FromFiles([]*os.File{file}, *verifyType, tty.ReadPassword, func(cert *x509.Certificate, err error) error {
if err != nil {
return err
} else {
chain = append(chain, cert)
}
return nil
})
if err != nil {
return printErr("error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n"))
}
verifyResult := lib.VerifyChain(chain, nil, *verifyName, *verifyCaPath)
if *verifyJSON {
blob, _ := json.Marshal(verifyResult)
fmt.Println(string(blob))
} else {
lib.PrintVerifyResult(stdout, verifyResult)
}
if verifyResult.Error != "" {
return 1
}
}
return 0
}
func inputFile(fileName string) (*os.File, error) {
if fileName == "" {
return os.Stdin, nil
}
rawFile, err := os.Open(fileName)
if err != nil {
return nil, fmt.Errorf("unable to open file: %s\n", err)
}
return rawFile, nil
}
func inputFiles(fileNames []string) ([]*os.File, error) {
var files []*os.File
if fileNames != nil {
for _, filename := range fileNames {
rawFile, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("unable to open file: %s\n", err)
}
files = append(files, rawFile)
}
} else {
files = append(files, os.Stdin)
}
return files, nil
}