From e53cd64482f5dbebc5857a988ec0b48c47136597 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Fri, 24 Jul 2026 14:22:01 +0200 Subject: [PATCH 1/2] Add WinRM CLI and standalone executable JAR --- README.md | 80 ++- pom.xml | 62 ++- .../metricshub/winrm/cli/CliArguments.java | 492 ++++++++++++++++++ .../winrm/cli/CliUsageException.java | 34 ++ .../winrm/cli/CommandLineBuilder.java | 84 +++ .../metricshub/winrm/cli/JsonLinesWriter.java | 91 ++++ .../org/metricshub/winrm/cli/WinRmCli.java | 357 +++++++++++++ .../winrm/cli/CliArgumentsTest.java | 245 +++++++++ .../winrm/cli/CommandLineBuilderTest.java | 27 + .../winrm/cli/JsonLinesWriterTest.java | 31 ++ .../metricshub/winrm/cli/StandaloneJarIT.java | 79 +++ .../metricshub/winrm/cli/WinRmCliTest.java | 300 +++++++++++ 12 files changed, 1880 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/cli/CliArguments.java create mode 100644 src/main/java/org/metricshub/winrm/cli/CliUsageException.java create mode 100644 src/main/java/org/metricshub/winrm/cli/CommandLineBuilder.java create mode 100644 src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java create mode 100644 src/main/java/org/metricshub/winrm/cli/WinRmCli.java create mode 100644 src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java create mode 100644 src/test/java/org/metricshub/winrm/cli/CommandLineBuilderTest.java create mode 100644 src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java create mode 100644 src/test/java/org/metricshub/winrm/cli/StandaloneJarIT.java create mode 100644 src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java diff --git a/README.md b/README.md index 4ef5373..e7ef5e6 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 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..5f1e48e --- /dev/null +++ b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java @@ -0,0 +1,91 @@ +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.util.Map; + +final class JsonLinesWriter { + + private JsonLinesWriter() {} + + static void write(final Map row, final PrintStream output) { + output.print('{'); + boolean first = true; + for (final Map.Entry entry : row.entrySet()) { + if (!first) { + output.print(','); + } + writeString(entry.getKey(), output); + output.print(':'); + final Object value = entry.getValue(); + if (value == null) { + output.print("null"); + } else { + writeString(String.valueOf(value), output); + } + first = false; + } + output.println('}'); + } + + private static void writeString(final String value, final PrintStream output) { + output.print('"'); + for (int index = 0; index < value.length(); index++) { + final char character = value.charAt(index); + switch (character) { + case '"': + output.print("\\\""); + break; + case '\\': + output.print("\\\\"); + break; + case '\b': + output.print("\\b"); + break; + case '\f': + output.print("\\f"); + break; + case '\n': + output.print("\\n"); + break; + case '\r': + output.print("\\r"); + break; + case '\t': + output.print("\\t"); + break; + default: + writeOrdinaryCharacter(character, output); + break; + } + } + output.print('"'); + } + + private static void writeOrdinaryCharacter(final char character, final PrintStream output) { + if (character < 0x20) { + output.printf("\\u%04x", (int) character); + } else { + output.print(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..4c85b80 --- /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.StandardCharsets; +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.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 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()); + } else { + System.clearProperty(INSECURE_TLS_PROPERTY); + } + } + + 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 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(); + } + + private static final class LightRemoteOperations implements RemoteOperations { + + private final LightWinRMService service; + + private 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 { + return service.executeCommand(command, null, StandardCharsets.UTF_8, 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..6e474a8 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java @@ -0,0 +1,31 @@ +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("Path", "C:\\Windows\nSystem32"); + row.put("Missing", null); + final ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + + try (PrintStream output = new PrintStream(bytes, true, StandardCharsets.UTF_8.name())) { + JsonLinesWriter.write(row, output); + } + + assertEquals( + "{\"Name\":\"Spooler\",\"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..646f998 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -0,0 +1,300 @@ +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.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.net.ConnectException; +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.metricshub.winrm.WindowsRemoteCommandResult; + +class WinRmCliTest { + + private static final String[] REQUIRED = { "-h", "host", "-u", "user", "-p", "secret" }; + 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); + } + } + + 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; + } + } + } +} From c1e9d1bc6d83191a284737e82c16552588f30d2d Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Fri, 24 Jul 2026 15:04:50 +0200 Subject: [PATCH 2/2] Preserve UTF-8 WQL output and detect remote command encoding --- README.md | 2 +- .../metricshub/winrm/cli/JsonLinesWriter.java | 44 +++++++++------- .../org/metricshub/winrm/cli/WinRmCli.java | 18 +++---- .../winrm/cli/JsonLinesWriterTest.java | 6 ++- .../metricshub/winrm/cli/WinRmCliTest.java | 52 +++++++++++++++++++ 5 files changed, 90 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index e7ef5e6..7823aac 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ earlier line endings, are part of the password. The two password options are mut 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 JSON object per row to stdout +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 diff --git a/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java index 5f1e48e..ed3253d 100644 --- a/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java +++ b/src/main/java/org/metricshub/winrm/cli/JsonLinesWriter.java @@ -21,6 +21,7 @@ */ import java.io.PrintStream; +import java.nio.charset.StandardCharsets; import java.util.Map; final class JsonLinesWriter { @@ -28,64 +29,67 @@ final class JsonLinesWriter { private JsonLinesWriter() {} static void write(final Map row, final PrintStream output) { - output.print('{'); + final StringBuilder json = new StringBuilder(); + json.append('{'); boolean first = true; for (final Map.Entry entry : row.entrySet()) { if (!first) { - output.print(','); + json.append(','); } - writeString(entry.getKey(), output); - output.print(':'); + writeString(entry.getKey(), json); + json.append(':'); final Object value = entry.getValue(); if (value == null) { - output.print("null"); + json.append("null"); } else { - writeString(String.valueOf(value), output); + writeString(String.valueOf(value), json); } first = false; } - output.println('}'); + 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 PrintStream output) { - output.print('"'); + 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.print("\\\""); + output.append("\\\""); break; case '\\': - output.print("\\\\"); + output.append("\\\\"); break; case '\b': - output.print("\\b"); + output.append("\\b"); break; case '\f': - output.print("\\f"); + output.append("\\f"); break; case '\n': - output.print("\\n"); + output.append("\\n"); break; case '\r': - output.print("\\r"); + output.append("\\r"); break; case '\t': - output.print("\\t"); + output.append("\\t"); break; default: writeOrdinaryCharacter(character, output); break; } } - output.print('"'); + output.append('"'); } - private static void writeOrdinaryCharacter(final char character, final PrintStream output) { + private static void writeOrdinaryCharacter(final char character, final StringBuilder output) { if (character < 0x20) { - output.printf("\\u%04x", (int) character); + output.append(String.format("\\u%04x", (int) character)); } else { - output.print(character); + 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 index 4c85b80..534779b 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -27,13 +27,14 @@ import java.net.NoRouteToHostException; import java.net.SocketException; import java.net.UnknownHostException; -import java.nio.charset.StandardCharsets; +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; @@ -41,8 +42,8 @@ /** * Command-line interface for WQL queries and remote command execution through WinRM. *

- * WQL results are emitted as JSON Lines on standard output. Remote command output is forwarded to - * the matching local output stream. Diagnostics are written only to standard error. + * 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. @@ -240,8 +241,6 @@ private static void diagnostic(final PrintStream standardError, final String mes private static void setPermissiveHttps(final boolean permissive) { if (permissive) { System.setProperty(INSECURE_TLS_PROPERTY, Boolean.TRUE.toString()); - } else { - System.clearProperty(INSECURE_TLS_PROPERTY); } } @@ -305,7 +304,7 @@ private static String help() { "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 JSON Lines. Command stdout and stderr are forwarded\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" + @@ -331,11 +330,11 @@ interface RemoteOperations extends AutoCloseable { void close(); } - private static final class LightRemoteOperations implements RemoteOperations { + static final class LightRemoteOperations implements RemoteOperations { private final LightWinRMService service; - private LightRemoteOperations(final LightWinRMService service) { + LightRemoteOperations(final LightWinRMService service) { this.service = service; } @@ -346,7 +345,8 @@ public List> executeWql(final String query, final long timeo @Override public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { - return service.executeCommand(command, null, StandardCharsets.UTF_8, timeout); + final Charset charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout); + return service.executeCommand(command, null, charset, timeout); } @Override diff --git a/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java index 6e474a8..97a805d 100644 --- a/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java +++ b/src/test/java/org/metricshub/winrm/cli/JsonLinesWriterTest.java @@ -15,16 +15,18 @@ class JsonLinesWriterTest { 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.UTF_8.name())) { + try (PrintStream output = new PrintStream(bytes, true, StandardCharsets.US_ASCII.name())) { JsonLinesWriter.write(row, output); } assertEquals( - "{\"Name\":\"Spooler\",\"Path\":\"C:\\\\Windows\\nSystem32\",\"Missing\":null}" + System.lineSeparator(), + "{\"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/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 646f998..4fb44f8 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -3,22 +3,34 @@ 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"; @@ -204,6 +216,46 @@ void configuresInferredKerberosRealmBeforeConnectingAndRestoresProperties() thro } } + @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"); }