diff --git a/Makefile b/Makefile index 6a46597..19f3b64 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ ################################################################################ -# This Makefile generated by GoMakeGen 1.0.0 using next command: +# This Makefile generated by GoMakeGen 1.1.0 using next command: # gomakegen . # # More info: https://kaos.sh/gomakegen @@ -17,10 +17,10 @@ all: sslcli ## Build all binaries sslcli: ## Build sslcli binary go build sslcli.go -install: ## Install binaries +install: ## Install all binaries cp sslcli /usr/bin/sslcli -uninstall: ## Uninstall binaries +uninstall: ## Uninstall all binaries rm -f /usr/bin/sslcli git-config: ## Configure git redirects for stable import path services @@ -28,7 +28,7 @@ git-config: ## Configure git redirects for stable import path services deps: git-config ## Download dependencies go get -d -v pkg.re/essentialkaos/ek.v10 - go get -d -v pkg.re/essentialkaos/sslscan.v10 + go get -d -v pkg.re/essentialkaos/sslscan.v11 fmt: ## Format source code with gofmt find . -name "*.go" -exec gofmt -s -w {} \; @@ -37,9 +37,10 @@ clean: ## Remove generated files rm -f sslcli help: ## Show this info - @echo -e '\nSupported targets:\n' + @echo -e '\n\033[1mSupported targets:\033[0m\n' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[33m%-12s\033[0m %s\n", $$1, $$2}' @echo -e '' + @echo -e '\033[90mGenerated by GoMakeGen 1.1.0\033[0m\n' ################################################################################ diff --git a/README.md b/README.md index 3cbc25d..dc57f50 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@

-

Usage demoInstallationFeature listUsageBuild StatusContributingTerms of UseLicense

-

@@ -9,6 +7,10 @@

+

Usage demoInstallationFeature listUsageBuild StatusContributingTerms of UseLicense

