001/** 002 * 003 * Copyright 2005-2008 Jive Software. 004 * 005 * Licensed under the Apache License, Version 2.0 (the "License"); 006 * you may not use this file except in compliance with the License. 007 * You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017 018package org.jivesoftware.smackx.commands; 019 020import java.lang.reflect.InvocationTargetException; 021import java.util.ArrayList; 022import java.util.Collection; 023import java.util.Iterator; 024import java.util.List; 025import java.util.Map; 026import java.util.WeakHashMap; 027import java.util.concurrent.ConcurrentHashMap; 028import java.util.concurrent.TimeUnit; 029import java.util.logging.Level; 030import java.util.logging.Logger; 031 032import org.jivesoftware.smack.ConnectionCreationListener; 033import org.jivesoftware.smack.Manager; 034import org.jivesoftware.smack.SmackException; 035import org.jivesoftware.smack.SmackException.NoResponseException; 036import org.jivesoftware.smack.SmackException.NotConnectedException; 037import org.jivesoftware.smack.XMPPConnection; 038import org.jivesoftware.smack.XMPPConnectionRegistry; 039import org.jivesoftware.smack.XMPPException; 040import org.jivesoftware.smack.XMPPException.XMPPErrorException; 041import org.jivesoftware.smack.iqrequest.AbstractIqRequestHandler; 042import org.jivesoftware.smack.iqrequest.IQRequestHandler.Mode; 043import org.jivesoftware.smack.packet.IQ; 044import org.jivesoftware.smack.packet.StanzaError; 045import org.jivesoftware.smack.util.StringUtils; 046 047import org.jivesoftware.smackx.commands.AdHocCommand.Action; 048import org.jivesoftware.smackx.commands.AdHocCommand.Status; 049import org.jivesoftware.smackx.commands.packet.AdHocCommandData; 050import org.jivesoftware.smackx.disco.AbstractNodeInformationProvider; 051import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; 052import org.jivesoftware.smackx.disco.packet.DiscoverInfo; 053import org.jivesoftware.smackx.disco.packet.DiscoverItems; 054import org.jivesoftware.smackx.xdata.form.FillableForm; 055import org.jivesoftware.smackx.xdata.packet.DataForm; 056 057import org.jxmpp.jid.Jid; 058 059/** 060 * An AdHocCommandManager is responsible for keeping the list of available 061 * commands offered by a service and for processing commands requests. 062 * 063 * Pass in an XMPPConnection instance to 064 * {@link #getAddHocCommandsManager(XMPPConnection)} in order to 065 * get an instance of this class. 066 * 067 * @author Gabriel Guardincerri 068 */ 069public final class AdHocCommandManager extends Manager { 070 public static final String NAMESPACE = "http://jabber.org/protocol/commands"; 071 072 private static final Logger LOGGER = Logger.getLogger(AdHocCommandManager.class.getName()); 073 074 /** 075 * The session time out in seconds. 076 */ 077 private static final int SESSION_TIMEOUT = 2 * 60; 078 079 /** 080 * Map an XMPPConnection with it AdHocCommandManager. This map have a key-value 081 * pair for every active connection. 082 */ 083 private static final Map<XMPPConnection, AdHocCommandManager> instances = new WeakHashMap<>(); 084 085 /** 086 * Register the listener for all the connection creations. When a new 087 * connection is created a new AdHocCommandManager is also created and 088 * related to that connection. 089 */ 090 static { 091 XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() { 092 @Override 093 public void connectionCreated(XMPPConnection connection) { 094 getAddHocCommandsManager(connection); 095 } 096 }); 097 } 098 099 /** 100 * Returns the <code>AdHocCommandManager</code> related to the 101 * <code>connection</code>. 102 * 103 * @param connection the XMPP connection. 104 * @return the AdHocCommandManager associated with the connection. 105 */ 106 public static synchronized AdHocCommandManager getAddHocCommandsManager(XMPPConnection connection) { 107 AdHocCommandManager ahcm = instances.get(connection); 108 if (ahcm == null) { 109 ahcm = new AdHocCommandManager(connection); 110 instances.put(connection, ahcm); 111 } 112 return ahcm; 113 } 114 115 /** 116 * Map a command node with its AdHocCommandInfo. Note: Key=command node, 117 * Value=command. Command node matches the node attribute sent by command 118 * requesters. 119 */ 120 private final Map<String, AdHocCommandInfo> commands = new ConcurrentHashMap<>(); 121 122 /** 123 * Map a command session ID with the instance LocalCommand. The LocalCommand 124 * is the an objects that has all the information of the current state of 125 * the command execution. Note: Key=session ID, Value=LocalCommand. Session 126 * ID matches the sessionid attribute sent by command responders. 127 */ 128 private final Map<String, LocalCommand> executingCommands = new ConcurrentHashMap<>(); 129 130 private final ServiceDiscoveryManager serviceDiscoveryManager; 131 132 private AdHocCommandManager(XMPPConnection connection) { 133 super(connection); 134 this.serviceDiscoveryManager = ServiceDiscoveryManager.getInstanceFor(connection); 135 136 // Add the feature to the service discovery manage to show that this 137 // connection supports the AdHoc-Commands protocol. 138 // This information will be used when another client tries to 139 // discover whether this client supports AdHoc-Commands or not. 140 ServiceDiscoveryManager.getInstanceFor(connection).addFeature( 141 NAMESPACE); 142 143 // Set the NodeInformationProvider that will provide information about 144 // which AdHoc-Commands are registered, whenever a disco request is 145 // received 146 ServiceDiscoveryManager.getInstanceFor(connection) 147 .setNodeInformationProvider(NAMESPACE, 148 new AbstractNodeInformationProvider() { 149 @Override 150 public List<DiscoverItems.Item> getNodeItems() { 151 152 List<DiscoverItems.Item> answer = new ArrayList<>(); 153 Collection<AdHocCommandInfo> commandsList = getRegisteredCommands(); 154 155 for (AdHocCommandInfo info : commandsList) { 156 DiscoverItems.Item item = new DiscoverItems.Item( 157 info.getOwnerJID()); 158 item.setName(info.getName()); 159 item.setNode(info.getNode()); 160 answer.add(item); 161 } 162 163 return answer; 164 } 165 }); 166 167 // The packet listener and the filter for processing some AdHoc Commands 168 // Packets 169 connection.registerIQRequestHandler(new AbstractIqRequestHandler(AdHocCommandData.ELEMENT, 170 AdHocCommandData.NAMESPACE, IQ.Type.set, Mode.async) { 171 @Override 172 public IQ handleIQRequest(IQ iqRequest) { 173 AdHocCommandData requestData = (AdHocCommandData) iqRequest; 174 try { 175 return processAdHocCommand(requestData); 176 } 177 catch (InterruptedException | NoResponseException | NotConnectedException e) { 178 LOGGER.log(Level.INFO, "processAdHocCommand threw exception", e); 179 return null; 180 } 181 } 182 }); 183 } 184 185 /** 186 * Registers a new command with this command manager, which is related to a 187 * connection. The <code>node</code> is an unique identifier of that command for 188 * the connection related to this command manager. The <code>name</code> is the 189 * human readable name of the command. The <code>class</code> is the class of 190 * the command, which must extend {@link LocalCommand} and have a default 191 * constructor. 192 * 193 * @param node the unique identifier of the command. 194 * @param name the human readable name of the command. 195 * @param clazz the class of the command, which must extend {@link LocalCommand}. 196 */ 197 public void registerCommand(String node, String name, final Class<? extends LocalCommand> clazz) { 198 registerCommand(node, name, new LocalCommandFactory() { 199 @Override 200 public LocalCommand getInstance() throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { 201 return clazz.getConstructor().newInstance(); 202 } 203 }); 204 } 205 206 /** 207 * Registers a new command with this command manager, which is related to a 208 * connection. The <code>node</code> is an unique identifier of that 209 * command for the connection related to this command manager. The <code>name</code> 210 * is the human readable name of the command. The <code>factory</code> generates 211 * new instances of the command. 212 * 213 * @param node the unique identifier of the command. 214 * @param name the human readable name of the command. 215 * @param factory a factory to create new instances of the command. 216 */ 217 public void registerCommand(String node, final String name, LocalCommandFactory factory) { 218 AdHocCommandInfo commandInfo = new AdHocCommandInfo(node, name, connection().getUser(), factory); 219 220 commands.put(node, commandInfo); 221 // Set the NodeInformationProvider that will provide information about 222 // the added command 223 serviceDiscoveryManager.setNodeInformationProvider(node, 224 new AbstractNodeInformationProvider() { 225 @Override 226 public List<String> getNodeFeatures() { 227 List<String> answer = new ArrayList<>(); 228 answer.add(NAMESPACE); 229 // TODO: check if this service is provided by the 230 // TODO: current connection. 231 answer.add("jabber:x:data"); 232 return answer; 233 } 234 @Override 235 public List<DiscoverInfo.Identity> getNodeIdentities() { 236 List<DiscoverInfo.Identity> answer = new ArrayList<>(); 237 DiscoverInfo.Identity identity = new DiscoverInfo.Identity( 238 "automation", name, "command-node"); 239 answer.add(identity); 240 return answer; 241 } 242 }); 243 } 244 245 /** 246 * Discover the commands of an specific JID. The <code>jid</code> is a 247 * full JID. 248 * 249 * @param jid the full JID to retrieve the commands for. 250 * @return the discovered items. 251 * @throws XMPPException if the operation failed for some reason. 252 * @throws SmackException if there was no response from the server. 253 * @throws InterruptedException if the calling thread was interrupted. 254 */ 255 public DiscoverItems discoverCommands(Jid jid) throws XMPPException, SmackException, InterruptedException { 256 return serviceDiscoveryManager.discoverItems(jid, NAMESPACE); 257 } 258 259 /** 260 * Returns a command that represents an instance of a command in a remote 261 * host. It is used to execute remote commands. The concept is similar to 262 * RMI. Every invocation on this command is equivalent to an invocation in 263 * the remote command. 264 * 265 * @param jid the full JID of the host of the remote command 266 * @param node the identifier of the command 267 * @return a local instance equivalent to the remote command. 268 */ 269 public RemoteCommand getRemoteCommand(Jid jid, String node) { 270 return new RemoteCommand(connection(), node, jid); 271 } 272 273 /** 274 * Process the AdHoc-Command stanza that request the execution of some 275 * action of a command. If this is the first request, this method checks, 276 * before executing the command, if: 277 * <ul> 278 * <li>The requested command exists</li> 279 * <li>The requester has permissions to execute it</li> 280 * <li>The command has more than one stage, if so, it saves the command and 281 * session ID for further use</li> 282 * </ul> 283 * 284 * <br> 285 * <br> 286 * If this is not the first request, this method checks, before executing 287 * the command, if: 288 * <ul> 289 * <li>The session ID of the request was stored</li> 290 * <li>The session life do not exceed the time out</li> 291 * <li>The action to execute is one of the available actions</li> 292 * </ul> 293 * 294 * @param requestData TODO javadoc me please 295 * the stanza to process. 296 * @throws NotConnectedException if the XMPP connection is not connected. 297 * @throws NoResponseException if there was no response from the remote entity. 298 * @throws InterruptedException if the calling thread was interrupted. 299 */ 300 private IQ processAdHocCommand(AdHocCommandData requestData) throws NoResponseException, NotConnectedException, InterruptedException { 301 // Creates the response with the corresponding data 302 AdHocCommandData response = new AdHocCommandData(); 303 response.setTo(requestData.getFrom()); 304 response.setStanzaId(requestData.getStanzaId()); 305 response.setNode(requestData.getNode()); 306 response.setId(requestData.getTo()); 307 308 String sessionId = requestData.getSessionID(); 309 String commandNode = requestData.getNode(); 310 311 if (sessionId == null) { 312 // A new execution request has been received. Check that the 313 // command exists 314 if (!commands.containsKey(commandNode)) { 315 // Requested command does not exist so return 316 // item_not_found error. 317 return respondError(response, StanzaError.Condition.item_not_found); 318 } 319 320 // Create new session ID 321 sessionId = StringUtils.randomString(15); 322 323 try { 324 // Create a new instance of the command with the 325 // corresponding sessionid 326 LocalCommand command; 327 try { 328 command = newInstanceOfCmd(commandNode, sessionId); 329 } 330 catch (InstantiationException | IllegalAccessException | IllegalArgumentException 331 | InvocationTargetException | NoSuchMethodException | SecurityException e) { 332 StanzaError xmppError = StanzaError.getBuilder() 333 .setCondition(StanzaError.Condition.internal_server_error) 334 .setDescriptiveEnText(e.getMessage()) 335 .build(); 336 return respondError(response, xmppError); 337 } 338 339 response.setType(IQ.Type.result); 340 command.setData(response); 341 342 // Check that the requester has enough permission. 343 // Answer forbidden error if requester permissions are not 344 // enough to execute the requested command 345 if (!command.hasPermission(requestData.getFrom())) { 346 return respondError(response, StanzaError.Condition.forbidden); 347 } 348 349 Action action = requestData.getAction(); 350 351 // If the action is unknown then respond an error. 352 if (action != null && action.equals(Action.unknown)) { 353 return respondError(response, StanzaError.Condition.bad_request, 354 AdHocCommand.SpecificErrorCondition.malformedAction); 355 } 356 357 // If the action is not execute, then it is an invalid action. 358 if (action != null && !action.equals(Action.execute)) { 359 return respondError(response, StanzaError.Condition.bad_request, 360 AdHocCommand.SpecificErrorCondition.badAction); 361 } 362 363 // Increase the state number, so the command knows in witch 364 // stage it is 365 command.incrementStage(); 366 // Executes the command 367 command.execute(); 368 369 if (command.isLastStage()) { 370 // If there is only one stage then the command is completed 371 response.setStatus(Status.completed); 372 } 373 else { 374 // Else it is still executing, and is registered to be 375 // available for the next call 376 response.setStatus(Status.executing); 377 executingCommands.put(sessionId, command); 378 // See if the session sweeper thread is scheduled. If not, start it. 379 maybeWindUpSessionSweeper(); 380 } 381 382 // Sends the response packet 383 return response; 384 385 } 386 catch (XMPPErrorException e) { 387 // If there is an exception caused by the next, complete, 388 // prev or cancel method, then that error is returned to the 389 // requester. 390 StanzaError error = e.getStanzaError(); 391 392 // If the error type is cancel, then the execution is 393 // canceled therefore the status must show that, and the 394 // command be removed from the executing list. 395 if (StanzaError.Type.CANCEL.equals(error.getType())) { 396 response.setStatus(Status.canceled); 397 executingCommands.remove(sessionId); 398 } 399 return respondError(response, error); 400 } 401 } 402 else { 403 LocalCommand command = executingCommands.get(sessionId); 404 405 // Check that a command exists for the specified sessionID 406 // This also handles if the command was removed in the meanwhile 407 // of getting the key and the value of the map. 408 if (command == null) { 409 return respondError(response, StanzaError.Condition.bad_request, 410 AdHocCommand.SpecificErrorCondition.badSessionid); 411 } 412 413 // Check if the Session data has expired (default is 10 minutes) 414 long creationStamp = command.getCreationDate(); 415 if (System.currentTimeMillis() - creationStamp > SESSION_TIMEOUT * 1000) { 416 // Remove the expired session 417 executingCommands.remove(sessionId); 418 419 // Answer a not_allowed error (session-expired) 420 return respondError(response, StanzaError.Condition.not_allowed, 421 AdHocCommand.SpecificErrorCondition.sessionExpired); 422 } 423 424 /* 425 * Since the requester could send two requests for the same 426 * executing command i.e. the same session id, all the execution of 427 * the action must be synchronized to avoid inconsistencies. 428 */ 429 synchronized (command) { 430 Action action = requestData.getAction(); 431 432 // If the action is unknown the respond an error 433 if (action != null && action.equals(Action.unknown)) { 434 return respondError(response, StanzaError.Condition.bad_request, 435 AdHocCommand.SpecificErrorCondition.malformedAction); 436 } 437 438 // If the user didn't specify an action or specify the execute 439 // action then follow the actual default execute action 440 if (action == null || Action.execute.equals(action)) { 441 action = command.getExecuteAction(); 442 } 443 444 // Check that the specified action was previously 445 // offered 446 if (!command.isValidAction(action)) { 447 return respondError(response, StanzaError.Condition.bad_request, 448 AdHocCommand.SpecificErrorCondition.badAction); 449 } 450 451 try { 452 // TODO: Check that all the required fields of the form are 453 // TODO: filled, if not throw an exception. This will simplify the 454 // TODO: construction of new commands 455 456 // Since all errors were passed, the response is now a 457 // result 458 response.setType(IQ.Type.result); 459 460 // Set the new data to the command. 461 command.setData(response); 462 463 if (Action.next.equals(action)) { 464 command.incrementStage(); 465 DataForm dataForm = requestData.getForm(); 466 command.next(new FillableForm(dataForm)); 467 if (command.isLastStage()) { 468 // If it is the last stage then the command is 469 // completed 470 response.setStatus(Status.completed); 471 } 472 else { 473 // Otherwise it is still executing 474 response.setStatus(Status.executing); 475 } 476 } 477 else if (Action.complete.equals(action)) { 478 command.incrementStage(); 479 DataForm dataForm = requestData.getForm(); 480 command.complete(new FillableForm(dataForm)); 481 response.setStatus(Status.completed); 482 // Remove the completed session 483 executingCommands.remove(sessionId); 484 } 485 else if (Action.prev.equals(action)) { 486 command.decrementStage(); 487 command.prev(); 488 } 489 else if (Action.cancel.equals(action)) { 490 command.cancel(); 491 response.setStatus(Status.canceled); 492 // Remove the canceled session 493 executingCommands.remove(sessionId); 494 } 495 496 return response; 497 } 498 catch (XMPPErrorException e) { 499 // If there is an exception caused by the next, complete, 500 // prev or cancel method, then that error is returned to the 501 // requester. 502 StanzaError error = e.getStanzaError(); 503 504 // If the error type is cancel, then the execution is 505 // canceled therefore the status must show that, and the 506 // command be removed from the executing list. 507 if (StanzaError.Type.CANCEL.equals(error.getType())) { 508 response.setStatus(Status.canceled); 509 executingCommands.remove(sessionId); 510 } 511 return respondError(response, error); 512 } 513 } 514 } 515 } 516 517 private boolean sessionSweeperScheduled; 518 519 private void sessionSweeper() { 520 final long currentTime = System.currentTimeMillis(); 521 synchronized (this) { 522 for (Iterator<Map.Entry<String, LocalCommand>> it = executingCommands.entrySet().iterator(); it.hasNext();) { 523 Map.Entry<String, LocalCommand> entry = it.next(); 524 LocalCommand command = entry.getValue(); 525 526 long creationStamp = command.getCreationDate(); 527 // Check if the Session data has expired (default is 10 minutes) 528 // To remove it from the session list it waits for the double of 529 // the of time out time. This is to let 530 // the requester know why his execution request is 531 // not accepted. If the session is removed just 532 // after the time out, then once the user requests to 533 // continue the execution he will received an 534 // invalid session error and not a time out error. 535 if (currentTime - creationStamp > SESSION_TIMEOUT * 1000 * 2) { 536 // Remove the expired session 537 it.remove(); 538 } 539 } 540 541 sessionSweeperScheduled = false; 542 } 543 544 if (!executingCommands.isEmpty()) { 545 maybeWindUpSessionSweeper(); 546 } 547 }; 548 549 private synchronized void maybeWindUpSessionSweeper() { 550 if (sessionSweeperScheduled) { 551 return; 552 } 553 554 sessionSweeperScheduled = true; 555 schedule(this::sessionSweeper, 10, TimeUnit.SECONDS); 556 } 557 558 /** 559 * Responds an error with an specific condition. 560 * 561 * @param response the response to send. 562 * @param condition the condition of the error. 563 */ 564 private static IQ respondError(AdHocCommandData response, 565 StanzaError.Condition condition) { 566 return respondError(response, StanzaError.getBuilder(condition).build()); 567 } 568 569 /** 570 * Responds an error with an specific condition. 571 * 572 * @param response the response to send. 573 * @param condition the condition of the error. 574 * @param specificCondition the adhoc command error condition. 575 */ 576 private static IQ respondError(AdHocCommandData response, StanzaError.Condition condition, 577 AdHocCommand.SpecificErrorCondition specificCondition) { 578 StanzaError error = StanzaError.getBuilder(condition) 579 .addExtension(new AdHocCommandData.SpecificError(specificCondition)) 580 .build(); 581 return respondError(response, error); 582 } 583 584 /** 585 * Responds an error with an specific error. 586 * 587 * @param response the response to send. 588 * @param error the error to send. 589 */ 590 private static IQ respondError(AdHocCommandData response, StanzaError error) { 591 response.setType(IQ.Type.error); 592 response.setError(error); 593 return response; 594 } 595 596 /** 597 * Creates a new instance of a command to be used by a new execution request 598 * 599 * @param commandNode the command node that identifies it. 600 * @param sessionID the session id of this execution. 601 * @return the command instance to execute. 602 * @throws XMPPErrorException if there is problem creating the new instance. 603 * @throws SecurityException if there was a security violation. 604 * @throws NoSuchMethodException if no such method is declared 605 * @throws InvocationTargetException if a reflection-based method or constructor invocation threw. 606 * @throws IllegalArgumentException if an illegal argument was given. 607 * @throws IllegalAccessException in case of an illegal access. 608 * @throws InstantiationException in case of an instantiation error. 609 */ 610 private LocalCommand newInstanceOfCmd(String commandNode, String sessionID) 611 throws XMPPErrorException, InstantiationException, IllegalAccessException, IllegalArgumentException, 612 InvocationTargetException, NoSuchMethodException, SecurityException { 613 AdHocCommandInfo commandInfo = commands.get(commandNode); 614 LocalCommand command = commandInfo.getCommandInstance(); 615 command.setSessionID(sessionID); 616 command.setName(commandInfo.getName()); 617 command.setNode(commandInfo.getNode()); 618 619 return command; 620 } 621 622 /** 623 * Returns the registered commands of this command manager, which is related 624 * to a connection. 625 * 626 * @return the registered commands. 627 */ 628 private Collection<AdHocCommandInfo> getRegisteredCommands() { 629 return commands.values(); 630 } 631 632 /** 633 * Stores ad-hoc command information. 634 */ 635 private static final class AdHocCommandInfo { 636 637 private String node; 638 private String name; 639 private final Jid ownerJID; 640 private LocalCommandFactory factory; 641 642 private AdHocCommandInfo(String node, String name, Jid ownerJID, 643 LocalCommandFactory factory) { 644 this.node = node; 645 this.name = name; 646 this.ownerJID = ownerJID; 647 this.factory = factory; 648 } 649 650 public LocalCommand getCommandInstance() throws InstantiationException, 651 IllegalAccessException, IllegalArgumentException, InvocationTargetException, NoSuchMethodException, SecurityException { 652 return factory.getInstance(); 653 } 654 655 public String getName() { 656 return name; 657 } 658 659 public String getNode() { 660 return node; 661 } 662 663 public Jid getOwnerJID() { 664 return ownerJID; 665 } 666 } 667}