mirror of
https://github.com/square/certigo.git
synced 2024-04-21 12:32:40 +00:00
Merge pull request #201 from square/refactor
refactor certigo package main
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