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