diff --git a/retroshare-service/src/retroshare-service.cc b/retroshare-service/src/retroshare-service.cc index 02e2e7f46..e5d787d1f 100644 --- a/retroshare-service/src/retroshare-service.cc +++ b/retroshare-service/src/retroshare-service.cc @@ -79,7 +79,18 @@ std::string colored(int color,const std::string& s) } } +/** A terminal that dies mid-prompt -- an ssh session dropping, a container + * losing its tty -- turns every following read into an immediate EOF. Every + * prompt below re-asks on empty or mismatched input, so without a bound that + * becomes a loop nobody can interrupt. Kept outside the terminal-login guard: + * the web interface password prompt has its own build option. */ +static constexpr int MAX_PROMPT_ATTEMPTS = 3; + #ifdef RS_SERVICE_TERMINAL_LOGIN +/** On POSIX rs_getpass() reads stdin, so this tests the very channel it uses. + * On Windows it reads the console directly through _getch(), so this is a + * conservative proxy: a service with no console has no interactive stdin + * either. */ static bool hasInteractiveStdin() { #ifdef WINDOWS_SYS @@ -88,6 +99,14 @@ static bool hasInteractiveStdin() return isatty(fileno(stdin)) != 0; #endif } + +static std::string trimmed(const std::string& s) +{ + const std::string blanks = " \t\r\n"; + const auto first = s.find_first_not_of(blanks); + if(first == std::string::npos) return std::string(); + return s.substr(first, s.find_last_not_of(blanks) - first + 1); +} #endif static void eventHandler(std::shared_ptr e) @@ -100,6 +119,18 @@ static void eventHandler(std::shared_ptr e) #ifdef RS_SERVICE_TERMINAL_LOGIN if(fe->mEventCode == RsSystemEventCode::PASSWORD_REQUESTED) { + // The core asks for the passphrase through this event on every login, + // including -U from systemd or docker where there is nothing to + // ask. Answering nothing lets attemptLogin() fail with a proper status; + // prompting an absent terminal cannot succeed and only hides the cause. + if(!hasInteractiveStdin()) + { + RsErr() << "A passphrase is required but stdin is not a terminal. " + "Run retroshare-service interactively, or unlock the " + "profile through the JSON API." << std::endl; + return; + } + std::string question1 = fe->passwd_request_title + colored(COLOR_GREEN,"Please enter your PGP password for key:\n ") + fe->passwd_request_key_details + " :"; std::string password = RsUtil::rs_getpass(question1.c_str()) ; @@ -146,51 +177,159 @@ void signalHandler(int signal) #ifdef RS_SERVICE_TERMINAL_LOGIN -static bool doTerminalCreateAccount() +enum class CreateAccountResult { Created, Cancelled, Failed }; + +/** Ask the user for a PGP profile to sign the new node with. + * Returns false when the user cancelled. A null pgpId on return means "make a + * new profile", which is what createLocationV2() does with a null id. */ +static bool askPgpProfile(RsPgpId& pgpId) +{ + pgpId.clear(); + + std::list pgpIds; + RsAccounts::GetPGPLogins(pgpIds); + + if(pgpIds.empty()) return true; // nothing to reuse, nothing to ask + + std::vector choices(pgpIds.begin(), pgpIds.end()); + + std::cout << std::endl + << colored(COLOR_GREEN, "Existing profiles on this machine:") + << std::endl << std::endl; + + for(size_t i = 0; i < choices.size(); ++i) + { + std::string name, email; + RsAccounts::GetPGPLoginDetails(choices[i], name, email); + std::cout << colored(COLOR_GREEN, " [" + RsUtil::NumberToString(i+1) + "]") << " " + << colored(COLOR_BLUE, choices[i].toStdString()) << ": " + << colored(COLOR_PURPLE, name) << std::endl; + } + + std::cout << colored(COLOR_GREEN, " [n]") << " " + << colored(COLOR_YELLOW, "Create a new profile") << std::endl << std::endl + << colored(COLOR_YELLOW, + "A new profile is a new identity: your existing friends " + "will not recognise it,\nand you will have to exchange " + "certificates with them again. Reuse a profile above\n" + "to simply add this machine as another node of it.") + << std::endl << std::endl; + + for(int attempt = 0; keepRunning && attempt < MAX_PROMPT_ATTEMPTS; ++attempt) + { + std::cout << colored(COLOR_GREEN, "Profile to use, or 'n' for a new one: "); + std::cout.flush(); + + std::string inputStr; + if(!std::getline(std::cin, inputStr)) + { + RsErr() << "Unable to read the profile selection from the terminal." << std::endl; + return false; + } + + inputStr = trimmed(inputStr); + + if(inputStr == "n" || inputStr == "N") return true; + + char* inputEnd = nullptr; + unsigned long selection = std::strtoul(inputStr.c_str(), &inputEnd, 10); + if(inputEnd != inputStr.c_str() && *inputEnd == '\0' && + selection >= 1 && selection <= choices.size()) + { + pgpId = choices[selection - 1]; + return true; + } + + std::cout << colored(COLOR_RED, "Invalid selection. Please try again.") << std::endl; + } + + if(keepRunning) + RsErr() << "Too many invalid selections, giving up." << std::endl; + + return false; +} + +static CreateAccountResult doTerminalCreateAccount() { if(!hasInteractiveStdin()) { RsErr() << "Account creation requires an interactive terminal." << std::endl; - return false; + return CreateAccountResult::Failed; } std::cout << std::endl << colored(COLOR_GREEN, "=== Create New RetroShare Account ===") << std::endl << std::endl; + RsPgpId pgpId; + if(!askPgpProfile(pgpId)) + return keepRunning ? CreateAccountResult::Failed : CreateAccountResult::Cancelled; + + const bool reusingProfile = !pgpId.isNull(); + + // Only asked when a profile is created: reusing one keeps its name, and + // createLocationV2() ignores pgpName as soon as pgpId is not null. std::string pgpName; - while (keepRunning && pgpName.empty()) + if(!reusingProfile) { - std::cout << colored(COLOR_GREEN, "Please enter your Username: "); - std::cout.flush(); - if(!std::getline(std::cin, pgpName)) + for(int attempt = 0; keepRunning && pgpName.empty() && attempt < MAX_PROMPT_ATTEMPTS; ++attempt) { - RsErr() << "Unable to read the account name from the terminal." << std::endl; - return false; + std::cout << colored(COLOR_GREEN, "Please enter your Username: "); + std::cout.flush(); + if(!std::getline(std::cin, pgpName)) + { + RsErr() << "Unable to read the account name from the terminal." << std::endl; + return CreateAccountResult::Failed; + } + pgpName = trimmed(pgpName); + if (pgpName.empty()) + std::cout << colored(COLOR_RED, "Name cannot be empty!") << std::endl; } + if (!keepRunning) return CreateAccountResult::Cancelled; if (pgpName.empty()) - std::cout << colored(COLOR_RED, "Name cannot be empty!") << std::endl; + { + RsErr() << "No account name given, giving up." << std::endl; + return CreateAccountResult::Failed; + } } - if (!keepRunning) return false; std::string locationName; - while (keepRunning && locationName.empty()) + for(int attempt = 0; keepRunning && locationName.empty() && attempt < MAX_PROMPT_ATTEMPTS; ++attempt) { std::cout << colored(COLOR_GREEN, "Please enter Node/Location Name (e.g. Laptop, Home): "); std::cout.flush(); if(!std::getline(std::cin, locationName)) { RsErr() << "Unable to read the location name from the terminal." << std::endl; - return false; + return CreateAccountResult::Failed; } + locationName = trimmed(locationName); if (locationName.empty()) std::cout << colored(COLOR_RED, "Location name cannot be empty!") << std::endl; } - if (!keepRunning) return false; + if (!keepRunning) return CreateAccountResult::Cancelled; + if (locationName.empty()) + { + RsErr() << "No location name given, giving up." << std::endl; + return CreateAccountResult::Failed; + } std::string pass1, pass2; - while (keepRunning) + bool passphraseAccepted = false; + for(int attempt = 0; keepRunning && attempt < MAX_PROMPT_ATTEMPTS; ++attempt) { - pass1 = RsUtil::rs_getpass(colored(COLOR_GREEN, "Please enter passphrase for new account: ")); + pass1 = RsUtil::rs_getpass(colored(COLOR_GREEN, + reusingProfile ? "Please enter the passphrase of that profile: " + : "Please enter passphrase for new account: ")); + + if(reusingProfile) + { + // Nothing to confirm: the passphrase already exists and a typo is + // caught by the key itself rather than by a second prompt. + if(!pass1.empty()) { passphraseAccepted = true; break; } + std::cout << colored(COLOR_RED, "Passphrase cannot be empty! Please try again.") << std::endl; + continue; + } + pass2 = RsUtil::rs_getpass(colored(COLOR_GREEN, "Please enter the same passphrase again: ")); if (pass1 != pass2) @@ -203,20 +342,28 @@ static bool doTerminalCreateAccount() std::cout << colored(COLOR_RED, "Passphrase cannot be empty! Please try again.") << std::endl; continue; } + passphraseAccepted = true; break; } - if (!keepRunning) return false; + if (!keepRunning) return CreateAccountResult::Cancelled; + if (!passphraseAccepted) + { + RsErr() << "No usable passphrase given, giving up." << std::endl; + return CreateAccountResult::Failed; + } - std::cout << colored(COLOR_YELLOW, "Generating 4096-bit PGP key & SSL certificate (this may take a few seconds)...") << std::endl; + if(reusingProfile) + std::cout << colored(COLOR_YELLOW, "Generating SSL certificate for the new node...") << std::endl; + else + std::cout << colored(COLOR_YELLOW, "Generating 4096-bit PGP key & SSL certificate (this may take a few seconds)...") << std::endl; RsPeerId locationId; - RsPgpId pgpId; std::error_condition err = rsLoginHelper->createLocationV2(locationId, pgpId, locationName, pgpName, pass1); if (err) { RsErr() << colored(COLOR_RED, "Account creation failed: " + err.message()) << std::endl; - return false; + return CreateAccountResult::Failed; } std::cout << std::endl @@ -224,7 +371,7 @@ static bool doTerminalCreateAccount() std::cout << colored(COLOR_GREEN, " Location ID : ") << colored(COLOR_YELLOW, locationId.toStdString()) << std::endl; std::cout << colored(COLOR_GREEN, " PGP ID : ") << colored(COLOR_BLUE, pgpId.toStdString()) << std::endl << std::endl; - return true; + return CreateAccountResult::Created; } #endif @@ -357,7 +504,9 @@ int main(int argc, char* argv[]) { std::string webui_pass2 = "N"; - while(keepRunning) + // Same bound as the account prompts: -W on a service with no terminal + // re-asks a question that can never be answered. + for(int attempt = 0; keepRunning && attempt < MAX_PROMPT_ATTEMPTS; ++attempt) { webui_pass1 = RsUtil::rs_getpass( colored(COLOR_GREEN,"Please register a password for the web interface: ")); webui_pass2 = RsUtil::rs_getpass( colored(COLOR_GREEN,"Please enter the same password again : ")); @@ -365,6 +514,7 @@ int main(int argc, char* argv[]) if(webui_pass1 != webui_pass2) { std::cout << colored(COLOR_RED,"Passwords do not match!") << std::endl; + webui_pass1.clear(); continue; } if(webui_pass1.empty()) @@ -375,6 +525,10 @@ int main(int argc, char* argv[]) break; } + + if(askWebUiPassword && webui_pass1.empty()) + RsErr() << "No web interface password given, the web interface will " + "not be started." << std::endl; } #ifdef RS_SERVICE_TERMINAL_WEBUI_PASSWORD if(askWebUiPassword && !webui_pass1.empty()) @@ -412,8 +566,12 @@ int main(int argc, char* argv[]) if(prefUserString == "create") { - alreadyLoggedIn = doTerminalCreateAccount(); - if (!alreadyLoggedIn) return -1; + switch(doTerminalCreateAccount()) + { + case CreateAccountResult::Created: alreadyLoggedIn = true; break; + case CreateAccountResult::Cancelled: return 0; + case CreateAccountResult::Failed: return -RsInit::ERR_UNKNOWN; + } } else if(prefUserString == "list") { @@ -439,18 +597,27 @@ int main(int argc, char* argv[]) } + // "-U list" is documented as a way to list accounts, so on a + // non-interactive stdin print the list and stop there rather than + // advertising a [c] entry nobody can type. The error is kept for + // the case it actually describes: no account to list. + if(!hasInteractiveStdin()) + { + if(locations.empty()) + { + RsErr() << "No available account, and stdin is not a terminal " + "to create one." << std::endl; + return -RsInit::ERR_NO_AVAILABLE_ACCOUNT; + } + return 0; + } + std::cout << colored(COLOR_GREEN," [c]") << " " << colored(COLOR_YELLOW,"Create new account") << std::endl << std::endl; - if(!hasInteractiveStdin()) - { - RsErr() << "Account selection and creation require an interactive terminal." - << std::endl; - return -RsInit::ERR_NO_AVAILABLE_ACCOUNT; - } - - while(keepRunning) + bool selectionMade = false; + for(int attempt = 0; keepRunning && attempt < MAX_PROMPT_ATTEMPTS; ++attempt) { std::cout << colored(COLOR_GREEN,"Please enter account number or 'c' to create: "); std::cout.flush(); @@ -462,10 +629,16 @@ int main(int argc, char* argv[]) return -RsInit::ERR_NO_AVAILABLE_ACCOUNT; } + inputStr = trimmed(inputStr); + if(inputStr == "c" || inputStr == "C") { - alreadyLoggedIn = doTerminalCreateAccount(); - if(!alreadyLoggedIn) return -1; + switch(doTerminalCreateAccount()) + { + case CreateAccountResult::Created: alreadyLoggedIn = true; break; + case CreateAccountResult::Cancelled: return 0; + case CreateAccountResult::Failed: return -RsInit::ERR_UNKNOWN; + } break; } @@ -475,11 +648,25 @@ int main(int argc, char* argv[]) selection >= 1 && selection <= locations.size()) { prefUserString = locations[selection - 1].mLocationId.toStdString(); + selectionMade = true; break; } std::cout << colored(COLOR_RED,"Invalid selection. Please try again.") << std::endl; } + + // Ctrl-C at a prompt: on glibc signal() installs the handler with + // SA_RESTART, so the blocked read is restarted and keepRunning is + // only seen once the user also presses Enter. Without this, control + // fell through with prefUserString still "list" and the user who + // just cancelled was told their location id was invalid. + if(!keepRunning) return 0; + + if(!alreadyLoggedIn && !selectionMade) + { + RsErr() << "No account selected, giving up." << std::endl; + return -RsInit::ERR_NO_AVAILABLE_ACCOUNT; + } } if(!alreadyLoggedIn) @@ -547,6 +734,7 @@ int main(int argc, char* argv[]) } } #endif // def RS_SERVICE_TERMINAL_LOGIN + rsControl->setShutdownCallback([&](int){keepRunning = false;}); while(keepRunning)