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}