+ +
+ `sslcli` is command-line client for SSLLabs public API. **IMPORTANT:** Currently, SSLLabs API doesn't provide same info as SSLLabs website. @@ -42,14 +44,14 @@ go get -u github.com/essentialkaos/sslcli #### From ESSENTIAL KAOS Public repo for RHEL6/CentOS6 ```bash -[sudo] yum install -y https://yum.kaos.st/6/release/x86_64/kaos-repo-9.1-0.el6.noarch.rpm +[sudo] yum install -y https://yum.kaos.st/kaos-repo-latest.el6.noarch.rpm [sudo] yum install sslcli ``` #### From ESSENTIAL KAOS Public repo for RHEL7/CentOS7 ```bash -[sudo] yum install -y https://yum.kaos.st/7/release/x86_64/kaos-repo-9.1-0.el7.noarch.rpm +[sudo] yum install -y https://yum.kaos.st/kaos-repo-latest.el7.noarch.rpm [sudo] yum install sslcli ``` @@ -92,6 +94,7 @@ Options --avoid-cache, -c Disable cache usage --public, -p Publish results on sslscan.com --perfect, -P Return non-zero exit code if not A+ + --max-left, -M duration Check expiry date (num + d/w/m/y) --notify, -n Notify when check is done --quiet, -q Don't show any output --no-color, -nc Disable colors in output @@ -109,6 +112,9 @@ Examples sslcli -p -c google.com Check google.com, publish results, disable cache usage + sslcli -M 3m -q google.com + Check google.com in quiet mode and return error if cert expire in 3 months + sslcli hosts.txt Check all hosts defined in hosts.txt file diff --git a/cli/cli.go b/cli/cli.go index c36ea6b..5256760 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -9,9 +9,11 @@ package cli import ( "errors" + "fmt" "io/ioutil" "os" "runtime" + "strconv" "strings" "time" @@ -19,17 +21,21 @@ import ( "pkg.re/essentialkaos/ek.v10/fmtutil" "pkg.re/essentialkaos/ek.v10/fsutil" "pkg.re/essentialkaos/ek.v10/options" + "pkg.re/essentialkaos/ek.v10/strutil" "pkg.re/essentialkaos/ek.v10/usage" + "pkg.re/essentialkaos/ek.v10/usage/completion/bash" + "pkg.re/essentialkaos/ek.v10/usage/completion/fish" + "pkg.re/essentialkaos/ek.v10/usage/completion/zsh" "pkg.re/essentialkaos/ek.v10/usage/update" - "pkg.re/essentialkaos/sslscan.v10" + "pkg.re/essentialkaos/sslscan.v11" ) // ////////////////////////////////////////////////////////////////////////////////// // const ( APP = "SSLScan Client" - VER = "2.3.1" + VER = "2.4.0" DESC = "Command-line client for the SSL Labs API" ) @@ -40,11 +46,14 @@ const ( OPT_AVOID_CACHE = "c:avoid-cache" OPT_PUBLIC = "p:public" OPT_PERFECT = "P:perfect" + OPT_MAX_LEFT = "M:max-left" OPT_QUIET = "q:quiet" OPT_NOTIFY = "n:notify" OPT_NO_COLOR = "nc:no-color" OPT_HELP = "h:help" OPT_VER = "v:version" + + OPT_COMPLETION = "completion" ) const ( @@ -80,6 +89,7 @@ type EndpointCheckInfo struct { var optMap = options.Map{ OPT_FORMAT: {}, + OPT_MAX_LEFT: {}, OPT_DETAILED: {Type: options.BOOL}, OPT_IGNORE_MISMATCH: {Type: options.BOOL}, OPT_AVOID_CACHE: {Type: options.BOOL}, @@ -90,6 +100,8 @@ var optMap = options.Map{ OPT_NO_COLOR: {Type: options.BOOL}, OPT_HELP: {Type: options.BOOL, Alias: "u:usage"}, OPT_VER: {Type: options.BOOL, Alias: "ver"}, + + OPT_COMPLETION: {}, } var gradeNumMap = map[string]float64{ @@ -107,6 +119,8 @@ var gradeNumMap = map[string]float64{ } var api *sslscan.API +var maxLeftToExpiry int64 +var serverMessageShown bool // ////////////////////////////////////////////////////////////////////////////////// // @@ -115,16 +129,21 @@ func Init() { args, errs := options.Parse(optMap) if len(errs) != 0 { - fmtc.Println("{r}Arguments parsing errors:{!}") + printError("Arguments parsing errors:") for _, err := range errs { - fmtc.Printf(" {r}%v{!}\n", err) + printError(" %v", err) } os.Exit(1) } + if options.Has(OPT_COMPLETION) { + genCompletion() + } + configureUI() + prepare() if options.GetB(OPT_VER) { showAbout() @@ -150,6 +169,22 @@ func configureUI() { fmtutil.SeparatorSymbol = "–" } +// prepare prepares utility for processing data +func prepare() { + if !options.Has(OPT_MAX_LEFT) { + return + } + + var err error + + maxLeftToExpiry, err = parseMaxLeft(options.GetS(OPT_MAX_LEFT)) + + if err != nil { + printError(err.Error()) + os.Exit(1) + } +} + // process starting request processing func process(args []string) { var ( @@ -162,7 +197,7 @@ func process(args []string) { if err != nil { if !options.GetB(OPT_FORMAT) { - fmtc.Printf("{r}%v{!}\n", err) + printError(err.Error()) } os.Exit(1) @@ -176,51 +211,38 @@ func process(args []string) { hosts, err = readHostList(hosts[0]) if err != nil && options.GetB(OPT_FORMAT) { - fmtc.Printf("{r}%v{!}\n", err) + printError(err.Error()) os.Exit(1) } } - var ( - grade string - checksInfo []*HostCheckInfo - checkInfo *HostCheckInfo - ) + var grade string + var expiredSoon bool + var checksInfo []*HostCheckInfo + var checkInfo *HostCheckInfo for _, host := range hosts { - switch { case options.GetB(OPT_QUIET): - grade, _ = quietCheck(host) + grade, expiredSoon, _ = quietCheck(host) case options.GetB(OPT_FORMAT): - grade, checkInfo = quietCheck(host) + grade, expiredSoon, checkInfo = quietCheck(host) checksInfo = append(checksInfo, checkInfo) default: - grade = check(host) + grade, expiredSoon = check(host) fmtc.NewLine() } switch { - case options.GetB(OPT_PERFECT) && grade != "A+": - ok = false - case grade[:1] != "A": + case options.GetB(OPT_PERFECT) && grade != "A+", + strutil.Head(grade, 1) != "A", + expiredSoon: ok = false } } - if options.GetB(OPT_FORMAT) { - switch options.GetS(OPT_FORMAT) { - case FORMAT_TEXT: - encodeAsText(checksInfo) - case FORMAT_JSON: - encodeAsJSON(checksInfo) - case FORMAT_XML: - encodeAsXML(checksInfo) - case FORMAT_YAML: - encodeAsYAML(checksInfo) - default: - os.Exit(1) - } + if options.Has(OPT_FORMAT) { + renderReport(checksInfo) } if options.GetB(OPT_NOTIFY) { @@ -233,7 +255,7 @@ func process(args []string) { } // check check some host -func check(host string) string { +func check(host string) (string, bool) { var err error var info *sslscan.AnalyzeInfo @@ -252,20 +274,20 @@ func check(host string) string { if err != nil { fmtc.TPrintf("{*}%s{!} → {r}%v{!}\n", host, err) - return "T" + return "T", false } for { - info, err = ap.Info(false) + info, err = ap.Info(false, params.FromCache) if err != nil { fmtc.TPrintf("{*}%s{!} → {r}%v{!}\n", host, err) - return "Err" + return "Err", false } if info.Status == sslscan.STATUS_ERROR { fmtc.TPrintf("{*}%s{!} → {r}%s{!}\n", host, info.StatusMessage) - return "Err" + return "Err", false } else if info.Status == sslscan.STATUS_READY { break } @@ -285,23 +307,29 @@ func check(host string) string { } } + expiryMessage := getExpiryMessage(ap, maxLeftToExpiry) + if len(info.Endpoints) == 1 { - fmtc.TPrintf("{*}%s{!} → "+getColoredGrade(info.Endpoints[0].Grade)+"\n", host) + fmtc.TPrintf("{*}%s{!} → "+getColoredGrade(info.Endpoints[0].Grade)+expiryMessage+"\n", host) } else { - fmtc.TPrintf("{*}%s{!} → "+getColoredGrades(info.Endpoints)+"\n", host) + fmtc.TPrintf("{*}%s{!} → "+getColoredGrades(info.Endpoints)+expiryMessage+"\n", host) } if options.GetB(OPT_DETAILED) { - printDetailedInfo(ap) + printDetailedInfo(ap, true) } lowestGrade, _ := getGrades(info.Endpoints) - return lowestGrade + return lowestGrade, expiryMessage != "" } // showServerMessage show message from SSL Labs API func showServerMessage() { + if serverMessageShown { + return + } + serverMessage := strings.Join(api.Info.Messages, " ") wrappedMessage := fmtutil.Wrap(serverMessage, "", 80) @@ -320,10 +348,12 @@ func showServerMessage() { api.Info.NewAssessmentCoolOff, ) fmtc.NewLine() + + serverMessageShown = true } // quietCheck check some host without any output to console -func quietCheck(host string) (string, *HostCheckInfo) { +func quietCheck(host string) (string, bool, *HostCheckInfo) { var err error var info *sslscan.AnalyzeInfo @@ -346,18 +376,18 @@ func quietCheck(host string) (string, *HostCheckInfo) { ap, err := api.Analyze(host, params) if err != nil { - return "Err", checkInfo + return "Err", false, checkInfo } for { - info, err = ap.Info(false) + info, err = ap.Info(false, params.FromCache) if err != nil { - return "Err", checkInfo + return "Err", false, checkInfo } if info.Status == sslscan.STATUS_ERROR { - return "Err", checkInfo + return "Err", false, checkInfo } else if info.Status == sslscan.STATUS_READY { break } @@ -365,6 +395,12 @@ func quietCheck(host string) (string, *HostCheckInfo) { time.Sleep(time.Second) } + var expiredSoon bool + + if maxLeftToExpiry > 0 { + expiredSoon = getExpiryMessage(ap, maxLeftToExpiry) != "" + } + appendEndpointsInfo(checkInfo, info.Endpoints) lowestGrade, highestGrade := getGrades(info.Endpoints) @@ -374,7 +410,23 @@ func quietCheck(host string) (string, *HostCheckInfo) { checkInfo.LowestGradeNum = gradeNumMap[lowestGrade] checkInfo.HighestGradeNum = gradeNumMap[highestGrade] - return lowestGrade, checkInfo + return lowestGrade, expiredSoon, checkInfo +} + +// renderReport renders report in different formats +func renderReport(checksInfo []*HostCheckInfo) { + switch options.GetS(OPT_FORMAT) { + case FORMAT_TEXT: + encodeAsText(checksInfo) + case FORMAT_JSON: + encodeAsJSON(checksInfo) + case FORMAT_XML: + encodeAsXML(checksInfo) + case FORMAT_YAML: + encodeAsYAML(checksInfo) + default: + os.Exit(1) + } } // getColoredGrade return grade with color tags @@ -445,7 +497,7 @@ func getStatusInProgress(endpoints []*sslscan.EndpointInfo) string { } if endpoint.StatusDetailsMessage != "" { - return fmtc.Sprintf("#%d: %s", num, endpoint.StatusDetailsMessage) + return fmt.Sprintf("#%d: %s", num, endpoint.StatusDetailsMessage) } } @@ -498,6 +550,28 @@ func appendEndpointsInfo(checkInfo *HostCheckInfo, endpoints []*sslscan.Endpoint } } +// parseMaxLeft parses max left option value +func parseMaxLeft(dur string) (int64, error) { + tm := strutil.Tail(dur, 1) + t := strings.Trim(dur, "dwmy") + ti, err := strconv.ParseInt(t, 10, 64) + + if err != nil { + return -1, fmt.Errorf("Invalid value for --max-left option: %s", dur) + } + + switch strings.ToLower(tm) { + case "w": + return ti * 604800, nil + case "m": + return ti * 2592000, nil + case "y": + return ti * 31536000, nil + default: + return ti * 86400, nil + } +} + // getNormGrade return grade or error func getNormGrade(grade string) string { switch grade { @@ -508,9 +582,20 @@ func getNormGrade(grade string) string { } } +// printError prints error message to console +func printError(f string, a ...interface{}) { + fmtc.Fprintf(os.Stderr, "{r}"+f+"{!}\n", a...) +} + // ////////////////////////////////////////////////////////////////////////////////// // +// showUsage prints usage info func showUsage() { + genUsage().Render() +} + +// genUsage generates usage info +func genUsage() *usage.Info { info := usage.NewInfo("", "host…") info.AddOption(OPT_FORMAT, "Output result in different formats", "text|json|yaml|xml") @@ -519,6 +604,7 @@ func showUsage() { info.AddOption(OPT_AVOID_CACHE, "Disable cache usage") info.AddOption(OPT_PUBLIC, "Publish results on sslscan.com") info.AddOption(OPT_PERFECT, "Return non-zero exit code if not A+") + info.AddOption(OPT_MAX_LEFT, "Check expiry date {s-}(num + d/w/m/y){!}", "duration") info.AddOption(OPT_NOTIFY, "Notify when check is done") info.AddOption(OPT_QUIET, "Don't show any output") info.AddOption(OPT_NO_COLOR, "Disable colors in output") @@ -528,11 +614,31 @@ func showUsage() { info.AddExample("google.com", "Check google.com") info.AddExample("-P google.com", "Check google.com and return zero exit code only if result is perfect (A+)") info.AddExample("-p -c google.com", "Check google.com, publish results, disable cache usage") + info.AddExample("-M 3m -q google.com", "Check google.com in quiet mode and return error if cert expire in 3 months") info.AddExample("hosts.txt", "Check all hosts defined in hosts.txt file") - info.Render() + return info +} + +// genCompletion generates completion for different shells +func genCompletion() { + info := genUsage() + + switch options.GetS(OPT_COMPLETION) { + case "bash": + fmt.Printf(bash.Generate(info, "sslcli")) + case "fish": + fmt.Printf(fish.Generate(info, "sslcli")) + case "zsh": + fmt.Printf(zsh.Generate(info, optMap, "sslcli")) + default: + os.Exit(1) + } + + os.Exit(0) } +// showAbout prints info about version func showAbout() { about := &usage.About{ App: APP, diff --git a/cli/details.go b/cli/details.go index 527c9f7..25a10be 100644 --- a/cli/details.go +++ b/cli/details.go @@ -2,12 +2,13 @@ package cli // ////////////////////////////////////////////////////////////////////////////////// // // // -// Copyright (c) 2009-2018 ESSENTIAL KAOS // +// Copyright (c) 2009-2019 ESSENTIAL KAOS // // Apache License, Version 2.0 // // // // ////////////////////////////////////////////////////////////////////////////////// // import ( + "fmt" "strings" "time" @@ -18,7 +19,7 @@ import ( "pkg.re/essentialkaos/ek.v10/strutil" "pkg.re/essentialkaos/ek.v10/timeutil" - "pkg.re/essentialkaos/sslscan.v10" + "pkg.re/essentialkaos/sslscan.v11" ) // ////////////////////////////////////////////////////////////////////////////////// // @@ -57,16 +58,16 @@ var isWeakForwardSecrecy bool // ////////////////////////////////////////////////////////////////////////////////// // // printDetailedInfo fetches and prints detailed info for all endpoints -func printDetailedInfo(ap *sslscan.AnalyzeProgress) { - info, err := ap.Info(true) +func printDetailedInfo(ap *sslscan.AnalyzeProgress, fromCache bool) { + info, err := ap.Info(true, fromCache) if err != nil { - fmtc.Printf("\n{r}Can't fetch full analyze info: %v{!}\n\n", err) + printError("\nCan't fetch full analyze info: %v\n", err) return } if strings.ToUpper(info.Status) != "READY" { - fmtc.Printf("\n{r}%s{!}\n\n", info.StatusMessage) + printError("\n%s\n", info.StatusMessage) return } @@ -1481,3 +1482,30 @@ func getTrustInfo(certID string, endpoints []*sslscan.EndpointInfo) (map[string] return result, true } + +// getExpiryMessage returns message if cert is expired in given period +func getExpiryMessage(ap *sslscan.AnalyzeProgress, dur int64) string { + if dur <= 0 { + return "" + } + + info, err := ap.Info(true, true) + + if err != nil || strings.ToUpper(info.Status) != "READY" || len(info.Certs) == 0 { + return "" + } + + cert := info.Certs[0] + validUntilDate := time.Unix(cert.NotAfter/1000, 0) + + if validUntilDate.Unix()-time.Now().Unix() > dur { + return "" + } + + validDays := (validUntilDate.Unix() - time.Now().Unix()) / 86400 + + return fmt.Sprintf( + " {r}(expires in %s){!}", + pluralize.Pluralize(int(validDays), "day", "days"), + ) +} diff --git a/common/sslcli.spec b/common/sslcli.spec index 32ecf71..4e2df0d 100644 --- a/common/sslcli.spec +++ b/common/sslcli.spec @@ -44,7 +44,7 @@ Summary: Pretty awesome command-line client for public SSLLabs API Name: sslcli -Version: 2.3.1 +Version: 2.4.0 Release: 0%{?dist} Group: Applications/System License: EKOL @@ -54,7 +54,7 @@ Source0: https://source.kaos.st/%{name}/%{name}-%{version}.tar.bz2 BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) -BuildRequires: golang >= 1.11 +BuildRequires: golang >= 1.12 Provides: %{name} = %{version}-%{release} @@ -76,12 +76,39 @@ go build src/github.com/essentialkaos/sslcli/%{name}.go rm -rf %{buildroot} install -dm 755 %{buildroot}%{_bindir} - install -pm 755 %{name} %{buildroot}%{_bindir}/ %clean rm -rf %{buildroot} +%post +if [[ -d %{_sysconfdir}/bash_completion.d ]] ; then + %{name} --completion=bash 1> %{_sysconfdir}/bash_completion.d/%{name} 2>/dev/null +fi + +if [[ -d %{_datarootdir}/fish/vendor_completions.d ]] ; then + %{name} --completion=fish 1> %{_datarootdir}/fish/vendor_completions.d/%{name}.fish 2>/dev/null +fi + +if [[ -d %{_datadir}/zsh/site-functions ]] ; then + %{name} --completion=zsh 1> %{_datadir}/zsh/site-functions/_%{name} 2>/dev/null +fi + +%postun +if [[ $1 == 0 ]] ; then + if [[ -f %{_sysconfdir}/bash_completion.d/%{name} ]] ; then + rm -f %{_sysconfdir}/bash_completion.d/%{name} &>/dev/null || : + fi + + if [[ -f %{_datarootdir}/fish/vendor_completions.d/%{name}.fish ]] ; then + rm -f %{_datarootdir}/fish/vendor_completions.d/%{name}.fish &>/dev/null || : + fi + + if [[ -f %{_datadir}/zsh/site-functions/_%{name} ]] ; then + rm -f %{_datadir}/zsh/site-functions/_%{name} &>/dev/null || : + fi +fi + ################################################################################ %files @@ -92,6 +119,11 @@ rm -rf %{buildroot} ################################################################################ %changelog +* Tue Jul 09 2019 Anton Novojilov - 2.4.0-0 +- Added '--max-left/-M' for checking certificate expiry date +- Added completions for bash, fish and zsh +- Minor improvements + * Mon Jun 03 2019 Anton Novojilov - 2.3.1-0 - Updated for compatibility with the latest version of SSLLabs API