001/*
002 * Syncany, www.syncany.org
003 * Copyright (C) 2011-2016 Philipp C. Heckel <philipp.heckel@gmail.com>
004 *
005 * This program is free software: you can redistribute it and/or modify
006 * it under the terms of the GNU General Public License as published by
007 * the Free Software Foundation, either version 3 of the License, or
008 * (at your option) any later version.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
013 * GNU General Public License for more details.
014 *
015 * You should have received a copy of the GNU General Public License
016 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
017 */
018package org.syncany.cli;
019
020import static java.util.Arrays.asList;
021
022import java.io.File;
023import java.io.IOException;
024import java.io.InputStream;
025import java.io.OutputStream;
026import java.io.PrintStream;
027import java.util.ArrayList;
028import java.util.List;
029import java.util.Map.Entry;
030import java.util.Properties;
031import java.util.Random;
032import java.util.logging.ConsoleHandler;
033import java.util.logging.FileHandler;
034import java.util.logging.Handler;
035import java.util.logging.Level;
036import java.util.logging.Logger;
037
038import javax.net.ssl.SSLContext;
039
040import joptsimple.OptionParser;
041import joptsimple.OptionSet;
042import joptsimple.OptionSpec;
043
044import org.apache.commons.io.IOUtils;
045import org.apache.http.HttpResponse;
046import org.apache.http.auth.AuthScope;
047import org.apache.http.auth.UsernamePasswordCredentials;
048import org.apache.http.client.CredentialsProvider;
049import org.apache.http.client.methods.HttpPost;
050import org.apache.http.conn.ssl.AllowAllHostnameVerifier;
051import org.apache.http.conn.ssl.X509HostnameVerifier;
052import org.apache.http.entity.StringEntity;
053import org.apache.http.impl.client.BasicCredentialsProvider;
054import org.apache.http.impl.client.CloseableHttpClient;
055import org.apache.http.impl.client.HttpClients;
056import org.simpleframework.xml.core.Persister;
057import org.syncany.Client;
058import org.syncany.config.Config;
059import org.syncany.config.ConfigException;
060import org.syncany.config.ConfigHelper;
061import org.syncany.config.LogFormatter;
062import org.syncany.config.Logging;
063import org.syncany.config.UserConfig;
064import org.syncany.config.to.PortTO;
065import org.syncany.operations.OperationOptions;
066import org.syncany.operations.daemon.DaemonOperation;
067import org.syncany.operations.daemon.WebServer;
068import org.syncany.operations.daemon.messages.AlreadySyncingResponse;
069import org.syncany.operations.daemon.messages.BadRequestResponse;
070import org.syncany.operations.daemon.messages.api.FolderRequest;
071import org.syncany.operations.daemon.messages.api.FolderResponse;
072import org.syncany.operations.daemon.messages.api.Request;
073import org.syncany.operations.daemon.messages.api.Response;
074import org.syncany.operations.daemon.messages.api.XmlMessageFactory;
075import org.syncany.util.EnvironmentUtil;
076import org.syncany.util.PidFileUtil;
077import org.syncany.util.StringUtil;
078
079/**
080 * The command line client implements a typical CLI. It represents the first entry
081 * point for the Syncany command line application and can be used to run all of the
082 * supported commands.
083 *
084 * <p>The responsibilities of the command line client include the parsing and interpretation
085 * of global options (like log file, debugging), displaying of help pages, and executing
086 * commands. It furthermore detects if a local folder is handled by the daemon and, if so,
087 * passes the command to the daemon via REST.
088 *
089 * @author Philipp C. Heckel (philipp.heckel@gmail.com)
090 */
091public class CommandLineClient extends Client {
092        private static final Logger logger = Logger.getLogger(CommandLineClient.class.getSimpleName());
093
094        private static final String SERVER_SCHEMA = "https://";
095        private static final String SERVER_HOSTNAME = "127.0.0.1";
096        private static final String SERVER_REST_API = WebServer.API_ENDPOINT_REST_XML;
097
098        private static final String LOG_FILE_PATTERN = "syncany.log";
099        private static final int LOG_FILE_COUNT = 4;
100        private static final int LOG_FILE_LIMIT = 25000000; // 25 MB
101
102        private static final String HELP_TEXT_RESOURCE_ROOT = "/" + CommandLineClient.class.getPackage().getName().replace(".", "/") + "/";
103        private static final String HELP_TEXT_HELP_SKEL_RESOURCE = "cmd/help.skel";
104        private static final String HELP_TEXT_VERSION_SHORT_SKEL_RESOURCE = "incl/version_short.skel";
105        private static final String HELP_TEXT_VERSION_FULL_SKEL_RESOURCE = "incl/version_full.skel";
106        private static final String HELP_TEXT_USAGE_SKEL_RESOURCE = "incl/usage.skel";
107        private static final String HELP_TEXT_CMD_SKEL_RESOURCE = "cmd/help.%s.skel";
108
109        private static final String MAN_PAGE_MAIN = "sy";
110        private static final String MAN_PAGE_COMMAND_FORMAT = "sy-%s";
111
112        private Config config;
113        
114        private String[] args;
115        private File localDir;
116
117        private PrintStream out;
118
119        static {
120                Logging.init();
121                Logging.disableLogging();
122        }
123
124        public CommandLineClient(String[] args) {
125                this.args = args;
126                this.out = System.out;
127        }
128
129        public void setOut(OutputStream out) {
130                this.out = new PrintStream(out);
131        }
132
133        public int start() throws Exception {
134                // WARNING: Do not re-order methods unless you know what you are doing!
135
136                try {
137                        // Define global options
138                        OptionParser parser = new OptionParser();
139                        parser.allowsUnrecognizedOptions();
140
141                        OptionSpec<Void> optionHelp = parser.acceptsAll(asList("h", "help"));
142                        OptionSpec<File> optionLocalDir = parser.acceptsAll(asList("l", "localdir")).withRequiredArg().ofType(File.class);
143                        OptionSpec<String> optionLog = parser.acceptsAll(asList("log")).withRequiredArg();
144                        OptionSpec<Void> optionLogPrint = parser.acceptsAll(asList("print"));
145                        OptionSpec<String> optionLogLevel = parser.acceptsAll(asList("loglevel")).withOptionalArg();
146                        OptionSpec<Void> optionDebug = parser.acceptsAll(asList("d", "debug"));
147                        OptionSpec<Void> optionShortVersion = parser.acceptsAll(asList("v"));
148                        OptionSpec<Void> optionFullVersion = parser.acceptsAll(asList("vv"));
149
150                        // Parse global options and operation name
151                        OptionSet options = parser.parse(args);
152                        List<?> nonOptions = options.nonOptionArguments();
153
154                        // -v, -vv, --version
155                        int versionOptionsCode = initVersionOptions(options, optionShortVersion, optionFullVersion);
156
157                        if (versionOptionsCode != -1) {
158                                // Version information was displayed, exit.
159                                return versionOptionsCode;
160                        }
161
162                        int helpOrUsageCode = initHelpOrUsage(options, nonOptions, optionHelp);
163
164                        if (helpOrUsageCode != -1) {
165                                // Help or usage was displayed, exit.
166                                return helpOrUsageCode;
167                        }
168
169                        // Run!
170                        List<Object> nonOptionsCopy = new ArrayList<Object>(nonOptions);
171                        String commandName = (String) nonOptionsCopy.remove(0);
172                        String[] commandArgs = nonOptionsCopy.toArray(new String[0]);
173
174                        // Find command
175                        Command command = CommandFactory.getInstance(commandName);
176
177                        if (command == null) {
178                                return showErrorAndExit("Given command is unknown: " + commandName);
179                        }
180
181                        // Potentially show help
182                        if (options.has(optionHelp)) {
183                                return showCommandHelpAndExit(commandName);
184                        }
185
186                        // Pre-init operations
187                        initLocalDir(options, optionLocalDir);
188
189                        int configInitCode = initConfigIfRequired(command.getRequiredCommandScope(), localDir);
190
191                        if (configInitCode != 0) {
192                                return configInitCode;
193                        }
194
195                        initLogOption(options, optionLog, optionLogLevel, optionLogPrint, optionDebug);
196
197                        // Init command
198                        return runCommand(command, commandName, commandArgs);
199                }
200                catch (Exception e) {
201                        logger.log(Level.SEVERE, "Exception while initializing or running command.", e);
202                        return showErrorAndExit(e.getMessage());
203                }
204        }
205
206        private int initVersionOptions(OptionSet options, OptionSpec<Void> optionShortVersion, OptionSpec<Void> optionFullVersion) throws IOException {
207                if (options.has(optionShortVersion)) {
208                        return showShortVersionAndExit();
209                }
210                else if (options.has(optionFullVersion)) {
211                        return showFullVersionAndExit();
212                }
213
214                return -1;
215        }
216
217        private int initHelpOrUsage(OptionSet options, List<?> nonOptions, OptionSpec<Void> optionHelp) throws IOException {
218                if (nonOptions.size() == 0) {
219                        if (options.has(optionHelp)) {
220                                return showHelpAndExit();
221                        }
222                        else {
223                                return showUsageAndExit();
224                        }
225                }
226
227                return -1;
228        }
229
230        private void initLocalDir(OptionSet options, OptionSpec<File> optionLocalDir) throws ConfigException, Exception {
231                // Find config or use --localdir option
232                if (options.has(optionLocalDir)) {
233                        localDir = options.valueOf(optionLocalDir);
234                }
235                else {
236                        File currentDir = new File(".").getAbsoluteFile();
237                        localDir = ConfigHelper.findLocalDirInPath(currentDir);
238
239                        // If no local directory was found, choose current directory
240                        if (localDir == null) {
241                                localDir = currentDir;
242                        }
243                }
244        }
245
246        /**
247         * Initializes configuration if required.
248         * Returns non-zero if something goes wrong.
249         */
250        private int initConfigIfRequired(CommandScope requiredCommandScope, File localDir) throws ConfigException {
251                switch (requiredCommandScope) {
252                case INITIALIZED_LOCALDIR:
253                        if (!ConfigHelper.configExists(localDir)) {
254                                return showErrorAndExit("No repository found in path, or configured plugin not installed. Use 'sy init' to create one.");
255                        }
256
257                        config = ConfigHelper.loadConfig(localDir);
258
259                        if (config == null) {
260                                return showErrorAndExit("Invalid config in " + localDir);
261                        }
262
263                        break;
264
265                case UNINITIALIZED_LOCALDIR:
266                        if (ConfigHelper.configExists(localDir)) {
267                                return showErrorAndExit("Repository found in path. Command can only be used outside a repository.");
268                        }
269
270                        break;
271
272                case ANY:
273                default:
274                        break;
275                }
276
277                return 0;
278        }
279
280        private void initLogOption(OptionSet options, OptionSpec<String> optionLog, OptionSpec<String> optionLogLevel, OptionSpec<Void> optionLogPrint,
281                        OptionSpec<Void> optionDebug) throws SecurityException, IOException {
282
283                initLogHandlers(options, optionLog, optionLogPrint, optionDebug);
284                initLogLevel(options, optionDebug, optionLogLevel);
285        }
286
287        private void initLogLevel(OptionSet options, OptionSpec<Void> optionDebug, OptionSpec<String> optionLogLevel) {
288                Level newLogLevel = null;
289
290                // --debug
291                if (options.has(optionDebug)) {
292                        newLogLevel = Level.ALL;
293                }
294
295                // --loglevel=<level>
296                else if (options.has(optionLogLevel)) {
297                        String newLogLevelStr = options.valueOf(optionLogLevel);
298
299                        try {
300                                newLogLevel = Level.parse(newLogLevelStr);
301                        }
302                        catch (IllegalArgumentException e) {
303                                showErrorAndExit("Invalid log level given " + newLogLevelStr + "'");
304                        }
305                }
306                else {
307                        newLogLevel = Level.INFO;
308                }
309
310                // Add handler to existing loggers, and future ones
311                Logging.setGlobalLogLevel(newLogLevel);
312
313                // Debug output
314                if (options.has(optionDebug)) {
315                        out.println("debug");
316                        out.println(String.format("Application version: %s", Client.getApplicationVersionFull()));
317
318                        logger.log(Level.INFO, "Application version: {0}", Client.getApplicationVersionFull());
319                }
320        }
321
322        private void initLogHandlers(OptionSet options, OptionSpec<String> optionLog, OptionSpec<Void> optionLogPrint, OptionSpec<Void> optionDebug)
323                        throws SecurityException, IOException {
324
325                // --log=<file>
326                String logFilePattern = null;
327
328                if (options.has(optionLog)) {
329                        if (!"-".equals(options.valueOf(optionLog))) {
330                                logFilePattern = options.valueOf(optionLog);
331                        }
332                }
333                else if (config != null && config.getLogDir().exists()) {
334                        logFilePattern = config.getLogDir() + File.separator + LOG_FILE_PATTERN;
335                }
336                else {
337                        logFilePattern = UserConfig.getUserLogDir() + File.separator + LOG_FILE_PATTERN;
338                }
339
340                if (logFilePattern != null) {
341                        Handler fileLogHandler = new FileHandler(logFilePattern, LOG_FILE_LIMIT, LOG_FILE_COUNT, true);
342                        fileLogHandler.setFormatter(new LogFormatter());
343
344                        Logging.addGlobalHandler(fileLogHandler);
345                }
346
347                // --debug, add console handler
348                if (options.has(optionDebug) || options.has(optionLogPrint) || (options.has(optionLog) && "-".equals(options.valueOf(optionLog)))) {
349                        Handler consoleLogHandler = new ConsoleHandler();
350                        consoleLogHandler.setFormatter(new LogFormatter());
351
352                        Logging.addGlobalHandler(consoleLogHandler);
353                }
354        }
355
356        private int runCommand(Command command, String commandName, String[] commandArgs) {
357                File portFile = null;
358
359                if (config != null) {
360                        portFile = config.getPortFile();
361                }
362
363                File daemonPidFile = new File(UserConfig.getUserConfigDir(), DaemonOperation.PID_FILE);
364
365                boolean localDirHandledInDaemonScope = portFile != null && portFile.exists();
366                boolean daemonRunning = PidFileUtil.isProcessRunning(daemonPidFile);
367                boolean needsToRunInInitializedScope = command.getRequiredCommandScope() == CommandScope.INITIALIZED_LOCALDIR;
368                boolean sendToRest = daemonRunning & localDirHandledInDaemonScope && needsToRunInInitializedScope;
369
370                command.setOut(out);
371
372                if (sendToRest) {
373                        if (command.canExecuteInDaemonScope()) {
374                                return sendToRest(command, commandName, commandArgs, portFile);
375                        }
376                        else {
377                                logger.log(Level.SEVERE, "Command not allowed when folder is daemon-managed: " + command.toString());
378                                return showErrorAndExit("Command not allowed when folder is daemon-managed");
379                        }
380                }
381                else {
382                        return runLocally(command, commandArgs);
383                }
384        }
385
386        private int runLocally(Command command, String[] commandArgs) {
387                command.setConfig(config);
388                command.setLocalDir(localDir);
389
390                // Run!
391                try {
392                        return command.execute(commandArgs);
393                }
394                catch (Exception e) {
395                        logger.log(Level.SEVERE, "Command " + command.toString() + " FAILED. ", e);
396                        return showErrorAndExit(e.getMessage());
397                }
398        }
399
400        private int sendToRest(Command command, String commandName, String[] commandArgs, File portFile) {
401                try {
402                        // Read port config (for daemon) from port file
403                        PortTO portConfig = readPortConfig(portFile);
404
405                        // Create authentication details
406                        CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
407                        credentialsProvider.setCredentials(
408                                        new AuthScope(SERVER_HOSTNAME, portConfig.getPort()),
409                                        new UsernamePasswordCredentials(portConfig.getUser().getUsername(), portConfig.getUser().getPassword()));
410
411                        // Allow all hostnames in CN; this is okay as long as hostname is localhost/127.0.0.1!
412                        // See: https://github.com/syncany/syncany/pull/196#issuecomment-52197017
413                        X509HostnameVerifier hostnameVerifier = new AllowAllHostnameVerifier();
414
415                        // Fetch the SSL context (using the user key/trust store)
416                        SSLContext sslContext = UserConfig.createUserSSLContext();
417
418                        // Create client with authentication details
419                        CloseableHttpClient client = HttpClients
420                                        .custom()
421                                        .setSslcontext(sslContext)
422                                        .setHostnameVerifier(hostnameVerifier)
423                                        .setDefaultCredentialsProvider(credentialsProvider)
424                                        .build();
425
426                        // Build and send request, print response
427                        Request request = buildFolderRequestFromCommand(command, commandName, commandArgs, config.getLocalDir().getAbsolutePath());
428                        String serverUri = SERVER_SCHEMA + SERVER_HOSTNAME + ":" + portConfig.getPort() + SERVER_REST_API;
429
430                        String xmlMessageString = XmlMessageFactory.toXml(request);
431                        StringEntity xmlMessageEntity = new StringEntity(xmlMessageString);
432
433                        HttpPost httpPost = new HttpPost(serverUri);
434                        httpPost.setEntity(xmlMessageEntity);
435
436                        logger.log(Level.INFO, "Sending HTTP Request to: " + serverUri);
437                        logger.log(Level.FINE, httpPost.toString());
438                        logger.log(Level.FINE, xmlMessageString);
439
440                        HttpResponse httpResponse = client.execute(httpPost);
441                        int exitCode = handleRestResponse(command, httpResponse);
442
443                        return exitCode;
444                }
445                catch (Exception e) {
446                        logger.log(Level.SEVERE, "Command " + command.toString() + " FAILED. ", e);
447                        return showErrorAndExit(e.getMessage());
448                }
449        }
450
451        private int handleRestResponse(Command command, HttpResponse httpResponse) throws Exception {
452                logger.log(Level.FINE, "Received HttpResponse: " + httpResponse);
453
454                String responseStr = IOUtils.toString(httpResponse.getEntity().getContent());
455                logger.log(Level.FINE, "Responding to message with responseString: " + responseStr);
456
457                Response response = XmlMessageFactory.toResponse(responseStr);
458
459                if (response instanceof FolderResponse) {
460                        FolderResponse folderResponse = (FolderResponse) response;
461                        command.printResults(folderResponse.getResult());
462
463                        return 0;
464                }
465                else if (response instanceof AlreadySyncingResponse) {
466                        out.println("Daemon is already syncing, please retry later.");
467                        return 1;
468                }
469                else if (response instanceof BadRequestResponse) {
470                        out.println(response.getMessage());
471                        return 1;
472                }
473
474                return 1;
475        }
476
477        private Request buildFolderRequestFromCommand(Command command, String commandName, String[] commandArgs, String root) throws Exception {
478                String thisPackage = BadRequestResponse.class.getPackage().getName(); // TODO [low] Medium-dirty hack.
479                String camelCaseMessageType = StringUtil.toCamelCase(commandName) + FolderRequest.class.getSimpleName();
480                String fqMessageClassName = thisPackage + "." + camelCaseMessageType;
481
482                FolderRequest folderRequest;
483
484                try {
485                        Class<? extends FolderRequest> folderRequestClass = Class.forName(fqMessageClassName).asSubclass(FolderRequest.class);
486                        folderRequest = folderRequestClass.newInstance();
487                }
488                catch (Exception e) {
489                        logger.log(Level.INFO, "Could not find FQCN " + fqMessageClassName, e);
490                        throw new Exception("Cannot read request class from request type: " + commandName, e);
491                }
492
493                OperationOptions operationOptions = command.parseOptions(commandArgs);
494                int requestId = Math.abs(new Random().nextInt());
495
496                folderRequest.setRoot(root);
497                folderRequest.setId(requestId);
498                folderRequest.setOptions(operationOptions);
499
500                return folderRequest;
501        }
502
503        private PortTO readPortConfig(File portFile) {
504                try {
505                        return new Persister().read(PortTO.class, portFile);
506                }
507                catch (Exception e) {
508                        logger.log(Level.SEVERE, "ERROR: Could not read portFile to connect to daemon.", e);
509
510                        showErrorAndExit("Cannot connect to daemon.");
511                        return null; // Never reached!
512                }
513        }
514
515        private int showShortVersionAndExit() throws IOException {
516                return printHelpTextAndExit(HELP_TEXT_VERSION_SHORT_SKEL_RESOURCE);
517        }
518
519        private int showFullVersionAndExit() throws IOException {
520                return printHelpTextAndExit(HELP_TEXT_VERSION_FULL_SKEL_RESOURCE);
521        }
522
523        private int showUsageAndExit() throws IOException {
524                return printHelpTextAndExit(HELP_TEXT_USAGE_SKEL_RESOURCE);
525        }
526
527        private int showHelpAndExit() throws IOException {
528                // Try opening man page (if on Linux)
529                if (EnvironmentUtil.isUnixLikeOperatingSystem()) {
530                        int manPageReturnCode = execManPageAndExit(MAN_PAGE_MAIN);
531
532                        if (manPageReturnCode == 0) { // Success
533                                return manPageReturnCode;
534                        }
535                }
536
537                // Fallback (and on Windows): Display man page on STDOUT
538                return printHelpTextAndExit(HELP_TEXT_HELP_SKEL_RESOURCE);
539        }
540
541        private int showCommandHelpAndExit(String commandName) throws IOException {
542                // Try opening man page (if on Linux)
543                if (EnvironmentUtil.isUnixLikeOperatingSystem()) {
544                        String commandManPage = String.format(MAN_PAGE_COMMAND_FORMAT, commandName);
545                        int manPageReturnCode = execManPageAndExit(commandManPage);
546
547                        if (manPageReturnCode == 0) { // Success
548                                return manPageReturnCode;
549                        }
550                }
551
552                // Fallback (and on Windows): Display man page on STDOUT
553                String helpTextResource = String.format(HELP_TEXT_CMD_SKEL_RESOURCE, commandName);
554                return printHelpTextAndExit(helpTextResource);
555        }
556
557        private int execManPageAndExit(String manPage) {
558                try {
559                        Runtime runtime = Runtime.getRuntime();
560                        Process manProcess = runtime.exec(new String[] { "sh", "-c", "man " + manPage + " > /dev/tty" });
561
562                        int manProcessExitCode = manProcess.waitFor();
563
564                        if (manProcessExitCode == 0) {
565                                return 0;
566                        }
567                }
568                catch (Exception e) {
569                        // Don't care!
570                }
571                return 1;
572        }
573
574        private int printHelpTextAndExit(String helpTextResource) throws IOException {
575                String fullHelpTextResource = HELP_TEXT_RESOURCE_ROOT + helpTextResource;
576                InputStream helpTextInputStream = CommandLineClient.class.getResourceAsStream(fullHelpTextResource);
577
578                if (helpTextInputStream == null) {
579                        return showErrorAndExit("No detailed help text available for this command.");
580                }
581
582                for (String line : IOUtils.readLines(helpTextInputStream)) {
583                        line = replaceVariables(line);
584                        out.println(line.replaceAll("\\s$", ""));
585                }
586
587                out.close();
588
589                return 0;
590        }
591
592        private String replaceVariables(String line) throws IOException {
593                Properties applicationProperties = Client.getApplicationProperties();
594
595                for (Entry<Object, Object> applicationProperty : applicationProperties.entrySet()) {
596                        String variableName = String.format("%%%s%%", applicationProperty.getKey());
597
598                        if (line.contains(variableName)) {
599                                line = line.replace(variableName, (String) applicationProperty.getValue());
600                        }
601                }
602
603                return line;
604        }
605
606        private int showErrorAndExit(String errorMessage) {
607                out.println("Error: " + errorMessage);
608                out.println("       Refer to help page using '--help'.");
609                out.println();
610
611                out.close();
612
613                return 1;
614        }
615}