diff --git a/README.md b/README.md index 4ef5373..7823aac 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,85 @@ The client is dependency-free (no Apache CXF / JAX-WS / JAXB — the only runtim (with message encryption) and HTTPS** and **Kerberos (SPNEGO) over HTTPS**. Over HTTPS it validates the certificate and verifies the hostname by default (see the upgrade warning above); `-Dorg.metricshub.winrm.tls.insecure=true` trusts all certificates (insecure, testing only). -Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`). +Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`) unless +the command-line KDC and realm options described below are used. + +## Command-line client + +Every build produces the regular library JAR and an additional self-contained executable: + +```text +target/winrm-java-.jar +target/winrm-java--standalone.jar +``` + +Run a WQL query: + +```bash +java -jar target/winrm-java--standalone.jar \ + --hostname server.example.net --username 'DOMAIN\user' \ + --password-file password.txt --ntlm \ + wql 'SELECT Name,State FROM Win32_Service' +``` + +Run a remote command (`cmd`, `exec`, and `run` are aliases for `command`): + +```bash +java -jar target/winrm-java--standalone.jar \ + -h server.example.net -u Administrator -pf password.txt --https \ + exec ipconfig /all +``` + +Use `--help` for the complete option list and `--version` for the build version. HTTP is the +default transport and uses port 5985; `--https` uses port 5986. `-P`/`--port` overrides either +default, and `-t`/`--timeout` sets the operation timeout in milliseconds (60,000 by default). + +NTLM is used when neither authentication flag is supplied. `--ntlm` and `--kerberos` are mutually +exclusive. Kerberos requires HTTPS. By default it uses the ambient JDK Kerberos configuration. The +CLI can instead configure the JDK for the current invocation with `--kerberos-kdc `. If no +`--kerberos-realm ` is supplied, the realm is inferred by removing the KDC hostname's first +DNS label and uppercasing the remaining suffix. For example: + +```bash +java -jar target/winrm-java--standalone.jar \ + -h server.internal.sentrysoftware.net -u 'DOMAIN\user' -pf password.txt \ + --https --kerberos --kerberos-kdc camus.internal.sentrysoftware.net \ + command whoami +``` + +This infers `INTERNAL.SENTRYSOFTWARE.NET`. The inference follows a common Active Directory DNS +naming convention; it is not guaranteed by Kerberos. Specify `--kerberos-realm` when the realm does +not match the KDC's DNS suffix or when the KDC is not a fully qualified DNS name. Both options are +valid only with `--kerberos`, and `--kerberos-realm` requires `--kerberos-kdc`. + +HTTPS validates the certificate and hostname by default. `--https-permissive` trusts any +certificate and hostname; it is intentionally insecure and should only be used for testing or +isolated hosts. + +`-p`/`--password` is convenient for interactive use, but command-line arguments may be visible to +other local processes. Prefer `-pf`/`--password-file` for automation. Password files are decoded as +UTF-8. Exactly one final LF, CRLF, or CR is removed; all other bytes, including whitespace and +earlier line endings, are part of the password. The two password options are mutually exclusive. +If neither is supplied, the CLI securely requests the password from the interactive console without +echoing it. Non-interactive runs must use `--password-file` (or, less securely, `--password`). + +WQL writes one compact UTF-8 JSON object per row to stdout +([JSON Lines](https://jsonlines.org/)); property order follows the WinRM response. Diagnostics go +only to stderr. Remote command stdout and stderr are forwarded to the corresponding local streams. +The current backend buffers an operation's result; the CLI output boundary is ready to consume the +streaming API when it becomes available. + +Exit behavior is stable: + +| Exit code | Meaning | +| ---: | --- | +| `0` | Successful WQL query or remote command | +| `0`–`255` | Remote command exit code, when representable | +| `64` | Invalid CLI usage | +| `69` | Connection, DNS, socket, or TLS failure | +| `70` | WinRM protocol or other remote failure | +| `77` | Authentication failure | +| `124` | Operation timeout | ## Build instructions diff --git a/pom.xml b/pom.xml index f03b70d..c7347c2 100644 --- a/pom.xml +++ b/pom.xml @@ -121,6 +121,46 @@ + + + maven-shade-plugin + + + package + + shade + + + true + standalone + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + META-INF/versions/*/module-info.class + + + + + + + org.metricshub.winrm.cli.WinRmCli + + ${project.name} + ${project.version} + + + + + + + + net.revelc.code.formatter @@ -147,6 +187,26 @@ + + + maven-failsafe-plugin + + + + integration-test + verify + + + + ${project.version} + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory}/${project.build.finalName}-standalone.jar + + + + + + @@ -170,4 +230,4 @@ - \ No newline at end of file + diff --git a/src/main/java/org/metricshub/winrm/cli/CliArguments.java b/src/main/java/org/metricshub/winrm/cli/CliArguments.java new file mode 100644 index 0000000..9ce6a4d --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/CliArguments.java @@ -0,0 +1,492 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +final class CliArguments implements AutoCloseable { + + enum Operation { + HELP, + VERSION, + WQL, + COMMAND + } + + static final long DEFAULT_TIMEOUT = 60_000L; + + private final Operation operation; + private final String hostname; + private final String username; + private char[] password; + private final WinRMHttpProtocolEnum protocol; + private final int port; + private final long timeout; + private final boolean permissiveHttps; + private final AuthenticationEnum authentication; + private final String kerberosKdc; + private final String kerberosRealm; + private final boolean kerberosRealmInferred; + private final String input; + + private CliArguments(final Builder builder) { + operation = builder.operation; + hostname = builder.hostname; + username = builder.username; + password = builder.password; + protocol = builder.https ? WinRMHttpProtocolEnum.HTTPS : WinRMHttpProtocolEnum.HTTP; + port = WinRMEndpoint.getEndpointPort(protocol, builder.port); + timeout = builder.timeout; + permissiveHttps = builder.permissiveHttps; + authentication = builder.kerberos ? AuthenticationEnum.KERBEROS : AuthenticationEnum.NTLM; + kerberosKdc = builder.kerberosKdc; + kerberosRealm = builder.kerberosRealm; + kerberosRealmInferred = builder.kerberosRealmInferred; + input = builder.input; + } + + static CliArguments parse(final String[] arguments) throws CliUsageException { + final Builder builder = new Builder(); + try { + int index = 0; + while (index < arguments.length) { + final String argument = arguments[index]; + if (isSubcommand(argument)) { + parseOperation(builder, argument, Arrays.asList(arguments).subList(index + 1, arguments.length)); + break; + } + index = parseOption(builder, arguments, index); + if (builder.operation == Operation.HELP || builder.operation == Operation.VERSION) { + break; + } + } + validate(builder); + return new CliArguments(builder); + } catch (final CliUsageException | RuntimeException e) { + builder.clearPassword(); + throw e; + } + } + + private static int parseOption(final Builder builder, final String[] arguments, final int index) + throws CliUsageException { + final String argument = arguments[index]; + final String option = optionName(argument); + switch (option) { + case "--help": + builder.operation = Operation.HELP; + return index + 1; + case "--version": + builder.operation = Operation.VERSION; + return index + 1; + case "--hostname": + case "-h": + builder.hostname = optionValue(arguments, index, option); + return nextIndex(argument, index); + case "--username": + case "-u": + builder.username = optionValue(arguments, index, option); + return nextIndex(argument, index); + case "--password": + case "-p": + if (builder.passwordFile) { + throw new CliUsageException("--password and --password-file are mutually exclusive"); + } + builder.directPassword = true; + builder.replacePassword(consumePassword(arguments, index, option)); + return nextIndex(argument, index); + case "--password-file": + case "-pf": + if (builder.directPassword) { + throw new CliUsageException("--password and --password-file are mutually exclusive"); + } + builder.passwordFile = true; + builder.replacePassword(readPassword(optionValue(arguments, index, option))); + return nextIndex(argument, index); + case "--port": + case "-P": + builder.port = parsePort(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + case "--timeout": + case "-t": + builder.timeout = parseTimeout(optionValue(arguments, index, option), option); + return nextIndex(argument, index); + case "--ntlm": + builder.ntlm = true; + return index + 1; + case "--kerberos": + builder.kerberos = true; + return index + 1; + case "--kerberos-kdc": + builder.kerberosKdc = optionValue(arguments, index, option); + return nextIndex(argument, index); + case "--kerberos-realm": + builder.kerberosRealm = optionValue(arguments, index, option); + return nextIndex(argument, index); + case "--https": + builder.https = true; + return index + 1; + case "--https-permissive": + builder.permissiveHttps = true; + return index + 1; + default: + throw new CliUsageException("unknown option " + safeOptionName(argument)); + } + } + + private static void parseOperation(final Builder builder, final String name, final List values) + throws CliUsageException { + if ("wql".equals(name)) { + builder.operation = Operation.WQL; + builder.input = String.join(" ", values); + } else { + builder.operation = Operation.COMMAND; + builder.input = CommandLineBuilder.join(values); + } + if (values.isEmpty()) { + throw new CliUsageException(name + " requires an argument"); + } + } + + private static void validate(final Builder builder) throws CliUsageException { + if (builder.operation == Operation.HELP || builder.operation == Operation.VERSION) { + return; + } + if (builder.operation == null) { + throw new CliUsageException("missing subcommand (wql or command)"); + } + if (isBlank(builder.input)) { + throw new CliUsageException( + builder.operation == Operation.WQL ? "wql requires a query" : "command requires a command line" + ); + } + if (isBlank(builder.hostname)) { + throw new CliUsageException("missing required option --hostname"); + } + if (isBlank(builder.username)) { + throw new CliUsageException("missing required option --username"); + } + if (builder.directPassword && builder.passwordFile) { + throw new CliUsageException("--password and --password-file are mutually exclusive"); + } + if (builder.ntlm && builder.kerberos) { + throw new CliUsageException("--ntlm and --kerberos are mutually exclusive"); + } + if (builder.kerberos && !builder.https) { + throw new CliUsageException("--kerberos requires --https"); + } + if (!builder.kerberos && (builder.kerberosKdc != null || builder.kerberosRealm != null)) { + throw new CliUsageException("--kerberos-kdc and --kerberos-realm require --kerberos"); + } + if (builder.kerberosRealm != null && builder.kerberosKdc == null) { + throw new CliUsageException("--kerberos-realm requires --kerberos-kdc"); + } + if (builder.kerberosKdc != null) { + validateKerberosConfiguration(builder); + } + if (builder.permissiveHttps && !builder.https) { + throw new CliUsageException("--https-permissive requires --https"); + } + } + + private static void validateKerberosConfiguration(final Builder builder) throws CliUsageException { + builder.kerberosKdc = builder.kerberosKdc.trim(); + if (builder.kerberosKdc.isEmpty()) { + throw new CliUsageException("--kerberos-kdc requires a value"); + } + if (builder.kerberosRealm != null) { + builder.kerberosRealm = builder.kerberosRealm.trim(); + if (builder.kerberosRealm.isEmpty()) { + throw new CliUsageException("--kerberos-realm requires a value"); + } + return; + } + builder.kerberosRealm = inferKerberosRealm(builder.kerberosKdc); + if (builder.kerberosRealm == null) { + throw new CliUsageException( + "cannot infer a realm from --kerberos-kdc; specify --kerberos-realm" + ); + } + builder.kerberosRealmInferred = true; + } + + private static String inferKerberosRealm(final String kdc) { + String host = kdc; + if (host.endsWith(".")) { + host = host.substring(0, host.length() - 1); + } + if (host.indexOf(':') >= 0 + || host.chars().allMatch(character -> Character.isDigit(character) || character == '.')) { + return null; + } + final int separator = host.indexOf('.'); + if (separator <= 0 || separator == host.length() - 1) { + return null; + } + return host.substring(separator + 1).toUpperCase(Locale.ROOT); + } + + private static String optionValue(final String[] arguments, final int index, final String option) + throws CliUsageException { + final int separator = arguments[index].indexOf('='); + if (separator >= 0) { + final String value = arguments[index].substring(separator + 1); + if (value.isEmpty()) { + throw new CliUsageException(option + " requires a value"); + } + return value; + } + if (index + 1 >= arguments.length) { + throw new CliUsageException(option + " requires a value"); + } + return arguments[index + 1]; + } + + private static char[] consumePassword(final String[] arguments, final int index, final String option) + throws CliUsageException { + final String value = optionValue(arguments, index, option); + final char[] password = value.toCharArray(); + if (arguments[index].indexOf('=') >= 0) { + arguments[index] = option; + } else { + arguments[index + 1] = ""; + } + return password; + } + + private static char[] readPassword(final String fileName) throws CliUsageException { + final byte[] bytes; + try { + final Path path = Path.of(fileName); + bytes = Files.readAllBytes(path); + } catch (final IOException | InvalidPathException e) { + throw new CliUsageException("cannot read --password-file", e); + } + + CharBuffer characters = null; + char[] decoded = null; + try { + characters = StandardCharsets.UTF_8 + .newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)); + decoded = new char[characters.remaining()]; + characters.get(decoded); + final int length = passwordLengthWithoutLineEnding(decoded); + return Arrays.copyOf(decoded, length); + } catch (final CharacterCodingException e) { + throw new CliUsageException("--password-file is not valid UTF-8", e); + } finally { + Arrays.fill(bytes, (byte) 0); + if (characters != null && characters.hasArray()) { + Arrays.fill(characters.array(), '\0'); + } + if (decoded != null) { + Arrays.fill(decoded, '\0'); + } + } + } + + private static int passwordLengthWithoutLineEnding(final char[] password) { + int length = password.length; + if (length > 0 && password[length - 1] == '\n') { + length--; + if (length > 0 && password[length - 1] == '\r') { + length--; + } + } else if (length > 0 && password[length - 1] == '\r') { + length--; + } + return length; + } + + private static Integer parsePort(final String value, final String option) throws CliUsageException { + final long port = parsePositiveNumber(value, option); + if (port > 65_535L) { + throw new CliUsageException(option + " must be between 1 and 65535"); + } + return (int) port; + } + + private static long parseTimeout(final String value, final String option) throws CliUsageException { + return parsePositiveNumber(value, option); + } + + private static long parsePositiveNumber(final String value, final String option) throws CliUsageException { + try { + final long number = Long.parseLong(value); + if (number <= 0) { + throw new CliUsageException(option + " must be greater than zero"); + } + return number; + } catch (final NumberFormatException e) { + throw new CliUsageException(option + " must be a number", e); + } + } + + private static int nextIndex(final String argument, final int index) { + return argument.indexOf('=') >= 0 ? index + 1 : index + 2; + } + + private static String optionName(final String argument) { + final int separator = argument.indexOf('='); + return separator >= 0 ? argument.substring(0, separator) : argument; + } + + private static String safeOptionName(final String argument) { + if (argument.startsWith("-")) { + return "'" + optionName(argument) + "'"; + } + return "before subcommand"; + } + + private static boolean isSubcommand(final String value) { + return "wql".equals(value) + || + "command".equals(value) + || + "cmd".equals(value) + || + "exec".equals(value) + || + "run".equals(value); + } + + private static boolean isBlank(final String value) { + return value == null || value.trim().isEmpty(); + } + + Operation operation() { + return operation; + } + + String hostname() { + return hostname; + } + + String username() { + return username; + } + + char[] password() { + return password; + } + + void replacePassword(final char[] replacement) { + if (password != null) { + Arrays.fill(password, '\0'); + } + password = replacement; + } + + WinRMHttpProtocolEnum protocol() { + return protocol; + } + + int port() { + return port; + } + + long timeout() { + return timeout; + } + + boolean permissiveHttps() { + return permissiveHttps; + } + + List authentications() { + final List result = new ArrayList<>(1); + result.add(authentication); + return result; + } + + String kerberosKdc() { + return kerberosKdc; + } + + String kerberosRealm() { + return kerberosRealm; + } + + boolean kerberosRealmInferred() { + return kerberosRealmInferred; + } + + String input() { + return input; + } + + @Override + public void close() { + if (password != null) { + Arrays.fill(password, '\0'); + } + } + + private static final class Builder { + + private Operation operation; + private String hostname; + private String username; + private char[] password; + private boolean directPassword; + private boolean passwordFile; + private boolean https; + private boolean permissiveHttps; + private boolean ntlm; + private boolean kerberos; + private String kerberosKdc; + private String kerberosRealm; + private boolean kerberosRealmInferred; + private Integer port; + private long timeout = DEFAULT_TIMEOUT; + private String input; + + private void replacePassword(final char[] replacement) { + clearPassword(); + password = replacement; + } + + private void clearPassword() { + if (password != null) { + Arrays.fill(password, '\0'); + password = null; + } + } + } +} diff --git a/src/main/java/org/metricshub/winrm/cli/CliUsageException.java b/src/main/java/org/metricshub/winrm/cli/CliUsageException.java new file mode 100644 index 0000000..9c45f79 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/CliUsageException.java @@ -0,0 +1,34 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +final class CliUsageException extends Exception { + + private static final long serialVersionUID = 1L; + + CliUsageException(final String message) { + super(message); + } + + CliUsageException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/metricshub/winrm/cli/CommandLineBuilder.java b/src/main/java/org/metricshub/winrm/cli/CommandLineBuilder.java new file mode 100644 index 0000000..59ec261 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/CommandLineBuilder.java @@ -0,0 +1,84 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.util.List; + +final class CommandLineBuilder { + + private CommandLineBuilder() {} + + static String join(final List arguments) { + final StringBuilder result = new StringBuilder(); + for (final String argument : arguments) { + if (result.length() > 0) { + result.append(' '); + } + appendArgument(result, argument); + } + return result.toString(); + } + + private static void appendArgument(final StringBuilder result, final String argument) { + if (!needsQuoting(argument)) { + result.append(argument); + return; + } + + result.append('"'); + int backslashes = 0; + for (int index = 0; index < argument.length(); index++) { + final char character = argument.charAt(index); + if (character == '\\') { + backslashes++; + } else if (character == '"') { + appendRepeated(result, '\\', backslashes * 2 + 1); + result.append('"'); + backslashes = 0; + } else { + appendRepeated(result, '\\', backslashes); + backslashes = 0; + result.append(character); + } + } + appendRepeated(result, '\\', backslashes * 2); + result.append('"'); + } + + private static boolean needsQuoting(final String argument) { + if (argument.isEmpty()) { + return true; + } + for (int index = 0; index < argument.length(); index++) { + final char character = argument.charAt(index); + if (Character.isWhitespace(character) || character == '"') { + return true; + } + } + return false; + } + + private static void appendRepeated(final StringBuilder result, final char character, final int count) { + for (int index = 0; index < count; index++) { + result.append(character); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java new file mode 100644 index 0000000..ed3253d --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java @@ -0,0 +1,95 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +final class JsonLinesWriter { + + private JsonLinesWriter() {} + + static void write(final Map row, final PrintStream output) { + final StringBuilder json = new StringBuilder(); + json.append('{'); + boolean first = true; + for (final Map.Entry entry : row.entrySet()) { + if (!first) { + json.append(','); + } + writeString(entry.getKey(), json); + json.append(':'); + final Object value = entry.getValue(); + if (value == null) { + json.append("null"); + } else { + writeString(String.valueOf(value), json); + } + first = false; + } + json.append('}').append(System.lineSeparator()); + final byte[] utf8 = json.toString().getBytes(StandardCharsets.UTF_8); + output.write(utf8, 0, utf8.length); + } + + private static void writeString(final String value, final StringBuilder output) { + output.append('"'); + for (int index = 0; index < value.length(); index++) { + final char character = value.charAt(index); + switch (character) { + case '"': + output.append("\\\""); + break; + case '\\': + output.append("\\\\"); + break; + case '\b': + output.append("\\b"); + break; + case '\f': + output.append("\\f"); + break; + case '\n': + output.append("\\n"); + break; + case '\r': + output.append("\\r"); + break; + case '\t': + output.append("\\t"); + break; + default: + writeOrdinaryCharacter(character, output); + break; + } + } + output.append('"'); + } + + private static void writeOrdinaryCharacter(final char character, final StringBuilder output) { + if (character < 0x20) { + output.append(String.format("\\u%04x", (int) character)); + } else { + output.append(character); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java new file mode 100644 index 0000000..534779b --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -0,0 +1,357 @@ +package org.metricshub.winrm.cli; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright 2023 - 2026 MetricsHub + * ჻჻჻჻჻჻ + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +import java.io.Console; +import java.io.IOException; +import java.io.PrintStream; +import java.net.ConnectException; +import java.net.NoRouteToHostException; +import java.net.SocketException; +import java.net.UnknownHostException; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLException; +import org.metricshub.winrm.WindowsRemoteCommandResult; +import org.metricshub.winrm.WindowsRemoteProcessUtils; +import org.metricshub.winrm.exceptions.WindowsRemoteException; +import org.metricshub.winrm.light.LightWinRMService; +import org.metricshub.winrm.service.WinRMEndpoint; + +/** + * Command-line interface for WQL queries and remote command execution through WinRM. + *

