001package com.identityworksllc.iiq.common.plugin; 002 003import java.io.BufferedReader; 004import java.io.Console; 005import java.io.FileNotFoundException; 006import java.io.IOException; 007import java.io.InputStream; 008import java.io.InputStreamReader; 009import java.io.OutputStream; 010import java.io.PrintWriter; 011import java.net.HttpURLConnection; 012import java.net.URI; 013import java.net.URISyntaxException; 014import java.nio.charset.StandardCharsets; 015import java.nio.file.Files; 016import java.nio.file.Path; 017import java.nio.file.Paths; 018import java.text.MessageFormat; 019import java.util.ArrayList; 020import java.util.Base64; 021import java.util.List; 022import java.util.Locale; 023import java.util.Optional; 024import java.util.Properties; 025import java.util.StringJoiner; 026import java.util.regex.Matcher; 027import java.util.regex.Pattern; 028 029/** 030 * A utility to install plugins remotely using the IIQ REST API. This class can has no 031 * external dependencies, so can be isolated. 032 * 033 * Usage: {@code java com.identityworksllc.iiq.common.plugin.RemotePluginInstaller -p /path/to/properties install /path/to/file} 034 * 035 * Commands exit with a non-zero exit code when problems occur. 036 * 037 * TODO when we migrate this library to minimum JDK 11, use the JDK HTTP client class 038 */ 039public class RemotePluginInstaller { 040 041 /** 042 * A class representing the metadata of an installed plugin 043 */ 044 public static class Plugin { 045 private boolean enabled; 046 private String id; 047 private String name; 048 private String version; 049 050 public Plugin() { 051 052 } 053 054 @Override 055 public String toString() { 056 StringJoiner joiner = new StringJoiner(", ", "{", "}"); 057 if ((id) != null) { 058 joiner.add("id=\"" + id + "\""); 059 } 060 if ((name) != null) { 061 joiner.add("name=\"" + name + "\""); 062 } 063 joiner.add("enabled=" + enabled); 064 if ((version) != null) { 065 joiner.add("version=\"" + version + "\""); 066 } 067 return joiner.toString(); 068 } 069 } 070 071 /** 072 * The output level at which to print a message 073 */ 074 public enum OutputLevel { 075 /** 076 * Debug level 077 */ 078 Debug, 079 080 /** 081 * Info level 082 */ 083 Info, 084 085 /** 086 * Error level 087 */ 088 Error 089 } 090 091 /** 092 * The main method for this utility. Parses the command line arguments, validates 093 * them, and optionally prompts the user for a password. Then, executes the command 094 * specified if the inputs are valid. 095 * 096 * @param args The arguments to the main method 097 * @throws Exception on any failures at all 098 */ 099 public static void main(String[] args) throws Exception { 100 101 Properties properties = new Properties(); 102 103 String propertiesFile = null; 104 String username = null; 105 String host = null; 106 String command = null; 107 String stringPassword = null; 108 char[] charPassword = null; 109 110 List<String> commandArgs = new ArrayList<>(); 111 112 boolean expectCommandArgs = false; 113 114 // Parse the inputs 115 for(int a = 0; a < args.length; a++) { 116 String value = args[a]; 117 if (!expectCommandArgs) { 118 if (value.equals("-u") && (a + 1) < args.length) { 119 username = args[a + 1].trim(); 120 a++; 121 } else if (value.equals("-h") && (a + 1) < args.length) { 122 host = args[a + 1].trim(); 123 a++; 124 } else if (value.equals("-p") && (a + 1) < args.length) { 125 propertiesFile = args[a + 1].trim(); 126 a++; 127 } else if (command == null) { 128 command = value.trim().toLowerCase(Locale.ROOT); 129 expectCommandArgs = true; 130 } 131 } else { 132 commandArgs.add(value); 133 } 134 } 135 136 if (propertiesFile != null) { 137 Path propsPath = Paths.get(propertiesFile); 138 if (!Files.exists(propsPath)) { 139 throw new FileNotFoundException("No such properties file: " + propertiesFile); 140 } 141 142 try (InputStream fis = Files.newInputStream(propsPath)) { 143 properties.load(fis); 144 } 145 } 146 147 if (!properties.isEmpty()) { 148 username = properties.getProperty("username", username); 149 stringPassword = properties.getProperty("password", stringPassword); 150 host = properties.getProperty("url", host); 151 } 152 153 boolean valid = validateInputs(username, host, command, commandArgs); 154 155 if (!valid) { 156 System.exit(1); 157 } 158 159 assert(host != null); 160 assert(username != null); 161 162 URI iiq = new URI(host); 163 164 if (!iiq.isAbsolute()) { 165 throw new IOException("The specified IIQ URL must be absolute (i.e., end with a /)"); 166 } 167 168 if (stringPassword == null || stringPassword.trim().isEmpty()) { 169 Console javaConsole = System.console(); 170 if (javaConsole == null) { 171 throw new IllegalStateException("Unable to open a Java console; are you in a normal terminal?"); 172 } 173 174 charPassword = javaConsole.readPassword("Password for IIQ user [" + username + "]: "); 175 176 if (charPassword == null || charPassword.length < 1) { 177 // Default to admin 178 charPassword = new char[] { 'a', 'd', 'm', 'i', 'n' }; 179 } 180 } else { 181 charPassword = stringPassword.toCharArray(); 182 } 183 184 RemotePluginInstaller pluginInstaller = new RemotePluginInstaller(iiq, username, charPassword); 185 int exitCode = 0; 186 187 try { 188 if (command.equals("check")) { 189 String pluginName = commandArgs.get(0); 190 boolean installed = pluginInstaller.isPluginInstalled(pluginName); 191 if (!installed) { 192 exitCode = 1; 193 } 194 195 System.out.println(installed); 196 } else if (command.equals("install")) { 197 String path = commandArgs.get(0); 198 Path pluginPath = Paths.get(path); 199 if (!Files.exists(pluginPath)) { 200 throw new IOException("Invalid or unreadable plugin ZIP path specified: " + path); 201 } 202 pluginInstaller.installPlugin(pluginPath); 203 204 System.out.println("Installed plugin"); 205 } else if (command.equals("get")) { 206 String pluginName = commandArgs.get(0); 207 Optional<Plugin> plugin = pluginInstaller.getPlugin(pluginName); 208 if (plugin.isPresent()) { 209 System.out.println(plugin.get()); 210 } else { 211 System.out.println("Not found"); 212 exitCode = 1; 213 } 214 } 215 } catch(Exception e) { 216 output(OutputLevel.Error, e.toString()); 217 exitCode = 1; 218 } 219 220 System.exit(exitCode); 221 } 222 223 /** 224 * Outputs the message in question at the given output level. The output 225 * level determines the prefix used. 226 * 227 * @param level The output level 228 * @param output The output to print 229 * @param variables Any variables to substitute into the output 230 */ 231 public static void output(OutputLevel level, String output, Object... variables) { 232 if (variables != null && variables.length > 0) { 233 output = MessageFormat.format(output, variables); 234 } 235 236 if (level == OutputLevel.Debug) { 237 System.err.print(" [+] "); 238 } else if (level == OutputLevel.Info) { 239 System.err.print(" [*] "); 240 } else { 241 System.err.print(" [!] "); 242 } 243 244 System.err.println(output); 245 } 246 247 /** 248 * Validates the command line inputs, printing a usage message if they are not valid. 249 * 250 * @param username The provided username 251 * @param host The provided URL 252 * @param command The provided command name 253 * @param commandArgs The provided command arguments 254 * @return True if validation passes and the task should proceed 255 */ 256 private static boolean validateInputs(String username, String host, String command, List<String> commandArgs) { 257 List<String> errors = new ArrayList<>(); 258 if (username == null || username.trim().isEmpty()) { 259 errors.add("Missing required value: username (-u or 'username' property)"); 260 } 261 262 if (host == null || host.trim().isEmpty()) { 263 errors.add("Missing required value: IIQ URL (-h or 'url' property)"); 264 } else if (!host.startsWith("http")) { 265 errors.add("Invalid format: IIQ URL must start with 'http'"); 266 } 267 268 if (command == null || command.trim().isEmpty()) { 269 errors.add("Missing command, valid values are : check, install"); 270 } else if (!(command.equals("check") || command.equals("install") || command.equals("get"))) { 271 errors.add("Invalid command '" + command + "', valid values are : check, install"); 272 } else if (commandArgs.isEmpty()) { 273 errors.add("You must specify one or more arguments for your command: '" + command + "'"); 274 } 275 276 if (!errors.isEmpty()) { 277 for(String error : errors) { 278 System.err.println("ERROR: " + error); 279 } 280 System.err.println(); 281 System.err.println("Usage: RemotePluginInstaller [-h URL] [-u username] [-p properties] command [commandOptions]"); 282 System.err.println(); 283 System.err.println(" Commands: "); 284 System.err.println(" check [pluginName]: Checks whether the plugin is installed and outputs true/false"); 285 System.err.println(" get [pluginName]: Outputs a JSON object of the plugin metadata, if it is installed"); 286 System.err.println(" install [pluginZipPath]: Installs the plugin file at the given path"); 287 return false; 288 } else { 289 return true; 290 } 291 } 292 private final URI iiq; 293 private final char[] password; 294 private final String username; 295 296 /** 297 * Constructs a new RemotePluginInstaller with the given connection info 298 * @param iiq The URI of the IIQ server 299 * @param username The username with which to connect 300 * @param password The password belonging to that uer 301 */ 302 public RemotePluginInstaller(URI iiq, String username, char[] password) { 303 this.iiq = iiq; 304 this.username = username; 305 this.password = password; 306 } 307 308 /** 309 * Gets the Plugin metadata for the plugin of the given name 310 * @param name The name of the plugin 311 * @return The resulting plugin metadata, if it exists, or an empty Optional 312 * @throws IOException if reading from the server fails 313 */ 314 public Optional<Plugin> getPlugin(String name) throws IOException { 315 URI pluginUrl = this.iiq.resolve("rest/plugins"); 316 317 output(OutputLevel.Debug, "Plugin URL: " + pluginUrl); 318 output(OutputLevel.Info, "Retrieving plugin data for " + name); 319 320 HttpURLConnection urlConnection = (HttpURLConnection)pluginUrl.toURL().openConnection(); 321 urlConnection.setDoInput(true); 322 urlConnection.setRequestMethod("GET"); 323 324 String usernamePasswordString = Base64.getEncoder().encodeToString((username + ":" + new String(password)).getBytes(StandardCharsets.UTF_8)); 325 326 urlConnection.addRequestProperty("Authorization", "Basic " + usernamePasswordString); 327 328 if (urlConnection.getResponseCode() > 299) { 329 throw new IOException("Received response code " + urlConnection.getResponseCode() + " " + urlConnection.getResponseMessage()); 330 } 331 332 StringBuilder output = new StringBuilder(); 333 334 try (BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getInputStream()))) { 335 String line; 336 while ((line = reader.readLine()) != null) { 337 output.append(line); 338 output.append("\n"); 339 } 340 } 341 342 String outputString = output.toString(); 343 344 // Time for a crazy regex, because we're determined to have no external dependencies 345 346 Pattern regex = Pattern.compile( 347 "\"id\":\\s*\"(.*?)\"," + 348 ".*?" + 349 "\"name\":\\s*\"" + Pattern.quote(name) + "\"," + 350 ".*?" + 351 "\"version\":\\s*\"(.*?)\"," + 352 ".*?" + 353 "\"disabled\":\\s*(.*?)," 354 ); 355 356 Matcher matcher = regex.matcher(outputString); 357 358 if (matcher.find()) { 359 Plugin vo = new Plugin(); 360 vo.id = matcher.group(1); 361 vo.name = name; 362 vo.version = matcher.group(2); 363 vo.enabled = !Boolean.parseBoolean(matcher.group(3)); 364 365 return Optional.of(vo); 366 } 367 368 return Optional.empty(); 369 } 370 371 /** 372 * Installs a plugin remotely using base Java8 classes 373 * 374 * @param toUpload The file to upload as a plugin 375 * @throws IOException on any send failures 376 */ 377 public void installPlugin(Path toUpload) throws IOException { 378 URI pluginUrl = this.iiq.resolve("rest/plugins"); 379 380 String filename = toUpload.getFileName().toString(); 381 382 output(OutputLevel.Debug, "Plugin URL: " + pluginUrl); 383 output(OutputLevel.Info, "Installing a new plugin from file " + toUpload); 384 385 String multipartFormBoundary = "----Boundary" + System.currentTimeMillis(); 386 387 HttpURLConnection urlConnection = (HttpURLConnection)pluginUrl.toURL().openConnection(); 388 urlConnection.setDoOutput(true); 389 urlConnection.setDoInput(true); 390 urlConnection.setRequestMethod("POST"); 391 urlConnection.addRequestProperty("Content-Type", "multipart/form-data; boundary=" + multipartFormBoundary); 392 393 String usernamePasswordString = Base64.getEncoder().encodeToString((username + ":" + new String(password)).getBytes(StandardCharsets.UTF_8)); 394 395 urlConnection.addRequestProperty("Authorization", "Basic " + usernamePasswordString); 396 397 try (OutputStream outputStream = urlConnection.getOutputStream()) { 398 try(PrintWriter writer = new PrintWriter(outputStream)) { 399 writer.print("\n\n--" + multipartFormBoundary); 400 writer.println("Content-Disposition: form-data; name=\"fileName\""); 401 writer.println(); 402 writer.println(filename); 403 writer.println("--" + multipartFormBoundary); 404 writer.println("Content-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\""); 405 writer.println("Content-Type: application/octet-stream"); 406 writer.println(); 407 writer.flush(); 408 409 try(InputStream is = Files.newInputStream(toUpload)) { 410 int bytesRead; 411 byte[] buffer = new byte[1024]; 412 while((bytesRead = is.read(buffer)) != -1) { 413 outputStream.write(buffer, 0, bytesRead); 414 } 415 } 416 417 outputStream.flush(); 418 419 writer.println(); 420 writer.println("--" + multipartFormBoundary + "--"); 421 writer.flush(); 422 } 423 } 424 if (urlConnection.getResponseCode() > 299) { 425 if (urlConnection.getResponseCode() == 400) { 426 output(OutputLevel.Error, "Received response code 400, indicating a problem with your uploaded file"); 427 } 428 429 try (BufferedReader reader = new BufferedReader(new InputStreamReader(urlConnection.getErrorStream()))) { 430 String line; 431 while((line = reader.readLine()) != null) { 432 output(OutputLevel.Error, line); 433 } 434 } 435 436 throw new IOException("Received response code " + urlConnection.getResponseCode()); 437 } else { 438 output(OutputLevel.Info, "Received response code " + urlConnection.getResponseCode()); 439 } 440 } 441 442 /** 443 * Makes a query to the Suggest Service to check whether the plugin is installed. The Suggest service is used because the basic plugin query API does not support names or display names, whereas the Suggester can take a filter. 444 * @param pluginName The plugin name 445 * @return The suggest service 446 * @throws IOException if any failures occur 447 */ 448 public boolean isPluginInstalled(String pluginName) throws IOException { 449 Optional<Plugin> vo = getPlugin(pluginName); 450 return vo.isPresent(); 451 } 452 453 /** 454 * Uninstall the plugin with the given name 455 * @param pluginName The name of the plugin 456 * @throws IOException If the de-installation operation fails 457 */ 458 // TODO 459 public void uninstallPlugin(String pluginName) throws IOException { 460 461 } 462 463}