diff --git a/README.adoc b/README.adoc index 3f1c0ef..554f767 100644 --- a/README.adoc +++ b/README.adoc @@ -18,6 +18,7 @@ Please see the source code of `SshConfiguration.java` for list of configuration However, most configuration will be probably fine with the usual `host`, `username` and `password`. The `argumentStyle` configuration property can take following values: + |==== |argumentStyle | Example command | Description @@ -45,6 +46,7 @@ The `argumentStyle` configuration property can take following values: |==== The `handleNullValues` configuration property can take the following values: + |==== | handleNullValues | Description | Combination with `argumentStyle` | Example where `foo` is `null` @@ -64,15 +66,36 @@ The `handleNullValues` configuration property can take the following values: |==== +The `variableTransport` configuration property can take the following values: + +|==== +| variableTransport | Description | Combination with `argumentStyle` | Example with `variableTransportEnvPrefix` set to `CONNID_ARG_` + +| `inplace` | Arguments will be moved inplace quoted in single quotes. + If arguments contain secrets, consider using transport env. + Single quotes in the value will be escaped by doubling them. +| `variables-bash` | `foo='value1'; bar='valueWithQuotes''Inplace'; command $foo $bar` +||| `variables-powershell` | `$foo='value1'; $bar='valueWithQuotes''Inplace'; command $foo $bar` + +| `env` +| Arguments will be set as ssh envs with the `variableTransportEnvPrefix` as prefix. +Make sure that the server allows env passing. +| `variables-bash` | `foo=$CONNID_ARG_foo; bar=$CONNID_ARG_bar; command $foo $bar` +||| `variables-powershell` | `$foo=$env:CONNID_ARG_foo; $bar=$env:CONNID_ARG_bar; command $foo $bar` + +|==== + +The `variableTransportEnvPrefix` configuration property sets the prefix for environment variable transport. +The default value is `CONNID_ARG_`. == Limitations * Only "execution mode" of SSH is supported. -The connector will create SSH connection, authenticate, execute the command and tear down the connection. +The connector will create SSH connection, authenticate, (maybe) set envs, execute the command and tear down the connection. This is slow, but it is reliable. The "session mode" would allow to set up a session and keep it open. This is supposed to be much faster, as we would avoid connection overhead. -However, that would also mean that we will have problems of detecting where command execution ends, the commands may influence session state, this may be shell-specific (different method for bash and powershell), etc. +However, that would also mean that we will have problems of detecting where command execution ends, the commands may influence session state, this may be shell-specific (different method for bash and powershell), the envs may leak into other commands, etc. * Script language parameter is ignored. However, for future compatibility, we recommend using following values: @@ -104,4 +127,8 @@ The error stream (stderr) is not processed by the connector yet. * The connector cannot process script exit code. SSH provides the exit code, but there is no good way how to pass the exit code through the ConnId layer. +* Passing arguments as envs is only supported for `variables-bash` and `variables-powershell` argument styles. +The value length is limited by the maximum env size on the server side. +Windows is more restrictive than Linux. + If you do not like the limitations, we will be more than happy to accept a contribution. diff --git a/pom.xml b/pom.xml index d1fe274..7734f1c 100644 --- a/pom.xml +++ b/pom.xml @@ -66,6 +66,10 @@ org.apache.maven.plugins maven-compiler-plugin + + 21 + 21 + org.apache.maven.plugins diff --git a/src/main/java/com/evolveum/polygon/connector/ssh/CommandProcessor.java b/src/main/java/com/evolveum/polygon/connector/ssh/CommandProcessor.java index 67bf066..093832f 100644 --- a/src/main/java/com/evolveum/polygon/connector/ssh/CommandProcessor.java +++ b/src/main/java/com/evolveum/polygon/connector/ssh/CommandProcessor.java @@ -18,6 +18,7 @@ import org.identityconnectors.framework.common.exceptions.ConfigurationException; import org.identityconnectors.framework.common.objects.ScriptContext; +import java.util.HashMap; import java.util.Map; public class CommandProcessor { @@ -28,43 +29,43 @@ public CommandProcessor(SshConfiguration configuration) { this.configuration = configuration; } - public String process(ScriptContext scriptCtx) { + public ProcessedCommand process(ScriptContext scriptCtx) { String command = scriptCtx.getScriptText(); if (command == null) { return null; } Map arguments = scriptCtx.getScriptArguments(); if (arguments == null) { - return command; + return new ProcessedCommand(command, Map.of()); } if (configuration.getArgumentStyle() == null) { return encodeArgumentsAndCommandToString(command, arguments, "-"); } - switch (configuration.getArgumentStyle()) { - case SshConfiguration.ARGUMENT_STYLE_VARIABLES_BASH: - return encodeVariablesAndCommandToString(command, arguments, "", false); - case SshConfiguration.ARGUMENT_STYLE_VARIABLES_POWERSHELL: - return encodeVariablesAndCommandToString(command, arguments, "$", true); - case SshConfiguration.ARGUMENT_STYLE_DASH: - return encodeArgumentsAndCommandToString(command, arguments, "-"); - case SshConfiguration.ARGUMENT_STYLE_SLASH: - return encodeArgumentsAndCommandToString(command, arguments, "/"); - default: - throw new ConfigurationException("Unknown value of argument style: "+configuration.getArgumentStyle()); - } + return switch (configuration.getArgumentStyle()) { + case SshConfiguration.ARGUMENT_STYLE_VARIABLES_BASH -> + encodeVariablesAndCommandToString(command, arguments, "", "$", false); + case SshConfiguration.ARGUMENT_STYLE_VARIABLES_POWERSHELL -> + encodeVariablesAndCommandToString(command, arguments, "$", "$env:", true); + case SshConfiguration.ARGUMENT_STYLE_DASH -> encodeArgumentsAndCommandToString(command, arguments, "-"); + case SshConfiguration.ARGUMENT_STYLE_SLASH -> encodeArgumentsAndCommandToString(command, arguments, "/"); + default -> throw new ConfigurationException( + "Unknown value of argument style: " + configuration.getArgumentStyle()); + }; } - private String encodeArgumentsAndCommandToString(String command, Map arguments, String paramPrefix) { + private ProcessedCommand encodeArgumentsAndCommandToString(String command, + Map arguments, + String paramPrefix) { StringBuilder commandLineBuilder = new StringBuilder(); commandLineBuilder.append(command); - for (Map.Entry argEntry: arguments.entrySet()) { + for (Map.Entry argEntry : arguments.entrySet()) { if (argEntry.getKey() == null) { // we want this to go last continue; } Object value = argEntry.getValue(); boolean insertAttribute = true; - if(value == null) { + if (value == null) { switch (configuration.getHandleNullValues()) { case SshConfiguration.HANDLE_NULL_AS_EMPTY_STRING: value = ""; @@ -73,10 +74,11 @@ private String encodeArgumentsAndCommandToString(String command, Map arguments, String variablePrefix, boolean spaces) { + private ProcessedCommand encodeVariablesAndCommandToString(String command, + Map arguments, + String variablePrefix, + String envPrefix, + boolean spaces) { if (arguments == null) { - return command; + return new ProcessedCommand(command, Map.of()); } StringBuilder commandLineBuilder = new StringBuilder(); - for (Map.Entry argEntry: arguments.entrySet()) { + Map envs = new HashMap<>(); + for (Map.Entry argEntry : arguments.entrySet()) { if (argEntry.getKey() == null) { // we want this to go last continue; } Object value = argEntry.getValue(); boolean insertAttribute = true; - if(value == null) { + if (value == null) { switch (configuration.getHandleNullValues()) { case SshConfiguration.HANDLE_NULL_AS_EMPTY_STRING: value = ""; @@ -111,18 +118,40 @@ private String encodeVariablesAndCommandToString(String command, Map { + commandLineBuilder.append(variablePrefix).append(argEntry.getKey()); + if (spaces) { + commandLineBuilder.append(" = "); + } else { + commandLineBuilder.append("="); + } + commandLineBuilder.append(quoteSingle(value)); + commandLineBuilder.append("; "); + } + case SshConfiguration.VARIABLE_TRANSPORT_ENV -> { + envs.put(configuration.getVariableTransportEnvPrefix() + argEntry.getKey(), value.toString()); + commandLineBuilder.append(variablePrefix).append(argEntry.getKey()); + if (spaces) { + commandLineBuilder.append(" = "); + } else { + commandLineBuilder.append("="); + } + commandLineBuilder.append(envPrefix) + .append(configuration.getVariableTransportEnvPrefix()) + .append(argEntry.getKey()) + .append("; "); + } + default -> throw new ConfigurationException( + "Unknown value of variable transport: " + configuration.getVariableTransport()); + } - commandLineBuilder.append(quoteSingle(value)); - commandLineBuilder.append("; "); + } } commandLineBuilder.append(command); @@ -130,7 +159,7 @@ private String encodeVariablesAndCommandToString(String command, Map envs) { +} diff --git a/src/main/java/com/evolveum/polygon/connector/ssh/SshConfiguration.java b/src/main/java/com/evolveum/polygon/connector/ssh/SshConfiguration.java index a8af748..cf403a4 100644 --- a/src/main/java/com/evolveum/polygon/connector/ssh/SshConfiguration.java +++ b/src/main/java/com/evolveum/polygon/connector/ssh/SshConfiguration.java @@ -20,6 +20,8 @@ import org.identityconnectors.framework.spi.AbstractConfiguration; import org.identityconnectors.framework.spi.ConfigurationProperty; +import java.util.Objects; + /** * SSH connector configuration. * @@ -105,6 +107,33 @@ public class SshConfiguration extends AbstractConfiguration { public static final String HANDLE_NULL_AS_EMPTY_STRING = "asEmptyString"; public static final String HANDLE_NULL_AS_GONE = "asGone"; + /** + * Defines how arguments will be transported into the script: + *
    + *
  • [default] "inplace" means that arguments will be inserted into the script as variables or parameters, wrapped by single quotes.
  • + *
  • "env" means that arguments will be transported as environment variables. This is only supported for + * the argument styles based on variables (variables-powershell, variables-bash). Make sure that the sshd_config + * of the ssh daemon is configured to allow setting envs via ssh. To prevent name clashes, the environment variable names + * will be prefixed by the value of variableTransportEnvPrefix property (default: CONNID_ARG_). + *

    Example sshd_config:

    AcceptEnv CONNID_ARG_*

    + *
  • + *
+ * + * @see sshd_config man page + */ + private String variableTransport = VARIABLE_TRANSPORT_INPLACE; + + public static final String VARIABLE_TRANSPORT_INPLACE = "inplace"; + public static final String VARIABLE_TRANSPORT_ENV = "env"; + + /** + * Prefix for environment variables used for argument transport, if variableTransport is set to "env". + * Make sure that the sshd_config of the ssh daemon is configured to allow setting envs with the given prefix via ssh. + * Make sure that the prefix does not collide with other environment variable names. + * Default: CONNID_ARG_ + */ + private String variableTransportEnvPrefix = "CONNID_ARG_"; + @ConfigurationProperty(order = 100) public String getHost() { return host; @@ -195,8 +224,34 @@ public void setHandleNullValues(String handleNullValues) { this.handleNullValues = handleNullValues; } + @ConfigurationProperty(order = 140) + public String getVariableTransport() { + return variableTransport; + } + + public void setVariableTransport(String variableTransport) { + this.variableTransport = variableTransport; + } + + @ConfigurationProperty(order = 150) + public String getVariableTransportEnvPrefix() { + return variableTransportEnvPrefix; + } + + public void setVariableTransportEnvPrefix(String variableTransportEnvPrefix) { + this.variableTransportEnvPrefix = variableTransportEnvPrefix; + } + @Override public void validate() { + if (Objects.equals(variableTransport, VARIABLE_TRANSPORT_ENV) && + !(ARGUMENT_STYLE_VARIABLES_BASH.equals(argumentStyle) || + ARGUMENT_STYLE_VARIABLES_POWERSHELL.equals(argumentStyle))) { + throw new IllegalArgumentException( + "variableTransport set to 'env' is only supported for argument styles '" + + ARGUMENT_STYLE_VARIABLES_BASH + "' and '" + ARGUMENT_STYLE_VARIABLES_POWERSHELL + "'"); + } + } } diff --git a/src/main/java/com/evolveum/polygon/connector/ssh/SshConnector.java b/src/main/java/com/evolveum/polygon/connector/ssh/SshConnector.java index cf43a85..15197bf 100644 --- a/src/main/java/com/evolveum/polygon/connector/ssh/SshConnector.java +++ b/src/main/java/com/evolveum/polygon/connector/ssh/SshConnector.java @@ -16,7 +16,6 @@ package com.evolveum.polygon.connector.ssh; -import com.evolveum.polygon.common.GuardedStringAccessor; import net.schmizz.sshj.SSHClient; import net.schmizz.sshj.common.IOUtils; import net.schmizz.sshj.connection.ConnectionException; @@ -40,7 +39,10 @@ import org.identityconnectors.framework.spi.operations.ScriptOnResourceOp; import org.identityconnectors.framework.spi.operations.TestOp; +import com.evolveum.polygon.common.GuardedStringAccessor; + import java.io.IOException; +import java.util.Map; import java.util.concurrent.TimeUnit; @ConnectorClass(displayNameKey = "connector.ssh.display", configurationClass = SshConfiguration.class) @@ -66,7 +68,7 @@ public Configuration getConfiguration() { @Override public void init(Configuration configuration) { LOG.info("Initializing {0} connector instance {1}", this.getClass().getSimpleName(), this); - this.configuration = (SshConfiguration)configuration; + this.configuration = (SshConfiguration) configuration; this.hostKeyVerifier = new ConnectorKnownHostsVerifier().parse(this.configuration.getKnownHosts()); this.commandProcessor = new CommandProcessor(this.configuration); } @@ -79,15 +81,19 @@ private void connect() { ssh.connect(configuration.getHost(), configuration.getPort()); } catch (IOException e) { LOG.error("Error creating SSH connection to {0}: {1}", getHostDesc(), e.getMessage()); - throw new ConnectionFailedException("Error creating SSH connection to " + getHostDesc() + ": " + e.getMessage(), e); + throw new ConnectionFailedException( + "Error creating SSH connection to " + getHostDesc() + ": " + e.getMessage(), e); } authenticate(); LOG.ok("Authentication to {0} successful", getConnectionDesc()); try { session = ssh.startSession(); } catch (ConnectionException | TransportException e) { - LOG.error("Communication error while creating SSH session for {1} failed: {2}", getConnectionDesc(), e.getMessage()); - throw new ConnectionFailedException("Communication error while creating SSH session for "+getConnectionDesc()+" failed: " + e.getMessage(), e); + LOG.error("Communication error while creating SSH session for {1} failed: {2}", getConnectionDesc(), + e.getMessage()); + throw new ConnectionFailedException( + "Communication error while creating SSH session for " + getConnectionDesc() + " failed: " + + e.getMessage(), e); } LOG.info("Connection to {0} fully established", getConnectionDesc()); } @@ -101,25 +107,33 @@ private void authenticate() { authenticatePublicKey(); break; default: - throw new ConfigurationException("Unknown authentication scheme '"+configuration.getAuthenticationScheme()+"'"); + throw new ConfigurationException( + "Unknown authentication scheme '" + configuration.getAuthenticationScheme() + "'"); } } private void authenticatePassword() { GuardedString password = configuration.getPassword(); if (password == null) { - throw new ConfigurationException("No authentication password configured '"+configuration.getAuthenticationScheme()+"'"); + throw new ConfigurationException( + "No authentication password configured '" + configuration.getAuthenticationScheme() + "'"); } LOG.ok("Authenticating to {0} using password authentication", getConnectionDesc()); - password.access( passwordChars -> { + password.access(passwordChars -> { try { ssh.authPassword(configuration.getUsername(), passwordChars); } catch (UserAuthException e) { - LOG.error("SSH password authentication as {0} to {1} failed: {2}", configuration.getUsername(), getHostDesc(), e.getMessage()); - throw new ConnectionFailedException("SSH password authentication as "+configuration.getUsername()+" to "+getHostDesc()+" failed: " + e.getMessage(), e); + LOG.error("SSH password authentication as {0} to {1} failed: {2}", configuration.getUsername(), + getHostDesc(), e.getMessage()); + throw new ConnectionFailedException( + "SSH password authentication as " + configuration.getUsername() + " to " + getHostDesc() + + " failed: " + e.getMessage(), e); } catch (TransportException e) { - LOG.error("Communication error during SSH password authentication as {0} to {1} failed: {2}", configuration.getUsername(), getHostDesc(), e.getMessage()); - throw new ConnectionFailedException("Communication error during SSH public key authentication as "+configuration.getUsername()+" to "+getHostDesc()+" failed: " + e.getMessage(), e); + LOG.error("Communication error during SSH password authentication as {0} to {1} failed: {2}", + configuration.getUsername(), getHostDesc(), e.getMessage()); + throw new ConnectionFailedException( + "Communication error during SSH public key authentication as " + configuration.getUsername() + + " to " + getHostDesc() + " failed: " + e.getMessage(), e); } }); } @@ -141,7 +155,8 @@ private void authenticatePublicKey() { KeyProvider keyProvider; try { if (passphrase.getClearChars() != null) { - keyProvider = ssh.loadKeys(privateKey.getClearString(), null, PasswordUtils.createOneOff(passphrase.getClearChars())); + keyProvider = ssh.loadKeys(privateKey.getClearString(), null, + PasswordUtils.createOneOff(passphrase.getClearChars())); } else { keyProvider = ssh.loadKeys(privateKey.getClearString(), null, null); } @@ -153,11 +168,17 @@ private void authenticatePublicKey() { ssh.authPublickey(configuration.getUsername()); } } catch (UserAuthException e) { - LOG.error(e, "SSH public key authentication as {0} to {1} failed: {2}", configuration.getUsername(), getHostDesc(), e.getMessage()); - throw new ConnectionFailedException("SSH public key authentication as "+configuration.getUsername()+" to "+getHostDesc()+" failed: " + e.getMessage(), e); + LOG.error(e, "SSH public key authentication as {0} to {1} failed: {2}", configuration.getUsername(), + getHostDesc(), e.getMessage()); + throw new ConnectionFailedException( + "SSH public key authentication as " + configuration.getUsername() + " to " + getHostDesc() + + " failed: " + e.getMessage(), e); } catch (TransportException e) { - LOG.error(e, "Communication error during SSH public key authentication as {0} to {1} failed: {2}", configuration.getUsername(), getHostDesc(), e.getMessage()); - throw new ConnectionFailedException("Communication error during SSH public key authentication as "+configuration.getUsername()+" to "+getHostDesc()+" failed: " + e.getMessage(), e); + LOG.error(e, "Communication error during SSH public key authentication as {0} to {1} failed: {2}", + configuration.getUsername(), getHostDesc(), e.getMessage()); + throw new ConnectionFailedException( + "Communication error during SSH public key authentication as " + configuration.getUsername() + + " to " + getHostDesc() + " failed: " + e.getMessage(), e); } } @@ -191,7 +212,8 @@ private void disconnect() { try { ssh.disconnect(); } catch (IOException e) { - LOG.warn("Error disconnecting SSH session for {0}: {1} (ignoring)", getConnectionDesc(), e.getMessage()); + LOG.warn("Error disconnecting SSH session for {0}: {1} (ignoring)", getConnectionDesc(), + e.getMessage()); } LOG.info("Connection to {0} disconnected", getConnectionDesc()); } @@ -217,7 +239,7 @@ public void checkAlive() { @Override public Object runScriptOnResource(ScriptContext scriptCtx, OperationOptions options) { String scriptLanguage = scriptCtx.getScriptLanguage(); - String processedCommand = commandProcessor.process(scriptCtx); + ProcessedCommand processedCommand = commandProcessor.process(scriptCtx); OperationLog.log("{0} Script REQ {1}: {2}", getConnectionDesc(), scriptLanguage, processedCommand); @@ -228,25 +250,36 @@ public Object runScriptOnResource(ScriptContext scriptCtx, OperationOptions opti } catch (Exception e) { OperationLog.error("{0} Script ERR {1}", getConnectionDesc(), e.getMessage()); - throw new ConnectorException("Script execution failed: "+e.getMessage(), e); + throw new ConnectorException("Script execution failed: " + e.getMessage(), e); } - OperationLog.log("{0} Script RES: {1}", getConnectionDesc(), (output==null||output.isEmpty())?"no output":("output "+output.length()+" chars")); + OperationLog.log("{0} Script RES: {1}", getConnectionDesc(), + (output == null || output.isEmpty()) ? "no output" : ("output " + output.length() + " chars")); LOG.ok("Script returned output\n{0}", output); return output; } // Exec can be run only once in each session. We need to explicitly connect and disconnect each time. - private String exec(String processedCommand) { + private String exec(ProcessedCommand processedCommand) { connect(); final Session.Command cmd; try { - cmd = session.exec(processedCommand); + //if any envs are defined, set them in the session + for (Map.Entry entry : processedCommand.envs().entrySet()) { + //https://man.openbsd.org/sshd_config#AcceptEnv + session.setEnvVar(entry.getKey(), entry.getValue()); + } + } catch (ConnectionException | TransportException e) { + throw new ConnectorIOException("Network error while setting SSH env (maybe configure AcceptEnv in sshd_config): " + e.getMessage(), e); + } + + try { + cmd = session.exec(processedCommand.commandString()); } catch (ConnectionException | TransportException e) { - throw new ConnectorIOException("Network error while executing SSH command: "+e.getMessage(), e); + throw new ConnectorIOException("Network error while executing SSH command: " + e.getMessage(), e); } String output; String error; @@ -268,7 +301,7 @@ private String exec(String processedCommand) { // - calling powershell successfully returned exitCode null // - there may be return codes <> 0 having empty errorstream. E.g. calling grep (linux) having empty result // simple solution: throw Exception if there is something in error stream - if (!error.isEmpty()){ + if (!error.isEmpty()) { LOG.error("---- error executing ssh command ----"); LOG.error("-- processedCommand: {0} ", processedCommand); LOG.error("-- command ouput: {0}", output); @@ -277,16 +310,16 @@ private String exec(String processedCommand) { LOG.error("-- command exitStatus: {0}", cmd.getExitStatus()); LOG.error("-- command exitSignal: {0}", cmd.getExitSignal()); LOG.error("--------------------------------------"); - throw new ConnectorException("Error executing SSH command: "+ error); + throw new ConnectorException("Error executing SSH command: " + error); } } catch (IOException e) { - throw new ConnectorIOException("Error reading output of SSH command: "+e.getMessage(), e); + throw new ConnectorIOException("Error reading output of SSH command: " + e.getMessage(), e); } try { cmd.join(5, TimeUnit.SECONDS); } catch (ConnectionException e) { - throw new ConnectorIOException("Error \"joining\" SSH command: "+e.getMessage(), e); + throw new ConnectorIOException("Error \"joining\" SSH command: " + e.getMessage(), e); } LOG.info("SSH command exit status: {0}", cmd.getExitStatus()); diff --git a/src/main/resources/com/evolveum/polygon/connector/ssh/Messages.properties b/src/main/resources/com/evolveum/polygon/connector/ssh/Messages.properties index ae9fc40..cbd2017 100644 --- a/src/main/resources/com/evolveum/polygon/connector/ssh/Messages.properties +++ b/src/main/resources/com/evolveum/polygon/connector/ssh/Messages.properties @@ -43,3 +43,9 @@ argumentStyle.help=The style in which the script arguments are formatted. It can handleNullValues.display=Null-Value Handling handleNullValues.help=Defines how to handle null-values in arguments. It can be "asEmptyString" or "asGone". + +variableTransport.display=Variable Transport +variableTransport.help=Defines how to transport variables into bash and powershell scripts. It can be "inplace" or "env". (default: "inplace") + +variableTransportEnvPrefix.display=Variable Transport ENV prefix +variableTransportEnvPrefix.help=Defines the prefix of the env if variable transport "env" has been set. (default: "CONNID_ARG_")