+ * WQL results are emitted as UTF-8 JSON Lines on standard output. Remote command output is + * forwarded to the matching local output stream. Diagnostics are written only to standard error. + *

+ * NTLM is the default authentication scheme. Kerberos requires HTTPS. HTTPS validates certificates + * and hostnames unless the explicitly insecure {@code --https-permissive} option is used. + * Password-file input is UTF-8 and has exactly one final LF, CRLF, or CR removed. Direct password + * arguments can be visible to other local processes and should be avoided in automation. When + * neither password option is supplied, the password is read securely from the interactive console. + *

+ * Usage errors exit with 64, connection/TLS errors with 69, WinRM protocol errors with 70, + * authentication errors with 77, and timeouts with 124. Representable remote command exit codes + * (0 through 255) are propagated directly. + */ +public final class WinRmCli { + + static final int EXIT_USAGE = 64; + static final int EXIT_PROTOCOL = 70; + static final int EXIT_CONNECTION = 69; + static final int EXIT_AUTHENTICATION = 77; + static final int EXIT_TIMEOUT = 124; + + private static final String INSECURE_TLS_PROPERTY = "org.metricshub.winrm.tls.insecure"; + private static final String KERBEROS_KDC_PROPERTY = "java.security.krb5.kdc"; + private static final String KERBEROS_REALM_PROPERTY = "java.security.krb5.realm"; + + private WinRmCli() {} + + /** + * Run the WinRM command-line client. + * + * @param arguments command-line arguments + */ + public static void main(final String[] arguments) { + System.exit(run(arguments, System.out, System.err, WinRmCli::connect)); + } + + static int run( + final String[] arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteFactory remoteFactory + ) { + return run(arguments, standardOutput, standardError, remoteFactory, WinRmCli::readConsolePassword); + } + + static int run( + final String[] arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteFactory remoteFactory, + final PasswordReader passwordReader + ) { + try (CliArguments parsed = CliArguments.parse(arguments)) { + if (parsed.operation() == CliArguments.Operation.HELP) { + standardOutput.print(help()); + return 0; + } + if (parsed.operation() == CliArguments.Operation.VERSION) { + standardOutput.println("winrm-java " + version()); + return 0; + } + ensurePassword(parsed, passwordReader); + return execute(parsed, standardOutput, standardError, remoteFactory); + } catch (final CliUsageException e) { + standardError.println("winrm-java: " + e.getMessage()); + standardError.println("Try 'winrm-java --help' for usage."); + return EXIT_USAGE; + } + } + + private static void ensurePassword(final CliArguments arguments, final PasswordReader passwordReader) + throws CliUsageException { + if (arguments.password() != null) { + return; + } + final char[] password = passwordReader.readPassword(); + if (password == null) { + throw new CliUsageException("no password was entered"); + } + arguments.replacePassword(password); + } + + private static char[] readConsolePassword() throws CliUsageException { + final Console console = System.console(); + if (console == null) { + throw new CliUsageException( + "no interactive console is available; use --password-file for non-interactive runs" + ); + } + return console.readPassword("Password: "); + } + + private static int execute( + final CliArguments arguments, + final PrintStream standardOutput, + final PrintStream standardError, + final RemoteFactory remoteFactory + ) { + final String previousInsecureTls = System.getProperty(INSECURE_TLS_PROPERTY); + final String previousKerberosKdc = System.getProperty(KERBEROS_KDC_PROPERTY); + final String previousKerberosRealm = System.getProperty(KERBEROS_REALM_PROPERTY); + try { + setPermissiveHttps(arguments.permissiveHttps()); + setKerberosConfiguration(arguments, standardError); + try (RemoteOperations remote = remoteFactory.connect(arguments)) { + if (arguments.operation() == CliArguments.Operation.WQL) { + final List> rows = remote.executeWql(arguments.input(), arguments.timeout()); + for (final Map row : rows) { + JsonLinesWriter.write(row, standardOutput); + } + standardOutput.flush(); + return 0; + } + final WindowsRemoteCommandResult result = remote.executeCommand(arguments.input(), arguments.timeout()); + standardOutput.print(result.getStdout()); + standardError.print(result.getStderr()); + standardOutput.flush(); + standardError.flush(); + return remoteExitCode(result.getStatusCode(), standardError); + } + } catch (final TimeoutException e) { + diagnostic(standardError, "operation timed out"); + return EXIT_TIMEOUT; + } catch (final Exception e) { + final int exitCode = classify(e); + diagnostic(standardError, safeMessage(e)); + return exitCode; + } finally { + restoreProperty(INSECURE_TLS_PROPERTY, previousInsecureTls); + restoreProperty(KERBEROS_KDC_PROPERTY, previousKerberosKdc); + restoreProperty(KERBEROS_REALM_PROPERTY, previousKerberosRealm); + } + } + + private static RemoteOperations connect(final CliArguments arguments) throws WindowsRemoteException { + final WinRMEndpoint endpoint = new WinRMEndpoint( + arguments.protocol(), + arguments.hostname(), + arguments.port(), + arguments.username(), + arguments.password(), + null + ); + return new LightRemoteOperations( + LightWinRMService.createInstance(endpoint, arguments.timeout(), null, arguments.authentications()) + ); + } + + private static int remoteExitCode(final int exitCode, final PrintStream standardError) { + if (exitCode >= 0 && exitCode <= 255) { + return exitCode; + } + diagnostic(standardError, "remote exit code " + exitCode + " cannot be represented as a process exit code"); + return EXIT_PROTOCOL; + } + + private static int classify(final Throwable throwable) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + final String className = current.getClass().getName(); + final String message = current.getMessage(); + if (className.startsWith("javax.security.auth.login.") + || + className.startsWith("org.ietf.jgss.") + || + (message != null && message.toLowerCase(Locale.ROOT).contains("authentication error"))) { + return EXIT_AUTHENTICATION; + } + if (current instanceof ConnectException + || + current instanceof NoRouteToHostException + || + current instanceof UnknownHostException + || + current instanceof SocketException + || + current instanceof SSLException + || + current instanceof IOException) { + return EXIT_CONNECTION; + } + } + return EXIT_PROTOCOL; + } + + private static String safeMessage(final Throwable throwable) { + final String message = throwable.getMessage(); + if (message == null || message.trim().isEmpty()) { + return throwable.getClass().getSimpleName(); + } + return message.replace('\r', ' ').replace('\n', ' '); + } + + private static void diagnostic(final PrintStream standardError, final String message) { + standardError.println("winrm-java: " + message); + } + + private static void setPermissiveHttps(final boolean permissive) { + if (permissive) { + System.setProperty(INSECURE_TLS_PROPERTY, Boolean.TRUE.toString()); + } + } + + private static void setKerberosConfiguration( + final CliArguments arguments, + final PrintStream standardError + ) { + if (arguments.kerberosKdc() == null) { + return; + } + System.setProperty(KERBEROS_KDC_PROPERTY, arguments.kerberosKdc()); + System.setProperty(KERBEROS_REALM_PROPERTY, arguments.kerberosRealm()); + if (arguments.kerberosRealmInferred()) { + diagnostic( + standardError, + "using Kerberos realm " + + arguments.kerberosRealm() + + " inferred from KDC " + + arguments.kerberosKdc() + ); + } + } + + private static void restoreProperty(final String name, final String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + + private static String version() { + final Package cliPackage = WinRmCli.class.getPackage(); + final String implementationVersion = cliPackage == null ? null : cliPackage.getImplementationVersion(); + return implementationVersion == null ? "development" : implementationVersion; + } + + private static String help() { + return "Usage:\n" + + " winrm-java [options] wql \n" + + " winrm-java [options] command|cmd|exec|run \n" + + "\n" + + "Connection options:\n" + + " -h, --hostname Target hostname or IP address (required)\n" + + " -u, --username User name, optionally DOMAIN\\\\user (required)\n" + + " -p, --password Password (visible to local processes; avoid in automation)\n" + + " -pf, --password-file Read a UTF-8 password from a file (preferred for automation)\n" + + " -P, --port Target port (default: HTTP 5985, HTTPS 5986)\n" + + " -t, --timeout Operation timeout in milliseconds (default: 60000)\n" + + " --https Use HTTPS\n" + + " --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" + + " --ntlm Use NTLM authentication (default)\n" + + " --kerberos Use Kerberos authentication (requires HTTPS)\n" + + " --kerberos-kdc Set the Kerberos KDC; infer realm from its DNS suffix\n" + + " --kerberos-realm Override the realm inferred from --kerberos-kdc\n" + + " --help Show this help\n" + + " --version Show the project version\n" + + "\n" + + "If neither password option is given, the password is requested from the interactive console.\n" + + "The password options are mutually exclusive. Password files have one final\n" + + "LF, CRLF, or CR removed; every other byte is part of the UTF-8 password.\n" + + "Kerberos KDC/realm options set the JDK Kerberos configuration for this invocation.\n" + + "Without them, the ambient JDK Kerberos configuration is used.\n" + + "WQL rows are written to stdout as UTF-8 JSON Lines. Command stdout and stderr are forwarded\n" + + "to the corresponding local streams.\n" + + "\n" + + "Exit codes: 0 success; remote command code 0..255 when available; 64 usage;\n" + + "69 connection/TLS; 70 WinRM protocol; 77 authentication; 124 timeout.\n"; + } + + @FunctionalInterface + interface RemoteFactory { + RemoteOperations connect(CliArguments arguments) throws Exception; + } + + @FunctionalInterface + interface PasswordReader { + char[] readPassword() throws CliUsageException; + } + + interface RemoteOperations extends AutoCloseable { + List> executeWql(String query, long timeout) throws Exception; + + WindowsRemoteCommandResult executeCommand(String command, long timeout) throws Exception; + + @Override + void close(); + } + + static final class LightRemoteOperations implements RemoteOperations { + + private final LightWinRMService service; + + LightRemoteOperations(final LightWinRMService service) { + this.service = service; + } + + @Override + public List> executeWql(final String query, final long timeout) throws Exception { + return service.executeWql(query, timeout); + } + + @Override + public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { + final Charset charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout); + return service.executeCommand(command, null, charset, timeout); + } + + @Override + public void close() { + service.close(); + } + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java new file mode 100644 index 0000000..1f06362 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java @@ -0,0 +1,245 @@ +package org.metricshub.winrm.cli; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +class CliArgumentsTest { + + @TempDir + private Path temporaryDirectory; + + @Test + void parsesDefaultsAndClearsDirectPassword() throws Exception { + final String[] arguments = { + "-h", + "server.example.net", + "-u", + "DOMAIN\\user", + "-p", + "secret", + "wql", + "SELECT Name FROM Win32_Service" + }; + + final CliArguments parsed = CliArguments.parse(arguments); + assertEquals(CliArguments.Operation.WQL, parsed.operation()); + assertEquals("server.example.net", parsed.hostname()); + assertEquals("DOMAIN\\user", parsed.username()); + assertArrayEquals("secret".toCharArray(), parsed.password()); + assertEquals("", arguments[5]); + assertEquals(WinRMHttpProtocolEnum.HTTP, parsed.protocol()); + assertEquals(5985, parsed.port()); + assertEquals(CliArguments.DEFAULT_TIMEOUT, parsed.timeout()); + assertEquals(List.of(AuthenticationEnum.NTLM), parsed.authentications()); + + final char[] password = parsed.password(); + parsed.close(); + assertArrayEquals(new char[password.length], password); + } + + @Test + void parsesHttpsKerberosAndExplicitValues() throws Exception { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { + "--hostname=host.example.net", + "--username=user@example.net", + "--password=secret", + "--https", + "--https-permissive", + "--kerberos", + "--kerberos-kdc=kdc.example.net", + "--kerberos-realm=CORP.EXAMPLE.NET", + "--port=1234", + "--timeout=9876", + "wql", + "SELECT", + "Name", + "FROM", + "Win32_Service" + } + )) { + assertEquals(WinRMHttpProtocolEnum.HTTPS, parsed.protocol()); + assertEquals(1234, parsed.port()); + assertEquals(9876, parsed.timeout()); + assertTrue(parsed.permissiveHttps()); + assertEquals(List.of(AuthenticationEnum.KERBEROS), parsed.authentications()); + assertEquals("kdc.example.net", parsed.kerberosKdc()); + assertEquals("CORP.EXAMPLE.NET", parsed.kerberosRealm()); + assertFalse(parsed.kerberosRealmInferred()); + assertEquals("SELECT Name FROM Win32_Service", parsed.input()); + } + } + + @Test + void infersKerberosRealmFromKdcDnsSuffix() throws Exception { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { + "-h", + "host", + "-u", + "user", + "-p", + "secret", + "--https", + "--kerberos", + "--kerberos-kdc", + "camus.internal.sentrysoftware.net", + "command", + "whoami" + } + )) { + assertEquals("camus.internal.sentrysoftware.net", parsed.kerberosKdc()); + assertEquals("INTERNAL.SENTRYSOFTWARE.NET", parsed.kerberosRealm()); + assertTrue(parsed.kerberosRealmInferred()); + } + } + + @Test + void httpsUsesItsDefaultPort() throws Exception { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", "--https", "command", "hostname" } + )) { + assertEquals(5986, parsed.port()); + } + } + + @Test + void acceptsOmittedPasswordForInteractivePrompting() throws Exception { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "command", "whoami" } + )) { + assertNull(parsed.password()); + } + } + + @Test + void acceptsEveryCommandAlias() throws Exception { + for (final String alias : List.of("command", "cmd", "exec", "run")) { + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { "-h", "host", "-u", "user", "-p", "secret", alias, "ipconfig", "/all" } + )) { + assertEquals(CliArguments.Operation.COMMAND, parsed.operation()); + assertEquals("ipconfig /all", parsed.input()); + } + } + } + + @Test + void stripsExactlyOneFinalPasswordFileLineEnding() throws Exception { + final Path passwordFile = temporaryDirectory.resolve("password.txt"); + Files.write(passwordFile, "line1\nline2\r\n".getBytes(StandardCharsets.UTF_8)); + + try ( + CliArguments parsed = CliArguments.parse( + new String[] + { + "-h", + "host", + "-u", + "user", + "-pf", + passwordFile.toString(), + "wql", + "SELECT Name FROM Win32_Service" + } + )) { + assertArrayEquals("line1\nline2".toCharArray(), parsed.password()); + } + } + + @Test + void rejectsInvalidArguments() { + final String[] base = { "-h", "host", "-u", "user", "-p", "secret" }; + final Object[][] invalidArguments = { + { + "--password and --password-file are mutually exclusive", + concat(base, "-pf", "other.txt", "wql", "SELECT Name FROM Win32_Service") + }, + { + "--ntlm and --kerberos are mutually exclusive", + concat(base, "--https", "--ntlm", "--kerberos", "command", "whoami") + }, + { "--kerberos requires --https", concat(base, "--kerberos", "command", "whoami") }, + { + "--kerberos-kdc and --kerberos-realm require --kerberos", + concat(base, "--kerberos-kdc", "kdc.example.net", "command", "whoami") + }, + { + "--kerberos-realm requires --kerberos-kdc", + concat( + base, + "--https", + "--kerberos", + "--kerberos-realm", + "EXAMPLE.NET", + "command", + "whoami" + ) + }, + { + "cannot infer a realm from --kerberos-kdc; specify --kerberos-realm", + concat( + base, + "--https", + "--kerberos", + "--kerberos-kdc", + "localhost", + "command", + "whoami" + ) + }, + { + "--https-permissive requires --https", + concat(base, "--https-permissive", "command", "whoami") + }, + { "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") }, + { "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") }, + { "missing subcommand (wql or command)", base }, + { "wql requires a query", concat(base, "wql", "") }, + { + "missing required option --hostname", + new String[] + { "-u", "user", "-p", "secret", "command", "whoami" } + }, + }; + for (final Object[] invalid : invalidArguments) { + final String expectedMessage = (String) invalid[0]; + final String[] arguments = (String[]) invalid[1]; + final CliUsageException exception = assertThrows( + CliUsageException.class, + () -> CliArguments.parse(arguments) + ); + assertEquals(expectedMessage, exception.getMessage()); + } + } + + private static String[] concat(final String[] prefix, final String... suffix) { + final String[] result = new String[prefix.length + suffix.length]; + System.arraycopy(prefix, 0, result, 0, prefix.length); + System.arraycopy(suffix, 0, result, prefix.length, suffix.length); + return result; + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/CommandLineBuilderTest.java b/src/test/java/org/metricshub/winrm/cli/CommandLineBuilderTest.java new file mode 100644 index 0000000..5c30568 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/CommandLineBuilderTest.java @@ -0,0 +1,27 @@ +package org.metricshub.winrm.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class CommandLineBuilderTest { + + @Test + void joinsSimpleArguments() { + assertEquals("ipconfig /all", CommandLineBuilder.join(List.of("ipconfig", "/all"))); + } + + @Test + void quotesArgumentsWithSpacesAndEmptyArguments() { + assertEquals("echo \"hello world\" \"\"", CommandLineBuilder.join(List.of("echo", "hello world", ""))); + } + + @Test + void escapesQuotesAndTrailingBackslashesUsingWindowsRules() { + assertEquals( + "program \"say \\\"hello\\\"\" \"C:\\Program Files\\\\\"", + CommandLineBuilder.join(List.of("program", "say \"hello\"", "C:\\Program Files\\")) + ); + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java new file mode 100644 index 0000000..97a805d --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java @@ -0,0 +1,33 @@ +package org.metricshub.winrm.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class JsonLinesWriterTest { + + @Test + void writesOrderedEscapedJsonValues() throws Exception { + final Map row = new LinkedHashMap<>(); + row.put("Name", "Spooler"); + row.put("Label", "Café 東京"); + row.put("Path", "C:\\Windows\nSystem32"); + row.put("Missing", null); + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try (PrintStream output = new PrintStream(bytes, true, StandardCharsets.US_ASCII.name())) { + JsonLinesWriter.write(row, output); + } + + assertEquals( + "{\"Name\":\"Spooler\",\"Label\":\"Café 東京\",\"Path\":\"C:\\\\Windows\\nSystem32\",\"Missing\":null}" + + System.lineSeparator(), + bytes.toString(StandardCharsets.UTF_8.name()) + ); + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/StandaloneJarIT.java b/src/test/java/org/metricshub/winrm/cli/StandaloneJarIT.java new file mode 100644 index 0000000..e409b53 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/StandaloneJarIT.java @@ -0,0 +1,79 @@ +package org.metricshub.winrm.cli; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.jar.Attributes; +import java.util.jar.JarFile; +import org.junit.jupiter.api.Test; + +class StandaloneJarIT { + + private static final long PROCESS_TIMEOUT_SECONDS = 30L; + + @Test + void packagedJarHasManifestAndLaunchesInSeparateJvm() throws Exception { + final Path regularJar = Path.of(System.getProperty("regularJar")); + final Path standaloneJar = Path.of(System.getProperty("standaloneJar")); + final String projectVersion = System.getProperty("projectVersion"); + + assertTrue(Files.isRegularFile(regularJar), () -> "Missing regular JAR: " + regularJar); + assertTrue(Files.isRegularFile(standaloneJar), () -> "Missing standalone JAR: " + standaloneJar); + try (JarFile jar = new JarFile(standaloneJar.toFile())) { + final Attributes attributes = jar.getManifest().getMainAttributes(); + assertEquals("org.metricshub.winrm.cli.WinRmCli", attributes.getValue(Attributes.Name.MAIN_CLASS)); + assertEquals(projectVersion, attributes.getValue("Implementation-Version")); + assertNotNull(jar.getEntry("com/hierynomus/smbj/SMBClient.class")); + } + + final ProcessResult help = launch(standaloneJar, "--help"); + assertEquals(0, help.exitCode); + assertTrue(help.stdout.contains("Usage:")); + assertEquals("", help.stderr); + + final ProcessResult version = launch(standaloneJar, "--version"); + assertEquals(0, version.exitCode); + assertEquals("winrm-java " + projectVersion + System.lineSeparator(), version.stdout); + assertEquals("", version.stderr); + } + + private static ProcessResult launch(final Path jar, final String argument) throws Exception { + final String javaExecutable = Path + .of(System.getProperty("java.home"), "bin", isWindows() ? "java.exe" : "java") + .toString(); + final Process process = new ProcessBuilder(javaExecutable, "-jar", jar.toString(), argument).start(); + final boolean finished = process.waitFor(PROCESS_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + throw new IOException("CLI process did not finish"); + } + return new ProcessResult( + process.exitValue(), + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8), + new String(process.getErrorStream().readAllBytes(), StandardCharsets.UTF_8) + ); + } + + private static boolean isWindows() { + return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("win"); + } + + private static final class ProcessResult { + + private final int exitCode; + private final String stdout; + private final String stderr; + + private ProcessResult(final int exitCode, final String stdout, final String stderr) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java new file mode 100644 index 0000000..4fb44f8 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -0,0 +1,352 @@ +package org.metricshub.winrm.cli; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.ConnectException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.metricshub.winrm.WindowsRemoteCommandResult; +import org.metricshub.winrm.WindowsRemoteProcessUtils; +import org.metricshub.winrm.light.LightWinRMService; + +class WinRmCliTest { + + private static final String[] REQUIRED = { "-h", "host", "-u", "user", "-p", "secret" }; + private static final String INSECURE_TLS_PROPERTY = "org.metricshub.winrm.tls.insecure"; + private static final String KERBEROS_KDC_PROPERTY = "java.security.krb5.kdc"; + private static final String KERBEROS_REALM_PROPERTY = "java.security.krb5.realm"; + + @Test + void helpAndVersionDoNotConnect() throws Exception { + final Invocation help = invoke(new String[] { "--help" }, arguments -> failingRemote()); + assertEquals(0, help.exitCode); + assertTrue(help.stdout.contains("command|cmd|exec|run")); + assertTrue(help.stdout.contains("-P, --port")); + assertTrue(help.stdout.contains("--kerberos-kdc")); + assertTrue(help.stdout.contains("--kerberos-realm")); + assertEquals("", help.stderr); + + final Invocation version = invoke(new String[] { "--version" }, arguments -> failingRemote()); + assertEquals(0, version.exitCode); + assertTrue(version.stdout.startsWith("winrm-java ")); + } + + @Test + void writesWqlAsJsonLines() throws Exception { + final Map first = new LinkedHashMap<>(); + first.put("Name", "Spooler"); + first.put("State", "Running"); + final Map second = new LinkedHashMap<>(); + second.put("Name", "WinRM"); + second.put("State", "Running"); + final FakeRemote remote = new FakeRemote(); + remote.rows = List.of(first, second); + + final Invocation invocation = invoke( + concat(REQUIRED, "wql", "SELECT Name,State FROM Win32_Service"), + args -> remote + ); + + assertEquals(0, invocation.exitCode); + assertEquals( + "{\"Name\":\"Spooler\",\"State\":\"Running\"}" + + System.lineSeparator() + + "{\"Name\":\"WinRM\",\"State\":\"Running\"}" + + System.lineSeparator(), + invocation.stdout + ); + assertEquals("", invocation.stderr); + assertTrue(remote.closed); + } + + @Test + void forwardsCommandStreamsAndExitCode() throws Exception { + final FakeRemote remote = new FakeRemote(); + remote.commandResult = new WindowsRemoteCommandResult("output", "warning", 0.1f, 7); + + final Invocation invocation = invoke(concat(REQUIRED, "exec", "echo", "hello world"), args -> remote); + + assertEquals(7, invocation.exitCode); + assertEquals("output", invocation.stdout); + assertEquals("warning", invocation.stderr); + assertEquals("echo \"hello world\"", remote.command); + } + + @Test + void mapsUsageTimeoutConnectionAuthenticationAndProtocolFailures() throws Exception { + assertEquals( + WinRmCli.EXIT_USAGE, + invoke(new String[] + { "--password=do-not-print" }, args -> failingRemote()).exitCode + ); + + final FakeRemote timeout = new FakeRemote(); + timeout.failure = new TimeoutException(); + assertEquals(WinRmCli.EXIT_TIMEOUT, invoke(concat(REQUIRED, "command", "whoami"), args -> timeout).exitCode); + + final FakeRemote connection = new FakeRemote(); + connection.failure = new ConnectException("connection refused"); + assertEquals(WinRmCli.EXIT_CONNECTION, invoke(concat(REQUIRED, "command", "whoami"), args -> connection).exitCode); + + final FakeRemote authentication = new FakeRemote(); + authentication.failure = new IllegalStateException("Authentication error on endpoint"); + assertEquals( + WinRmCli.EXIT_AUTHENTICATION, + invoke(concat(REQUIRED, "command", "whoami"), args -> authentication).exitCode + ); + + final FakeRemote protocol = new FakeRemote(); + protocol.failure = new IllegalStateException("WSMan fault"); + assertEquals(WinRmCli.EXIT_PROTOCOL, invoke(concat(REQUIRED, "command", "whoami"), args -> protocol).exitCode); + } + + @Test + void neverPrintsPasswordInUsageDiagnostics() throws Exception { + final String secret = "unique-password-value"; + final Invocation invocation = invoke( + new String[] + { "--password=" + secret, "--unknown=" + secret }, + args -> failingRemote() + ); + + assertEquals(WinRmCli.EXIT_USAGE, invocation.exitCode); + assertFalse(invocation.stdout.contains(secret)); + assertFalse(invocation.stderr.contains(secret)); + } + + @Test + void requestsAnOmittedPasswordAndClearsThePromptBuffer() throws Exception { + final char[] promptedPassword = "prompted-secret".toCharArray(); + final String[] observedPassword = new String[1]; + final FakeRemote remote = new FakeRemote(); + + final Invocation invocation = invoke( + new String[] + { "-h", "host", "-u", "user", "command", "whoami" }, + arguments -> { + observedPassword[0] = new String(arguments.password()); + return remote; + }, + () -> promptedPassword + ); + + assertEquals(0, invocation.exitCode); + assertEquals("prompted-secret", observedPassword[0]); + assertArrayEquals(new char[promptedPassword.length], promptedPassword); + } + + @Test + void reportsMissingConsoleForNonInteractiveRuns() throws Exception { + final Invocation invocation = invoke( + new String[] + { "-h", "host", "-u", "user", "command", "whoami" }, + arguments -> failingRemote(), + () -> { + throw new CliUsageException( + "no interactive console is available; use --password-file for non-interactive runs" + ); + } + ); + + assertEquals(WinRmCli.EXIT_USAGE, invocation.exitCode); + assertTrue(invocation.stderr.contains("no interactive console is available")); + assertTrue(invocation.stderr.contains("--password-file")); + } + + @Test + void configuresInferredKerberosRealmBeforeConnectingAndRestoresProperties() throws Exception { + final String originalKdc = System.getProperty(KERBEROS_KDC_PROPERTY); + final String originalRealm = System.getProperty(KERBEROS_REALM_PROPERTY); + System.setProperty(KERBEROS_KDC_PROPERTY, "previous-kdc.example.net"); + System.setProperty(KERBEROS_REALM_PROPERTY, "PREVIOUS.EXAMPLE.NET"); + try { + final Invocation invocation = invoke( + concat( + REQUIRED, + "--https", + "--kerberos", + "--kerberos-kdc", + "camus.internal.sentrysoftware.net", + "command", + "whoami" + ), + arguments -> { + assertEquals( + "camus.internal.sentrysoftware.net", + System.getProperty(KERBEROS_KDC_PROPERTY) + ); + assertEquals( + "INTERNAL.SENTRYSOFTWARE.NET", + System.getProperty(KERBEROS_REALM_PROPERTY) + ); + return new FakeRemote(); + } + ); + + assertEquals(0, invocation.exitCode); + assertTrue( + invocation.stderr.contains( + "using Kerberos realm INTERNAL.SENTRYSOFTWARE.NET inferred from KDC " + + "camus.internal.sentrysoftware.net" + ) + ); + assertEquals("previous-kdc.example.net", System.getProperty(KERBEROS_KDC_PROPERTY)); + assertEquals("PREVIOUS.EXAMPLE.NET", System.getProperty(KERBEROS_REALM_PROPERTY)); + } finally { + restoreProperty(KERBEROS_KDC_PROPERTY, originalKdc); + restoreProperty(KERBEROS_REALM_PROPERTY, originalRealm); + } + } + + @Test + void honorsAnAmbientInsecureTlsProperty() throws Exception { + final String original = System.getProperty(INSECURE_TLS_PROPERTY); + System.setProperty(INSECURE_TLS_PROPERTY, Boolean.TRUE.toString()); + try { + final Invocation invocation = invoke( + concat(REQUIRED, "--https", "command", "whoami"), + arguments -> { + assertEquals(Boolean.TRUE.toString(), System.getProperty(INSECURE_TLS_PROPERTY)); + return new FakeRemote(); + } + ); + + assertEquals(0, invocation.exitCode); + assertEquals(Boolean.TRUE.toString(), System.getProperty(INSECURE_TLS_PROPERTY)); + } finally { + restoreProperty(INSECURE_TLS_PROPERTY, original); + } + } + + @Test + void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception { + final LightWinRMService service = mock(LightWinRMService.class); + final Charset windowsCharset = Charset.forName("windows-1251"); + final WindowsRemoteCommandResult expected = new WindowsRemoteCommandResult("Результат", "", 0.0f, 0); + final long timeout = 1_234L; + when(service.executeCommand("whoami", null, windowsCharset, timeout)).thenReturn(expected); + + try (MockedStatic processUtils = mockStatic(WindowsRemoteProcessUtils.class)) { + processUtils + .when(() -> WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout)) + .thenReturn(windowsCharset); + final WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations(service); + + assertSame(expected, remote.executeCommand("whoami", timeout)); + processUtils.verify(() -> WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout)); + verify(service).executeCommand(eq("whoami"), isNull(), eq(windowsCharset), eq(timeout)); + } + } + + private static WinRmCli.RemoteOperations failingRemote() { + throw new AssertionError("No connection expected"); + } + + private static Invocation invoke(final String[] arguments, final WinRmCli.RemoteFactory factory) throws Exception { + return invoke( + arguments, + factory, + () -> { + throw new AssertionError("No password prompt expected"); + } + ); + } + + private static Invocation invoke( + final String[] arguments, + final WinRmCli.RemoteFactory factory, + final WinRmCli.PasswordReader passwordReader + ) throws Exception { + final ByteArrayOutputStream stdoutBytes = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderrBytes = new ByteArrayOutputStream(); + try ( + PrintStream stdout = new PrintStream(stdoutBytes, true, StandardCharsets.UTF_8.name()); + PrintStream stderr = new PrintStream(stderrBytes, true, StandardCharsets.UTF_8.name())) { + final int exitCode = WinRmCli.run(arguments, stdout, stderr, factory, passwordReader); + return new Invocation( + exitCode, + stdoutBytes.toString(StandardCharsets.UTF_8.name()), + stderrBytes.toString(StandardCharsets.UTF_8.name()) + ); + } + } + + private static String[] concat(final String[] prefix, final String... suffix) { + final String[] result = new String[prefix.length + suffix.length]; + System.arraycopy(prefix, 0, result, 0, prefix.length); + System.arraycopy(suffix, 0, result, prefix.length, suffix.length); + return result; + } + + private static void restoreProperty(final String name, final String value) { + if (value == null) { + System.clearProperty(name); + } else { + System.setProperty(name, value); + } + } + + private static final class Invocation { + + private final int exitCode; + private final String stdout; + private final String stderr; + + private Invocation(final int exitCode, final String stdout, final String stderr) { + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + } + + private static final class FakeRemote implements WinRmCli.RemoteOperations { + + private List> rows = List.of(); + private WindowsRemoteCommandResult commandResult = new WindowsRemoteCommandResult("", "", 0.0f, 0); + private Exception failure; + private String command; + private boolean closed; + + @Override + public List> executeWql(final String query, final long timeout) throws Exception { + failIfConfigured(); + return rows; + } + + @Override + public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { + this.command = command; + failIfConfigured(); + return commandResult; + } + + @Override + public void close() { + closed = true; + } + + private void failIfConfigured() throws Exception { + if (failure != null) { + throw failure; + } + } + } +}