Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 23 additions & 47 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ The package includes `certi`, a command-line tool that serves as both a practica

## Features

* Certificate chain inspection
* Expiration time tracking
* Subject and issuer information
* Certificate authority detection
* Programmatic certificate chain inspection API
* CLI tool included for manual certificate chain inspection
* Certificate expiration time tracking
* Subject Alternative Name (SAN) parsing
* IPv6 address support
* Zero external dependencies

## Command-line Tool

Expand All @@ -32,52 +34,26 @@ For other platforms, see the [latest binary release](https://github.com/chronohq

### Basic Usage

Inspect a certificate on the default HTTPS port (443):

```shell
certi www.chronohq.com
{
...
"chain": [
...
]
}
```

Inspect a certificate on a custom port:

```shell
certi --host www.chronohq.com
certi www.chronohq.com:3000
{
"hostname": "www.chronohq.com",
"port": 443,
"remote_addr": "redacted:443",
"tls_version": "1.3",
"cipher_suite": "TLS_AES_128_GCM_SHA256",
"leaf_expires_at": "redacted",
"inspected_at": "redacted",
...
"chain": [
{
"subject": "CN=chronohq.com",
"issuer": "CN=E8,O=Let's Encrypt,C=US",
"serial_number": "redacted",
"version": 3,
"not_before": "redacted",
"not_after": "redacted",
"expires_in": redacted,
"public_key_algorithm": "ecdsa",
"signature_algorithm": "ecdsa-sha384",
"san": [
{
"type": "dns",
"value": "chronohq.com"
},
{
"type": "dns",
"value": "www.chronohq.com"
}
],
"is_ca": false
},
{
"subject": "CN=E8,O=Let's Encrypt,C=US",
"issuer": "CN=ISRG Root X1,O=Internet Security Research Group,C=US",
"serial_number": "redacted",
"version": 3,
"not_before": "redacted",
"not_after": "redacted",
"expires_in": redacted,
"public_key_algorithm": "ecdsa",
"signature_algorithm": "sha256-rsa",
"san": [],
"is_ca": true
}
...
]
}
```
Expand Down
122 changes: 49 additions & 73 deletions cmd/certi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,126 +14,102 @@ import (
)

const (
defaultPort = 443
errHostRequired = "--host requires a value"
errPortRequired = "--port requires a value"
errPortInvalid = "invalid port value"
defaultPort = 443
)

var (
// version holds the application version number.
// This value is overridden at build time using the -ldflags build flag.
version = "dev"

errHostnameRequired = errors.New("hostname is required")
)

type config struct {
host string
port int
}

// parseArgs implements GNU-style flag parsing to provide familiar
// --double-dash option syntax without external dependencies.
// parseArgs implements GNU-style custom flag parsing to provide --double-dash
// options and position-independent hostname argument, similar to curl and dig.
func parseArgs(args []string) (config, error) {
cfg := config{port: defaultPort}
hostArgs := []string{}

for i := range args {
// skip program name
if i == 0 {
continue
}

for i := 1; i < len(args); i++ {
arg := args[i]

switch {
case arg == "--host":
if i+1 >= len(args) {
return cfg, errors.New(errHostRequired)
}

val := args[i+1]

if len(val) == 0 {
return cfg, errors.New(errHostRequired)
}

cfg.host = val
i++ // skip the value token

case strings.HasPrefix(arg, "--host="):
val := strings.SplitN(arg, "=", 2)[1]

if len(val) == 0 {
return cfg, errors.New(errHostRequired)
}

cfg.host = val
case arg == "--help" || arg == "-h":
printUsage()
os.Exit(0)

case arg == "--port":
if i+1 >= len(args) {
return cfg, errors.New(errPortRequired)
}
case arg == "--version" || arg == "-v":
fmt.Printf("certi %s\n", version)
os.Exit(0)

val := args[i+1]
case strings.HasPrefix(arg, "-"):
return cfg, fmt.Errorf("unknown option: %s", arg)

if len(val) == 0 {
return cfg, errors.New(errPortRequired)
}
default:
hostArgs = append(hostArgs, arg)
}
}

port, err := strconv.Atoi(val)
if len(hostArgs) == 0 {
return cfg, errHostnameRequired
}

if err != nil {
return cfg, errors.New(errPortInvalid)
}
if len(hostArgs) > 1 {
return cfg, errors.New("only one hostname allowed")
}

cfg.port = port
i++ // skip the value token
target := hostArgs[0]

case strings.HasPrefix(arg, "--port="):
val := strings.SplitN(arg, "=", 2)[1]
if len(target) == 0 {
return cfg, errHostnameRequired
}

if len(val) == 0 {
return cfg, errors.New(errPortRequired)
}
// use LastIndex to support IPv6.
idx := strings.LastIndex(target, ":")

port, err := strconv.Atoi(val)
// no port detected, treat as a hostname-only query.
if idx == -1 {
cfg.host = target
return cfg, nil
}

if err != nil {
return cfg, errors.New(errPortInvalid)
}
// hostname is missing, for example: ":443".
if idx == 0 {
return cfg, errHostnameRequired
}

cfg.port = port
cfg.host = target[:idx]
portStr := target[idx+1:]

case arg == "--help" || arg == "-h":
printUsage()
os.Exit(0)
port, err := strconv.Atoi(portStr)

case arg == "--version" || arg == "-v":
fmt.Printf("certi %s\n", version)
os.Exit(0)
}
if err != nil {
return cfg, errors.New("invalid port number")
}

if len(cfg.host) == 0 {
return cfg, errors.New("--host is required")
}
cfg.port = port

return cfg, nil
}

func printUsage() {
usage := `certi - TLS certificate chain inspection tool

Usage: certi [OPTIONS]
Usage: certi [OPTIONS] <hostname>[:<port>]

Options:
--host host Server host (required)
--port port Server port (default: 443)
--version, -v Show the version and quit
--help, -h Show this help message and quit

Examples:
certi --host example.com
certi --host 192.168.1.100 --port 3000`
certi example.com
certi 192.168.0.1:3000`

fmt.Fprintln(os.Stderr, usage)
}
Expand Down