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}