From 8dc997f6be21b30c2124b9347903b707b15c2a9e Mon Sep 17 00:00:00 2001 From: Toru Maesaka Date: Wed, 17 Sep 2025 15:13:31 -0700 Subject: [PATCH 1/2] cmd/certi: simplify the command line interface --- cmd/certi/main.go | 122 +++++++++++++++++++--------------------------- 1 file changed, 49 insertions(+), 73 deletions(-) diff --git a/cmd/certi/main.go b/cmd/certi/main.go index 9eca30d..f0bb4a5 100644 --- a/cmd/certi/main.go +++ b/cmd/certi/main.go @@ -14,16 +14,15 @@ 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 { @@ -31,91 +30,70 @@ type config struct { 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 } @@ -123,17 +101,15 @@ func parseArgs(args []string) (config, error) { func printUsage() { usage := `certi - TLS certificate chain inspection tool -Usage: certi [OPTIONS] +Usage: certi [OPTIONS] [:] 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) } From e2d34c11690523bb9f8fc03d0e9c484f5e207c52 Mon Sep 17 00:00:00 2001 From: Toru Maesaka Date: Wed, 17 Sep 2025 15:39:15 -0700 Subject: [PATCH 2/2] README.md: update usage examples and feature list --- README.md | 70 ++++++++++++++++++------------------------------------- 1 file changed, 23 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index ccfe1f2..2989fa3 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 - } + ... ] } ```