Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,18 @@ et dev (etserver running on port 2022 on both hostname and jumphost)
et dev:8000 -jport 9000 (etserver running on port 9000 on jumphost)
```

To isolate ET from ambient SSH configuration, pass an absolute path with
`--ssh-config`. ET reads only that file for its own destination and jumphost
lookup and passes the same file to every SSH process it starts, including the
implicit ProxyJump connection. The file must be readable, regular, and not a
symbolic link. The path must be absolute (`/path` on Unix; `C:\path`,
`C:/path`, or a UNC path on Windows) and may contain only ASCII letters, digits,
`/`, `.`, `_`, and `-` (plus `:` and `\` on Windows). Spaces are not allowed:
OpenSSH does not quote this path when constructing its implicit ProxyJump
command. Pass `--ssh-config none` or the equivalent `--no-ssh-config` to
disable both user and system SSH configuration entirely. The two options are
mutually exclusive.

## Building from Source

### macOS
Expand Down
46 changes: 42 additions & 4 deletions src/terminal/SshSetupHandler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@
namespace et {
const string SshSetupHandler::ETTERMINAL_BIN = "etterminal";

namespace {
/** @brief Characters OpenSSH can splice into an unquoted ProxyJump -F path. */
bool isSshConfigPathCharSafeForProxyJump(unsigned char c) {
if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
(c >= '0' && c <= '9') || c == '/' || c == '.' || c == '_' || c == '-') {
return true;
}
#ifdef WIN32
// Native Windows paths need a drive colon and backslash; those are not
// Bourne metacharacters, and Unix clients never pass Windows paths.
return c == ':' || c == '\\';
#else
return false;
#endif
}
} // namespace

bool SshSetupHandler::IsSshConfigPathSafeForProxyJump(const string& path) {
if (!std::filesystem::path(path).is_absolute()) {
return false;
}
for (unsigned char c : path) {
if (!isSshConfigPathCharSafeForProxyJump(c)) {
return false;
}
}
return true;
}

string genCommand(const string& passkey, const string& id,
const string& clientTerm, const string& user, bool kill,
const string& etterminal_path, const string& options) {
Expand Down Expand Up @@ -56,11 +85,16 @@ pair<string, string> SshSetupHandler::SetupSsh(
}

std::vector<std::string> ssh_args;
if (!sshConfigPath_.empty()) {
// An explicit `-F` suppresses OpenSSH's user and system configuration.
// OpenSSH also propagates it to the implicit proxy command created by
// `-J`, so the explicit jumphost uses the same selected policy.
ssh_args.push_back("-F");
ssh_args.push_back(sshConfigPath_);
}
if (!jumphost.empty()) {
ssh_args = {
"-J",
jumphost,
};
ssh_args.push_back("-J");
ssh_args.push_back(jumphost);
}

ssh_args.push_back(SSH_USER_PREFIX + host_alias);
Expand Down Expand Up @@ -141,6 +175,10 @@ pair<string, string> SshSetupHandler::SetupSsh(
: parsedJump.user + "@" + jumphostAddr;

std::vector<std::string> jump_ssh_args;
if (!sshConfigPath_.empty()) {
jump_ssh_args.push_back("-F");
jump_ssh_args.push_back(sshConfigPath_);
}
if (!parsedJump.portSuffix.empty()) {
// portSuffix includes the colon, e.g. ":22"
jump_ssh_args.push_back("-p");
Expand Down
19 changes: 17 additions & 2 deletions src/terminal/SshSetupHandler.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ class SshSetupHandler {
/**
* @brief Constructs an SshSetupHandler with a subprocess utility.
* @param subprocessUtils The subprocess utility to use for running ssh.
* @param sshConfigPath An exact SSH configuration path for every SSH
* process, "none" to disable configuration, or empty for OpenSSH defaults.
*/
explicit SshSetupHandler(shared_ptr<SubprocessUtils> subprocessUtils)
: subprocessUtils_(subprocessUtils) {}
explicit SshSetupHandler(shared_ptr<SubprocessUtils> subprocessUtils,
string sshConfigPath = "")
: subprocessUtils_(subprocessUtils),
sshConfigPath_(std::move(sshConfigPath)) {}

/**
* @brief Constructs the ssh command line for connecting to the ET server.
Expand All @@ -29,8 +33,19 @@ class SshSetupHandler {
/** @brief Path to the packaged `etterminal` helper binary. */
static const string ETTERMINAL_BIN;

/**
* @brief Whether OpenSSH can safely propagate this path through `-J`.
*
* Requires a native absolute path. Allowed characters are ASCII letters,
* digits, `/`, `.`, `_`, and `-`; Windows also allows `:` and `\\` so drive
* and UNC paths work. Spaces and shell metacharacters are rejected because
* OpenSSH interpolates `-F` into the implicit ProxyJump command unquoted.
*/
static bool IsSshConfigPathSafeForProxyJump(const string& path);

private:
shared_ptr<SubprocessUtils> subprocessUtils_;
string sshConfigPath_;
};
} // namespace et
#endif // __ET_SSH_SETUP_HANDLER__
111 changes: 91 additions & 20 deletions src/terminal/TerminalClientMain.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#include <cxxopts.hpp>
#include <filesystem>
#include <fstream>

#include "Headers.hpp"
#include "HostParsing.hpp"
Expand Down Expand Up @@ -55,19 +57,39 @@ struct ResolvedSshConfig {
string username; // Username from SSH config (empty if not specified)
};

// Parse the selected SSH policy. An empty path preserves ET's legacy ambient
// behavior, "none" disables parsing, and every other value names the sole
// configuration file to read.
void parseSelectedSshConfig(const string& host, Options* options,
const string& sshConfigPath) {
if (sshConfigPath == "none") {
return;
}
if (!sshConfigPath.empty()) {
parse_ssh_config_file(host.c_str(), options, sshConfigPath);
return;
}

char* homeDir = ssh_get_user_home_dir();
if (homeDir != NULL) {
parse_ssh_config_file(host.c_str(), options,
string(homeDir) + USER_SSH_CONFIG_PATH);
free(homeDir);
}
parse_ssh_config_file(host.c_str(), options, SYSTEM_SSH_CONFIG_PATH);
}

// Resolve a host alias via SSH config lookup
ResolvedSshConfig resolveSshConfigHost(const string& hostAlias) {
ResolvedSshConfig resolveSshConfigHost(const string& hostAlias,
const string& sshConfigPath) {
ResolvedSshConfig result;
result.hostname = hostAlias; // Default to original if not resolved

char* home_dir = ssh_get_user_home_dir();
Options opts = {NULL, NULL, NULL, NULL, NULL, NULL, 0, 0, 0,
0, 0, NULL, NULL, 0, 0, NULL, {}};

ssh_options_set(&opts, SSH_OPTIONS_HOST, hostAlias.c_str());
parse_ssh_config_file(hostAlias.c_str(), &opts,
string(home_dir) + USER_SSH_CONFIG_PATH);
parse_ssh_config_file(hostAlias.c_str(), &opts, SYSTEM_SSH_CONFIG_PATH);
parseSelectedSshConfig(hostAlias, &opts, sshConfigPath);

if (opts.host) {
result.hostname = string(opts.host);
Expand All @@ -77,7 +99,6 @@ ResolvedSshConfig resolveSshConfigHost(const string& hostAlias) {
}

freeOptionsFields(&opts);
free(home_dir);
return result;
}

Expand Down Expand Up @@ -178,6 +199,12 @@ int main(int argc, char** argv) {
("f,forward-ssh-agent", "Forward ssh-agent socket") //
("ssh-socket", "The ssh-agent socket to forward",
cxxopts::value<std::string>()) //
("ssh-config",
"Read only this absolute SSH configuration file (or 'none')",
cxxopts::value<std::string>()) //
("no-ssh-config",
"Do not read user or system SSH configuration for the destination "
"or jumphost") //
("telemetry",
"Allow et to anonymously send errors to guide future improvements",
cxxopts::value<bool>()->default_value("true")) //
Expand Down Expand Up @@ -280,6 +307,54 @@ int main(int argc, char** argv) {

string jumphost =
extractSingleOptionWithDefault<string>(result, options, "jumphost", "");
if (result.count("ssh-config") && result.count("no-ssh-config")) {
CLOG(INFO, "stdout")
<< "--ssh-config and --no-ssh-config are mutually exclusive" << endl;
exit(1);
}
string sshConfigPath;
if (result.count("ssh-config")) {
sshConfigPath = result["ssh-config"].as<string>();
if (sshConfigPath != "none" &&
!std::filesystem::path(sshConfigPath).is_absolute()) {
CLOG(INFO, "stdout")
<< "--ssh-config must be an absolute path or 'none': "
<< sshConfigPath << endl;
exit(1);
}
if (sshConfigPath != "none" &&
!SshSetupHandler::IsSshConfigPathSafeForProxyJump(sshConfigPath)) {
CLOG(INFO, "stdout")
<< "--ssh-config must contain only ASCII letters, digits, '/', "
#ifdef WIN32
"'\\\\', ':', "
#endif
"'.', '_', and '-'; OpenSSH does not quote this path when "
"propagating it through ProxyJump"
<< endl;
exit(1);
}
if (sshConfigPath != "none") {
std::error_code configError;
std::filesystem::file_status configStatus =
std::filesystem::symlink_status(sshConfigPath, configError);
bool regularFile = std::filesystem::is_regular_file(configStatus);
bool readableFile = false;
if (!configError && regularFile) {
std::ifstream configFile(sshConfigPath);
readableFile = configFile.good();
}
if (!readableFile) {
CLOG(INFO, "stdout")
<< "--ssh-config must name a readable, non-symlink regular file"
<< endl;
exit(1);
}
}
} else if (result.count("no-ssh-config")) {
sshConfigPath = "none";
}
bool noSshConfig = sshConfigPath == "none";
int keepaliveDuration = extractSingleOptionWithDefault<int>(
result, options, "keepalive", MAX_CLIENT_KEEP_ALIVE_DURATION);
if (keepaliveDuration < 1 ||
Expand All @@ -291,22 +366,15 @@ int main(int argc, char** argv) {
exit(0);
}

{
char* home_dir = ssh_get_user_home_dir();
const char* host_from_command = destinationHost.c_str();
if (!noSshConfig) {
ssh_options_set(&sshConfigOptions, SSH_OPTIONS_HOST,
destinationHost.c_str());
// First parse user-specific ssh config, then system-wide config.
parse_ssh_config_file(host_from_command, &sshConfigOptions,
string(home_dir) + USER_SSH_CONFIG_PATH);
parse_ssh_config_file(host_from_command, &sshConfigOptions,
SYSTEM_SSH_CONFIG_PATH);
parseSelectedSshConfig(destinationHost, &sshConfigOptions, sshConfigPath);
if (sshConfigOptions.host) {
LOG(INFO) << "Parsed ssh config file, connecting to "
<< sshConfigOptions.host;
destinationHost = string(sshConfigOptions.host);
}
free(home_dir);
}

// Parse username: cmdline > sshconfig > localuser
Expand Down Expand Up @@ -337,8 +405,10 @@ int main(int argc, char** argv) {
// Parse [user@]host[:sshport] format
ParsedHostString parsed = parseHostString(jumphost);

// Resolve jumphost alias to actual hostname via SSH config
ResolvedSshConfig resolved = resolveSshConfigHost(parsed.host);
// Resolve jumphost aliases only when SSH configuration is enabled.
// In --no-ssh-config mode, keep the command-line host and user exact.
ResolvedSshConfig resolved =
resolveSshConfigHost(parsed.host, sshConfigPath);
if (resolved.hostname != parsed.host) {
LOG(INFO) << "Resolved jumphost alias '" << parsed.host
<< "' to hostname: " << resolved.hostname;
Expand All @@ -351,14 +421,15 @@ int main(int argc, char** argv) {
LOG(INFO) << "Using jumphost username from SSH config: "
<< jumphostUser;
}
if (jumphostUser.empty()) {
if (jumphostUser.empty() && !noSshConfig) {
char* localUsernamePtr = ssh_get_local_username();
jumphostUser = string(localUsernamePtr);
SAFE_FREE(localUsernamePtr);
}

// Reconstruct jumphost with resolved hostname for SSH -J flag
jumphost = jumphostUser + "@" + resolved.hostname + parsed.portSuffix;
jumphost = (jumphostUser.empty() ? "" : jumphostUser + "@") +
resolved.hostname + parsed.portSuffix;

socketEndpoint.set_name(resolved.hostname);
socketEndpoint.set_port(result["jport"].as<int>());
Expand Down Expand Up @@ -433,7 +504,7 @@ int main(int argc, char** argv) {
}

auto subprocessUtils = make_shared<SubprocessUtils>();
SshSetupHandler sshSetupHandler(subprocessUtils);
SshSetupHandler sshSetupHandler(subprocessUtils, sshConfigPath);
pair<string, string> idpasskeypair = sshSetupHandler.SetupSsh(
username, destinationHost, host_alias, destinationPort, jumphost,
jServerFifo, result.count("x") > 0, result["verbose"].as<int>(),
Expand Down
Loading
Loading