Skip to content

Commit 08459a3

Browse files
committed
feat(cli): Add support for mTLS
Adds support for TLS configuration. This adds a TLS section to the API configuration object. It allows specifying InsecureSkipVerify, an additional CA cert, and client certs. Client certs are not added as an API authentication method, since they are often used in together with these other authentication methods. Global CLI options have been added to overwrite the API specific options and to be able to use them during API setup. The values used during initial setup will be written to the config. An additional CLI wizard/asker is also provided. As part of this change, a small refactoring on the CLI flags handling has been done: a secondary flagset with fixed global flags is created. This flagset can be parsed before API discovery, without getting in the way of cobra.
1 parent 34162f7 commit 08459a3

File tree

6 files changed

+215
-29
lines changed

6 files changed

+215
-29
lines changed

cli/apiconfig.go

+9
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ type APIAuth struct {
1919
Params map[string]string `json:"params"`
2020
}
2121

22+
// TLSConfig contains the TLS setup for the HTTP client
23+
type TLSConfig struct {
24+
InsecureSkipVerify bool `json:"insecure" mapstructure:"insecure"`
25+
Cert string `json:"cert"`
26+
Key string `json:"key"`
27+
CACert string `json:"ca_cert" mapstructure:"ca_cert"`
28+
}
29+
2230
// APIProfile contains account-specific API information
2331
type APIProfile struct {
2432
Headers map[string]string `json:"headers,omitempty"`
@@ -33,6 +41,7 @@ type APIConfig struct {
3341
Base string `json:"base"`
3442
SpecFiles []string `json:"spec_files,omitempty" mapstructure:"spec_files,omitempty"`
3543
Profiles map[string]*APIProfile `json:"profiles,omitempty" mapstructure:",omitempty"`
44+
TLS *TLSConfig `json:"tls,omitempty" mapstructure:",omitempty"`
3645
}
3746

3847
// Save the API configuration to disk.

cli/cli.go

+41-15
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ import (
1919
"github.com/mattn/go-colorable"
2020
"github.com/mattn/go-isatty"
2121
"github.com/spf13/cobra"
22+
"github.com/spf13/pflag"
2223
"github.com/spf13/viper"
2324
)
2425

2526
// Root command (entrypoint) of the CLI.
2627
var Root *cobra.Command
2728

29+
// GlobalFlags contains all the fixed up front flags
30+
// This allows us to parse them before we hand over control
31+
// to cobra
32+
var GlobalFlags *pflag.FlagSet
33+
2834
// Cache is used to store temporary data between runs.
2935
var Cache *viper.Viper
3036

@@ -135,16 +141,6 @@ func Init(name string, version string) {
135141
PersistentPreRun: func(cmd *cobra.Command, args []string) {
136142
settings := viper.AllSettings()
137143
LogDebug("Configuration: %v", settings)
138-
139-
if viper.GetBool("rsh-insecure") {
140-
if t, ok := http.DefaultTransport.(*http.Transport); ok {
141-
LogWarning("Disabling TLS security checks")
142-
if t.TLSClientConfig == nil {
143-
t.TLSClientConfig = &tls.Config{}
144-
}
145-
t.TLSClientConfig.InsecureSkipVerify = true
146-
}
147-
}
148144
},
149145
Run: func(cmd *cobra.Command, args []string) {
150146
generic(http.MethodGet, args[0], args[1:])
@@ -317,6 +313,14 @@ Not after (expires): %s (%s)
317313
}
318314
Root.AddCommand(linkCmd)
319315

316+
GlobalFlags = pflag.NewFlagSet("eager-flags", pflag.ContinueOnError)
317+
GlobalFlags.ParseErrorsWhitelist.UnknownFlags = true
318+
// GlobalFlags are 'hidden', don't print anything on error
319+
GlobalFlags.Usage = func() {}
320+
// Ensure parsing doesn't stop if the help flag is set
321+
// (help seems to be special cased from ParseErrorsWhitelist.UnknownFlags)
322+
GlobalFlags.BoolP("help", "h", false, "")
323+
320324
AddGlobalFlag("rsh-verbose", "v", "Enable verbose log output", false, false)
321325
AddGlobalFlag("rsh-output-format", "o", "Output format [auto, json, yaml]", "auto", false)
322326
AddGlobalFlag("rsh-filter", "f", "Filter / project results using JMESPath Plus", "", false)
@@ -328,6 +332,9 @@ Not after (expires): %s (%s)
328332
AddGlobalFlag("rsh-profile", "p", "API auth profile", "default", false)
329333
AddGlobalFlag("rsh-no-cache", "", "Disable HTTP cache", false, false)
330334
AddGlobalFlag("rsh-insecure", "", "Disable SSL verification", false, false)
335+
AddGlobalFlag("rsh-client-cert", "", "Path to a PEM encoded client certificate", "", false)
336+
AddGlobalFlag("rsh-client-key", "", "Path to a PEM encoded private key", "", false)
337+
AddGlobalFlag("rsh-ca-cert", "", "Path to a PEM encoded CA cert", "", false)
331338

332339
initAPIConfig()
333340
}
@@ -427,15 +434,34 @@ func Run() {
427434
if !strings.HasPrefix(arg, "-") {
428435
args = append(args, arg)
429436
}
437+
}
430438

431-
// Try to detect if verbose mode is enabled via a flag.
432-
if arg == "-v" || arg == "--rsh-verbose" {
433-
enableVerbose = true
439+
// Because we may be doing HTTP calls before cobra has parsed the flags
440+
// we parse the GlobalFlags here and already set some config values
441+
// to ensure they are available
442+
if err := GlobalFlags.Parse(os.Args[1:]); err != nil {
443+
if err != pflag.ErrHelp {
444+
panic(err)
434445
}
435446
}
447+
if verbose, _ := GlobalFlags.GetBool("rsh-verbose"); verbose {
448+
viper.Set("rsh-verbose", true)
449+
}
450+
if insecure, _ := GlobalFlags.GetBool("rsh-insecure"); insecure {
451+
viper.Set("rsh-insecure", true)
452+
}
453+
if cert, _ := GlobalFlags.GetString("rsh-client-cert"); cert != "" {
454+
viper.Set("rsh-client-cert", cert)
455+
}
456+
if key, _ := GlobalFlags.GetString("rsh-client-key"); key != "" {
457+
viper.Set("rsh-client-key", key)
458+
}
459+
if caCert, _ := GlobalFlags.GetString("rsh-ca-cert"); caCert != "" {
460+
viper.Set("rsh-ca-cert", caCert)
461+
}
436462

437-
// Now that flags are parsed we can enable verbose mode if requested.
438-
if enableVerbose || viper.GetBool("rsh-verbose") {
463+
// Now that global flags are parsed we can enable verbose mode if requested.
464+
if viper.GetBool("rsh-verbose") {
439465
enableVerbose = true
440466
}
441467

cli/flag.go

+7
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,25 @@ func AddGlobalFlag(name, short, description string, defaultValue interface{}, mu
1616
case bool:
1717
if multi {
1818
flags.BoolSliceP(name, short, viper.Get(name).([]bool), description)
19+
GlobalFlags.BoolSliceP(name, short, viper.Get(name).([]bool), description)
1920
} else {
2021
flags.BoolP(name, short, viper.GetBool(name), description)
22+
GlobalFlags.BoolP(name, short, viper.GetBool(name), description)
2123
}
2224
case int, int16, int32, int64, uint16, uint32, uint64:
2325
if multi {
2426
flags.IntSliceP(name, short, viper.Get(name).([]int), description)
27+
GlobalFlags.IntSliceP(name, short, viper.Get(name).([]int), description)
2528
} else {
2629
flags.IntP(name, short, viper.GetInt(name), description)
30+
GlobalFlags.IntP(name, short, viper.GetInt(name), description)
2731
}
2832
case float32, float64:
2933
if multi {
3034
panic(fmt.Errorf("unsupported float slice param"))
3135
} else {
3236
flags.Float64P(name, short, viper.GetFloat64(name), description)
37+
GlobalFlags.Float64P(name, short, viper.GetFloat64(name), description)
3338
}
3439
default:
3540
if multi {
@@ -40,8 +45,10 @@ func AddGlobalFlag(name, short, description string, defaultValue interface{}, mu
4045
viper.Set(name, v)
4146
}
4247
flags.StringSliceP(name, short, v.([]string), description)
48+
GlobalFlags.StringSliceP(name, short, v.([]string), description)
4349
} else {
4450
flags.StringP(name, short, fmt.Sprintf("%v", viper.Get(name)), description)
51+
GlobalFlags.StringP(name, short, fmt.Sprintf("%v", viper.Get(name)), description)
4552
}
4653
}
4754

cli/interactive.go

+80-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/AlecAivazis/survey/v2"
99
"github.com/AlecAivazis/survey/v2/terminal"
1010
"github.com/spf13/cobra"
11+
"github.com/spf13/viper"
1112
)
1213

1314
var surveyOpts = []survey.AskOpt{}
@@ -291,11 +292,83 @@ func askAddProfile(a asker, config *APIConfig) {
291292
askEditProfile(a, name, config.Profiles[name])
292293
}
293294

295+
func askTLSConfig(a asker, config *APIConfig) {
296+
if config.TLS == nil {
297+
config.TLS = &TLSConfig{}
298+
}
299+
300+
for {
301+
options := make([]string, 0, 7)
302+
303+
if config.TLS.InsecureSkipVerify {
304+
options = append(options, "Delete insecure")
305+
} else {
306+
options = append(options, "Set insecure")
307+
}
308+
309+
if config.TLS.Cert == "" {
310+
options = append(options, "Set certificate")
311+
} else {
312+
options = append(options, "Edit certificate", "Delete certificate")
313+
}
314+
315+
if config.TLS.Key == "" {
316+
options = append(options, "Set key")
317+
} else {
318+
options = append(options, "Edit key", "Delete key")
319+
}
320+
321+
if config.TLS.CACert == "" {
322+
options = append(options, "Set CA certificate")
323+
} else {
324+
options = append(options, "Edit CA certificate", "Delete CA certificate")
325+
}
326+
327+
options = append(options, "Finished with TLS configuration")
328+
329+
switch choice := a.askSelect("Select TLS configuration options", options, nil, ""); choice {
330+
case "Delete insecure":
331+
config.TLS.InsecureSkipVerify = false
332+
case "Set insecure":
333+
config.TLS.InsecureSkipVerify = true
334+
case "Set certificate":
335+
config.TLS.Cert = a.askInput("Certificate path", "", false, "")
336+
case "Edit certificate":
337+
config.TLS.Cert = a.askInput("Certificate path", config.TLS.Cert, false, "")
338+
case "Delete certificate":
339+
config.TLS.Cert = ""
340+
case "Set key":
341+
config.TLS.Key = a.askInput("Key path", "", false, "")
342+
case "Edit key":
343+
config.TLS.Key = a.askInput("Key path", config.TLS.Key, false, "")
344+
case "Delete key":
345+
config.TLS.Key = ""
346+
case "Set CA certificate":
347+
config.TLS.CACert = a.askInput("CA Certificate path", "", false, "")
348+
case "Edit CA certificate":
349+
config.TLS.CACert = a.askInput("CA Certificate path", config.TLS.CACert, false, "")
350+
case "Delete CA certificate":
351+
config.TLS.CACert = ""
352+
case "Finished with TLS configuration":
353+
return
354+
}
355+
}
356+
}
357+
294358
func askInitAPI(a asker, cmd *cobra.Command, args []string) {
295359
var config *APIConfig = configs[args[0]]
296360

297361
if config == nil {
298-
config = &APIConfig{name: args[0], Profiles: map[string]*APIProfile{}}
362+
config = &APIConfig{
363+
name: args[0],
364+
Profiles: map[string]*APIProfile{},
365+
TLS: &TLSConfig{
366+
InsecureSkipVerify: viper.GetBool("rsh-insecure"),
367+
Cert: viper.GetString("rsh-client-cert"),
368+
Key: viper.GetString("rsh-client-key"),
369+
CACert: viper.GetString("rsh-ca-cert"),
370+
},
371+
}
299372
configs[args[0]] = config
300373

301374
// Do an initial setup with a default profile first.
@@ -324,6 +397,10 @@ func askInitAPI(a asker, cmd *cobra.Command, args []string) {
324397
options = append(options, "Edit profile "+k)
325398
}
326399

400+
if (config.TLS != nil) && (*config.TLS != TLSConfig{}) {
401+
options = append(options, "Edit TLS configuration")
402+
}
403+
327404
options = append(options, "Save and exit")
328405

329406
choice := a.askSelect("Select option", options, nil, "")
@@ -336,6 +413,8 @@ func askInitAPI(a asker, cmd *cobra.Command, args []string) {
336413
case strings.HasPrefix(choice, "Edit profile"):
337414
profile := strings.SplitN(choice, " ", 3)[2]
338415
askEditProfile(a, profile, config.Profiles[profile])
416+
case choice == "Edit TLS configuration":
417+
askTLSConfig(a, config)
339418
case choice == "Save and exit":
340419
config.Save()
341420
return

cli/request.go

+63
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli
22

33
import (
4+
"crypto/tls"
5+
"crypto/x509"
46
"fmt"
57
"io/ioutil"
68
"net/http"
@@ -161,6 +163,58 @@ func MakeRequest(req *http.Request, options ...requestOption) (*http.Response, e
161163
}
162164
}
163165

166+
// The assumption is that all Transport implementations eventually use the
167+
// default HTTP transport.
168+
// We can therefore inject the TLS config once here, along with all the other
169+
// config options, instead of modifying all the places where Transports are
170+
// created
171+
LogDebug("Adding TLS configuration")
172+
if t, ok := http.DefaultTransport.(*http.Transport); ok {
173+
if t.TLSClientConfig == nil {
174+
t.TLSClientConfig = &tls.Config{}
175+
}
176+
if config.TLS == nil {
177+
config.TLS = &TLSConfig{}
178+
}
179+
180+
// CLI flags overwrite profile options
181+
if viper.GetBool("rsh-insecure") {
182+
config.TLS.InsecureSkipVerify = true
183+
}
184+
if cert := viper.GetString("rsh-client-cert"); cert != "" {
185+
config.TLS.Cert = cert
186+
}
187+
if key := viper.GetString("rsh-client-key"); key != "" {
188+
config.TLS.Key = key
189+
}
190+
if caCert := viper.GetString("rsh-ca-cert"); caCert != "" {
191+
config.TLS.CACert = caCert
192+
}
193+
194+
if config.TLS.InsecureSkipVerify {
195+
LogWarning("Disabling TLS security checks")
196+
t.TLSClientConfig.InsecureSkipVerify = config.TLS.InsecureSkipVerify
197+
}
198+
if config.TLS.Cert != "" {
199+
cert, err := tls.LoadX509KeyPair(config.TLS.Cert, config.TLS.Key)
200+
if err != nil {
201+
return nil, err
202+
}
203+
t.TLSClientConfig.Certificates = append(t.TLSClientConfig.Certificates, cert)
204+
}
205+
if config.TLS.CACert != "" {
206+
caCert, err := ioutil.ReadFile(config.TLS.CACert)
207+
if err != nil {
208+
return nil, err
209+
}
210+
systemCerts := BestEffortSystemCertPool()
211+
if !systemCerts.AppendCertsFromPEM(caCert) {
212+
return nil, fmt.Errorf("Failed to append CACert %s RootCA list", config.TLS.CACert)
213+
}
214+
t.TLSClientConfig.RootCAs = systemCerts
215+
}
216+
}
217+
164218
if log {
165219
LogDebugRequest(req)
166220
}
@@ -348,3 +402,12 @@ func MakeRequestAndFormat(req *http.Request) {
348402
panic(err)
349403
}
350404
}
405+
406+
// BestEffortSystemCertPool returns system cert pool as best effort, otherwise an empty cert pool
407+
func BestEffortSystemCertPool() *x509.CertPool {
408+
rootCAs, _ := x509.SystemCertPool()
409+
if rootCAs == nil {
410+
return x509.NewCertPool()
411+
}
412+
return rootCAs
413+
}

docs/configuration.md

+15-13
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,21 @@ Global configuration affects all commands and can be set in one of three ways, g
1515

1616
The global options in addition to `--help` and `--version` are:
1717

18-
| Argument | Env Var | Example | Description |
19-
| --------------------------- | ------------------- | ----------------- | -------------------------------------------------------------------------------- |
20-
| `-f`, `--rsh-filter` | `RSH_FILTER` | `body.users[].id` | [JMESPath Plus](https://github.com/danielgtaylor/go-jmespath-plus#readme) filter |
21-
| `-H`, `--rsh-header` | `RSH_HEADER` | `Version:2020-05` | Set a header name/value |
22-
| `--rsh-insecure` | `RSH_INSECURE` | | Disable TLS certificate checks |
23-
| `--rsh-no-cache` | `RSH_NO_CACHE` | | Disable HTTP caching |
24-
| `--rsh-no-paginate` | `RSH_NO_PAGINATE` | | Disable automatic `next` link pagination |
25-
| `-o`, `--rsh-output-format` | `RSH_OUTPUT_FORMAT` | `json` | [Output format](/output.md), defaults to `auto` |
26-
| `-p`, `--rsh-profile` | `RSH_PROFILE` | `testing` | Auth profile name, defaults to `default` |
27-
| `-q`, `--rsh-query` | `RSH_QUERY` | `search=foo` | Set a query parameter |
28-
| `-r`, `--rsh-raw` | `RSH_RAW` | | Raw output for shell processing |
29-
| `-s`, `--rsh-server` | `RSH_SERVER` | `https://foo.com` | Override API server base URL |
30-
| `-v`, `--rsh-verbose` | `RSH_VERBOSE` | | Enable verbose output |
18+
| Argument | Env Var | Example | Description |
19+
| --------------------------- | ------------------- | ------------------- | -------------------------------------------------------------------------------- |
20+
| `-f`, `--rsh-filter` | `RSH_FILTER` | `body.users[].id` | [JMESPath Plus](https://github.com/danielgtaylor/go-jmespath-plus#readme) filter |
21+
| `-H`, `--rsh-header` | `RSH_HEADER` | `Version:2020-05` | Set a header name/value |
22+
| `--rsh-insecure` | `RSH_INSECURE` | | Disable TLS certificate checks |
23+
| `--rsh-client-cert` | `RSH_CLIENT_CERT` | `/etc/ssl/cert.pem` | Path to a PEM encoded client certificate |
24+
| `--rsh-client-key` | `RSH_CLIENT_KEY` | `/etc/ssl/key.pem` | Path to a PEM encoded private key |
25+
| `--rsh-ca-cert` | `RSH_CA_CERT` | `/etc/ssl/ca.pem` | Path to a PEM encoded CA certificate |
26+
| `--rsh-no-paginate` | `RSH_NO_PAGINATE` | | Disable automatic `next` link pagination |
27+
| `-o`, `--rsh-output-format` | `RSH_OUTPUT_FORMAT` | `json` | [Output format](/output.md), defaults to `auto` |
28+
| `-p`, `--rsh-profile` | `RSH_PROFILE` | `testing` | Auth profile name, defaults to `default` |
29+
| `-q`, `--rsh-query` | `RSH_QUERY` | `search=foo` | Set a query parameter |
30+
| `-r`, `--rsh-raw` | `RSH_RAW` | | Raw output for shell processing |
31+
| `-s`, `--rsh-server` | `RSH_SERVER` | `https://foo.com` | Override API server base URL |
32+
| `-v`, `--rsh-verbose` | `RSH_VERBOSE` | | Enable verbose output |
3133

3234
Configuration file keys are the same as long-form arguments without the `--` prefix.
3335

0 commit comments

Comments
 (0)