mirror of
https://github.com/square/certigo.git
synced 2024-04-21 12:32:40 +00:00
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.
This commit is contained in:
+231
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/square/certigo/cli/terminal"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testCert string = `
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIE1DCCArygAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwPzELMAkGA1UEBhMCVVMx
|
||||
FzAVBgNVBAoMDnRlc3QxLmFjbWUuY29tMRcwFQYDVQQDDA5JbnRlcm1lZGlhZXRD
|
||||
QTAeFw0xNzA3MTkxNjUwMjBaFw0xNzA3MjkxNjUwMjBaMDUxCzAJBgNVBAYTAlVT
|
||||
MRcwFQYDVQQKDA50ZXN0MS5hY21lLmNvbTENMAsGA1UEAwwEYmxvZzCCASIwDQYJ
|
||||
KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKm8P47lABp4+rz2nN+QYrxedbaFVWoF
|
||||
FuoSkqcHsafMwbMrN+kI6wJVtlbwviDvxWFJ92q0H71QNFybTsmof3KUN/kYCp7P
|
||||
+LKhBrN0ttWI5q6v5eDrjN0VdtVdnlZOYmJFbvETOgfK/qXKNRRM8HYW0tdqrtEw
|
||||
CR5dIu53xVUSViBdwXpuy2c5W2mFn1gxTpdW+3hbZsL1pHrU9qPWLtTgl/KY8kjs
|
||||
I7KW1cIcinE4SJomhB5L/4emhxKGY+kEa2+fN9IPjjvKSMOw9kiBKk1GHZcIY5EA
|
||||
O3TIfUk3fysPzi5qA0su/bNtPQy1uXgXS10xUlV7pqRPvHjiNzgFkXUCAwEAAaOB
|
||||
4zCB4DAJBgNVHRMEAjAAMB0GA1UdDgQWBBRVQ91jSOONzVr1VGBdJOlPN+3XxTBg
|
||||
BgNVHSMEWTBXgBQ13bfx50rDZO3y2CZdHPgleFUEoKE7pDkwNzELMAkGA1UEBhMC
|
||||
VVMxFzAVBgNVBAoMDnRlc3QxLmFjbWUuY29tMQ8wDQYDVQQDDAZSb290Q0GCAhAA
|
||||
MA4GA1UdDwEB/wQEAwIDqDATBgNVHSUEDDAKBggrBgEFBQcDATAtBgNVHREEJjAk
|
||||
hiJzcGlmZmU6Ly9kZXYuYWNtZS5jb20vcGF0aC9zZXJ2aWNlMA0GCSqGSIb3DQEB
|
||||
CwUAA4ICAQBp2+rtUxt1VmNM/vi6PwoSoYzWFmQ2nc4OM7bsOG4uppU54wRYZ+T7
|
||||
c42EcrpyBgWn+rWHT1Hi6SNcmloKHydaUTZ4pq3IlKKnBNqwivU5BzIxYLDrhR/U
|
||||
wd9s1tgmLvADqkQa1XjjSFn5Auoj1R640ry4qpw8IOusdm6wVhru4ssRnHX4E2uR
|
||||
jQe7b3ws38aZhjtL78Ip0BB4yPxWJRp/WmEoT33QP+cZhA4IYWECxNODr6DSJeq2
|
||||
VNu/6JACGrNfM2Sjt4Wxz+nIa3cKDNCA6PR8StTUTcoQ6ZBzpn+n/Q1xSRIOJz6N
|
||||
hgfkyb9O7HAMdAP+TxehjqG3gh5Ky2DgYMCIZOztVzsuOb1DGJe/kGUKeRJLl2/O
|
||||
QwkctwUOcVIxckNu6OvclriFzvoXObqO77XeCI2V1Vef0wGTWlWNOdbFa4708Y7f
|
||||
5UdwInYQUi87RFDnc1SDU4Jrsv4KzZiv9FCfDg8pCBIdWpWT7DAuI0d7i7PZ+iFt
|
||||
ZZ6sb/YDkyiDXU4ar/dja0FDE2r7jsN9D+FfW49+iDvXr4ELQyhZpW3Zr1Ojwm58
|
||||
CJzjZwbRYiVwPBRsKmiYfO1E7esvw3CmjK5chfz8c40f6/APDro9ZmYNBRv2CnJy
|
||||
t/DtcM/GpAhBbLP9Tk7kPB41v5fRIxVDo50Iz/qvkr37pQ4RsejSFg==
|
||||
-----END CERTIFICATE-----
|
||||
`
|
||||
|
||||
const expectedVerbose string = `** CERTIFICATE 1 **
|
||||
Serial: 4096
|
||||
Valid: 2017-07-19 16:50 UTC to 2017-07-29 16:50 UTC
|
||||
Signature: SHA256-RSA
|
||||
Subject Info:
|
||||
Country: US
|
||||
Organization: test1.acme.com
|
||||
CommonName: blog
|
||||
Issuer Info:
|
||||
Country: US
|
||||
Organization: test1.acme.com
|
||||
CommonName: IntermediaetCA
|
||||
Subject Key ID: 55:43:DD:63:48:E3:8D:CD:5A:F5:54:60:5D:24:E9:4F:37:ED:D7:C5
|
||||
Authority Key ID: 35:DD:B7:F1:E7:4A:C3:64:ED:F2:D8:26:5D:1C:F8:25:78:55:04:A0
|
||||
Basic Constraints: CA:false
|
||||
Key Usage:
|
||||
Digital Signature
|
||||
Key Encipherment
|
||||
Key Agreement
|
||||
Extended Key Usage:
|
||||
Server Auth
|
||||
URI Names:
|
||||
spiffe://dev.acme.com/path/service
|
||||
|
||||
`
|
||||
|
||||
// Test basic dump functionality: Dump a cert
|
||||
func TestDump(t *testing.T) {
|
||||
tmpfile, err := ioutil.TempFile("", t.Name())
|
||||
require.NoError(t, err)
|
||||
defer os.Remove(tmpfile.Name())
|
||||
|
||||
_, err = tmpfile.Write([]byte(testCert))
|
||||
require.NoError(t, err)
|
||||
|
||||
args := []string{"dump", "--verbose", "--format", "PEM", tmpfile.Name()}
|
||||
testTerminal := terminal.TestTerminal{Width: 80}
|
||||
|
||||
assert.EqualValues(t, 0, Run(args, &testTerminal), "process should exit 0")
|
||||
assert.Empty(t, testTerminal.ErrorBuf.Bytes(), "no error output expected")
|
||||
assert.EqualValues(t, expectedVerbose, testTerminal.OutputBuf.String())
|
||||
}
|
||||
|
||||
func TestDumpMissingFile(t *testing.T) {
|
||||
testTerminal := terminal.TestTerminal{Width: 80}
|
||||
args := []string{"dump", "this-is-a-file-that-definitely-does-not-exist1111.pem"}
|
||||
assert.EqualValues(t, 2, Run(args, &testTerminal), "process should exit 0")
|
||||
const expected = "path 'this-is-a-file-that-definitely-does-not-exist1111.pem' does not exist, try --help\n"
|
||||
assert.Equal(t, expected, testTerminal.ErrorBuf.String())
|
||||
assert.Empty(t, testTerminal.OutputBuf.Bytes())
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
)
|
||||
|
||||
const minWidth = 60
|
||||
const maxWidth = 80
|
||||
|
||||
// Terminal handles interacting with the user in Certigo
|
||||
type Terminal interface {
|
||||
Output() io.Writer
|
||||
Error() io.Writer
|
||||
SetDefaultPassword(password string)
|
||||
ReadPassword(prompt string) string
|
||||
DetermineWidth() int
|
||||
}
|
||||
|
||||
// TTY represents unixish stdio, possibly with /dev/tty used to read user input
|
||||
type TTY struct {
|
||||
defaultPassword *string
|
||||
}
|
||||
|
||||
func OpenTTY() *TTY {
|
||||
return &TTY{}
|
||||
}
|
||||
|
||||
func (t *TTY) Output() io.Writer {
|
||||
return colorable.NewColorableStdout()
|
||||
}
|
||||
|
||||
func (t *TTY) Error() io.Writer {
|
||||
return os.Stderr
|
||||
}
|
||||
|
||||
func (t *TTY) SetDefaultPassword(password string) {
|
||||
t.defaultPassword = &password
|
||||
}
|
||||
|
||||
func (t *TTY) ReadPassword(prompt string) string {
|
||||
if t.defaultPassword != nil {
|
||||
return *t.defaultPassword
|
||||
}
|
||||
|
||||
var tty *os.File
|
||||
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
tty = os.Stdin
|
||||
} else {
|
||||
defer tty.Close()
|
||||
}
|
||||
|
||||
tty.WriteString("Enter password")
|
||||
if prompt != "" {
|
||||
tty.WriteString(fmt.Sprintf(" for entry [%s]", prompt))
|
||||
}
|
||||
tty.WriteString(": ")
|
||||
|
||||
password, err := terminal.ReadPassword(int(tty.Fd()))
|
||||
tty.WriteString("\n")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading password: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(string(password), "\n")
|
||||
}
|
||||
|
||||
func (t *TTY) DetermineWidth() int {
|
||||
var width int
|
||||
fd := int(os.Stdout.Fd())
|
||||
if terminal.IsTerminal(fd) {
|
||||
var err error
|
||||
width, _, err = terminal.GetSize(fd)
|
||||
if err != nil {
|
||||
width = minWidth
|
||||
}
|
||||
} else {
|
||||
width = minWidth
|
||||
}
|
||||
|
||||
if width > maxWidth {
|
||||
width = maxWidth
|
||||
} else if width < minWidth {
|
||||
width = minWidth
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
// Assert TTY implements terminal
|
||||
var _ Terminal = &TTY{}
|
||||
@@ -0,0 +1,39 @@
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
|
||||
"github.com/mattn/go-colorable"
|
||||
)
|
||||
|
||||
// TestTerminal just collects input into buffers
|
||||
// That can be used to check output in tests
|
||||
type TestTerminal struct {
|
||||
OutputBuf bytes.Buffer
|
||||
ErrorBuf bytes.Buffer
|
||||
Password string
|
||||
Width int
|
||||
}
|
||||
|
||||
var _ Terminal = &TestTerminal{}
|
||||
|
||||
func (t *TestTerminal) Output() io.Writer {
|
||||
return colorable.NewNonColorable(&t.OutputBuf)
|
||||
}
|
||||
|
||||
func (t *TestTerminal) Error() io.Writer {
|
||||
return &t.ErrorBuf
|
||||
}
|
||||
|
||||
func (t *TestTerminal) SetDefaultPassword(password string) {
|
||||
t.Password = password
|
||||
}
|
||||
|
||||
func (t *TestTerminal) ReadPassword(prompt string) string {
|
||||
return t.Password
|
||||
}
|
||||
|
||||
func (t TestTerminal) DetermineWidth() int {
|
||||
return t.Width
|
||||
}
|
||||
@@ -12,10 +12,11 @@ require (
|
||||
github.com/huandu/xstrings v1.2.0 // indirect
|
||||
github.com/imdario/mergo v0.3.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.4
|
||||
github.com/mattn/go-isatty v0.0.11
|
||||
github.com/mitchellh/copystructure v1.0.0 // indirect
|
||||
github.com/mwitkow/go-http-dialer v0.0.0-20161116154839-378f744fb2b8
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/stretchr/testify v1.2.2 // indirect
|
||||
github.com/stretchr/testify v1.2.2
|
||||
golang.org/x/crypto v0.0.0-20181015023909-0c41d7ab0a0e
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6
|
||||
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225
|
||||
|
||||
+44
-19
@@ -88,8 +88,8 @@ func errorFromErrors(errs []error) error {
|
||||
// data may be in plain-text PEM files, DER-encoded certificates or PKCS7
|
||||
// envelopes, or PKCS12/JCEKS keystores. All inputs will be converted to PEM
|
||||
// blocks and passed to the callback.
|
||||
func ReadAsPEMFromFiles(files []*os.File, format string, password func(string) string, callback func(*pem.Block)) error {
|
||||
errs := []error{}
|
||||
func ReadAsPEMFromFiles(files []*os.File, format string, password func(string) string, callback func(*pem.Block) error) error {
|
||||
var errs []error
|
||||
for _, file := range files {
|
||||
reader := bufio.NewReaderSize(file, 4)
|
||||
format, err := formatForFile(reader, file.Name(), format)
|
||||
@@ -109,7 +109,7 @@ func ReadAsPEMFromFiles(files []*os.File, format string, password func(string) s
|
||||
// be in plain-text PEM files, DER-encoded certificates or PKCS7 envelopes, or
|
||||
// PKCS12/JCEKS keystores. All inputs will be converted to PEM blocks and
|
||||
// passed to the callback.
|
||||
func ReadAsPEM(readers []io.Reader, format string, password func(string) string, callback func(*pem.Block)) error {
|
||||
func ReadAsPEM(readers []io.Reader, format string, password func(string) string, callback func(*pem.Block) error) error {
|
||||
errs := []error{}
|
||||
for _, r := range readers {
|
||||
reader := bufio.NewReaderSize(r, 4)
|
||||
@@ -130,7 +130,7 @@ func ReadAsPEM(readers []io.Reader, format string, password func(string) string,
|
||||
// inputs. Input data may be in plain-text PEM files, DER-encoded certificates
|
||||
// or PKCS7 envelopes, or PKCS12/JCEKS keystores. All inputs will be converted
|
||||
// to X.509 certificates (private keys are skipped) and passed to the callback.
|
||||
func ReadAsX509FromFiles(files []*os.File, format string, password func(string) string, callback func(*x509.Certificate, error)) error {
|
||||
func ReadAsX509FromFiles(files []*os.File, format string, password func(string) string, callback func(*x509.Certificate, error) error) error {
|
||||
errs := []error{}
|
||||
for _, file := range files {
|
||||
reader := bufio.NewReaderSize(file, 4)
|
||||
@@ -151,7 +151,7 @@ func ReadAsX509FromFiles(files []*os.File, format string, password func(string)
|
||||
// data may be in plain-text PEM files, DER-encoded certificates or PKCS7
|
||||
// envelopes, or PKCS12/JCEKS keystores. All inputs will be converted to X.509
|
||||
// certificates (private keys are skipped) and passed to the callback.
|
||||
func ReadAsX509(readers []io.Reader, format string, password func(string) string, callback func(*x509.Certificate, error)) error {
|
||||
func ReadAsX509(readers []io.Reader, format string, password func(string) string, callback func(*x509.Certificate, error) error) error {
|
||||
errs := []error{}
|
||||
for _, r := range readers {
|
||||
reader := bufio.NewReaderSize(r, 4)
|
||||
@@ -168,27 +168,28 @@ func ReadAsX509(readers []io.Reader, format string, password func(string) string
|
||||
return errorFromErrors(errs)
|
||||
}
|
||||
|
||||
func pemToX509(callback func(*x509.Certificate, error)) func(*pem.Block) {
|
||||
return func(block *pem.Block) {
|
||||
func pemToX509(callback func(*x509.Certificate, error) error) func(*pem.Block) error {
|
||||
return func(block *pem.Block) error {
|
||||
switch block.Type {
|
||||
case "CERTIFICATE":
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
callback(cert, err)
|
||||
return callback(cert, err)
|
||||
case "PKCS7":
|
||||
certs, err := pkcs7.ExtractCertificates(block.Bytes)
|
||||
if err == nil {
|
||||
for _, cert := range certs {
|
||||
callback(cert, nil)
|
||||
return callback(cert, nil)
|
||||
}
|
||||
} else {
|
||||
callback(nil, err)
|
||||
return callback(nil, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// readCertsFromStream takes some input and converts it to PEM blocks.
|
||||
func readCertsFromStream(reader io.Reader, filename string, format string, password func(string) string, callback func(*pem.Block)) error {
|
||||
func readCertsFromStream(reader io.Reader, filename string, format string, password func(string) string, callback func(*pem.Block) error) error {
|
||||
headers := map[string]string{}
|
||||
if filename != "" && filename != os.Stdin.Name() {
|
||||
headers[fileHeader] = filename
|
||||
@@ -200,7 +201,10 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw
|
||||
for scanner.Scan() {
|
||||
block, _ := pem.Decode(scanner.Bytes())
|
||||
block.Headers = mergeHeaders(block.Headers, headers)
|
||||
callback(block)
|
||||
err := callback(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "DER":
|
||||
@@ -211,14 +215,20 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw
|
||||
x509Certs, err0 := x509.ParseCertificates(data)
|
||||
if err0 == nil {
|
||||
for _, cert := range x509Certs {
|
||||
callback(EncodeX509ToPEM(cert, headers))
|
||||
err := callback(EncodeX509ToPEM(cert, headers))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
p7bBlocks, err1 := pkcs7.ParseSignedData(data)
|
||||
if err1 == nil {
|
||||
for _, block := range p7bBlocks {
|
||||
callback(pkcs7ToPem(block, headers))
|
||||
err := callback(pkcs7ToPem(block, headers))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -234,7 +244,10 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw
|
||||
}
|
||||
for _, block := range blocks {
|
||||
block.Headers = mergeHeaders(block.Headers, headers)
|
||||
callback(block)
|
||||
err := callback(block)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
case "JCEKS":
|
||||
@@ -244,20 +257,32 @@ func readCertsFromStream(reader io.Reader, filename string, format string, passw
|
||||
}
|
||||
for _, alias := range keyStore.ListCerts() {
|
||||
cert, _ := keyStore.GetCert(alias)
|
||||
callback(EncodeX509ToPEM(cert, mergeHeaders(headers, map[string]string{nameHeader: alias})))
|
||||
err := callback(EncodeX509ToPEM(cert, mergeHeaders(headers, map[string]string{nameHeader: alias})))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, alias := range keyStore.ListPrivateKeys() {
|
||||
key, certs, err := keyStore.GetPrivateKeyAndCerts(alias, []byte(password(alias)))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to parse keystore: %s\n", err)
|
||||
}
|
||||
block, err := keyToPem(key, mergeHeaders(headers, map[string]string{nameHeader: alias}))
|
||||
|
||||
mergedHeaders := mergeHeaders(headers, map[string]string{nameHeader: alias})
|
||||
|
||||
block, err := keyToPem(key, mergedHeaders)
|
||||
if err != nil {
|
||||
return fmt.Errorf("problem reading key: %s\n", err)
|
||||
}
|
||||
callback(block)
|
||||
|
||||
if err := callback(block); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, cert := range certs {
|
||||
callback(EncodeX509ToPEM(cert, mergeHeaders(headers, map[string]string{nameHeader: alias})))
|
||||
if err = callback(EncodeX509ToPEM(cert, mergedHeaders)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
+14
-10
@@ -76,15 +76,14 @@ func (s SimpleResult) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(out)
|
||||
}
|
||||
|
||||
func caBundle(caPath string) *x509.CertPool {
|
||||
func caBundle(caPath string) (*x509.CertPool, error) {
|
||||
if caPath == "" {
|
||||
return nil
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
caFile, err := os.Open(caPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error opening CA bundle %s: %s\n", caPath, err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("error opening CA bundle %s: %s\n", caPath, err)
|
||||
}
|
||||
|
||||
bundle := x509.NewCertPool()
|
||||
@@ -95,18 +94,18 @@ func caBundle(caPath string) *x509.CertPool {
|
||||
// TODO: The JDK trust store ships with this password.
|
||||
return "changeit"
|
||||
},
|
||||
func(cert *x509.Certificate, err error) {
|
||||
func(cert *x509.Certificate, err error) error {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing CA bundle: %s\n", err)
|
||||
return fmt.Errorf("error parsing CA bundle: %s\n", err)
|
||||
} else {
|
||||
bundle.AddCert(cert)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing CA bundle: %s\n", err)
|
||||
os.Exit(1)
|
||||
return nil, fmt.Errorf("error parsing CA bundle: %s\n", err)
|
||||
}
|
||||
return bundle
|
||||
return bundle, nil
|
||||
}
|
||||
|
||||
func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath string) SimpleVerification {
|
||||
@@ -120,9 +119,14 @@ func VerifyChain(certs []*x509.Certificate, ocspStaple []byte, dnsName, caPath s
|
||||
intermediates.AddCert(certs[i])
|
||||
}
|
||||
|
||||
roots, err := caBundle(caPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("%s", err)
|
||||
return result
|
||||
}
|
||||
opts := x509.VerifyOptions{
|
||||
DNSName: dnsName,
|
||||
Roots: caBundle(caPath),
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
}
|
||||
|
||||
|
||||
@@ -17,263 +17,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
colorable "github.com/mattn/go-colorable"
|
||||
"github.com/square/certigo/lib"
|
||||
"github.com/square/certigo/starttls"
|
||||
"golang.org/x/crypto/ssh/terminal"
|
||||
kingpin "gopkg.in/alecthomas/kingpin.v2"
|
||||
"github.com/square/certigo/cli"
|
||||
"github.com/square/certigo/cli/terminal"
|
||||
)
|
||||
|
||||
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.").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()
|
||||
)
|
||||
|
||||
const minWidth = 60
|
||||
const maxWidth = 80
|
||||
|
||||
func main() {
|
||||
app.Version("1.11.0")
|
||||
|
||||
terminalWidth := determineTerminalWidth()
|
||||
|
||||
// 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)
|
||||
|
||||
stdout := colorable.NewColorableStdout()
|
||||
result := lib.SimpleResult{}
|
||||
switch kingpin.MustParse(app.Parse(os.Args[1:])) {
|
||||
case dump.FullCommand(): // Dump certificate
|
||||
files := inputFiles(*dumpFiles)
|
||||
defer func() {
|
||||
for _, file := range files {
|
||||
file.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
var err error
|
||||
if *dumpPem {
|
||||
err = lib.ReadAsPEMFromFiles(files, *dumpType, readPassword, func(block *pem.Block) {
|
||||
block.Headers = nil
|
||||
pem.Encode(os.Stdout, block)
|
||||
})
|
||||
} else {
|
||||
err = lib.ReadAsX509FromFiles(files, *dumpType, readPassword, func(cert *x509.Certificate, err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n"))
|
||||
} else {
|
||||
result.Certificates = append(result.Certificates, cert)
|
||||
}
|
||||
})
|
||||
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr, "error: %s\n", strings.TrimSuffix(err.Error(), "\n"))
|
||||
os.Exit(1)
|
||||
} else if len(result.Certificates) == 0 && !*dumpPem {
|
||||
fmt.Fprintf(os.Stderr, "warning: no certificates found in input\n")
|
||||
}
|
||||
|
||||
case connect.FullCommand(): // Get certs by connecting to a server
|
||||
if connectStartTLS == nil && connectIdentity != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: --identity can only be used with --start-tls")
|
||||
os.Exit(1)
|
||||
}
|
||||
connState, cri, err := starttls.GetConnectionState(
|
||||
*connectStartTLS, *connectName, *connectTo, *connectIdentity,
|
||||
*connectCert, *connectKey, *connectProxy, *connectTimeout)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", strings.TrimSuffix(err.Error(), "\n"))
|
||||
os.Exit(1)
|
||||
}
|
||||
result.TLSConnectionState = connState
|
||||
result.CertificateRequestInfo = cri
|
||||
for _, cert := range connState.PeerCertificates {
|
||||
if *connectPem {
|
||||
pem.Encode(os.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 {
|
||||
os.Exit(1)
|
||||
}
|
||||
case verify.FullCommand():
|
||||
file := inputFile(*verifyFile)
|
||||
defer file.Close()
|
||||
|
||||
chain := []*x509.Certificate{}
|
||||
lib.ReadAsX509FromFiles([]*os.File{file}, *verifyType, readPassword, func(cert *x509.Certificate, err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing block: %s\n", strings.TrimSuffix(err.Error(), "\n"))
|
||||
} else {
|
||||
chain = append(chain, cert)
|
||||
}
|
||||
})
|
||||
|
||||
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 != "" {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func inputFile(fileName string) *os.File {
|
||||
if fileName == "" {
|
||||
return os.Stdin
|
||||
}
|
||||
|
||||
rawFile, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "unable to open file: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return rawFile
|
||||
}
|
||||
|
||||
func inputFiles(fileNames []string) []*os.File {
|
||||
files := []*os.File{}
|
||||
if fileNames != nil {
|
||||
for _, filename := range fileNames {
|
||||
rawFile, err := os.Open(filename)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "unable to open file: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
files = append(files, rawFile)
|
||||
}
|
||||
} else {
|
||||
files = append(files, os.Stdin)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func determineTerminalWidth() (width int) {
|
||||
fd := int(os.Stdout.Fd())
|
||||
if terminal.IsTerminal(fd) {
|
||||
var err error
|
||||
width, _, err = terminal.GetSize(fd)
|
||||
if err != nil {
|
||||
width = minWidth
|
||||
}
|
||||
} else {
|
||||
width = minWidth
|
||||
}
|
||||
|
||||
if width > maxWidth {
|
||||
width = maxWidth
|
||||
} else if width < minWidth {
|
||||
width = minWidth
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func readPassword(alias string) string {
|
||||
if *dumpPassword != "" {
|
||||
return *dumpPassword
|
||||
}
|
||||
if *verifyPassword != "" {
|
||||
return *verifyPassword
|
||||
}
|
||||
|
||||
var tty *os.File
|
||||
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
tty = os.Stdin
|
||||
} else {
|
||||
defer tty.Close()
|
||||
}
|
||||
|
||||
tty.WriteString("Enter password")
|
||||
if alias != "" {
|
||||
tty.WriteString(fmt.Sprintf(" for entry [%s]", alias))
|
||||
}
|
||||
tty.WriteString(": ")
|
||||
|
||||
password, err := terminal.ReadPassword(int(tty.Fd()))
|
||||
tty.WriteString("\n")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading password: %s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(string(password), "\n")
|
||||
os.Exit(cli.Run(os.Args[1:], terminal.OpenTTY()))
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ import (
|
||||
)
|
||||
|
||||
// Protocols are the names of supported protocols
|
||||
var Protocols []string = []string{"mysql", "postgres", "psql", "smtp", "ldap", "ftp", "imap"}
|
||||
var Protocols = []string{"mysql", "postgres", "psql", "smtp", "ldap", "ftp", "imap"}
|
||||
|
||||
type connectResult struct {
|
||||
state *tls.ConnectionState
|
||||
|
||||
Reference in New Issue
Block a user