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}