/* * Syncany, www.syncany.org * Copyright (C) 2011-2014 Philipp C. Heckel <philipp.heckel@gmail.com> * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.syncany.cli; import static java.util.Arrays.asList; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintStream; import java.util.ArrayList; import java.util.List; import java.util.Map.Entry; import java.util.Properties; import java.util.Random; import java.util.logging.ConsoleHandler; import java.util.logging.FileHandler; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.net.ssl.SSLContext; import joptsimple.OptionParser; import joptsimple.OptionSet; import joptsimple.OptionSpec; import org.apache.commons.io.IOUtils; import org.apache.http.HttpResponse; import org.apache.http.auth.AuthScope; import org.apache.http.auth.UsernamePasswordCredentials; import org.apache.http.client.CredentialsProvider; import org.apache.http.client.methods.HttpPost; import org.apache.http.conn.ssl.AllowAllHostnameVerifier; import org.apache.http.conn.ssl.X509HostnameVerifier; import org.apache.http.entity.StringEntity; import org.apache.http.impl.client.BasicCredentialsProvider; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClients; import org.simpleframework.xml.core.Persister; import org.syncany.Client; import org.syncany.config.ConfigException; import org.syncany.config.ConfigHelper; import org.syncany.config.LogFormatter; import org.syncany.config.Logging; import org.syncany.config.UserConfig; import org.syncany.config.to.PortTO; import org.syncany.operations.OperationOptions; import org.syncany.operations.daemon.DaemonOperation; import org.syncany.operations.daemon.messages.AlreadySyncingResponse; import org.syncany.operations.daemon.messages.BadRequestResponse; import org.syncany.operations.daemon.messages.api.FolderRequest; import org.syncany.operations.daemon.messages.api.FolderResponse; import org.syncany.operations.daemon.messages.api.MessageFactory; import org.syncany.operations.daemon.messages.api.Request; import org.syncany.operations.daemon.messages.api.Response; import org.syncany.util.EnvironmentUtil; import org.syncany.util.PidFileUtil; import org.syncany.util.StringUtil; /** * The command line client implements a typical CLI. It represents the first entry * point for the Syncany command line application and can be used to run all of the * supported commands. * * <p>The responsibilities of the command line client include the parsing and interpretation * of global options (like log file, debugging), displaying of help pages, and executing * commands. It furthermore detects if a local folder is handled by the daemon and, if so, * passes the command to the daemon via REST. * * @author Philipp C. Heckel <philipp.heckel@gmail.com> */ public class CommandLineClient extends Client { private static final Logger logger = Logger.getLogger(CommandLineClient.class.getSimpleName()); private static final String SERVER_SCHEMA = "https://"; private static final String SERVER_HOSTNAME = "127.0.0.1"; private static final String SERVER_REST_API = "/api/rs"; private static final String LOG_FILE_PATTERN = "syncany.log"; private static final int LOG_FILE_COUNT = 4; private static final int LOG_FILE_LIMIT = 25000000; // 25 MB private static final Pattern HELP_TEXT_RESOURCE_PATTERN = Pattern.compile("\\%RESOURCE:([^%]+)\\%"); private static final String HELP_TEXT_RESOURCE_ROOT = "/" + CommandLineClient.class.getPackage().getName().replace(".", "/") + "/"; private static final String HELP_TEXT_HELP_SKEL_RESOURCE = "cmd/help.skel"; private static final String HELP_TEXT_VERSION_SHORT_SKEL_RESOURCE = "incl/version_short.skel"; private static final String HELP_TEXT_VERSION_FULL_SKEL_RESOURCE = "incl/version_full.skel"; private static final String HELP_TEXT_USAGE_SKEL_RESOURCE = "incl/usage.skel"; private static final String HELP_TEXT_CMD_SKEL_RESOURCE = "cmd/help.%s.skel"; private static final String MAN_PAGE_MAIN = "sy"; private static final String MAN_PAGE_COMMAND_FORMAT = "sy-%s"; private String[] args; private File localDir; private PrintStream out; static { Logging.init(); Logging.disableLogging(); } public CommandLineClient(String[] args) { this.args = args; this.out = System.out; } public void setOut(OutputStream out) { this.out = new PrintStream(out); } public int start() throws Exception { // WARNING: Do not re-order methods unless you know what you are doing! try { // Define global options OptionParser parser = new OptionParser(); parser.allowsUnrecognizedOptions(); OptionSpec<Void> optionHelp = parser.acceptsAll(asList("h", "help")); OptionSpec<File> optionLocalDir = parser.acceptsAll(asList("l", "localdir")).withRequiredArg().ofType(File.class); OptionSpec<String> optionLog = parser.acceptsAll(asList("log")).withRequiredArg(); OptionSpec<Void> optionLogPrint = parser.acceptsAll(asList("print")); OptionSpec<String> optionLogLevel = parser.acceptsAll(asList("loglevel")).withOptionalArg(); OptionSpec<Void> optionDebug = parser.acceptsAll(asList("D", "debug")); OptionSpec<Void> optionShortVersion = parser.acceptsAll(asList("v")); OptionSpec<Void> optionFullVersion = parser.acceptsAll(asList("vv")); // Parse global options and operation name OptionSet options = parser.parse(args); List<?> nonOptions = options.nonOptionArguments(); // -v, -vv, --version int versionOptionsCode = initVersionOptions(options, optionShortVersion, optionFullVersion); if (versionOptionsCode != -1) { // Version information was displayed, exit. return versionOptionsCode; } int helpOrUsageCode = initHelpOrUsage(options, nonOptions, optionHelp); if (helpOrUsageCode != -1) { // Help or usage was displayed, exit. return helpOrUsageCode; } // Run! List<Object> nonOptionsCopy = new ArrayList<Object>(nonOptions); String commandName = (String) nonOptionsCopy.remove(0); String[] commandArgs = nonOptionsCopy.toArray(new String[0]); // Find command Command command = CommandFactory.getInstance(commandName); if (command == null) { return showErrorAndExit("Given command is unknown: " + commandName); } // Potentially show help if (options.has(optionHelp)) { return showCommandHelpAndExit(commandName); } // Pre-init operations initLocalDir(options, optionLocalDir); int configInitCode = initConfigIfRequired(command.getRequiredCommandScope(), localDir); if (configInitCode != 0) { return configInitCode; } initLogOption(options, optionLog, optionLogLevel, optionLogPrint, optionDebug); // Init command return runCommand(command, commandName, commandArgs); } catch (Exception e) { logger.log(Level.SEVERE, "Exception while initializing or running command.", e); return showErrorAndExit(e.getMessage()); } } private int initVersionOptions(OptionSet options, OptionSpec<Void> optionShortVersion, OptionSpec<Void> optionFullVersion) throws IOException { if (options.has(optionShortVersion)) { return showShortVersionAndExit(); } else if (options.has(optionFullVersion)) { return showFullVersionAndExit(); } return -1; } private int initHelpOrUsage(OptionSet options, List<?> nonOptions, OptionSpec<Void> optionHelp) throws IOException { if (nonOptions.size() == 0) { if (options.has(optionHelp)) { return showHelpAndExit(); } else { return showUsageAndExit(); } } return -1; } private void initLocalDir(OptionSet options, OptionSpec<File> optionLocalDir) throws ConfigException, Exception { // Find config or use --localdir option if (options.has(optionLocalDir)) { localDir = options.valueOf(optionLocalDir); } else { File currentDir = new File(".").getAbsoluteFile(); localDir = ConfigHelper.findLocalDirInPath(currentDir); // If no local directory was found, choose current directory if (localDir == null) { localDir = currentDir; } } } /** * Initializes configuration if required. * Returns non-zero if something goes wrong. */ private int initConfigIfRequired(CommandScope requiredCommandScope, File localDir) throws ConfigException { switch (requiredCommandScope) { case INITIALIZED_LOCALDIR: if (!ConfigHelper.configExists(localDir)) { return showErrorAndExit("No repository found in path, or configured plugin not installed. Use 'sy init' to create one."); } config = ConfigHelper.loadConfig(localDir); if (config == null) { return showErrorAndExit("Invalid config in " + localDir); } break; case UNINITIALIZED_LOCALDIR: if (ConfigHelper.configExists(localDir)) { return showErrorAndExit("Repository found in path. Command can only be used outside a repository."); } break; case ANY: default: break; } return 0; } private void initLogOption(OptionSet options, OptionSpec<String> optionLog, OptionSpec<String> optionLogLevel, OptionSpec<Void> optionLogPrint, OptionSpec<Void> optionDebug) throws SecurityException, IOException { initLogHandlers(options, optionLog, optionLogPrint, optionDebug); initLogLevel(options, optionDebug, optionLogLevel); } private void initLogLevel(OptionSet options, OptionSpec<Void> optionDebug, OptionSpec<String> optionLogLevel) { Level newLogLevel = null; // --debug if (options.has(optionDebug)) { newLogLevel = Level.ALL; } // --loglevel=<level> else if (options.has(optionLogLevel)) { String newLogLevelStr = options.valueOf(optionLogLevel); try { newLogLevel = Level.parse(newLogLevelStr); } catch (IllegalArgumentException e) { showErrorAndExit("Invalid log level given " + newLogLevelStr + "'"); } } else { newLogLevel = Level.INFO; } // Add handler to existing loggers, and future ones Logging.setGlobalLogLevel(newLogLevel); // Debug output if (options.has(optionDebug)) { out.println("debug"); out.println(String.format("Application version: %s", Client.getApplicationVersionFull())); logger.log(Level.INFO, "Application version: {0}", Client.getApplicationVersionFull()); } } private void initLogHandlers(OptionSet options, OptionSpec<String> optionLog, OptionSpec<Void> optionLogPrint, OptionSpec<Void> optionDebug) throws SecurityException, IOException { // --log=<file> String logFilePattern = null; if (options.has(optionLog)) { if (!"-".equals(options.valueOf(optionLog))) { logFilePattern = options.valueOf(optionLog); } } else if (config != null && config.getLogDir().exists()) { logFilePattern = config.getLogDir() + File.separator + LOG_FILE_PATTERN; } else { logFilePattern = UserConfig.getUserLogDir() + File.separator + LOG_FILE_PATTERN; } if (logFilePattern != null) { Handler fileLogHandler = new FileHandler(logFilePattern, LOG_FILE_LIMIT, LOG_FILE_COUNT, true); fileLogHandler.setFormatter(new LogFormatter()); Logging.addGlobalHandler(fileLogHandler); } // --debug, add console handler if (options.has(optionDebug) || options.has(optionLogPrint) || (options.has(optionLog) && "-".equals(options.valueOf(optionLog)))) { Handler consoleLogHandler = new ConsoleHandler(); consoleLogHandler.setFormatter(new LogFormatter()); Logging.addGlobalHandler(consoleLogHandler); } } private int runCommand(Command command, String commandName, String[] commandArgs) { File portFile = null; if (config != null) { portFile = config.getPortFile(); } File daemonPidFile = new File(UserConfig.getUserConfigDir(), DaemonOperation.PID_FILE); boolean localDirHandledInDaemonScope = portFile != null && portFile.exists(); boolean daemonRunning = PidFileUtil.isProcessRunning(daemonPidFile); boolean needsToRunInInitializedScope = command.getRequiredCommandScope() == CommandScope.INITIALIZED_LOCALDIR; boolean sendToRest = daemonRunning & localDirHandledInDaemonScope && needsToRunInInitializedScope; command.setOut(out); if (sendToRest) { if (command.canExecuteInDaemonScope()) { return sendToRest(command, commandName, commandArgs, portFile); } else { logger.log(Level.SEVERE, "Command not allowed when folder is daemon-managed: " + command.toString()); return showErrorAndExit("Command not allowed when folder is daemon-managed"); } } else { return runLocally(command, commandArgs); } } private int runLocally(Command command, String[] commandArgs) { command.setClient(this); command.setLocalDir(localDir); // Run! try { return command.execute(commandArgs); } catch (Exception e) { logger.log(Level.SEVERE, "Command " + command.toString() + " FAILED. ", e); return showErrorAndExit(e.getMessage()); } } private int sendToRest(Command command, String commandName, String[] commandArgs, File portFile) { try { // Read port config (for daemon) from port file PortTO portConfig = readPortConfig(portFile); // Create authentication details CredentialsProvider credentialsProvider = new BasicCredentialsProvider(); credentialsProvider.setCredentials( new AuthScope(SERVER_HOSTNAME, portConfig.getPort()), new UsernamePasswordCredentials(portConfig.getUser().getUsername(), portConfig.getUser().getPassword())); // Allow all hostnames in CN; this is okay as long as hostname is localhost/127.0.0.1! // See: https://github.com/syncany/syncany/pull/196#issuecomment-52197017 X509HostnameVerifier hostnameVerifier = new AllowAllHostnameVerifier(); // Fetch the SSL context (using the user key/trust store) SSLContext sslContext = UserConfig.createUserSSLContext(); // Create client with authentication details CloseableHttpClient client = HttpClients .custom() .setSslcontext(sslContext) .setHostnameVerifier(hostnameVerifier) .setDefaultCredentialsProvider(credentialsProvider) .build(); // Build and send request, print response Request request = buildFolderRequestFromCommand(command, commandName, commandArgs, config.getLocalDir().getAbsolutePath()); String serverUri = SERVER_SCHEMA + SERVER_HOSTNAME + ":" + portConfig.getPort() + SERVER_REST_API; String xmlMessageString = MessageFactory.toXml(request); StringEntity xmlMessageEntity = new StringEntity(xmlMessageString); HttpPost httpPost = new HttpPost(serverUri); httpPost.setEntity(xmlMessageEntity); logger.log(Level.INFO, "Sending HTTP Request to: " + serverUri); logger.log(Level.FINE, httpPost.toString()); logger.log(Level.FINE, xmlMessageString); HttpResponse httpResponse = client.execute(httpPost); int exitCode = handleRestResponse(command, httpResponse); return exitCode; } catch (Exception e) { logger.log(Level.SEVERE, "Command " + command.toString() + " FAILED. ", e); return showErrorAndExit(e.getMessage()); } } private int handleRestResponse(Command command, HttpResponse httpResponse) throws Exception { logger.log(Level.FINE, "Received HttpResponse: " + httpResponse); String responseStr = IOUtils.toString(httpResponse.getEntity().getContent()); logger.log(Level.FINE, "Responding to message with responseString: " + responseStr); Response response = MessageFactory.toResponse(responseStr); if (response instanceof FolderResponse) { FolderResponse folderResponse = (FolderResponse) response; command.printResults(folderResponse.getResult()); return 0; } else if (response instanceof AlreadySyncingResponse) { out.println("Daemon is already syncing, please retry later."); return 1; } else if (response instanceof BadRequestResponse) { out.println(response.getMessage()); return 1; } return 1; } private Request buildFolderRequestFromCommand(Command command, String commandName, String[] commandArgs, String root) throws Exception { String thisPackage = BadRequestResponse.class.getPackage().getName(); // TODO [low] Medium-dirty hack. String camelCaseMessageType = StringUtil.toCamelCase(commandName) + FolderRequest.class.getSimpleName(); String fqMessageClassName = thisPackage + "." + camelCaseMessageType; FolderRequest folderRequest; try { Class<? extends FolderRequest> folderRequestClass = Class.forName(fqMessageClassName).asSubclass(FolderRequest.class); folderRequest = folderRequestClass.newInstance(); } catch (Exception e) { logger.log(Level.INFO, "Could not find FQCN " + fqMessageClassName, e); throw new Exception("Cannot read request class from request type: " + commandName, e); } OperationOptions operationOptions = command.parseOptions(commandArgs); int requestId = Math.abs(new Random().nextInt()); folderRequest.setRoot(root); folderRequest.setId(requestId); folderRequest.setOptions(operationOptions); return folderRequest; } private PortTO readPortConfig(File portFile) { try { return new Persister().read(PortTO.class, portFile); } catch (Exception e) { logger.log(Level.SEVERE, "ERROR: Could not read portFile to connect to daemon.", e); showErrorAndExit("Cannot connect to daemon."); return null; // Never reached! } } private int showShortVersionAndExit() throws IOException { return printHelpTextAndExit(HELP_TEXT_VERSION_SHORT_SKEL_RESOURCE); } private int showFullVersionAndExit() throws IOException { return printHelpTextAndExit(HELP_TEXT_VERSION_FULL_SKEL_RESOURCE); } private int showUsageAndExit() throws IOException { return printHelpTextAndExit(HELP_TEXT_USAGE_SKEL_RESOURCE); } private int showHelpAndExit() throws IOException { // Try opening man page (if on Linux) if (EnvironmentUtil.isUnixLikeOperatingSystem()) { int manPageReturnCode = execManPageAndExit(MAN_PAGE_MAIN); if (manPageReturnCode == 0) { // Success return manPageReturnCode; } } // Fallback (and on Windows): Display man page on STDOUT return printHelpTextAndExit(HELP_TEXT_HELP_SKEL_RESOURCE); } private int showCommandHelpAndExit(String commandName) throws IOException { // Try opening man page (if on Linux) if (EnvironmentUtil.isUnixLikeOperatingSystem()) { String commandManPage = String.format(MAN_PAGE_COMMAND_FORMAT, commandName); int manPageReturnCode = execManPageAndExit(commandManPage); if (manPageReturnCode == 0) { // Success return manPageReturnCode; } } // Fallback (and on Windows): Display man page on STDOUT String helpTextResource = String.format(HELP_TEXT_CMD_SKEL_RESOURCE, commandName); return printHelpTextAndExit(helpTextResource); } private int execManPageAndExit(String manPage) { try { Runtime runtime = Runtime.getRuntime(); Process manProcess = runtime.exec(new String[] { "sh", "-c", "man " + manPage + " > /dev/tty" }); int manProcessExitCode = manProcess.waitFor(); if (manProcessExitCode == 0) { return 0; } } catch (Exception e) { // Don't care! } return 1; } private int printHelpTextAndExit(String helpTextResource) throws IOException { String fullHelpTextResource = HELP_TEXT_RESOURCE_ROOT + helpTextResource; InputStream helpTextInputStream = CommandLineClient.class.getResourceAsStream(fullHelpTextResource); if (helpTextInputStream == null) { return showErrorAndExit("No detailed help text available for this command."); } for (String line : IOUtils.readLines(helpTextInputStream)) { line = replaceVariables(line); out.println(line.replaceAll("\\s$", "")); } out.close(); return 0; } private String replaceVariables(String line) throws IOException { Properties applicationProperties = Client.getApplicationProperties(); for (Entry<Object, Object> applicationProperty : applicationProperties.entrySet()) { String variableName = String.format("%%%s%%", applicationProperty.getKey()); if (line.contains(variableName)) { line = line.replace(variableName, (String) applicationProperty.getValue()); } } Matcher includeResourceMatcher = HELP_TEXT_RESOURCE_PATTERN.matcher(line); if (includeResourceMatcher.find()) { String includeResource = HELP_TEXT_RESOURCE_ROOT + includeResourceMatcher.group(1); InputStream includeResourceInputStream = CommandLineClient.class.getResourceAsStream(includeResource); String includeResourceStr = IOUtils.toString(includeResourceInputStream); line = includeResourceMatcher.replaceAll(includeResourceStr); line = replaceVariables(line); } return line; } private int showErrorAndExit(String errorMessage) { out.println("Error: " + errorMessage); out.println(" Refer to help page using '--help'."); out.println(); out.close(); return 1; } }