diff --git a/config.go b/config.go index 7a1eeed5e..54fc7b5fe 100644 --- a/config.go +++ b/config.go @@ -6,12 +6,17 @@ package main import ( + "bufio" "context" + "crypto/rand" + "encoding/base64" "fmt" + "io" "net" "os" "os/user" "path/filepath" + "regexp" "runtime" "sort" "strconv" @@ -71,6 +76,7 @@ const ( var ( dcrdDefaultCAFile = filepath.Join(dcrutil.AppDataDir("dcrd", false), "rpc.cert") + dcrdDefaultConfigFile = filepath.Join(dcrutil.AppDataDir("dcrd", false), "dcrd.conf") defaultAppDataDir = dcrutil.AppDataDir("dcrwallet", false) defaultConfigFile = filepath.Join(defaultAppDataDir, defaultConfigFilename) defaultRPCKeyFile = filepath.Join(defaultAppDataDir, "rpc.key") @@ -159,6 +165,8 @@ type config struct { LegacyRPCMaxWebsockets int64 `long:"rpcmaxwebsockets" description:"Max JSON-RPC websocket clients"` Username string `short:"u" long:"username" description:"JSON-RPC username and default dcrd RPC username"` Password string `short:"P" long:"password" default-mask:"-" description:"JSON-RPC password and default dcrd RPC password"` + RPCUser string `long:"rpcuser" description:"JSON-RPC username and default dcrd RPC username"` + RPCPass string `long:"rpcpass" default-mask:"-" description:"JSON-RPC password and default dcrd RPC password"` JSONRPCAuthType string `long:"jsonrpcauthtype" description:"Method for JSON-RPC client authentication (basic or clientcert)"` // IPC options @@ -427,6 +435,7 @@ func loadConfig(ctx context.Context) (*config, []string, error) { } } err = flags.NewIniParser(parser).ParseFile(configFilePath) + manualSPVConfig := false if err != nil { var e *os.PathError if !errors.As(err, &e) { @@ -434,7 +443,53 @@ func loadConfig(ctx context.Context) (*config, []string, error) { parser.WriteHelp(os.Stderr) return loadConfigError(err) } - configFileError = err + // if path error, create default config file, assign default dcrd rpc config data if any + dcrdExist, createFileErr := createDefaultConfigFile(configFilePath, preCfg.DcrdAuthType) + if createFileErr != nil { + fmt.Fprintf(os.Stderr, "Error creating default "+ + "config file: %v\n", createFileErr) + configFileError = createFileErr + } else { + configFileError = nil + // Reparse data on config file + err = flags.NewIniParser(parser).ParseFile(configFilePath) + if err != nil { + var e *os.PathError + if !errors.As(err, &e) { + fmt.Fprintln(os.Stderr, err) + parser.WriteHelp(os.Stderr) + return loadConfigError(err) + } + configFileError = err + } + if !dcrdExist { + manualSPVConfig = true + } + } + } else { + // If rpc parameter is not set. Check dcrd rpc info and update config file + if cfg.RPCUser == "" && cfg.RPCPass == "" && cfg.Username == "" && cfg.Password == "" && preCfg.DcrdAuthType == authTypeBasic { + rpcUser, rpcPass, err := updateDefaultDcrdRPCInfos(configFilePath) + if err != nil { + log.Warnf("Updating rpc params from dcrd failed: %v", err) + manualSPVConfig = true + } else { + cfg.RPCUser = rpcUser + cfg.RPCPass = rpcPass + log.Info("Update rpc params from dcrd successfully") + } + } + } + + if cfg.RPCUser != "" { + cfg.Username = cfg.RPCUser + } else if cfg.Username != "" { + log.Warn("The 'username' attribute in the config file is outdated. You should update it to 'rpcuser'") + } + if cfg.RPCPass != "" { + cfg.Password = cfg.RPCPass + } else if cfg.Password != "" { + log.Warn("The 'password' attribute in the config file is outdated. You should update it to 'rpcpass'") } // Parse command line options again to ensure they take precedence. @@ -446,7 +501,14 @@ func loadConfig(ctx context.Context) (*config, []string, error) { } return loadConfigError(err) } - + if manualSPVConfig { + cfg, err = configWithSPVMode(cfg, configFilePath) + if err != nil { + log.Errorf("SPV mode setting error: %v", err) + parser.WriteHelp(os.Stderr) + return loadConfigError(err) + } + } // If an alternate data directory was specified, and paths with defaults // relative to the data dir are unchanged, modify each path to be // relative to the new data dir. @@ -660,10 +722,30 @@ func loadConfig(ctx context.Context) (*config, []string, error) { // Created successfully, so exit now with success. os.Exit(0) } else if !dbFileExists && !cfg.NoInitialLoad { - err := errors.Errorf("The wallet does not exist. Run with the " + - "--create option to initialize and create it.") - fmt.Fprintln(os.Stderr, err) - return loadConfigError(err) + isCreate, err := ConfirmBool("The wallet does not exist. Do you want to create a wallet now?", "y") + if err != nil { + log.Errorf("Error creating wallet: %v", err) + return loadConfigError(err) + } + if isCreate { + err := createWallet(ctx, &cfg) + if err != nil { + fmt.Fprintln(os.Stderr, "Unable to create wallet:", err) + return loadConfigError(err) + } + // Ask user whether to launch dcrwallet or exit + launch, err := ConfirmBool("Do you want to launch dcrwallet now?", "y") + if err != nil { + fmt.Fprintln(os.Stderr, "Unable to launch dcrwallet:", err) + return loadConfigError(err) + } + if !launch { + os.Exit(0) + } + } else { + log.Warnf("Can be run with --create option to initialize and create wallet.") + os.Exit(0) + } } ipNet := func(cidr string) net.IPNet { @@ -1049,3 +1131,177 @@ func loadConfig(ctx context.Context) (*config, []string, error) { return &cfg, remainingArgs, nil } + +// Config with SPV mode +func configWithSPVMode(cfg config, cfgFilePath string) (config, error) { + var err error + if cfg.SPV { + cfg, err = autoGenerateRpcUserPass(cfg, cfgFilePath) + } else { + cfg, err = spvConfigWithQuestion(cfg, cfgFilePath) + } + if err != nil { + return cfg, err + } + return cfg, nil +} + +// In case RPC information is not automatically fetched from dcrd, ask the user whether they would like to run SPV mode. +// The RPC username and password will be automatically generated. +func spvConfigWithQuestion(cfg config, cfgFilePath string) (config, error) { + spvSelect, err := ConfirmBool("Would you like to launch SPV mode? (If SPV mode is selected, the username and password will be automatically generated.)", "y") + if err != nil { + return cfg, err + } + if spvSelect { + cfg.SPV = true + cfg.Offline = false + cfg, err = autoGenerateRpcUserPass(cfg, cfgFilePath) + if err != nil { + return cfg, err + } + } + return cfg, nil +} + +// Automatically generate rpcuser and rpcpassword and save to config file +func autoGenerateRpcUserPass(cfg config, configFilePath string) (config, error) { + // Generate a random user and password for the RPC server credentials. + randomBytes := make([]byte, 20) + _, err := rand.Read(randomBytes) + if err != nil { + return cfg, err + } + randomUsername := base64.StdEncoding.EncodeToString(randomBytes) + _, err = rand.Read(randomBytes) + if err != nil { + return cfg, err + } + generatedRPCPass := base64.StdEncoding.EncodeToString(randomBytes) + cfgBuff, err := os.ReadFile(configFilePath) + if err != nil { + return cfg, err + } + cfgStr := string(cfgBuff) + rpcUserRE := regexp.MustCompile(`(?m)^;\s*rpcuser=[^\s]*$`) + rpcPassRE := regexp.MustCompile(`(?m)^;\s*rpcpass=[^\s]*$`) + updatedCfg := rpcUserRE.ReplaceAllString(cfgStr, fmt.Sprintf("rpcuser=%s", randomUsername)) + updatedCfg = rpcPassRE.ReplaceAllString(updatedCfg, fmt.Sprintf("rpcpass=%s", generatedRPCPass)) + cfgStr = updatedCfg + err = os.WriteFile(configFilePath, []byte(cfgStr), 0644) + if err != nil { + return cfg, err + } + cfg.RPCUser = randomUsername + cfg.Username = randomUsername + cfg.RPCPass = generatedRPCPass + cfg.Password = generatedRPCPass + log.Info("The RPC username and password were randomly generated and saved in the configuration file.") + return cfg, nil +} + +func updateDefaultDcrdRPCInfos(destPath string) (string, string, error) { + // get rpc user, password info from dcrd + dcrdRpcUser, dcrdRpcPass, getRpcErr := getRpcConfigFromDcrdConfigFile(dcrdDefaultConfigFile) + if getRpcErr != nil { + return "", "", getRpcErr + } + if dcrdRpcUser == "" && dcrdRpcPass == "" { + return "", "", fmt.Errorf("get dcrd rpc info failed") + } + cfgBuff, err := os.ReadFile(destPath) + if err != nil { + return "", "", err + } + cfg := string(cfgBuff) + rpcUserRE := regexp.MustCompile(`(?m)^;\s*rpcuser=[^\s]*$`) + rpcPassRE := regexp.MustCompile(`(?m)^;\s*rpcpass=[^\s]*$`) + updatedCfg := rpcUserRE.ReplaceAllString(cfg, dcrdRpcUser) + updatedCfg = rpcPassRE.ReplaceAllString(updatedCfg, dcrdRpcPass) + cfg = updatedCfg + err = os.WriteFile(destPath, []byte(cfg), 0644) + if err != nil { + return "", "", err + } + return dcrdRpcUser, dcrdRpcPass, nil +} + +// createDefaultConfig copies the file sample-dcrd.conf to the given destination path, +// and populates it with some randomly generated RPC username and password. +func createDefaultConfigFile(destPath string, authType string) (dcrdExist bool, err error) { + // Create the destination directory if it does not exist. + err = os.MkdirAll(filepath.Dir(destPath), 0700) + if err != nil { + return false, err + } + cfg := Dcrwallet() + dcrdRpcUser := "" + dcrdRpcPass := "" + // check and read dcrd config file + if authType == authTypeBasic { + if exists(dcrdDefaultConfigFile) { + var getRpcErr error + // get rpc user, password info from dcrd + dcrdRpcUser, dcrdRpcPass, getRpcErr = getRpcConfigFromDcrdConfigFile(dcrdDefaultConfigFile) + if getRpcErr == nil { + // Replace the rpcuser and rpcpass lines in the sample configuration + // file contents with their generated values. + rpcUserRE := regexp.MustCompile(`(?m)^;\s*rpcuser=[^\s]*$`) + rpcPassRE := regexp.MustCompile(`(?m)^;\s*rpcpass=[^\s]*$`) + updatedCfg := rpcUserRE.ReplaceAllString(cfg, dcrdRpcUser) + updatedCfg = rpcPassRE.ReplaceAllString(updatedCfg, dcrdRpcPass) + cfg = updatedCfg + } + } + } + // Create config file at the provided path. + dest, err := os.OpenFile(destPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return false, err + } + defer dest.Close() + _, err = dest.WriteString(cfg) + if err == nil { + log.Warnf("Config file does not exist. New default file created: %s", destPath) + } else { + return false, err + } + // If dcrd rpc info cannot be obtained, the dcrwallet.conf file is still created, but warns the user about setting rpc parameters manually. + if dcrdRpcUser == "" && dcrdRpcPass == "" { + log.Warnf("Unable to get rpc informations from dcrd. Launch dcrd to automatically update or set it manually on the config file.") + return false, nil + } + return true, nil +} +func getRpcConfigFromDcrdConfigFile(filePath string) (string, string, error) { + f, err := os.Open(filePath) + if err != nil { + return "", "", err + } + defer f.Close() + br := bufio.NewReader(f) + username := "" + password := "" + for { + line, err := br.ReadString('\n') + if errors.Is(err, io.EOF) { + break + } + if strings.HasPrefix(line, "rpcuser=") { + username = strings.TrimSpace(line) + } + if strings.HasPrefix(line, "rpcpass=") { + password = strings.TrimSpace(line) + } + } + if username == "" || password == "" { + return "", "", fmt.Errorf("get dcrd rpc info failed") + } + return username, password, nil +} + +// exists return true if the provided path exists. +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index 349ec73ed..e995b398c 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -104,6 +104,10 @@ func promptList(reader *bufio.Reader, prefix string, validResponses []string, de } } +func AskConfirmBool(reader *bufio.Reader, prefix string, defaultEntry string) (bool, error) { + return promptListBool(reader, prefix, defaultEntry) +} + // promptListBool prompts the user for a boolean (yes/no) with the given prefix. // The function will repeat the prompt to the user until they enter a valid // response. diff --git a/rpcserver.go b/rpcserver.go index 6e3870f90..80610d7c8 100644 --- a/rpcserver.go +++ b/rpcserver.go @@ -297,11 +297,11 @@ func startRPCServers(walletLoader *loader.Loader) (*grpc.Server, *jsonrpc.Server } clientCAsExist = clientCAsExist || cfg.IssueClientCert - if !clientCAsExist && len(cfg.GRPCListeners) != 0 { + if !clientCAsExist && len(cfg.GRPCListeners) != 0 && cfg.JSONRPCAuthType == "clientcert" { log.Warnf("gRPC server is configured with listeners, but no "+ "trusted client certificates exist (looked in %v)", cfg.ClientCAFile) - } else if clientCAsExist && len(cfg.GRPCListeners) != 0 { + } else if clientCAsExist && len(cfg.GRPCListeners) != 0 && cfg.JSONRPCAuthType == "clientcert" { tlsConfig := tlsConfig.Clone() tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert listeners := makeListeners(cfg.GRPCListeners, net.Listen) diff --git a/sample-dcrwallet.conf b/sample-dcrwallet.conf index e2ec3d3ba..f58ee48e0 100644 --- a/sample-dcrwallet.conf +++ b/sample-dcrwallet.conf @@ -180,10 +180,10 @@ ; RPC settings (both client and server) ; ------------------------------------------------------------------------------ -; Username and password to authenticate to a dcrd RPC server and authenticate +; The official authentication information connects to the dcrd rpc server and authenticate ; new client connections to dcrwallet. -; username= -; password= +; rpcuser= +; rpcpass= ; Alternative username and password for dcrd. If set, these will be used ; instead of the username and password set above for authentication to a diff --git a/sampleconfig.go b/sampleconfig.go new file mode 100644 index 000000000..617d48da3 --- /dev/null +++ b/sampleconfig.go @@ -0,0 +1,24 @@ +// Copyright (c) 2017-2022 The Decred developers +// Use of this source code is governed by an ISC +// license that can be found in the LICENSE file. +package main + +import ( + _ "embed" +) + +//go:embed sample-dcrwallet.conf +var sampleDcrwalletConf string + +// Dcrd returns a string containing the commented example config for dcrd. +func Dcrwallet() string { + return sampleDcrwalletConf +} + +// FileContents returns a string containing the commented example config for +// dcrwallet. +// +// Deprecated: Use the [Dcrwallet] function instead. +func FileContents() string { + return Dcrwallet() +} diff --git a/walletsetup.go b/walletsetup.go index a42474612..d4834b3e7 100644 --- a/walletsetup.go +++ b/walletsetup.go @@ -105,6 +105,13 @@ func displaySimnetMiningAddrs(seed []byte, imported bool) error { return nil } +// Confirm yes/no/y/n with confirm question and default confirmation value +func ConfirmBool(prefix, defaultEntry string) (bool, error) { + r := bufio.NewReader(os.Stdin) + confirmYes, err := prompt.AskConfirmBool(r, prefix, defaultEntry) + return confirmYes, err +} + // createWallet prompts the user for information needed to generate a new wallet // and generates the wallet accordingly. The new wallet will reside at the // provided path. The bool passed back gives whether or not the wallet was