/* * ==================== * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER. * * Copyright 2008-2009 Sun Microsystems, Inc. All rights reserved. * * The contents of this file are subject to the terms of the Common Development * and Distribution License("CDDL") (the "License"). You may not use this file * except in compliance with the License. * * You can obtain a copy of the License at * http://opensource.org/licenses/cddl1.php * See the License for the specific language governing permissions and limitations * under the License. * * When distributing the Covered Code, include this CDDL Header Notice in each file * and include the License file at http://opensource.org/licenses/cddl1.php. * If applicable, add the following below this CDDL Header, with the fields * enclosed by brackets [] replaced by your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" * ==================== * Portions Copyrighted 2012 Evolveum, Radovan Semancik */ package org.identityconnectors.solaris; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Hashtable; import java.util.List; import java.util.Map; import java.util.Set; import org.apache.oro.text.regex.MalformedPatternException; import org.apache.oro.text.regex.Perl5Compiler; import org.identityconnectors.common.CollectionUtil; import org.identityconnectors.common.StringUtil; import org.identityconnectors.common.logging.Log; import org.identityconnectors.common.security.GuardedString; import org.identityconnectors.framework.common.exceptions.AlreadyExistsException; import org.identityconnectors.framework.common.exceptions.ConfigurationException; import org.identityconnectors.framework.common.exceptions.ConnectorException; import org.identityconnectors.framework.common.exceptions.ConnectorSecurityException; import org.identityconnectors.solaris.command.RegExpCaseInsensitiveMatch; import org.identityconnectors.solaris.mode.AixModeDriver; import org.identityconnectors.solaris.mode.LinuxModeDriver; import org.identityconnectors.solaris.mode.SolarisModeDriver; import org.identityconnectors.solaris.mode.UnixModeDriver; import org.identityconnectors.solaris.operation.SolarisCreate; import org.identityconnectors.solaris.operation.SolarisUpdate; import com.jcraft.jsch.ChannelShell; import com.jcraft.jsch.JSch; import com.jcraft.jsch.JSchException; import com.jcraft.jsch.Session; import expect4j.Closure; import expect4j.Expect4j; import expect4j.ExpectState; import expect4j.ExpectUtils; import expect4j.matches.Match; import expect4j.matches.RegExpMatch; import expect4j.matches.TimeoutMatch; /** * Connection class provides connection to the Solaris resource. It also creates * an abstraction layer for interpretation of errors. * * <p> * The connection offers the following authentication types: SSH, Telnet and SSH * Pubkey. * * @author David Adam * * <p> * <i>Implementation notes:</i> Expect4J is an expect based matching * system for analyzing the responses from a unix resource, and for * defining actions to these responses. * <p> * Expect4j uses internally JSch to provide the SSH connection channel * to the resource. (This is transparent to the user of Expect4J, so it * is for {@link SolarisConnection}. * */ public class SolarisConnection { /* * Implementation constant: the maximum timeout that the connection waits * before retrying to read an emergency output (after occurrence of ERROR in * output stream from the resource. * * Constant's unit: millisecond. */ private static final int WAITFOR_TIMEOUT_FOR_ERROR = 600; private static final String HOST_END_OF_LINE_TERMINATOR = "\n"; /* * Root Shell Prompt used by the connector. As Expect uses regular * expressions, the pattern should be quoted as a string literal. */ private static final String CONNECTOR_PROMPT = "~ConnectorPrompt"; /* * Implementation constant: the maximum length of overall timeout that we * wait after discovering ERROR in output stream of the unix resource. * * - we are using System.nanotime() for time measuring, so the unit of this * constant is nanosecond. */ // 100 mseconds, hard-coded constant in the adapter. private static final double ERROR_WAIT = 100 * Math.pow(10, 6); /** * default way of handling error messages. */ private static final ErrorHandler DEFAULT_ERROR_HANDLER = new ErrorHandler() { public void handle(String buffer) { if (buffer.contains("is already in use. Choose another.")) { throw new AlreadyExistsException("ERROR, buffer content: <" + buffer + ">"); } throw new ConnectorException("ERROR, buffer content: <" + buffer + ">"); } }; private String loginShellPrompt; private Expect4j expect4j; /** * the configuration object from which this connection is created. */ private final SolarisConfiguration configuration; public SolarisConfiguration getConfiguration() { return configuration; } private final Log log = Log.getLog(SolarisConnection.class); private Session session; private ChannelShell channel; private Boolean isVersionLT10; private final UnixModeDriver modeDriver; public SolarisConnection(SolarisConfiguration config) { if (config == null) { throw new ConfigurationException( "Cannot create a SolarisConnection on a null configuration."); } configuration = config; loginShellPrompt = configuration.getLoginShellPrompt(); final String loginUser = configuration.getLoginUser(); final GuardedString password = configuration.getPassword(); final ConnectionType connType = ConnectionType.toConnectionType(configuration.getConnectionType()); switch (connType) { case SSH: createSSHConn(loginUser, password); break; case SSHPUBKEY: createSSHPubKeyConn(loginUser); break; case TELNET: createTelnetConn(loginUser, password); break; } modeDriver = createModeDriver(configuration.getUnixMode()); // TODO: it is not ideal to do this in a constructor. Refactor later. try { if (!connType.selfAuthenticates()) { /* * telnet doesn't authenticate automatically, so an extra step * is needed: */ executeCommand(null, Collections.<String> emptySet(), CollectionUtil .newSet("login")/* wait for login prompt */); executeCommand(loginUser.trim(), Collections.<String> emptySet(), CollectionUtil .newSet("assword")); sendPassword(password); } waitForRootShellPrompt(CollectionUtil.newSet("incorrect")); /* * turn off the echoing of keyboard input on the resource. Saves * bandwith too. */ executeCommand("stty -echo"); // if the login and root users are different, we will need to su to // root here. final String rootUser = configuration.getRootUser(); if (!configuration.isSudoAuthorization() && configuration.isSuAuthorization()) { executeCommand("su " + rootUser, CollectionUtil.newSet("Unknown id", "does not exist"), CollectionUtil.newSet("assword:")); // we need to change the type of rootShellPrompt here (we used // loginUser's up to now) final String rootShellPrompt = (StringUtil.isNotBlank(configuration.getRootShellPrompt())) ? configuration .getRootShellPrompt() : loginShellPrompt; loginShellPrompt = rootShellPrompt; final GuardedString rootPassword = configuration.getCredentials(); sendPassword(rootPassword, CollectionUtil.newSet("Sorry", "incorrect password"), Collections.<String> emptySet() /* * wait for * rootShellPrompt */); executeCommand("stty -echo"); } /* * Change root shell prompt, for simplier parsing of the output. * Revert the changes after the connection is closed. */ loginShellPrompt = CONNECTOR_PROMPT; executeCommand("PS1=\"" + CONNECTOR_PROMPT + "\""); } catch (Exception e) { throw new ConnectorException(String.format( "Connection failed to host '%s:%s' for user '%s'", configuration.getHost(), configuration.getPort(), loginUser), e); } } private void createTelnetConn(String username, GuardedString password) { Expect4j expect4j = null; try { expect4j = ExpectUtils.telnet(configuration.getHost(), configuration.getPort()); } catch (Exception e1) { throw ConnectorException.wrap(e1); } this.expect4j = expect4j; } private UnixModeDriver createModeDriver(String unixMode) { // TODO: Ugly and difficult to extend. Refactor later. if (unixMode == null || unixMode.equals(SolarisModeDriver.MODE_NAME)) { return new SolarisModeDriver(this); } else if (unixMode.equals(LinuxModeDriver.MODE_NAME)) { return new LinuxModeDriver(this); } else if (unixMode.equals(AixModeDriver.MODE_NAME)){ return new AixModeDriver(this); } else { throw new ConfigurationException("Unknown unix mode '" + unixMode + "'"); } } /** * Connect to the resource using privateKey + passphrase pair. * * @param username * * Implementational note: this piece of code is a combination of * the adapter's SSHPubKeyConnection#OpenSession() method and * ExpectUtils#SSH() */ private void createSSHPubKeyConn(final String username) { final JSch jsch = new JSch(); final GuardedString privateKey = getConfiguration().getPrivateKey(); final GuardedString keyPassphrase = getConfiguration().getPassphrase(); privateKey.access(new GuardedString.Accessor() { public void access(final char[] privateKeyClearText) { keyPassphrase.access(new GuardedString.Accessor() { public void access(final char[] keyPassphraseClearText) { final String identityName = "IdentityConnector"; try { jsch.addIdentity(identityName, convertToBytes(privateKeyClearText), null, convertToBytes(keyPassphraseClearText)); session = jsch.getSession(username, getConfiguration().getHost(), getConfiguration().getPort()); Hashtable<String, String> config = new Hashtable<String, String>(); config.put("StrictHostKeyChecking", "no"); session.setConfig(config); session.setDaemonThread(true); // making a connection with timeout. session.connect(3 * 1000); } catch (JSchException e) { throw ConnectorException.wrap(e); } finally { try { jsch.removeIdentity(identityName); } catch (JSchException e) { // OK } } } private byte[] convertToBytes(char[] text) { byte[] bytes = new byte[text.length]; for (int i = 0; i < text.length; i++) { bytes[i] = (byte) text[i]; } return bytes; } }); } }); try { channel = (ChannelShell) session.openChannel("shell"); } catch (JSchException e) { throw ConnectorException.wrap(e); } channel.setPtyType("vt102"); Expect4j expect = null; try { expect = new Expect4j(channel.getInputStream(), channel.getOutputStream()); channel.connect(5 * 1000); } catch (Exception e) { throw ConnectorException.wrap(e); } expect4j = expect; } private void createSSHConn(final String username, GuardedString password) { JSch jsch = new JSch(); try { session = jsch.getSession(username, configuration.getHost(), configuration.getPort()); } catch (JSchException e) { throw ConnectorException.wrap(e); } password.access(new GuardedString.Accessor() { public void access(char[] clearChars) { session.setPassword(new String(clearChars)); java.util.Hashtable<Object, Object> config = new java.util.Hashtable<Object, Object>(); config.put("StrictHostKeyChecking", "no"); session.setConfig(config); session.setDaemonThread(true); try { log.info("Connecting to {0}:{1}", configuration.getHost(), configuration .getPort()); session.connect(/* 3 * 1000 */); // in adapter there's no // timeout given log.ok("Connected"); } catch (JSchException e) { log.error("Unable to connect to {0}:{1}: {2}", configuration.getHost(), configuration.getPort(), e.getMessage()); throw ConnectorException.wrap(e); } finally { session.setPassword(""); // cleaning password, as it is no // longer used } } }); try { channel = (ChannelShell) session.openChannel("shell"); channel.setPtyType("vt102"); expect4j = new Expect4j(channel.getInputStream(), channel.getOutputStream()); // in adapter there's no timeout given channel.connect(/* 5 * 1000 */); } catch (Exception ex) { throw ConnectorException.wrap(ex); } } public UnixModeDriver getModeDriver() { return modeDriver; } /* *************** METHODS ****************** */ /** * send a command to the resource, no end of line needed. * * @param string */ private void sendInternal(String string, boolean isLog) throws IOException { expect4j.send(string + HOST_END_OF_LINE_TERMINATOR); if (isLog) { log.ok("TX: {0}", string); } else { log.ok("TX: *******"); } } /** * send a password to the resource, and return the response from the * resource, if any. * * @param passwd * the password to send * @param rejects * Optional parameter. {@see * SolarisConnection#executeCommand(String, Set, Set)} contract * @param accepts * Optional parameter. {@see * SolarisConnection#executeCommand(String, Set, Set)} contract * @return feedback on the sent password from the resource. * * Note on usage of params 'rejects', 'accepts': If none of the * parameters are given, we wait for RootShellPrompt */ public String sendPassword(GuardedString passwd, Set<String> rejects, Set<String> accepts) { sendPassword(passwd); return executeCommand(null/* no command is executed here */, rejects, accepts); } /** * just send a password but don't anticipate any response from the resource. */ public void sendPassword(GuardedString passwd) { passwd.access(new GuardedString.Accessor() { public void access(char[] clearChars) { try { for (char c : clearChars) { if (Character.isISOControl(c)) { throw new IllegalArgumentException( "User password contains one or more control characters."); } } sendInternal(new String(clearChars), false); } catch (IOException e) { throw ConnectorException.wrap(e); } } }); } /** * {@see SolarisConnection#executeCommand(String, Set, Set)}. */ public String executeCommand(String command) { return executeCommand(command, Collections.<String> emptySet(), Collections .<String> emptySet()); } /** * {@see SolarisConnection#executeCommand(String, Set, Set)}. * * @param timeout * the time interval, that we will wait for the response (marked * by {@link SolarisConfiguration#getRootShellPrompt()} */ public String executeCommand(String command, int timeout) { return executeCommand(command, Collections.<String> emptySet(), Collections .<String> emptySet(), timeout); } /** * {@see SolarisConnection#executeCommand(String, Set, Set)}. */ public String executeCommand(String command, Set<String> rejects) { return executeCommand(command, rejects, Collections.<String> emptySet()); } /** * This method's contract is similar to * {@link SolarisConnection#executeCommand(String, Map, Set)}. The only * difference is the 'rejects' parameter. * * @param rejects * the error messages that can occur. If they are found, a * {@link ConnectorException} is thrown. */ public String executeCommand(String command, Set<String> rejects, Set<String> accepts) { Map<String, ErrorHandler> rejectsMap = new HashMap<String, ErrorHandler>(); for (String rej : rejects) { // by default rejects throw ConnectorException. rejectsMap.put(rej, DEFAULT_ERROR_HANDLER); } return executeCommand(command, CollectionUtil.asReadOnlyMap(rejectsMap), accepts); } /** * {@see SolarisConnection#executeCommand(String, Set, Set)}. * * @param timeout * the time interval, that we will wait for the response (marked * by {@link SolarisConfiguration#getRootShellPrompt()} */ private String executeCommand(String command, Set<String> rejects, Set<String> accepts, int timeout) { Map<String, ErrorHandler> rejectsMap = new HashMap<String, ErrorHandler>(); for (String rej : rejects) { // by default rejects throw ConnectorException. rejectsMap.put(rej, DEFAULT_ERROR_HANDLER); } return executeCommand(command, CollectionUtil.asReadOnlyMap(rejectsMap), accepts, timeout); } /** * {@link SolarisConnection#executeCommand(String, Map, Set)}. * * @param timeout * the time interval, that we will wait for the response (marked * by {@link SolarisConfiguration#getRootShellPrompt()} */ private String executeCommand(String command, Map<String, ErrorHandler> rejects, Set<String> accepts, int timeout) { try { if (command != null) { sendInternal(command, true); } } catch (Exception e) { throw new ConnectorException( "Error occured in SolarisConnection, during send(). Exception message: " + e.getMessage()); } /* * IMPLEMENTATION NOTE on 'Ordering of matchers w.r.t. Expect4j' * * Expect4j matches the first pattern, that occurs at the minimum index * in the input stream. That said, for instance input stream is '$ * foobar $' with List of patterns = { "foobar", "$" }. The matching * pattern according to expect is "$". *WHY*? Expect matches the "$" * pattern, despite of "foobar" being the first in the list of patterns. * In fact, the implementation iterates over the list of matchers, and * searches for the index of matching substring. The first minimal index * wins. In other words, if two matchers both match the input from the * start, the matcher, which is the first in the list of patterns, wins. * * This explains why "$" was matched (it has index 0), rather then * foobar (with larger match index 2). */ MatchBuilder builder = new MatchBuilder(); SudoPasswordClosure sudoClosure = new SudoPasswordClosure(); if (configuration.isSudoAuthorization()) { SudoErrorClosure errorClosure = new SudoErrorClosure(); builder.addRegExpMatch("Sorry, try again", errorClosure); String sudoRegexp = configuration.getSudoPasswordPrompt(); if (sudoRegexp == null) { sudoRegexp = getModeDriver().getSudoPasswordRegexp(); } builder.addRegExpMatch(sudoRegexp, sudoClosure); } // according to the previous implementation note, the error matchers // should go first!: // #1 Adding Error matchers final List<ErrorClosure> cecList = new ArrayList<ErrorClosure>(); for (Map.Entry<String, ErrorHandler> rejEntry : rejects.entrySet()) { ErrorClosure cec = new ErrorClosure(rejEntry.getValue()); cecList.add(cec); /* * Errors are matched without respect to case. E.g. 'Error' and * 'ERROR' is treated the same. */ builder.addCaseInsensitiveRegExpMatch(rejEntry.getKey(), cec); } // #2 Adding RootShellPrompt matcher or other acceptance matchers if // given List<SolarisClosure> captureClosures = null; if (accepts.size() > 0) { captureClosures = CollectionUtil.<SolarisClosure> newList(); for (String acc : accepts) { if (acc == null) { // This means that we accept also empty output, which means // that a prompt appears acc = Perl5Compiler.quotemeta(getRootShellPrompt()); } SolarisClosure closure = new SolarisClosure(); captureClosures.add(closure); builder.addCaseInsensitiveRegExpMatch(acc, closure); } } else { // by default rootShellPrompt is added. SolarisClosure closure = new SolarisClosure(); captureClosures = CollectionUtil.newList(closure); // captureClosures.add(closure); builder.addRegExpMatch(Perl5Compiler.quotemeta(getRootShellPrompt()), closure); } // #3 set the timeout for matching too builder.addTimeoutMatch(timeout, new SolarisClosure() { public void run(ExpectState state) throws Exception { log.ok("RX (timeout): {0}", state.getBuffer()); throw new ConnectorException( "executeCommand: timeout occured, and no ERROR matched. Buffer: <" + state.getBuffer() + ">"); } }); try { /* * Implementation notes on Expect4J (v. 1.0) * * 1) What is Expect4J and how it is used in the connector? * * Expect4J is a wrapping library over SSH connection (provided by * JSch library), that is capable of analyzing the response from the * SSH connection. Expect supports various other connection types * too: telnet, SSH keypair. * * The role of Expect in SolarisConnector is to analyze the feedback * from the resource, and to invoke the assigned actions based on * the resource's feedback. * * The previous paragraph sounds a bit generic, so let's be more * specific about the way Expect analyzes the resource's response * (referred as "response" further). Expect has two basic commands: * -- send(string) -- that sends the string to an SSH connection * * -- expect(Match[]) -- waits for response, that will contain the * given matches. A match consists of a [string, Closure] pair. If * the given string is matched, the Closure is executed. * * Expect follows the algorithm: -- a) read the response from the * resource -- b) scan through matches, and choose a single winner * match that is in the response -- c) if no matches found try to * read from the resource, until some time is left from timeout. * * WARNING: Expect's way of matching patterns is pretty intricate! * * Note: a Closure is a callback interface, that is invoked when the * associated matching string is matched. * * * * * * 2) How does expect choose which matcher matches the response? * * If you'd like to verify the following paragraphs in the code, * look at expect4j.Expect4j.runFirstMatch(List). * * Expect receives a list of matchers (String, Closure) pairs. * * * * What matters for Expect when choosing the "winner" matching * string? * * CRITERIA #1 -- The order of matcher string *does matter*. * * CRITERIA #2 -- The position of match in the response for the * given matcher *does matter*. * * * * * == CRITERIA #1 explained -- "order matters" == * * In general, the most specific matcher strings should come first * in the list of matchers that expect receives. The more generic * should come to the end of List. If we take an extreme example, * when we have a matcher list: ["Error Foo specific", "Error"] In * case the response is identical to the first matcher, than it'll * be the winner. The second matcher could be matched too, but it is * only coming as second, moreover it has the same matching position * -- see the criteria further. * * * * == CRITERIA #2 explained -- "match position matters" == * * If there are two matchers, one is _prefix_ of another (for * example the previous ["Error Foo specific", "Error"]) so we can * have cases, when both matchers are matched. In this case Expect * *prefers* the matcher who: -- is matched as first -- && match * position is closer or equal to the start of the string. * * Reflected back onto the example, if the responses are: matcher * list: ["Error Foo specific", "Error"] response#1: * "bla Error Foo specific bla bla" winner#1: "Error Foo specific" * -- both matchers have the same position of the first match * character (=4), so the one earlier on the matching list wins. * * response#2: "bla Error bla Error Foo specific bla" winner #2: * "Error" -- the earlier match position wins here. * * * * 3) What part of the output do I get from expect in case of * successful match? * * Let's give an example: matcher list = ["Ahoj"] response: * "foo bar baz Ahoj ship" returned output from expect: * "foo bar baz Ahoj" * * In other words, expect returns the response buffer content up to * the first successful match (including characters of this match). * * Note: connector has and additional funcionality of altering the * output: it deletes the trailing rootShellPrompt (e.g. typicalli * $). * * * * 4) What happens if the match is unsuccessful? A timeout occurs. * This timeout can be registered by a timeout closure, as it is * done in the SolarisConnection too. * * * 5) How do we handle connection errors? In case an error string is * matched we enter into mode of special processing, that is * inherited from the Solaris resource adapter. This means, that we * try to read as much of error output as it is possible. (We * disregard any terminating characters exceptionally.) This * fuctionality is visible in method: * org.identityconnectors.solaris. * SolarisConnection.handleRejects(List<ErrorClosure>) . * * 6) Can I pass regurlar expressions in matchers? Yes, Expect4j * uses Apache Oro regular expression library. The regular * expressions are matched throughout the whole response. For * example: -- regexp: "ship" -- string to match: "ahoj ship" the * match is successful, the initial other letters are ignored by the * matcher. */ expect4j.expect(builder.build()); } catch (Exception e) { throw ConnectorException.wrap(e); } if (sudoClosure.isMatched) { GuardedString passwd = configuration.getCredentials(); passwd.access(new GuardedString.Accessor() { public void access(char[] clearChars) { for (char c : clearChars) { if (Character.isISOControl(c)) { throw new IllegalArgumentException( "User password contains one or more control characters."); } } try { log.ok("Sending sudo password"); expect4j.send(new String(clearChars) + HOST_END_OF_LINE_TERMINATOR); } catch (IOException e) { throw ConnectorException.wrap(e); } } }); // restart ... sudoClosure.reset(); try { expect4j.expect(builder.build()); } catch (Exception e) { throw ConnectorException.wrap(e); } } String output = null; for (SolarisClosure cl : captureClosures) { if (cl.isMatched()) { // get regular output matched by root shell prompt. output = cl.getMatchedBuffer(); break; } } if (output == null) { log.ok("RX <nothing>"); // handle error message processing, throw an exception if error // found handleRejects(cecList); } else { log.ok("RX: {0}", output); output = trimOutput(output); } return output; } /** * Execute a issue a command on the resource. Return the match of feedback * up to the root shell prompt * {@link SolarisConnection#getRootShellPrompt()}. * * Warning: all the matches (rejects, accepts parameter) are interpreted as * regular expressions (see {@link java.util.regex.Pattern}). Be careful * with special symbols, such as {@code $, ^}, as they should be escaped. * For instance to match the {@code $} prompt you should use {@code \\$}. * * @param command * the executed command. In special cases (such as waiting for * the first prompt after login, the command can have * {@code null} value. If {@code command} is {@code null}, then * we wait for root shell prompt without executing any other * commands (given that {@code root shell prompt} was not * overriden by {@code accepts} parameter. * * @param rejects * Map that contains error message, * {@link SolarisConnection.ErrorHandler} pairs. If the error * message is found in response from the resource, the error * handler is called. . * <p> * * @param accepts * these are accepting strings, if they are found the result is * returned. If empty set is given, the default accept is * {@link SolarisConnection#getRootShellPrompt()}. Caution: in * case <code>accepts</code> parameter is specified, it'll be the * last element in the response from the resource. If we don't * respect this rule than we will loose precious output of the * following commands, that are issued. <i>A larger illustration * of violation of the previous contract follows. It is above the * basic usage, however we should be aware of consequences of * violating contract for 'accepts' parameter.</i> EXAMPLE: see * the following calls / respones from the * {@link SolarisConnection}: * * <pre> * out = * conn.executeCommand("echo 'one'", Collections.<String> emptySet(), CollectionUtil * .newSet("one")); * Assert.assertEquals("one", out); // will succeed * out = * conn.executeCommand("echo 'two'", Collections.<String> emptySet(), CollectionUtil * .newSet("two")); * Assert.assertEquals("two", out); // fail, due to empty output * out = * conn.executeCommand("echo 'three'", Collections.<String> emptySet(), CollectionUtil * .newSet("three")); * Assert.assertEquals("three", out); // fail, due to empty output * </pre> * * If we analyze the previous example we will see the following * sequence of request/response going on: * * <pre> * >> echo 'one' * << one * >> echo 'two' * << two~ConnectorPrompt // at least this is what we expect, but we'll get an empty output * // because of {@link SolarisConnection#trimOutput(String)}, that cuts off everything after root shell prompt. * </pre> * * The solution is to wait for rootShellPrompt after every * 'accept' parameter, that doesn't terminate the output: * * <pre> * out = conn.executeCommand("echo 'one'", Collections.<String>emptySet(), CollectionUtil.newSet("one")); * conn.executeCommand(null) // will cause waiting for the rootShellPrompt. * Assert.assertEquals("one", out); // will succeed * out = conn.executeCommand("echo 'two'", Collections.<String>emptySet(), CollectionUtil.newSet("two")); * conn.executeCommand(null) // will cause waiting for the rootShellPrompt. * Assert.assertEquals("two", out); // will succeed * out = conn.executeCommand("echo 'three'", Collections.<String>emptySet(), CollectionUtil.newSet("three")); * conn.executeCommand(null) // will cause waiting for the rootShellPrompt. * Assert.assertEquals("three", out); // will succeed * </pre> * * @return the response from the resource when the command is successful, * free of error messages. Otherwise throw a * {@link ConnectorException}. * * @throws ConnectorException * in case a <code>rejects</code> string is found in the * response of the resource. */ public String executeCommand(String command, Map<String, ErrorHandler> rejects, Set<String> accepts) { return executeCommand(command, rejects, accepts, getConfiguration().getCommandTimeout()); } private String trimOutput(String output) { int index = output.lastIndexOf(getRootShellPrompt()); if (index != -1) { output = output.substring(0, index); } output = output.trim(); return output; } private void handleRejects(List<ErrorClosure> cecList) { for (ErrorClosure connectorExceptionClosure : cecList) { if (connectorExceptionClosure.isMatched()) { String out = connectorExceptionClosure.getMatchedBuffer(); out = waitForInput(out); connectorExceptionClosure.getErrorHandler().handle(out); } } } private String waitForInput(final String out) { StringBuilder buffer = new StringBuilder(out); long start = System.nanoTime(); while (System.nanoTime() - start <= ERROR_WAIT) { String tmp = null; try { tmp = waitForImpl(".+", WAITFOR_TIMEOUT_FOR_ERROR, true); } catch (Exception ex) { // OK } int lastLength = buffer.length(); buffer.append((tmp != null) ? tmp : ""); if (buffer.indexOf(getRootShellPrompt(), lastLength) > -1) { break; } } return trimOutput(buffer.toString()); } /** * wait for shell prompt. * * @param rejects * throw {@link ConnectorException} if a reject is matched in the * feedback up to the rootShellPrompt. * @throws ConnectorException * in case of timeout in waiting for rootShellPrompt character. */ public void waitForRootShellPrompt(Set<String> rejects) { executeCommand(null, rejects); } /** {@see SolarisConnection#waitForRootShellPrompt(Set)}. */ public void waitForRootShellPrompt() { executeCommand(null, Collections.<String> emptySet()); } private String waitForImpl(final String string, final int millis, boolean caseInsensitive) throws MalformedPatternException, Exception { log.info("waitFor(''{0}'', {1}, {2})", string, millis, Boolean.toString(caseInsensitive)); /** internal buffer for the Solaris resource's output */ final StringBuilder buffer = new StringBuilder(); // build the matchers /** in case of successful match this closure is called */ SolarisClosure successClosure = new SolarisClosure() { public void run(ExpectState state) { // save the content of buffer (the response from Solaris // resource) buffer.append(state.getBuffer()); } }; MatchBuilder builder = new MatchBuilder(); if (caseInsensitive) { builder.addCaseInsensitiveRegExpMatch(string, successClosure); } else { builder.addRegExpMatch(string, successClosure); } builder.addTimeoutMatch(millis, new SolarisClosure() { public void run(ExpectState state) throws Exception { String msg = String.format("Timeout in waitFor('%s', %s) buffer: <%s>", string, Integer .toString(millis), state.getBuffer()); throw new ConnectorException(msg); } }); expect4j.expect(builder.build()); return buffer.toString(); } /** * once connection is disposed it won't be used at all. This method performs * logoff and assigns null to the internal expect libraries reference. */ public void dispose() { try { sendInternal("exit", true); if (configuration.isSuAuthorization()) { sendInternal("exit", true); } } catch (IOException e) { // OK } log.info("dispose()"); if (expect4j != null) { expect4j.close(); expect4j = null; } if (channel != null) { channel.disconnect(); } if (session != null) { session.disconnect(); } } /** * The method formats the given command, and inserts {@code sudo} prefix in * front of the command according to the state of the connection's * {@link SolarisConnection#configuration}. * * @param command * the command can be a chain of strings separated by spaces. In * case for some reason we want to delegate the chaining to this * builder, we can use the additional arguments parameter. * @param arguments * optional parameter for chaining extra arguments at the end of * command. <br> * Note: Don't use this method, if you want to have a plain * command, <b>withouth</b> the {@code sudo} prefix. */ public String buildCommand(boolean needSudo, String command, CharSequence... arguments) { StringBuilder buff = new StringBuilder(); if (needSudo && configuration.isSudoAuthorization()) { buff.append("sudo "); } buff.append(command); // for safety reasons, in case there are no arguments, and the command // is used within legacy scrips from adapter. buff.append(" "); for (CharSequence string : arguments) { buff.append(" "); buff.append(string.toString()); } return SolarisUtil.limitString(buff); } public String getRootShellPrompt() { return loginShellPrompt; } /* * MUTEXING */ /** mutex acquire constants. */ private static final String TMP_PID_MUTEX_FILE = "/tmp/WSlockuid.$$"; private static final String PID_MUTEX_FILE = "/tmp/WSlockuid"; private static final String PID_FOUND_FILE = "/tmp/WSpidfound.$$"; /** * Mutexing script is used to prevent race conditions when creating multiple * users. These conditions are present at {@link SolarisCreate} and * {@link SolarisUpdate}. The code is taken from the resource adapter. */ private String getAcquireMutexScript() { // This code is from SolarisResouceAdapter long timeout = getConfiguration().getMutexAcquireTimeout(); String rmCmd = buildCommand(false, "rm"); String catCmd = buildCommand(false, "cat"); if (timeout < 1) { timeout = SolarisConfiguration.DEFAULT_MUTEX_ACQUIRE_TIMEOUT; } // @formatter:off String pidMutexAcquireScript = "TIMEOUT=" + timeout + "; " + "echo $$ > " + TMP_PID_MUTEX_FILE + "; " + "while test 1; " + "do " + "ln -n " + TMP_PID_MUTEX_FILE + " " + PID_MUTEX_FILE + " 2>/dev/null; " + "rc=$?; " + "if [ $rc -eq 0 ]; then\n" + "LOCKPID=`" + catCmd + " " + PID_MUTEX_FILE + "`; " + "if [ \"$LOCKPID\" = \"$$\" ]; then " + rmCmd + " -f " + TMP_PID_MUTEX_FILE + "; " + "break; " + "fi; " + "fi\n" + "if [ -f " + PID_MUTEX_FILE + " ]; then " + "LOCKPID=`" + catCmd + " " + PID_MUTEX_FILE + "`; " + "if [ \"$LOCKPID\" = \"$$\" ]; then " + rmCmd + " -f " + PID_MUTEX_FILE + "\n" + "else " + "ps -ef | while read REPLY\n" + "do " + "TESTPID=`echo $REPLY | awk '{ print $2 }'`; " + "if [ \"$LOCKPID\" = \"$TESTPID\" ]; then " + "touch " + PID_FOUND_FILE + "; " + "break; " + "fi\n" + "done\n" + "if [ ! -f " + PID_FOUND_FILE + " ]; then " + rmCmd + " -f " + PID_MUTEX_FILE + "; " + "else " + rmCmd + " -f " + PID_FOUND_FILE + "; " + "fi\n" + "fi\n" + "fi\n" + "TIMEOUT=`echo | awk 'BEGIN { n = '$TIMEOUT' } { n -= 1 } END { print n }'`\n" + "if [ $TIMEOUT = 0 ]; then " + "echo \"ERROR: failed to obtain uid mutex\"; " + rmCmd + " -f " + TMP_PID_MUTEX_FILE + "; " + "break; " + "fi\n" + "sleep 1; " + "done"; // @formatter:on return pidMutexAcquireScript; } private String getAcquireMutexScript(String uidMutexFile, String tmpUidMutexFile, String pidFoundFile) { long timeout = getConfiguration().getMutexAcquireTimeout(); String rmCmd = buildCommand(false, "rm"); String catCmd = buildCommand(false, "cat"); if (timeout < 1) { timeout = SolarisConfiguration.DEFAULT_MUTEX_ACQUIRE_TIMEOUT; } // @formatter:off String uidMutexAcquireScript = "TIMEOUT=" + timeout + "; " + "echo $$ > " + tmpUidMutexFile + "; " + "while test 1; " + "do " + "ln -n " + tmpUidMutexFile + " " + uidMutexFile + " 2>/dev/null; " + "rc=$?; " + "if [ $rc -eq 0 ]; then\n" + rmCmd + " -f " + tmpUidMutexFile + "; " + "break; " + "fi\n" + "LOCKPID=`" + catCmd + " " + uidMutexFile + "`; " + "if [ \"$LOCKPID\" = \"$$\" ]; then " + rmCmd + " -f " + uidMutexFile + "\n" + "else " + "ps -ef | while read REPLY\n" + "do " + "TESTPID=`echo $REPLY | awk '{ print $2 }'`; " + "if [ \"$LOCKPID\" = \"$TESTPID\" ]; then " + "touch " + pidFoundFile + "; " + "break; " + "fi\n" + "done\n" + "if [ ! -f " + pidFoundFile + " ]; then " + rmCmd + " -f " + uidMutexFile + "; " + "else " + rmCmd + " -f " + pidFoundFile + "; " + "fi\n" + "fi\n" + "if [ -f " + uidMutexFile + " ]; then " + "TIMEOUT=`echo | awk 'BEGIN { n = '$TIMEOUT' } { n -= 1 } END { print n }'`\n" + "if [ $TIMEOUT = 0 ]; then " + "echo \"ERROR: failed to obtain uid mutex\"; " + rmCmd + " -f " + tmpUidMutexFile + "; " + "break; " + "fi\n" + "sleep 1; " + "fi\n" + "done"; // @formatter:on return uidMutexAcquireScript; } /** Counterpart of {@link SolarisConnection#getAcquireMutexScript()}. */ private String getMutexReleaseScript(String uidMutexFile) { String rmCmd = buildCommand(false, "rm"); // @formatter:off String pidMutexReleaseScript = "if [ -f " + uidMutexFile + " ]; then " + "LOCKPID=`cat " + uidMutexFile + "`; " + "if [ \"$LOCKPID\" = \"$$\" ]; then " + rmCmd + " -f " + uidMutexFile + "; " + "fi; " + "fi"; // @formatter:off return pidMutexReleaseScript; } private String getMutexReleaseScript() { return getMutexReleaseScript(PID_MUTEX_FILE); } /** * Acquires Mutex before manipulating users or groups. Prevents concurrency * issues. * * Finally the {@link SolarisConnection#executeMutexReleaseScript()} should * be called to release the allocated mutex. */ public void executeMutexAcquireScript() { if (isVersionLT10()) { executeCommand(getAcquireMutexScript(), CollectionUtil.newSet("ERROR")); } } /** * Acquires Mutex before manipulating users or groups. Prevents concurrency * issues. This is a special version used for operations on Solaris with NIS * user database. * * Finally the {@link SolarisConnection#executeMutexReleaseScript(String)} * should be called to release the allocated mutex. */ public void executeMutexAcquireScript(String uidMutexFile, String tmpUidMutexFile, String pidFoundFile) { executeCommand(getAcquireMutexScript(uidMutexFile, tmpUidMutexFile, pidFoundFile), CollectionUtil.newSet("ERROR")); } /** {@see SolarisConnection#executeMutexAcquireScript()}. */ public void executeMutexReleaseScript() { if (isVersionLT10()) { executeCommand(getMutexReleaseScript()); } } /** * {@see SolarisConnection#executeMutexAcquireScript(String, String, * String)}. */ public void executeMutexReleaseScript(String uidMutexFile) { executeCommand(getMutexReleaseScript(uidMutexFile)); } public void checkAlive() { String out = executeCommand("echo 'checkAlive'"); if (StringUtil.isBlank(out) || !out.contains("checkAlive")) { throw new RuntimeException("Solaris Connector no longer alive."); } } /* * SUDO */ private static final String SUDO_START_COMMAND = "sudo -v"; private static final String SUDO_RESET_COMMAND = "sudo -k"; public void doSudoStart() { final SolarisConfiguration config = getConfiguration(); if (config.isSudoAuthorization()) { try { // 1) send sudo reset command log.ok("sudo reset (start will follow)"); executeCommand(SUDO_RESET_COMMAND, CollectionUtil.newSet("not found")); // 2) send sudo start command log.ok("sudo start"); executeCommand(SUDO_START_COMMAND); // Sudo password will be sent from the expect4j closure inside executeCommand() method // this is the same way as is used for other subsequent commands that might request sudo password // e.g. because the initial sudo start timed out log.ok("sudo start done"); } catch (Exception e) { throw ConnectorException.wrap(e); } } } public void doSudoReset() { final SolarisConfiguration config = getConfiguration(); if (config.isSudoAuthorization()) { // send sudo reset command log.ok("sudo reset"); executeCommand(SUDO_RESET_COMMAND); } } public boolean isNis() { final String sysDB = getConfiguration().getSystemDatabaseType(); return sysDB != null && sysDB.equalsIgnoreCase("nis"); } public boolean isDefaultNisPwdDir() { return configuration.getNisPwdDir().equals(SolarisConfiguration.DEFAULT_NISPWDDIR); } /** * This method returns true if Solaris version is 8 or 9 otherwise return * false. */ public boolean isVersionLT10() { if (isVersionLT10 == null) { isVersionLT10 = initIsVersionLT10(); } return isVersionLT10; } private boolean initIsVersionLT10() { String versionOut = executeCommand("uname -r"); if (StringUtil.isBlank(versionOut)) { return true; } versionOut = versionOut.trim(); String[] version = versionOut.split("\\."); boolean isVersionLT10 = true; if (version.length >= 2) { try { int minor = Integer.parseInt(version[1]); if (minor >= 10) { isVersionLT10 = false; } } catch (NumberFormatException e) { // OK } } return isVersionLT10; } /** * Use this class to construct a sequence of matchers. Matchers consists of * two parts: * <ul> * <li>1) regular expression -- used to detect the match,</li> * <li>2) closure -- call-back interface that is executed upon the match.</li> * </ul> * * @author David Adam */ private final static class MatchBuilder { private List<Match> matches; public MatchBuilder() { matches = new ArrayList<Match>(); } /** * adds a case sensitive matcher. Compare with * {@link MatchBuilder#addCaseInsensitiveRegExpMatch(String, SolarisClosure)} * . */ public void addRegExpMatch(String regExp, Closure closure) { try { matches.add(new RegExpMatch(regExp, closure)); } catch (MalformedPatternException ex) { throw ConnectorException.wrap(ex); } } /** * adds a case *insensitive matcher. Compare with * {@link MatchBuilder#addRegExpMatch(String, SolarisClosure)} */ public void addCaseInsensitiveRegExpMatch(String regExp, SolarisClosure closure) { try { matches.add(new RegExpCaseInsensitiveMatch(regExp, closure)); } catch (MalformedPatternException ex) { throw ConnectorException.wrap(ex); } } /** add a timeout match with given 'millis' period. */ public void addTimeoutMatch(long millis, SolarisClosure closure) { matches.add(new TimeoutMatch(millis, closure)); } public Match[] build() { return matches.toArray(new Match[matches.size()]); } } /** * internal Closure to hold the buffer state of the resource, plus indicator * of match. */ private class SolarisClosure implements Closure { private String buffer; private boolean isMatched; /** @return the buffer content up to the matched string */ public String getMatchedBuffer() { return buffer; } public boolean isMatched() { return isMatched; } public void run(ExpectState state) throws Exception { buffer = state.getBuffer(); isMatched = true; } } private class SudoPasswordClosure implements Closure { private boolean isMatched; public boolean isMatched() { return isMatched; } public void reset() { isMatched = false; } public void run(final ExpectState state) throws Exception { log.ok("Sudo password prompt detected ({0}), sending password", state.getBuffer()); isMatched = true; } } private class SudoErrorClosure implements Closure { public void run(final ExpectState state) throws Exception { String errorMessage = state.getBuffer(); log.ok("Sudo error: {0}", errorMessage); throw new ConnectorSecurityException("Sudo authentication failed: '"+errorMessage+"'"); } } private class ErrorClosure extends SolarisClosure { private final ErrorHandler errHandler; public ErrorClosure(ErrorHandler errHandler) { this.errHandler = errHandler; } public ErrorHandler getErrorHandler() { return errHandler; } } /** * Call-back interface for {@link SolarisConnection}. Used for customizable * exception messages. * * <p> * If an error is detected, {@link ErrorHandler#handle(String)} method will * be called with the error message received from the resource. */ public interface ErrorHandler { /** * typically throws a customized ConnectorException, with a message, * possibly including buffer's state. */ public void handle(String buffer); } }