github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/cli/client_url.go (about)

     1  // Copyright 2018 The Cockroach Authors.
     2  //
     3  // Use of this software is governed by the Business Source License
     4  // included in the file licenses/BSL.txt.
     5  //
     6  // As of the Change Date specified in that file, in accordance with
     7  // the Business Source License, use of this software will be governed
     8  // by the Apache License, Version 2.0, included in the file
     9  // licenses/APL.txt.
    10  
    11  package cli
    12  
    13  import (
    14  	"fmt"
    15  	"net"
    16  	"net/url"
    17  	"os"
    18  	"path/filepath"
    19  	"strings"
    20  
    21  	"github.com/cockroachdb/cockroach/pkg/cli/cliflags"
    22  	"github.com/cockroachdb/cockroach/pkg/security"
    23  	"github.com/cockroachdb/errors"
    24  	"github.com/spf13/cobra"
    25  )
    26  
    27  // This file implements the parsing of the client --url flag.
    28  //
    29  // This aims to offer consistent UX between uses the "combined" --url
    30  // flag and the "discrete" separate flags --host / --port / etc.
    31  //
    32  // The flow of data between flags, configuration variables and usage
    33  // by client commands goes as follows:
    34  //
    35  //            flags parser
    36  //                /    \
    37  //         .-----'      `-------.
    38  //         |                    |
    39  //       --url               --host, --port, etc
    40  //         |                    |
    41  //         |                    |
    42  //   urlParser.Set()            |
    43  //     (this file)              |
    44  //         |                    |
    45  //         `-------.    .-------'
    46  //                  \  /
    47  //          sqlCtx/cliCtx/baseCtx
    48  //                   |
    49  //                  / \
    50  //         .-------'   `--------.
    51  //         |                    |
    52  //         |                    |
    53  //      non-SQL           makeClientConnURL()
    54  //     commands             (this file)
    55  //    (quit, init, etc)         |
    56  //                          SQL commands
    57  //                        (user, zone, etc)
    58  //
    59  
    60  type urlParser struct {
    61  	cmd    *cobra.Command
    62  	cliCtx *cliContext
    63  
    64  	// sslStrict, when set to true, requires that the SSL file paths in
    65  	// a URL clearly map to a certificate directory and restricts the
    66  	// set of supported SSL modes to just "disable" and "require".
    67  	//
    68  	// This is set for all non-SQL client commands, which only support
    69  	// the insecure boolean and certs-dir with maximum SSL validation.
    70  	sslStrict bool
    71  }
    72  
    73  func (u urlParser) String() string { return "" }
    74  
    75  func (u urlParser) Type() string {
    76  	return "postgresql://[user[:passwd]@]host[:port]/[db][?parameters...]"
    77  }
    78  
    79  func (u urlParser) Set(v string) error {
    80  	return u.setInternal(v, true /* warn */)
    81  }
    82  
    83  func (u urlParser) setInternal(v string, warn bool) error {
    84  	parsedURL, err := url.Parse(v)
    85  	if err != nil {
    86  		return err
    87  	}
    88  
    89  	// General URL format compatibility check.
    90  	//
    91  	// The canonical PostgreSQL URL scheme is "postgresql", however our
    92  	// own client commands also accept "postgres" which is the scheme
    93  	// registered/supported by lib/pq. Internally, lib/pq supports
    94  	// both.
    95  	if parsedURL.Scheme != "postgresql" && parsedURL.Scheme != "postgres" {
    96  		return fmt.Errorf(`URL scheme must be "postgresql", not "%s"`, parsedURL.Scheme)
    97  	}
    98  
    99  	if parsedURL.Opaque != "" {
   100  		return fmt.Errorf("unknown URL format: %s", v)
   101  	}
   102  
   103  	cliCtx := u.cliCtx
   104  	fl := flagSetForCmd(u.cmd)
   105  
   106  	// If user name / password information is available, forward it to
   107  	// --user. We store the password for later re-collection by
   108  	// makeClientConnURL().
   109  	if parsedURL.User != nil {
   110  		f := fl.Lookup(cliflags.User.Name)
   111  		if f == nil {
   112  			// A client which does not support --user will also not use
   113  			// makeClientConnURL(), so we can ignore/forget about the
   114  			// information. We do not produce an error however, so that a
   115  			// user can readily copy-paste the URL produced by `cockroach
   116  			// start` even if the client command does not accept a username.
   117  			if warn {
   118  				fmt.Fprintf(stderr,
   119  					"warning: --url specifies user/password, but command %q does not accept user/password details - details ignored\n",
   120  					u.cmd.Name())
   121  			}
   122  		} else {
   123  			if err := f.Value.Set(parsedURL.User.Username()); err != nil {
   124  				return errors.Wrapf(err, "extracting user")
   125  			}
   126  			if pw, pwSet := parsedURL.User.Password(); pwSet {
   127  				cliCtx.sqlConnPasswd = pw
   128  			}
   129  		}
   130  	}
   131  
   132  	// If some host/port information is available, forward it to
   133  	// --host / --port.
   134  	if parsedURL.Host != "" {
   135  		prevHost, prevPort := cliCtx.clientConnHost, cliCtx.clientConnPort
   136  		if err := u.cmd.Flags().Set(cliflags.ClientHost.Name, parsedURL.Host); err != nil {
   137  			return errors.Wrapf(err, "extracting host/port")
   138  		}
   139  		// Fill in previously set values for each component that wasn't specified.
   140  		if cliCtx.clientConnHost == "" {
   141  			cliCtx.clientConnHost = prevHost
   142  		}
   143  		if cliCtx.clientConnPort == "" {
   144  			cliCtx.clientConnPort = prevPort
   145  		}
   146  	}
   147  
   148  	// If a database path is available, forward it to --database.
   149  	if parsedURL.Path != "" {
   150  		dbPath := strings.TrimLeft(parsedURL.Path, "/")
   151  		f := fl.Lookup(cliflags.Database.Name)
   152  		if f == nil {
   153  			// A client which does not support --database does not need this
   154  			// bit of information, so we can ignore/forget about it. We do
   155  			// not produce an error however, so that a user can readily
   156  			// copy-paste an URL they picked up from another tool (a GUI
   157  			// tool for example).
   158  			if warn {
   159  				fmt.Fprintf(stderr,
   160  					"warning: --url specifies database %q, but command %q does not accept a database name - database name ignored\n",
   161  					dbPath, u.cmd.Name())
   162  			}
   163  		} else {
   164  			if err := f.Value.Set(dbPath); err != nil {
   165  				return errors.Wrapf(err, "extracting database name")
   166  			}
   167  		}
   168  	}
   169  
   170  	// If some query options are available, try to decompose/capture as
   171  	// much as possible. Anything not decomposed will be accumulated in
   172  	// cliCtx.extraConnURLOptions.
   173  	if parsedURL.RawQuery != "" {
   174  		options, err := url.ParseQuery(parsedURL.RawQuery)
   175  		if err != nil {
   176  			return err
   177  		}
   178  
   179  		// If the URL specifies host/port as query args, we're having to do
   180  		// with a unix socket. In that case, we don't want to populate
   181  		// the host field in the URL.
   182  		if options.Get("host") != "" {
   183  			cliCtx.clientConnHost = ""
   184  			cliCtx.clientConnPort = ""
   185  		}
   186  
   187  		cliCtx.extraConnURLOptions = options
   188  
   189  		switch sslMode := options.Get("sslmode"); sslMode {
   190  		case "", "disable":
   191  			if err := fl.Set(cliflags.ClientInsecure.Name, "true"); err != nil {
   192  				return errors.Wrapf(err, "setting insecure connection based on --url")
   193  			}
   194  		case "require", "verify-ca", "verify-full":
   195  			if sslMode != "verify-full" && u.sslStrict {
   196  				return fmt.Errorf("command %q only supports sslmode=disable or sslmode=verify-full", u.cmd.Name())
   197  			}
   198  			if err := fl.Set(cliflags.ClientInsecure.Name, "false"); err != nil {
   199  				return errors.Wrapf(err, "setting secure connection based on --url")
   200  			}
   201  
   202  			if u.sslStrict {
   203  				// The "sslStrict" flag means the client command is using our
   204  				// certificate manager instead of the certificate handler in
   205  				// lib/pq.
   206  				//
   207  				// Our certificate manager is peculiar in that it requires
   208  				// every file in the same directory (the "certs dir") and also
   209  				// the files to be named after a fixed naming convention.
   210  				//
   211  				// Meanwhile, the URL format for security flags consists
   212  				// of 3 options (sslrootcert, sslcert, sslkey) that *may*
   213  				// refer to arbitrary files in arbitrary directories.
   214  				// Regular SQL drivers are fine with that (including lib/pq)
   215  				// but our cert manager definitely not (or, at least, not yet).
   216  				//
   217  				// So here we have to reverse-engineer the parameters needed
   218  				// for the certificate manager from the URL and verify that
   219  				// they conform to the restrictions of our cert manager. There
   220  				// are three things that need to happen:
   221  				//
   222  				// - if the flag --certs-dir is not specified in the command
   223  				//   line, we need to derive a path for the certificate
   224  				//   directory from the URL options; our cert manager needs
   225  				//   this as input.
   226  				//
   227  				// - we must verify that all 3 url options that determine
   228  				//   files refer to the same directory; our cert manager does
   229  				//   not know how to work otherwise.
   230  				//
   231  				// - we must also verify that the 3 options specify a file
   232  				//   name that is compatible with our cert manager (namely,
   233  				//   "ca.crt", "client.USERNAME.crt" and
   234  				//   "client.USERNAME.key").
   235  				//
   236  
   237  				candidateCertsDir := ""
   238  				foundCertsDir := false
   239  				if fl.Lookup(cliflags.CertsDir.Name).Changed {
   240  					// If a --certs-dir flag was preceding --url, we want to
   241  					// check that the paths inside the URL match the value of
   242  					// that explicit --certs-dir.
   243  					//
   244  					// If --certs-dir was not specified, we'll pick up
   245  					// the first directory encountered below.
   246  					candidateCertsDir = cliCtx.SSLCertsDir
   247  					candidateCertsDir = os.ExpandEnv(candidateCertsDir)
   248  					candidateCertsDir, err = filepath.Abs(candidateCertsDir)
   249  					if err != nil {
   250  						return err
   251  					}
   252  				}
   253  
   254  				// tryCertsDir digs into the SSL URL options to extract a valid
   255  				// certificate directory. It also checks that the file names are those
   256  				// expected by the certificate manager.
   257  				tryCertsDir := func(optName, expectedFilename string) error {
   258  					opt := options.Get(optName)
   259  					if opt == "" {
   260  						// Option not set: nothing to do.
   261  						return nil
   262  					}
   263  
   264  					// Check the expected base file name.
   265  					base := filepath.Base(opt)
   266  					if base != expectedFilename {
   267  						return fmt.Errorf("invalid file name for %q: expected %q, got %q", optName, expectedFilename, base)
   268  					}
   269  
   270  					// Extract the directory part.
   271  					dir := filepath.Dir(opt)
   272  					dir, err = filepath.Abs(dir)
   273  					if err != nil {
   274  						return err
   275  					}
   276  					if candidateCertsDir != "" {
   277  						// A certificate directory has already been found in a previous option;
   278  						// check that the new option uses the same.
   279  						if candidateCertsDir != dir {
   280  							return fmt.Errorf("non-homogeneous certificate directory: %s=%q, expected %q", optName, opt, candidateCertsDir)
   281  						}
   282  					} else {
   283  						// First time seeing a directory, remember it.
   284  						candidateCertsDir = dir
   285  						foundCertsDir = true
   286  					}
   287  
   288  					return nil
   289  				}
   290  
   291  				userName := security.RootUser
   292  				if cliCtx.sqlConnUser != "" {
   293  					userName = cliCtx.sqlConnUser
   294  				}
   295  				if err := tryCertsDir("sslrootcert", security.CACertFilename()); err != nil {
   296  					return err
   297  				}
   298  				if err := tryCertsDir("sslcert", security.ClientCertFilename(userName)); err != nil {
   299  					return err
   300  				}
   301  				if err := tryCertsDir("sslkey", security.ClientKeyFilename(userName)); err != nil {
   302  					return err
   303  				}
   304  
   305  				if foundCertsDir {
   306  					if err := fl.Set(cliflags.CertsDir.Name, candidateCertsDir); err != nil {
   307  						return errors.Wrapf(err, "extracting certificate directory")
   308  					}
   309  				}
   310  			}
   311  		default:
   312  			return fmt.Errorf(
   313  				"unsupported sslmode=%s (supported: disable, require, verify-ca, verify-full)", sslMode)
   314  		}
   315  	}
   316  
   317  	return nil
   318  }
   319  
   320  // makeClientConnURL constructs a connection URL from the parsed options.
   321  // Do not call this function before command-line argument parsing has completed:
   322  // this initializes the certificate manager with the configured --certs-dir.
   323  func (cliCtx *cliContext) makeClientConnURL() (url.URL, error) {
   324  	netHost := ""
   325  	if cliCtx.clientConnHost != "" || cliCtx.clientConnPort != "" {
   326  		netHost = net.JoinHostPort(cliCtx.clientConnHost, cliCtx.clientConnPort)
   327  	}
   328  	pgurl := url.URL{
   329  		Scheme: "postgresql",
   330  		Host:   netHost,
   331  		Path:   cliCtx.sqlConnDBName,
   332  	}
   333  
   334  	if cliCtx.sqlConnUser != "" {
   335  		if cliCtx.sqlConnPasswd != "" {
   336  			pgurl.User = url.UserPassword(cliCtx.sqlConnUser, cliCtx.sqlConnPasswd)
   337  		} else {
   338  			pgurl.User = url.User(cliCtx.sqlConnUser)
   339  		}
   340  	}
   341  
   342  	opts := url.Values{}
   343  	for k, v := range cliCtx.extraConnURLOptions {
   344  		opts[k] = v
   345  	}
   346  
   347  	if netHost != "" {
   348  		// Only add TLS parameters when using a network connection.
   349  		userName := cliCtx.sqlConnUser
   350  		if userName == "" {
   351  			userName = security.RootUser
   352  		}
   353  		if err := cliCtx.LoadSecurityOptions(opts, userName); err != nil {
   354  			return url.URL{}, err
   355  		}
   356  	}
   357  
   358  	pgurl.RawQuery = opts.Encode()
   359  	return pgurl, nil
   360  }