/*
* 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;
}
}