From a77af5700d24a4ce74f26470ad5652c8607a063d Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 18:54:57 +0200 Subject: [PATCH 01/10] Add the fluent WinRMClient API (builders, typed results, unchecked exceptions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new front door of the library (issue #131): WinRMClient.builder(host) creates a reusable, AutoCloseable client — one authentication, any number of WQL queries and commands over the same connection. Per-operation builders end in execute() and return typed results (WqlResult/WqlRow with case-insensitive lookup, CommandResult), with Duration timeouts throughout. Failures surface through a new unchecked hierarchy (WinRMClientException + Authentication/Fault/Timeout/WqlSyntax subtypes); WinRMFaultException carries the WSManFault code, reason, and provider detail as fields. The legacy static helpers are unchanged. Also closes #86: pageSize(int) and pullTimeout(Duration) on the WQL builder plumb MaxElements and MaxTime down to the WSMan envelopes (defaults unchanged: OptimizeEnumeration + MaxElements=32000). Supporting changes to the light internals: - WsmanClient throws the typed WinRMFaultException / WinRMAuthenticationException (messages byte-identical to before). - The WSMan OperationTimeout header and the socket read timeout now follow each operation's own timeout instead of the creation timeout (identical values through the legacy API; the fluent API can override per operation). - LightWinRMService/WinRMExecutorFactory gain a TLS-override overload (SSLContext or trust-all) backing the per-client trustAllCertificates() and sslContext(...) builder options. - ShellFileCopy.copyLocalFileToRemoteFile(...) uploads to an explicit remote path (digest-verified, skip-if-identical, directory created), backing client.uploadFile(...). Tests: builder validation, WqlRow semantics, and e2e coverage of the new API against FakeWsmanServer (typed results, wire effect of pageSize / pullTimeout / namespace / timeout, charset detection caching, typed fault/auth/timeout mapping); FakeWsmanServer gains enqueueDelayed() for a deterministic timeout test. README, site index, and CHANGELOG updated. Closes #131 Closes #86 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 22 + README.md | 68 ++- .../java/org/metricshub/winrm/AuthScheme.java | 33 ++ .../org/metricshub/winrm/CommandRequest.java | 187 +++++++ .../org/metricshub/winrm/CommandResult.java | 93 ++++ .../org/metricshub/winrm/ShellFileCopy.java | 111 ++++- .../org/metricshub/winrm/WinRMClient.java | 458 ++++++++++++++++++ .../winrm/WindowsRemoteExecutor.java | 42 ++ .../java/org/metricshub/winrm/WqlRequest.java | 147 ++++++ .../java/org/metricshub/winrm/WqlResult.java | 113 +++++ .../java/org/metricshub/winrm/WqlRow.java | 88 ++++ .../WinRMAuthenticationException.java | 40 ++ .../exceptions/WinRMClientException.java | 54 +++ .../winrm/exceptions/WinRMFaultException.java | 100 ++++ .../exceptions/WinRMTimeoutException.java | 40 ++ .../winrm/exceptions/WqlSyntaxException.java | 41 ++ .../org/metricshub/winrm/light/Envelopes.java | 29 +- .../metricshub/winrm/light/HttpTransport.java | 28 +- .../org/metricshub/winrm/light/LightTls.java | 14 +- .../winrm/light/LightWinRMService.java | 70 ++- .../metricshub/winrm/light/WsmanClient.java | 86 +++- .../winrm/service/WinRMExecutorFactory.java | 34 ++ src/site/markdown/index.md | 59 ++- .../metricshub/winrm/ShellFileCopyTest.java | 78 +++ .../winrm/WinRMClientBuilderTest.java | 141 ++++++ .../org/metricshub/winrm/WinRMClientTest.java | 343 +++++++++++++ .../winrm/light/FakeWsmanServer.java | 30 ++ .../winrm/light/WsmanClientParityTest.java | 3 +- 28 files changed, 2482 insertions(+), 70 deletions(-) create mode 100644 src/main/java/org/metricshub/winrm/AuthScheme.java create mode 100644 src/main/java/org/metricshub/winrm/CommandRequest.java create mode 100644 src/main/java/org/metricshub/winrm/CommandResult.java create mode 100644 src/main/java/org/metricshub/winrm/WinRMClient.java create mode 100644 src/main/java/org/metricshub/winrm/WqlRequest.java create mode 100644 src/main/java/org/metricshub/winrm/WqlResult.java create mode 100644 src/main/java/org/metricshub/winrm/WqlRow.java create mode 100644 src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java create mode 100644 src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java create mode 100644 src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java create mode 100644 src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java create mode 100644 src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java create mode 100644 src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java create mode 100644 src/test/java/org/metricshub/winrm/WinRMClientTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c9211ac..8765ddc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,28 @@ Consequences: ### Added +- **Fluent client API** (issue #131): `WinRMClient.builder(host)` creates a reusable, + `AutoCloseable` client — one authentication, any number of WQL queries and commands over the + same connection. Per-operation builders (`client.wql(...)`, `client.command(...)`) end in + `execute()` and return typed results (`WqlResult`/`WqlRow` with case-insensitive property + lookup, `CommandResult`), with `java.time.Duration` timeouts throughout. Failures are reported + through a new unchecked exception hierarchy (`WinRMClientException`, with + `WinRMAuthenticationException`, `WinRMFaultException` — carrying the WSMan fault code, reason, + and provider detail as fields — `WinRMTimeoutException`, and `WqlSyntaxException`). The legacy + static helpers and their checked exceptions are unchanged. +- **WQL enumeration tuning** (issue #86): `pageSize(int)` sets the WS-Enumeration `MaxElements` + batch size (default 32000) and `pullTimeout(Duration)` sets the per-Pull `MaxTime`; both on the + fluent WQL builder, plumbed down to the WSMan envelopes. +- **Per-client TLS configuration**: `trustAllCertificates()` and `sslContext(SSLContext)` on the + client builder override the global `org.metricshub.winrm.tls.insecure` system property for that + client only. +- **First-class file upload**: `client.uploadFile(localPath, remotePath)` copies a file to an + explicit remote path through the WinRM channel (digest-verified, skip-if-identical, destination + directory created when needed) — also available to the legacy API as + `ShellFileCopy.copyLocalFileToRemoteFile(...)`. +- The WSMan `OperationTimeout` header and the socket read timeout now follow each operation's own + timeout instead of the executor's creation timeout (they were always the same value through the + legacy API; the fluent API can override the timeout per operation). - Dependency-free WinRM client with no Apache CXF / JAX-WS / JAXB stack, immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports NTLM over HTTP (with message encryption) and HTTPS, and Kerberos (SPNEGO, via the JDK GSS-API) over HTTPS. diff --git a/README.md b/README.md index e4dc2a5..3e22741 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,54 @@ The Windows Remote Management (WinRM) Java Client is a library that enables to: > (and `WinRMWqlExecutor` copies the lists passed to its constructor): callers that mutated > the returned collections must now copy them first. +## Quick start + +The fluent `WinRMClient` is the entry point of the library: one client authenticates once and can +run any number of WQL queries and commands over the same connection. All failures are reported +through the unchecked `WinRMClientException` hierarchy (`WinRMAuthenticationException`, +`WinRMFaultException` with the WSMan fault code and detail as fields, `WinRMTimeoutException`, +`WqlSyntaxException`). + +```java +import java.nio.file.Path; +import java.time.Duration; +import org.metricshub.winrm.*; + +try (WinRMClient client = WinRMClient.builder("server01.acme.com") + .credentials("ACME\\admin", password) // char[], wiped by you afterward + .timeout(Duration.ofSeconds(30)) // default for all operations + .build()) { + + // WQL query + WqlResult services = client.wql("SELECT Name, State FROM Win32_Service").execute(); + for (WqlRow row : services) { + System.out.println(row.string("Name") + " is " + row.string("State")); + } + + // Remote command + CommandResult result = client.command("ipconfig /all").execute(); + System.out.println(result.stdout()); + + // Copy a file to the host (through the WinRM channel itself — no SMB) + client.uploadFile(Path.of("collect.ps1"), "C:\\Windows\\Temp\\collect.ps1"); +} +``` + +Connection-scoped options on the builder: `https()`, `port(int)`, +`authentication(AuthScheme.KERBEROS, AuthScheme.NTLM)` (ordered fallback; NTLM is the default), +`ticketCache(Path)`, `namespace(String)`, `trustAllCertificates()` (per-client alternative to the +`org.metricshub.winrm.tls.insecure` system property; insecure, testing only), and +`sslContext(SSLContext)` for a dedicated trust store. + +Per-operation options: `namespace(...)`, `timeout(...)`, and for WQL enumeration tuning +`pageSize(int)` (WS-Enumeration `MaxElements`, 32000 by default) and `pullTimeout(Duration)` +(`MaxTime` per Pull). Commands accept `workingDirectory(String)`, `charset(Charset)` (detected +from the remote code set by default), and `upload(Path...)` to copy local script files and rewrite +the command to reference the remote copies. + +The pre-existing static helpers (`WinRMWqlExecutor.executeWql(...)`, +`WinRMCommandExecutor.execute(...)`) keep working unchanged — see **Legacy API** below. + ## The WinRM client The client has **zero runtime dependencies** (no Apache CXF / JAX-WS / JAXB, no BouncyCastle, no @@ -35,16 +83,24 @@ SLF4J — problems are reported through exceptions only) and is immune by constr `ServiceLoader` conflicts (it uses the JDK-default XML factories). It supports **NTLM over HTTP (with message encryption) and HTTPS** and **Kerberos (SPNEGO) over HTTPS**. -Files listed in `localFileToCopyList` are copied to the remote host **through the WinRM channel -itself** (chunked base64 through the command shell, decoded with `certutil` and verified with a -digest): no SMB, no TCP port 445, no administrative share — and it works from any client OS. A -file already present on the remote host with an identical digest is not transferred again. This -transport is designed for small script files, not bulk data. Over HTTPS it -validates the certificate and verifies the hostname by default (see the upgrade warning above); +Files passed to `upload(...)` (or `localFileToCopyList` in the legacy API) are copied to the +remote host **through the WinRM channel itself** (chunked base64 through the command shell, +decoded with `certutil` and verified with a digest): no SMB, no TCP port 445, no administrative +share — and it works from any client OS. A file already present on the remote host with an +identical digest is not transferred again. This transport is designed for small script files, not +bulk data. Over HTTPS it validates the certificate and verifies the hostname by default (see the +upgrade warning above); `trustAllCertificates()` on the builder or `-Dorg.metricshub.winrm.tls.insecure=true` trusts all certificates (insecure, testing only). Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`) unless the command-line KDC and realm options described below are used. +### Legacy API + +The static one-shot helpers that predate `WinRMClient` remain available and unchanged, with their +checked exceptions: `WinRMWqlExecutor.executeWql(...)` and `WinRMCommandExecutor.execute(...)`. +They open a connection, run one operation, and close it — prefer `WinRMClient` for anything that +runs more than one operation against the same host. + ## Command-line client Every build produces the regular library JAR and an additional self-contained executable: diff --git a/src/main/java/org/metricshub/winrm/AuthScheme.java b/src/main/java/org/metricshub/winrm/AuthScheme.java new file mode 100644 index 0000000..cd9a99f --- /dev/null +++ b/src/main/java/org/metricshub/winrm/AuthScheme.java @@ -0,0 +1,33 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * Authentication scheme for {@link WinRMClient#builder(String)}. Several schemes form an + * ordered fallback list: each is tried in the given order until one succeeds. + */ +public enum AuthScheme { + /** NTLM authentication — over HTTP (with message encryption) or HTTPS. The default. */ + NTLM, + + /** Kerberos (SPNEGO) authentication — requires HTTPS and connecting by the FQDN the KDC knows. */ + KERBEROS +} diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java new file mode 100644 index 0000000..305457a --- /dev/null +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -0,0 +1,187 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.charset.Charset; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import org.metricshub.winrm.exceptions.WinRMClientException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; +import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; + +/** + * A command being prepared for execution, created by {@link WinRMClient#command(String)}. + * Every option has a sensible default; {@link #execute()} runs the command and returns its + * output and exit code. + */ +public final class CommandRequest { + + private final WinRMClient client; + private final String commandLine; + private String workingDirectory; + private Duration timeout; + private Charset charset; + private final List uploads = new ArrayList<>(); + + /** + * Create the request. + * + * @param client the client the command runs on + * @param commandLine the command line to execute + */ + CommandRequest(final WinRMClient client, final String commandLine) { + Utils.checkNonBlank(commandLine, "commandLine"); + this.client = client; + this.commandLine = commandLine; + this.timeout = client.defaultTimeout(); + } + + /** + * Set the working directory of the remote process. The remote command shell is created on + * the first command a client executes and is reused afterward, so this setting takes effect + * only when this is the client's first command. + * + * @param workingDirectory the working directory path on the remote host + * @return this request + */ + public CommandRequest workingDirectory(final String workingDirectory) { + Utils.checkNonBlank(workingDirectory, "workingDirectory"); + this.workingDirectory = workingDirectory; + return this; + } + + /** + * Set the timeout of this command — a wall-clock deadline covering file uploads, encoding + * detection, and the command itself. Default: the client's timeout. + * + * @param timeout the timeout (must be positive) + * @return this request + */ + public CommandRequest timeout(final Duration timeout) { + this.timeout = WinRMClient.checkPositive(timeout, "timeout"); + return this; + } + + /** + * Set the charset used to decode the command output. Default: detected from the remote + * operating system's code set (one extra WQL query, cached on the client). + * + * @param charset the output charset + * @return this request + */ + public CommandRequest charset(final Charset charset) { + Utils.checkNonNull(charset, "charset"); + this.charset = charset; + return this; + } + + /** + * Copy local files to the remote host (through the WinRM connection itself) before running + * the command. Each reference to a local file path inside the command line is rewritten to + * the remote copy, exactly like the legacy + * {@link org.metricshub.winrm.command.WinRMCommandExecutor}: + * + *
{@code
+	 * client.command("CSCRIPT c:\\scripts\\collect.vbs")
+	 * 	.upload(Path.of("c:\\scripts\\collect.vbs"))
+	 * 	.execute();
+	 * }
+ * + * transfers the script and executes {@code CSCRIPT }. + * + * @param files the local files to copy + * @return this request + */ + public CommandRequest upload(final Path... files) { + Utils.checkNonNull(files, "files"); + for (final Path file : files) { + Utils.checkNonNull(file, "files"); + uploads.add(file); + } + return this; + } + + /** + * Execute the command and collect its complete output. + * + * @return the command result: stdout, stderr, exit code, and execution time + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the timeout elapses first + * @throws org.metricshub.winrm.exceptions.WinRMAuthenticationException when the credentials are rejected + * @throws org.metricshub.winrm.exceptions.WinRMFaultException when the remote service answers with a WSMan fault + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public CommandResult execute() { + final long start = Utils.getCurrentTimeMillis(); + final long timeoutMillis = WinRMClient.toMillis(timeout); + try { + String actualCommand = commandLine; + String actualWorkingDirectory = workingDirectory; + + if (!uploads.isEmpty()) { + // Copy the files through the command shell and rewrite the command to reference the + // remote copies; the transfer commands create the shell, so the working directory no + // longer applies (the shell already exists when the real command runs). + final List localFiles = uploads.stream().map(Path::toString).collect(Collectors.toList()); + final String updatedCommand = ShellFileCopy.copyLocalFilesToRemote( + client.executor(), + commandLine, + localFiles, + TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to copy the local files") + ); + actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand); + actualWorkingDirectory = null; + } + + final Charset actualCharset = charset != null ? charset : client.detectCharset(timeoutMillis, start); + + final WindowsRemoteCommandResult result = client + .executor() + .executeCommand( + actualCommand, + actualWorkingDirectory, + actualCharset, + TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to execute the command") + ); + + return new CommandResult( + result.getStdout(), + result.getStderr(), + result.getStatusCode(), + Duration.ofMillis(Utils.getCurrentTimeMillis() - start) + ); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("Command timed out after %s on %s", timeout, client.hostname()), + e + ); + } catch (final IOException | WqlQuerySyntaxException e) { + throw new WinRMClientException(e.getMessage(), e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandResult.java b/src/main/java/org/metricshub/winrm/CommandResult.java new file mode 100644 index 0000000..31ca87c --- /dev/null +++ b/src/main/java/org/metricshub/winrm/CommandResult.java @@ -0,0 +1,93 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.time.Duration; + +/** + * The result of a command executed on the remote host: its output streams, its process exit + * code, and the time it took. + */ +public final class CommandResult { + + private final String stdout; + private final String stderr; + private final int exitCode; + private final Duration elapsed; + + /** + * Create the result. + * + * @param stdout the standard output of the command + * @param stderr the standard error of the command + * @param exitCode the process exit code + * @param elapsed the execution time + */ + CommandResult(final String stdout, final String stderr, final int exitCode, final Duration elapsed) { + this.stdout = stdout; + this.stderr = stderr; + this.exitCode = exitCode; + this.elapsed = elapsed; + } + + /** + * Get the standard output of the command. + * + * @return the stdout content + */ + public String stdout() { + return stdout; + } + + /** + * Get the standard error of the command. + * + * @return the stderr content + */ + public String stderr() { + return stderr; + } + + /** + * Get the process exit code of the command. Windows may report HRESULT codes as unsigned + * 32-bit values; they are narrowed to the equivalent signed {@code int}. + * + * @return the exit code + */ + public int exitCode() { + return exitCode; + } + + /** + * Get the time the command took, from request to completion. + * + * @return the elapsed time + */ + public Duration elapsed() { + return elapsed; + } + + @Override + public String toString() { + return String + .format("CommandResult[exitCode=%d, elapsed=%s]%nstdout:%n%s%nstderr:%n%s", exitCode, elapsed, stdout, stderr); + } +} diff --git a/src/main/java/org/metricshub/winrm/ShellFileCopy.java b/src/main/java/org/metricshub/winrm/ShellFileCopy.java index 87e135c..79b4f1e 100644 --- a/src/main/java/org/metricshub/winrm/ShellFileCopy.java +++ b/src/main/java/org/metricshub/winrm/ShellFileCopy.java @@ -223,12 +223,117 @@ static String copyFile( final String remoteFile = remoteDirectory + "\\" + contentAddressedName(fileName, content, maxRemoteNameLength(remoteDirectory)); + transferContent(windowsRemoteExecutor, localPath, content, remoteFile, timeout, start); + + return remoteFile; + } + + /** + * Copy one local file to an explicit path on the remote host through the WinRM command + * shell, creating the destination directory when needed. The transfer is digest-verified, + * and skipped entirely when the destination already carries the digest of the local file. + * + * @param windowsRemoteExecutor Executor connected to the remote host (mandatory) + * @param localPath The local file to copy (mandatory) + * @param remoteFile The absolute destination path on the remote host, e.g. + * {@code C:\Windows\Temp\collect.ps1} (mandatory) + * @param timeout Timeout in milliseconds (throws an IllegalArgumentException if negative or zero) + * @throws IOException If the local file cannot be read + * @throws TimeoutException To notify userName of timeout + * @throws WindowsRemoteException For any problem encountered on the remote host + */ + public static void copyLocalFileToRemoteFile( + final WindowsRemoteExecutor windowsRemoteExecutor, + final Path localPath, + final String remoteFile, + final long timeout + ) throws IOException, TimeoutException, WindowsRemoteException { + Utils.checkNonNull(windowsRemoteExecutor, "windowsRemoteExecutor"); + Utils.checkNonNull(localPath, "localPath"); + Utils.checkNonBlank(remoteFile, "remoteFile"); + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + + final int separator = remoteFile.lastIndexOf('\\'); + if (separator <= 0 || separator == remoteFile.length() - 1) { + throw new IllegalArgumentException( + String.format("Remote path %s must be an absolute Windows file path.", remoteFile) + ); + } + final String remoteDirectory = remoteFile.substring(0, separator); + checkEmbeddableRemotePath(remoteDirectory); + checkTransferableFileName(remoteFile.substring(separator + 1)); + + // The staging suffixes ride the destination path: the COMPLETE staging path must honor the + // traditional MAX_PATH limit that old hosts still enforce. + if (remoteFile.length() + STAGING_SUFFIX_BUDGET > MAX_WINDOWS_PATH_LENGTH) { + throw new IllegalArgumentException( + String.format("Remote path %s is too long to be transferred to a Windows host.", remoteFile) + ); + } + + final long start = Utils.getCurrentTimeMillis(); + + final byte[] content = Files.readAllBytes(localPath); + + runChecked( + windowsRemoteExecutor, + WindowsTempShare.buildCreateRemoteDirectoryCommand(remoteDirectory), + "create the remote directory", + timeout, + start + ); + + transferContent(windowsRemoteExecutor, localPath, content, remoteFile, timeout, start); + } + + /** + * Reject a remote directory path that cannot be embedded safely in a quoted cmd.exe argument + * ({@code %} and {@code !} expand even between quotes, {@code "} and control characters break + * the quoting) or that Windows cannot create ({@code < > / | ? *} are forbidden in path + * components; {@code :} and {@code \} are structural and allowed). + * + * @param path The remote directory path + */ + static void checkEmbeddableRemotePath(final String path) { + if (path.contains("%") + || + path.contains("!") + || + path.chars().anyMatch(c -> c < 0x20 || "<>\"/|?*".indexOf(c) >= 0)) { + throw new IllegalArgumentException( + String.format("Remote path %s cannot be used on a Windows host safely.", path) + ); + } + } + + /** + * Transfer the given content to the remote destination, skipping the transfer when the + * destination already carries the identical digest, and repairing a destination seen with a + * mismatched digest by replacement. + * + * @param windowsRemoteExecutor Executor connected to the remote host + * @param localPath The local file, for the failure messages + * @param content The file content + * @param remoteFile The destination path on the remote host + * @param timeout Timeout in milliseconds + * @param start Operation start time in milliseconds + * @throws TimeoutException To notify userName of timeout + * @throws WindowsRemoteException For any problem encountered on the remote host + */ + private static void transferContent( + final WindowsRemoteExecutor windowsRemoteExecutor, + final Path localPath, + final byte[] content, + final String remoteFile, + final long timeout, + final long start + ) throws TimeoutException, WindowsRemoteException { // Skip the transfer if the remote host already has an identical copy. A destination that // exists with a DIFFERENT digest (e.g. a cached copy corrupted or modified in place) is // remembered: it must be repaired by replacement, not trusted. final Optional existing = remoteDigest(windowsRemoteExecutor, remoteFile, timeout, start); if (existing.isPresent() && existing.get().matches(content)) { - return remoteFile; + return; } final boolean mismatchedDestination = existing.isPresent(); @@ -255,7 +360,7 @@ static String copyFile( throw integrityCheckFailure(localPath, remoteFile, windowsRemoteExecutor); } - return remoteFile; + return; } // Upload and verify in an operation-unique staging file, then publish: the shared, @@ -280,8 +385,6 @@ static String copyFile( throw e; } - - return remoteFile; } private static WindowsRemoteException integrityCheckFailure( diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java new file mode 100644 index 0000000..6c21344 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -0,0 +1,458 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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 edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeoutException; +import javax.net.ssl.SSLContext; +import org.metricshub.winrm.exceptions.WinRMClientException; +import org.metricshub.winrm.exceptions.WinRMException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; +import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; +import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.service.WinRMExecutorFactory; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; + +/** + * The fluent entry point of the library: a reusable WinRM connection to one host, created with + * a builder and closed with try-with-resources. One client authenticates once and can run any + * number of WQL queries and commands over the same connection. + * + *
{@code
+ * try (
+ * 	WinRMClient client = WinRMClient.builder("server01.acme.com")
+ * 		.credentials("ACME\\admin", password)
+ * 		.timeout(Duration.ofSeconds(30))
+ * 		.build()) {
+ *
+ * 	WqlResult services = client.wql("SELECT Name, State FROM Win32_Service").execute();
+ * 	for (WqlRow row : services) {
+ * 		System.out.println(row.string("Name") + " is " + row.string("State"));
+ * 	}
+ *
+ * 	CommandResult result = client.command("ipconfig /all").execute();
+ * 	System.out.println(result.stdout());
+ * }
+ * }
+ *

+ * Thread-safety: a client may be shared between threads, but a WinRM connection is a serial + * channel — concurrent operations are executed one at a time. + *

+ * Failures are reported through the unchecked + * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy; the legacy static + * helpers ({@link org.metricshub.winrm.wql.WinRMWqlExecutor}, + * {@link org.metricshub.winrm.command.WinRMCommandExecutor}) and their checked exceptions are + * unaffected. + */ +public final class WinRMClient implements AutoCloseable { + + /** Default operation timeout when the builder does not set one. */ + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); + + private final WindowsRemoteExecutor executor; + private final String hostname; + private final String namespace; + private final Duration timeout; + + // The remote OS code set does not change during a session: detect it once, on the first + // command that needs it, and reuse it for every later command. + private volatile Charset detectedCharset; + + private WinRMClient( + final WindowsRemoteExecutor executor, + final String hostname, + final String namespace, + final Duration timeout + ) { + this.executor = executor; + this.hostname = hostname; + this.namespace = namespace; + this.timeout = timeout; + } + + /** + * Start building a client for the given host. + * + * @param hostname the host to connect to (mandatory; for Kerberos, use the FQDN the KDC knows) + * @return a new {@link Builder} + */ + public static Builder builder(final String hostname) { + return new Builder(hostname); + } + + /** + * Prepare a WQL query. Nothing is sent until {@link WqlRequest#execute()} is called. + * + * @param query the WQL query, e.g. {@code SELECT Name, State FROM Win32_Service} + * @return the request, to configure and execute + */ + public WqlRequest wql(final String query) { + return new WqlRequest(this, query); + } + + /** + * Prepare a command execution. Nothing is sent until {@link CommandRequest#execute()} is + * called. + * + * @param commandLine the command line to execute (run through {@code cmd.exe} by the remote shell) + * @return the request, to configure and execute + */ + public CommandRequest command(final String commandLine) { + return new CommandRequest(this, commandLine); + } + + /** + * Copy a local file to an explicit path on the remote host, through the WinRM connection + * itself (no SMB, no extra port). The transfer is digest-verified and skipped when the + * destination already has identical content; the destination directory is created when + * needed. The client's timeout applies. + * + * @param localFile the local file to copy + * @param remoteFile the absolute destination path on the remote host, e.g. + * {@code C:\Windows\Temp\collect.ps1} + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the timeout elapses first + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public void uploadFile(final Path localFile, final String remoteFile) { + try { + ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, remoteFile, toMillis(timeout)); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("Upload of %s timed out after %s on %s", localFile, timeout, hostname), + e + ); + } catch (final IOException e) { + throw new WinRMClientException(e.getMessage(), e); + } catch (final WindowsRemoteException e) { + throw translate(e); + } + } + + /** + * Get the hostname this client connects to. + * + * @return the hostname + */ + public String hostname() { + return hostname; + } + + /** + * Close the client and release its connection. Idempotent; operations attempted after + * closing throw {@link IllegalStateException}. + */ + @Override + public void close() { + executor.close(); + } + + /** The executor backing this client. */ + WindowsRemoteExecutor executor() { + return executor; + } + + /** The client-level default WMI namespace. */ + String defaultNamespace() { + return namespace; + } + + /** The client-level default operation timeout. */ + Duration defaultTimeout() { + return timeout; + } + + /** + * The charset of the remote command output, detected from the remote OS code set on first + * use and cached for the lifetime of the client. + */ + Charset detectCharset(final long timeoutMillis, final long start) + throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + Charset charset = detectedCharset; + if (charset == null) { + charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset( + executor, + TimeoutHelper.getRemainingTime(timeoutMillis, start, "No time left to detect the remote encoding") + ); + detectedCharset = charset; + } + return charset; + } + + /** + * Translate a legacy checked exception into the unchecked hierarchy: when a typed + * {@link WinRMClientException} (fault, authentication) is in the cause chain, it is unwrapped + * and rethrown directly; anything else is wrapped with its message preserved. + */ + static WinRMClientException translate(final Exception exception) { + for (Throwable cause = exception; cause != null; cause = cause.getCause()) { + if (cause instanceof WinRMClientException) { + return (WinRMClientException) cause; + } + } + return new WinRMClientException( + exception.getMessage() != null ? exception.getMessage() : exception.toString(), + exception + ); + } + + /** Validate that a duration is non-null and positive, and return it. */ + static Duration checkPositive(final Duration duration, final String name) { + Utils.checkNonNull(duration, name); + if (duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive."); + } + return duration; + } + + /** Convert a positive duration to milliseconds, saturating instead of overflowing. */ + static long toMillis(final Duration duration) { + try { + return duration.toMillis(); + } catch (final ArithmeticException e) { + return Long.MAX_VALUE; + } + } + + /** + * Builder of {@link WinRMClient} instances: connection-scoped settings with sensible + * defaults. Only the hostname and the credentials are mandatory. + */ + public static final class Builder { + + private final String hostname; + private WinRMHttpProtocolEnum protocol = WinRMHttpProtocolEnum.HTTP; + private Integer port; + private String username; + private char[] password; + private String namespace; + private List authentication; + private Path ticketCache; + private boolean trustAllCertificates; + private SSLContext sslContext; + private Duration timeout = DEFAULT_TIMEOUT; + + private Builder(final String hostname) { + Utils.checkNonBlank(hostname, "hostname"); + this.hostname = hostname; + } + + /** + * Connect over HTTPS (port 5986 unless {@link #port(int)} is set). The server certificate + * is validated against the platform trust store and the hostname is verified, unless + * {@link #trustAllCertificates()} or {@link #sslContext(SSLContext)} says otherwise. + * + * @return this builder + */ + public Builder https() { + this.protocol = WinRMHttpProtocolEnum.HTTPS; + return this; + } + + /** + * Connect over HTTP (port 5985 unless {@link #port(int)} is set) — the default. The SOAP + * messages are NTLM-encrypted on the wire. + * + * @return this builder + */ + public Builder http() { + this.protocol = WinRMHttpProtocolEnum.HTTP; + return this; + } + + /** + * Set the port. Default: 5985 for HTTP, 5986 for HTTPS. + * + * @param port the TCP port (1-65535) + * @return this builder + */ + public Builder port(final int port) { + if (port < 1 || port > 65535) { + throw new IllegalArgumentException("port must be between 1 and 65535."); + } + this.port = port; + return this; + } + + /** + * Set the credentials (mandatory). + * + * @param username the user name, plain ({@code user}) or domain-qualified ({@code DOMAIN\\user})* + * @param + * password the password; + * the array + * is deliberately + * not copied, so + * the caller + * can wipe* + * the single + * authoritative copy + * of the + * secret after + * closing the client*@return this builder + */ + + @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "The password char[] is deliberately shared, not copied, so the caller " + + + "can wipe the single authoritative copy of the secret") + public Builder credentials(final String username, final char[] password) { + Utils.checkNonNull(username, "username"); + Utils.checkNonNull(password, "password"); + this.username = username; + this.password = password; + return this; + } + + /** + * Set the default WMI namespace for WQL queries. Default: {@code ROOT\CIMV2}. Each query + * can override it with {@link WqlRequest#namespace(String)}. + * + * @param namespace the WMI namespace + * @return this builder + */ + public Builder namespace(final String namespace) { + Utils.checkNonBlank(namespace, "namespace"); + this.namespace = namespace; + return this; + } + + /** + * Set the authentication schemes, tried in the given order until one succeeds. Default: + * NTLM only. Kerberos requires HTTPS. + * + * @param schemes the schemes in fallback order, e.g. {@code KERBEROS, NTLM} + * @return this builder + */ + public Builder authentication(final AuthScheme... schemes) { + Utils.checkNonNull(schemes, "schemes"); + if (schemes.length == 0) { + throw new IllegalArgumentException("At least one authentication scheme is required."); + } + final List list = new ArrayList<>(schemes.length); + for (final AuthScheme scheme : schemes) { + Utils.checkNonNull(scheme, "schemes"); + list.add(scheme); + } + this.authentication = list; + return this; + } + + /** + * Set the Kerberos ticket cache path. Default: none — Kerberos logs in with the password. + * + * @param ticketCache the ticket cache path + * @return this builder + */ + public Builder ticketCache(final Path ticketCache) { + Utils.checkNonNull(ticketCache, "ticketCache"); + this.ticketCache = ticketCache; + return this; + } + + /** + * Trust every server certificate and skip hostname verification over HTTPS — for + * self-signed test hosts. Insecure: do not use in production. This per-client setting + * replaces the global {@code org.metricshub.winrm.tls.insecure} system property. + * + * @return this builder + */ + public Builder trustAllCertificates() { + this.trustAllCertificates = true; + return this; + } + + /** + * Use a custom {@link SSLContext} for HTTPS — e.g. one built around a dedicated trust + * store. Hostname verification stays on. Mutually exclusive with + * {@link #trustAllCertificates()}. + * + * @param sslContext the TLS context providing the socket factory + * @return this builder + */ + public Builder sslContext(final SSLContext sslContext) { + Utils.checkNonNull(sslContext, "sslContext"); + this.sslContext = sslContext; + return this; + } + + /** + * Set the default timeout of every operation — a wall-clock deadline covering + * authentication, every WSMan round trip, and result collection. Default: 30 seconds. + * Each operation can override it. + * + * @param timeout the timeout (must be positive) + * @return this builder + */ + public Builder timeout(final Duration timeout) { + this.timeout = checkPositive(timeout, "timeout"); + return this; + } + + /** + * Build the client. This does not connect yet: the connection is established and + * authenticated by the first operation. + * + * @return the client, to use with try-with-resources + * @throws org.metricshub.winrm.exceptions.WinRMClientException when the configuration is + * rejected (e.g. Kerberos requested over HTTP) + */ + public WinRMClient build() { + if (username == null || password == null) { + throw new IllegalStateException("credentials(username, password) is required."); + } + if (sslContext != null && trustAllCertificates) { + throw new IllegalStateException("Set either sslContext(...) or trustAllCertificates(), not both."); + } + + final WinRMEndpoint endpoint = new WinRMEndpoint(protocol, hostname, port, username, password, namespace); + + List authentications = null; + if (authentication != null) { + authentications = new ArrayList<>(authentication.size()); + for (final AuthScheme scheme : authentication) { + authentications.add( + scheme == AuthScheme.KERBEROS ? AuthenticationEnum.KERBEROS : AuthenticationEnum.NTLM + ); + } + } + + try { + final WindowsRemoteExecutor executor = WinRMExecutorFactory.createInstance( + endpoint, + toMillis(timeout), + ticketCache, + authentications, + sslContext, + trustAllCertificates + ); + return new WinRMClient(executor, endpoint.getHostname(), endpoint.getNamespace(), timeout); + } catch (final WinRMException e) { + throw translate(e); + } + } + } +} diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index a2ec572..8da56f4 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -28,6 +28,12 @@ import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; public interface WindowsRemoteExecutor extends AutoCloseable { + /** + * Default WS-Enumeration {@code MaxElements} batch size for WQL queries: how many rows the + * server may return per Enumerate/Pull response. + */ + int DEFAULT_WQL_MAX_ELEMENTS = 32000; + /** *

* Execute a WQL query and process its result. @@ -44,6 +50,42 @@ public interface WindowsRemoteExecutor extends AutoCloseable { List> executeWql(final String wqlQuery, final long timeout) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException; + /** + *

+ * Execute a WQL query with explicit enumeration parameters: the WMI namespace, the + * WS-Enumeration {@code MaxElements} batch size, and the per-Pull {@code MaxTime}. + *

+ *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * can honor the namespace and enumeration parameters (such as the built-in lightweight backend) + * implement this method, and silently ignoring a namespace would query the wrong resource. + *

+ * + * @param namespace the WMI namespace to query, e.g. {@code ROOT\CIMV2} (required) + * @param wqlQuery the WQL query (required) + * @param timeout Timeout in milliseconds (throws an IllegalArgumentException if negative or zero) + * @param maxElements maximum number of rows per Enumerate/Pull response (throws an + * IllegalArgumentException if negative or zero); see {@link #DEFAULT_WQL_MAX_ELEMENTS} + * @param pullTimeout maximum time in milliseconds the server may hold a single Pull open before + * answering with the rows it has ({@code MaxTime}); 0 leaves it to the server default + * @return a list of result rows. A result row is a Map(LinkedHashMap to preserve the query order) of + * properties/values. + * @throws TimeoutException to notify userName of timeout. + * @throws WqlQuerySyntaxException if WQL query syntax is invalid + * @throws WindowsRemoteException For any problem encountered + */ + default List> executeWql( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + throw new UnsupportedOperationException( + getClass().getName() + " does not support WQL enumeration parameters." + ); + } + /** * Execute the command on the remote * diff --git a/src/main/java/org/metricshub/winrm/WqlRequest.java b/src/main/java/org/metricshub/winrm/WqlRequest.java new file mode 100644 index 0000000..a42d9d3 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WqlRequest.java @@ -0,0 +1,147 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; +import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; +import org.metricshub.winrm.exceptions.WqlSyntaxException; + +/** + * A WQL query being prepared for execution, created by {@link WinRMClient#wql(String)}. + * Every option has a sensible default; {@link #execute()} runs the query and returns the + * complete result. + */ +public final class WqlRequest { + + private final WinRMClient client; + private final String query; + private String namespace; + private Duration timeout; + private int pageSize = WindowsRemoteExecutor.DEFAULT_WQL_MAX_ELEMENTS; + private Duration pullTimeout; + + /** + * Create the request. + * + * @param client the client the query runs on + * @param query the WQL query + */ + WqlRequest(final WinRMClient client, final String query) { + Utils.checkNonBlank(query, "query"); + this.client = client; + this.query = query; + this.namespace = client.defaultNamespace(); + this.timeout = client.defaultTimeout(); + } + + /** + * Set the WMI namespace to query. Default: the client's namespace + * ({@code ROOT\CIMV2} unless configured on the builder). + * + * @param namespace the WMI namespace, e.g. {@code root\cimv2} + * @return this request + */ + public WqlRequest namespace(final String namespace) { + Utils.checkNonBlank(namespace, "namespace"); + this.namespace = namespace; + return this; + } + + /** + * Set the timeout of this query — a wall-clock deadline covering every WSMan round trip and + * result collection. Default: the client's timeout. + * + * @param timeout the timeout (must be positive) + * @return this request + */ + public WqlRequest timeout(final Duration timeout) { + this.timeout = WinRMClient.checkPositive(timeout, "timeout"); + return this; + } + + /** + * Set the enumeration batch size: how many rows the server may return per WSMan + * Enumerate/Pull response ({@code MaxElements}). Default: + * {@value WindowsRemoteExecutor#DEFAULT_WQL_MAX_ELEMENTS}. + * + * @param pageSize the maximum number of rows per response (must be positive) + * @return this request + */ + public WqlRequest pageSize(final int pageSize) { + Utils.checkArgumentNotZeroOrNegative(pageSize, "pageSize"); + this.pageSize = pageSize; + return this; + } + + /** + * Set the maximum time the server may hold a single Pull request open before answering with + * the rows it has ({@code MaxTime}). Default: none — the server decides. + * + * @param pullTimeout the per-Pull timeout (must be positive) + * @return this request + */ + public WqlRequest pullTimeout(final Duration pullTimeout) { + this.pullTimeout = WinRMClient.checkPositive(pullTimeout, "pullTimeout"); + return this; + } + + /** + * Execute the query and collect the complete result. + * + * @return the query result: rows, columns in query order, and execution time + * @throws WqlSyntaxException when the WQL query is invalid + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the timeout elapses first + * @throws org.metricshub.winrm.exceptions.WinRMAuthenticationException when the credentials are rejected + * @throws org.metricshub.winrm.exceptions.WinRMFaultException when the remote service answers with a WSMan fault + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public WqlResult execute() { + final long start = Utils.getCurrentTimeMillis(); + final long timeoutMillis = WinRMClient.toMillis(timeout); + final long pullTimeoutMillis = pullTimeout != null ? WinRMClient.toMillis(pullTimeout) : 0; + try { + final List> result = client + .executor() + .executeWql(namespace, query, timeoutMillis, pageSize, pullTimeoutMillis); + + // Extract the list of properties from the result, with same order as in the WQL query + final List columns = WmiHelper.extractPropertiesFromResult(result, query); + final List rows = result.stream().map(WqlRow::new).collect(Collectors.toList()); + + return new WqlResult(columns, rows, Duration.ofMillis(Utils.getCurrentTimeMillis() - start)); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("WQL query timed out after %s on %s", timeout, client.hostname()), + e + ); + } catch (final WqlQuerySyntaxException e) { + throw new WqlSyntaxException(e.getMessage(), e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } +} diff --git a/src/main/java/org/metricshub/winrm/WqlResult.java b/src/main/java/org/metricshub/winrm/WqlResult.java new file mode 100644 index 0000000..e02787c --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WqlResult.java @@ -0,0 +1,113 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; + +/** + * The complete result of a WQL query: the rows, the column names in query order, and the + * time the query took. Iterable, so it can be consumed directly: + * + *
{@code
+ * for (WqlRow row : client.wql("SELECT Name, State FROM Win32_Service").execute()) {
+ * 	System.out.println(row.string("Name"));
+ * }
+ * }
+ */ +public final class WqlResult implements Iterable { + + private final List columns; + private final List rows; + private final Duration elapsed; + + /** + * Create the result (lists are copied defensively). + * + * @param columns the column names, in query order + * @param rows the result rows + * @param elapsed the query execution time + */ + WqlResult(final List columns, final List rows, final Duration elapsed) { + this.columns = Collections.unmodifiableList(new ArrayList<>(columns)); + this.rows = Collections.unmodifiableList(new ArrayList<>(rows)); + this.elapsed = elapsed; + } + + /** + * Get the column names, in the order they appear in the WQL query ({@code SELECT *} yields + * the order the server returned). + * + * @return an unmodifiable list of column names + */ + public List columns() { + return columns; + } + + /** + * Get the result rows. + * + * @return an unmodifiable list of rows + */ + public List rows() { + return rows; + } + + /** + * Get the number of rows. + * + * @return the row count + */ + public int size() { + return rows.size(); + } + + /** + * Whether the query returned no rows. + * + * @return {@code true} when the result is empty + */ + public boolean isEmpty() { + return rows.isEmpty(); + } + + /** + * Get the time the query took, from request to complete result. + * + * @return the elapsed time + */ + public Duration elapsed() { + return elapsed; + } + + @Override + public Iterator iterator() { + return rows.iterator(); + } + + @Override + public String toString() { + return String.format("WqlResult[%d rows, columns=%s, elapsed=%s]", rows.size(), columns, elapsed); + } +} diff --git a/src/main/java/org/metricshub/winrm/WqlRow.java b/src/main/java/org/metricshub/winrm/WqlRow.java new file mode 100644 index 0000000..1582114 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WqlRow.java @@ -0,0 +1,88 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One row of a WQL query result: an immutable, ordered view of the instance properties. + * Property lookup is case-insensitive, matching WMI semantics. + */ +public final class WqlRow { + + private final Map values; + + /** + * Create a row over the given property map (copied defensively, order preserved). + * + * @param values the property name/value map + */ + WqlRow(final Map values) { + this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + /** + * Get the value of a property. The lookup first tries the exact property name, then falls + * back to a case-insensitive match — WMI property names are case-insensitive. + * + * @param property the property name + * @return the property value, or {@code null} when the property is absent or null + */ + public Object get(final String property) { + Utils.checkNonNull(property, "property"); + if (values.containsKey(property)) { + return values.get(property); + } + for (final Map.Entry entry : values.entrySet()) { + if (entry.getKey().equalsIgnoreCase(property)) { + return entry.getValue(); + } + } + return null; + } + + /** + * Get the value of a property as a string. Same lookup semantics as {@link #get(String)}. + * + * @param property the property name + * @return the property value as a string, or {@code null} when the property is absent or null + */ + public String string(final String property) { + final Object value = get(property); + return value != null ? value.toString() : null; + } + + /** + * Get all properties of the row, in the order the server returned them. + * + * @return an unmodifiable ordered map of property names to values + */ + public Map asMap() { + return values; + } + + @Override + public String toString() { + return values.toString(); + } +} diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java new file mode 100644 index 0000000..4bdaf1b --- /dev/null +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java @@ -0,0 +1,40 @@ +package org.metricshub.winrm.exceptions; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * The remote host rejected the credentials (or every authentication scheme of an ordered + * fallback list). The message keeps the historical format + * {@code Authentication error on with user name ""} that operators match on. + */ +public class WinRMAuthenticationException extends WinRMClientException { + + private static final long serialVersionUID = 1L; + + /** + * Create the exception with a message. + * + * @param message the detail message + */ + public WinRMAuthenticationException(final String message) { + super(message); + } +} diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java new file mode 100644 index 0000000..dbc2115 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java @@ -0,0 +1,54 @@ +package org.metricshub.winrm.exceptions; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * Base unchecked exception of the fluent {@link org.metricshub.winrm.WinRMClient} API. + *

+ * Specific failures are reported through the subtypes {@link WinRMAuthenticationException}, + * {@link WinRMFaultException}, {@link WinRMTimeoutException} and {@link WqlSyntaxException}, + * so callers can catch exactly what they care about — or just this type for everything. + * The legacy checked exceptions ({@link WinRMException}, {@link WindowsRemoteException}, + * {@link WqlQuerySyntaxException}) remain on the legacy API and are unaffected. + */ +public class WinRMClientException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + /** + * Create the exception with a message. + * + * @param message the detail message + */ + public WinRMClientException(final String message) { + super(message); + } + + /** + * Create the exception with a message and the underlying cause. + * + * @param message the detail message + * @param cause the underlying cause + */ + public WinRMClientException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java new file mode 100644 index 0000000..45d393e --- /dev/null +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java @@ -0,0 +1,100 @@ +package org.metricshub.winrm.exceptions; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * The remote WinRM service answered with a WSMan fault (or a non-success HTTP status). + *

+ * Beyond the human-readable message, the fault is exposed programmatically: + * {@link #getFaultCode()} is the numeric WSManFault code (e.g. {@code 2150858778}) and + * {@link #getFaultDetail()} the provider-level detail text — where WMI puts mnemonics such as + * {@code WBEM_E_INVALID_CLASS} or {@code WBEM_E_INVALID_NAMESPACE} that callers historically had + * to extract from the message with {@code contains()}. + */ +public class WinRMFaultException extends WinRMClientException { + + private static final long serialVersionUID = 1L; + + private final int httpStatus; + private final String faultCode; + private final String faultReason; + private final String faultDetail; + + /** + * Create the exception. + * + * @param message the complete detail message (same format as the legacy API) + * @param httpStatus the HTTP status of the faulting response + * @param faultCode the WSManFault code, or {@code null} when the response carried none + * @param faultReason the SOAP fault reason text, or {@code null} + * @param faultDetail the detailed WSManFault message (provider-level detail), or {@code null} + */ + public WinRMFaultException( + final String message, + final int httpStatus, + final String faultCode, + final String faultReason, + final String faultDetail + ) { + super(message); + this.httpStatus = httpStatus; + this.faultCode = faultCode; + this.faultReason = faultReason; + this.faultDetail = faultDetail; + } + + /** + * Get the HTTP status of the faulting response (typically 500 for a SOAP fault). + * + * @return the HTTP status code + */ + public int getHttpStatus() { + return httpStatus; + } + + /** + * Get the numeric WSManFault code, e.g. {@code 2150858778}. + * + * @return the fault code, or {@code null} when the response carried none + */ + public String getFaultCode() { + return faultCode; + } + + /** + * Get the SOAP fault reason text. + * + * @return the reason text, or {@code null} + */ + public String getFaultReason() { + return faultReason; + } + + /** + * Get the detailed WSManFault message — the provider-level detail where WMI puts mnemonics + * such as {@code WBEM_E_INVALID_CLASS}. + * + * @return the fault detail, or {@code null} + */ + public String getFaultDetail() { + return faultDetail; + } +} diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java new file mode 100644 index 0000000..6656d73 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java @@ -0,0 +1,40 @@ +package org.metricshub.winrm.exceptions; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * The operation did not complete within its timeout — the whole operation is bounded by a + * wall-clock deadline, covering authentication, every WSMan round trip, and result collection. + */ +public class WinRMTimeoutException extends WinRMClientException { + + private static final long serialVersionUID = 1L; + + /** + * Create the exception with a message and the underlying cause. + * + * @param message the detail message + * @param cause the underlying {@link java.util.concurrent.TimeoutException} + */ + public WinRMTimeoutException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java b/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java new file mode 100644 index 0000000..781bf6c --- /dev/null +++ b/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java @@ -0,0 +1,41 @@ +package org.metricshub.winrm.exceptions; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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. + * ╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱ + */ + +/** + * The WQL query is syntactically invalid — the unchecked counterpart of the legacy + * {@link WqlQuerySyntaxException}, thrown by the fluent {@link org.metricshub.winrm.WinRMClient} + * API before anything is sent to the remote host. + */ +public class WqlSyntaxException extends WinRMClientException { + + private static final long serialVersionUID = 1L; + + /** + * Create the exception with a message and the underlying cause. + * + * @param message the detail message + * @param cause the underlying {@link WqlQuerySyntaxException} + */ + public WqlSyntaxException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index 9e67dcb..8332c4b 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -58,26 +58,47 @@ private Envelopes() {} // --- WQL --------------------------------------------------------------- - static String enumerateWql(final String url, final String namespace, final String wql, final long timeoutMs) { + static String enumerateWql( + final String url, + final String namespace, + final String wql, + final long timeoutMs, + final int maxElements + ) { return envelopeOpen(false) + header(url, wmiResourceUri(namespace), ACTION_ENUMERATE, timeoutMs, null, null) + "" + "" + - "32000" + + "" + + maxElements + + "" + "" + escape(wql) + "" + ""; } - static String pull(final String url, final String namespace, final String context, final long timeoutMs) { + static String pull( + final String url, + final String namespace, + final String context, + final long timeoutMs, + final int maxElements, + final long maxTimeMs + ) { + // Per WS-Enumeration, MaxTime precedes MaxElements inside Pull. MaxTime bounds how long the + // server may hold this single Pull open before answering with the rows it has; 0 omits it and + // the OperationTimeout header applies alone. return envelopeOpen(false) + header(url, wmiResourceUri(namespace), ACTION_PULL, timeoutMs, null, null) + "" + "" + escape(context) + "" + - "32000" + + (maxTimeMs > 0 ? "" + operationTimeout(maxTimeMs) + "" : "") + + "" + + maxElements + + "" + ""; } diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index 9b5a10d..7dc1b85 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -61,6 +61,9 @@ final class HttpTransport implements AutoCloseable { private OutputStream out; private BufferedInputStream in; private long lastActivityMillis; + // Read timeout for the current operation; starts at the connection default and follows the + // per-operation timeout (see operationTimeout(int)). + private int readTimeoutMillis; HttpTransport(final String host, final int port, final int timeoutMillis) { this(host, port, timeoutMillis, null, false); @@ -78,6 +81,27 @@ final class HttpTransport implements AutoCloseable { this.timeoutMillis = timeoutMillis; this.sslSocketFactory = sslSocketFactory; this.verifyHostname = verifyHostname; + // Read timeout slightly above the caller's timeout so the WSMan OperationTimeout fault + // (which the Receive loop retries) reliably arrives before a socket read times out. + this.readTimeoutMillis = timeoutMillis + 10_000; + } + + /** + * Align the socket read timeout with the current operation's timeout (plus headroom, so the + * WSMan OperationTimeout fault arrives before the socket read gives up). Applies to the live + * connection immediately and to any future reconnection. + * + * @param operationTimeoutMillis the current operation's timeout in milliseconds + */ + void operationTimeout(final int operationTimeoutMillis) { + readTimeoutMillis = operationTimeoutMillis + 10_000; + if (socket != null && !socket.isClosed()) { + try { + socket.setSoTimeout(readTimeoutMillis); + } catch (final IOException ignored) { + // the next read fails and request() re-establishes the connection + } + } } static final class Response { @@ -200,9 +224,7 @@ private void ensureConnected() throws IOException { sslSocket.setSSLParameters(params); } newSocket.connect(new InetSocketAddress(host, port), timeoutMillis); - // Read timeout slightly above the caller's timeout so the WSMan OperationTimeout fault - // (which the Receive loop retries) reliably arrives before a socket read times out. - newSocket.setSoTimeout(timeoutMillis + 10_000); + newSocket.setSoTimeout(readTimeoutMillis); if (newSocket instanceof SSLSocket) { // Force the TLS handshake now so certificate/hostname failures surface here, not on // the first read after we have already sent the request. diff --git a/src/main/java/org/metricshub/winrm/light/LightTls.java b/src/main/java/org/metricshub/winrm/light/LightTls.java index 5315687..34a48fb 100644 --- a/src/main/java/org/metricshub/winrm/light/LightTls.java +++ b/src/main/java/org/metricshub/winrm/light/LightTls.java @@ -61,9 +61,17 @@ static boolean verifyHostname() { * @return an {@link SSLSocketFactory} */ static SSLSocketFactory socketFactory() { - if (!isInsecure()) { - return (SSLSocketFactory) SSLSocketFactory.getDefault(); - } + return isInsecure() ? insecureSocketFactory() : (SSLSocketFactory) SSLSocketFactory.getDefault(); + } + + /** + * A trust-all socket factory that skips certificate validation — the per-client counterpart of + * {@value #INSECURE_PROPERTY}, used by the fluent API's {@code trustAllCertificates()} option. + * Insecure; testing only. + * + * @return a trust-all {@link SSLSocketFactory} + */ + static SSLSocketFactory insecureSocketFactory() { try { final SSLContext context = SSLContext.getInstance("TLS"); context.init(null, new TrustManager[] { trustAllManager() }, null); diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 8086a21..2bc7fb0 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -29,6 +29,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; +import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; @@ -80,14 +81,57 @@ public static LightWinRMService createInstance( final long timeout, final java.nio.file.Path ticketCache, final List authentications + ) throws WinRMException { + return createInstance(winRMEndpoint, timeout, ticketCache, authentications, null, false); + } + + /** + * Create a light WinRM executor with an explicit TLS configuration, overriding the + * {@code org.metricshub.winrm.tls.insecure} system property for this instance. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (used by the Kerberos scheme; {@code null} logs + * in with the password) + * @param authentications requested authentication schemes, tried in order (NTLM and/or Kerberos); + * {@code null}/empty means NTLM only + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @return a new {@code LightWinRMService} + * @throws WinRMException on invalid arguments or an unsupported authentication request + */ + public static LightWinRMService createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final java.nio.file.Path ticketCache, + final List authentications, + final SSLContext sslContext, + final boolean trustAllCertificates ) throws WinRMException { Utils.checkNonNull(winRMEndpoint, "winRMEndpoint"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); // HTTPS wraps the transport in TLS and exchanges plaintext SOAP; HTTP uses NTLM message sealing. - // TLS validates by default (platform trust store + hostname verification); see LightTls. + // TLS validates by default (platform trust store + hostname verification); see LightTls. A + // caller-provided SSLContext keeps hostname verification on; trust-all disables both checks. final boolean https = winRMEndpoint.getProtocol() == WinRMHttpProtocolEnum.HTTPS; - final SSLSocketFactory sslSocketFactory = https ? LightTls.socketFactory() : null; + final SSLSocketFactory sslSocketFactory; + final boolean verifyHostname; + if (!https) { + sslSocketFactory = null; + verifyHostname = false; + } else if (sslContext != null) { + sslSocketFactory = sslContext.getSocketFactory(); + verifyHostname = true; + } else if (trustAllCertificates) { + sslSocketFactory = LightTls.insecureSocketFactory(); + verifyHostname = false; + } else { + sslSocketFactory = LightTls.socketFactory(); + verifyHostname = LightTls.verifyHostname(); + } final AuthScheme authScheme = resolveAuthScheme(winRMEndpoint, authentications, https, ticketCache); @@ -99,7 +143,7 @@ public static LightWinRMService createInstance( winRMEndpoint.getPort(), timeout, sslSocketFactory, - https && LightTls.verifyHostname(), + verifyHostname, authScheme, winRMEndpoint.getRawUsername() ); @@ -159,18 +203,34 @@ private static AuthScheme resolveAuthScheme( @Override public List> executeWql(final String wqlQuery, final long timeout) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + return executeWql(winRMEndpoint.getNamespace(), wqlQuery, timeout, DEFAULT_WQL_MAX_ELEMENTS, 0); + } + + @Override + public List> executeWql( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { checkNotClosed(); + Utils.checkNonNull(namespace, "namespace"); Utils.checkNonNull(wqlQuery, "wqlQuery"); if (!WmiHelper.isValidWql(wqlQuery)) { throw new WqlQuerySyntaxException(wqlQuery); } Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + Utils.checkArgumentNotZeroOrNegative(maxElements, "maxElements"); + if (pullTimeout < 0) { + throw new IllegalArgumentException("pullTimeout must not be negative."); + } // Enforce the caller's timeout as a wall-clock deadline (throwing TimeoutException), matching // the CXF WinRMService and bounding the WSMan Pull loop. return executeWithTimeout( () -> { - final List> rows = client.wql(winRMEndpoint.getNamespace(), wqlQuery); + final List> rows = client.wql(namespace, wqlQuery, timeout, maxElements, pullTimeout); final List> result = new ArrayList<>(rows.size()); for (final Map row : rows) { result.add(new LinkedHashMap<>(row)); @@ -197,7 +257,7 @@ public WindowsRemoteCommandResult executeCommand( return executeWithTimeout( () -> { final long start = Utils.getCurrentTimeMillis(); - final WsmanClient.CommandOutput output = client.executeCommand(command, workingDirectory, charset); + final WsmanClient.CommandOutput output = client.executeCommand(command, workingDirectory, charset, timeout); final float executionTime = (Utils.getCurrentTimeMillis() - start) / 1000.0f; return new WindowsRemoteCommandResult(output.stdout, output.stderr, executionTime, output.exitCode); }, diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 0aed8a0..fea22dd 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -34,6 +34,8 @@ import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; import javax.xml.parsers.DocumentBuilderFactory; +import org.metricshub.winrm.exceptions.WinRMAuthenticationException; +import org.metricshub.winrm.exceptions.WinRMFaultException; import org.w3c.dom.Document; import org.w3c.dom.Element; import org.w3c.dom.Node; @@ -115,17 +117,33 @@ private static final class Decoded { } } - /** Run a WQL query and return the rows as ordered property maps. */ - List> wql(final String namespace, final String query) throws Exception { + /** + * Run a WQL query and return the rows as ordered property maps. + * + * @param namespace the WMI namespace + * @param query the WQL query + * @param operationTimeoutMs this operation's timeout, driving the WSMan OperationTimeout header + * and the socket read timeout + * @param maxElements the WS-Enumeration MaxElements batch size for Enumerate and every Pull + * @param maxTimeMs the WS-Enumeration MaxTime for each Pull in milliseconds; 0 omits the element + */ + List> wql( + final String namespace, + final String query, + final long operationTimeoutMs, + final int maxElements, + final long maxTimeMs + ) throws Exception { // Serialize the whole enumeration (Enumerate + all Pulls) against any other operation sharing // this connection; see operationLock. operationLock.lock(); try { + transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. final String ns = namespace.replace('\\', '/'); final List> rows = new ArrayList<>(); - Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, timeoutMs), "Enumerate"); + Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), "Enumerate"); collectItems(doc, rows); // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate @@ -133,7 +151,7 @@ List> wql(final String namespace, final String query) throws boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); while (!endOfSequence && context != null && !context.isEmpty()) { - doc = expectOk(Envelopes.pull(url, ns, context, timeoutMs), "Pull"); + doc = expectOk(Envelopes.pull(url, ns, context, operationTimeoutMs, maxElements, maxTimeMs), "Pull"); collectItems(doc, rows); endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); @@ -158,29 +176,42 @@ static final class CommandOutput { } } - /** Execute a command in the remote command shell, creating the shell on first use. */ - CommandOutput executeCommand(final String commandLine, final String workingDirectory, final Charset charset) - throws Exception { + /** + * Execute a command in the remote command shell, creating the shell on first use. + * + * @param commandLine the command line to run + * @param workingDirectory working directory of the shell (only honored when the shell is created) + * @param charset the charset decoding the output streams + * @param operationTimeoutMs this operation's timeout, driving the WSMan OperationTimeout header + * and the socket read timeout + */ + CommandOutput executeCommand( + final String commandLine, + final String workingDirectory, + final Charset charset, + final long operationTimeoutMs + ) throws Exception { // Serialize the whole shell lifecycle (Create + Command + Receive loop + Signal) against any // other operation sharing this connection and the shellId field; see operationLock. operationLock.lock(); try { + transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); if (shellId == null) { - createShell(workingDirectory); + createShell(workingDirectory, operationTimeoutMs); } final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; - final String commandId = startCommand(commandLine); + final String commandId = startCommand(commandLine, operationTimeoutMs); try { - return receiveLoop(commandId, cs); + return receiveLoop(commandId, cs, operationTimeoutMs); } finally { - terminate(commandId); + terminate(commandId, operationTimeoutMs); } } finally { operationLock.unlock(); } } - private void createShell(final String workingDirectory) throws Exception { + private void createShell(final String workingDirectory, final long timeoutMs) throws Exception { final Document doc = expectOk(Envelopes.createShell(url, workingDirectory, timeoutMs), "Create shell"); final NodeList selectors = doc.getElementsByTagNameNS("*", "Selector"); for (int i = 0; i < selectors.getLength(); i++) { @@ -193,7 +224,7 @@ private void createShell(final String workingDirectory) throws Exception { throw new IllegalStateException("Shell ID not found in Create response"); } - private String startCommand(final String commandLine) throws Exception { + private String startCommand(final String commandLine, final long timeoutMs) throws Exception { final Document doc = expectOk(Envelopes.command(url, shellId, commandLine, timeoutMs), "Command"); final String commandId = text(doc, "CommandId"); if (commandId == null) { @@ -202,7 +233,8 @@ private String startCommand(final String commandLine) throws Exception { return commandId; } - private CommandOutput receiveLoop(final String commandId, final Charset charset) throws Exception { + private CommandOutput receiveLoop(final String commandId, final Charset charset, final long timeoutMs) + throws Exception { // Accumulate the raw stream BYTES and decode once at the end: a multibyte character (e.g. UTF-8) // can be split across Stream elements or Receive responses, and decoding each chunk independently // would corrupt the boundary bytes into replacement characters. @@ -216,7 +248,7 @@ private CommandOutput receiveLoop(final String commandId, final Charset charset) if (FAULT_OPERATION_TIMEOUT.equals(faultCode)) { continue; } - throw new IllegalStateException("Receive failed: " + faultSummary(resp)); + throw faultException("Receive", resp); } collectStreams(resp.document, stdout, stderr); final Integer exitCode = doneExitCode(resp.document); @@ -230,11 +262,11 @@ private CommandOutput receiveLoop(final String commandId, final Charset charset) } } - private void terminate(final String commandId) throws Exception { + private void terminate(final String commandId, final long timeoutMs) throws Exception { final Decoded resp = request(Envelopes.signal(url, shellId, commandId, timeoutMs)); // A missing shell is fine here — the command already finished and the shell may be gone. if (resp.status != 200 && !FAULT_SHELL_NOT_FOUND.equals(wsmanFaultCode(resp.document))) { - throw new IllegalStateException("Signal failed: " + faultSummary(resp)); + throw faultException("Signal", resp); } } @@ -244,11 +276,27 @@ private void terminate(final String commandId) throws Exception { private Document expectOk(final String soap, final String operation) throws Exception { final Decoded resp = request(soap); if (resp.status != 200) { - throw new IllegalStateException(operation + " failed: " + faultSummary(resp)); + throw faultException(operation, resp); } return resp.document; } + /** + * Build the exception for a faulting response: the message keeps the historical + * {@code failed:

} format (part of the exception-message contract inherited + * from the CXF backend), and the WSMan fault code, reason and provider detail travel as fields so + * the fluent API can expose them programmatically. + */ + private static WinRMFaultException faultException(final String operation, final Decoded resp) { + return new WinRMFaultException( + operation + " failed: " + faultSummary(resp), + resp.status, + trimToNull(wsmanFaultCode(resp.document)), + trimToNull(text(resp.document, "Text")), + trimToNull(wsmanFaultMessage(resp.document)) + ); + } + /** * Send one SOAP request (authenticating the connection on first use via the {@link AuthScheme}) * and decode the response. The caller must hold {@link #operationLock}; every path here is reached @@ -293,7 +341,7 @@ private Decoded request(final String soap) throws Exception { } // Same message format as the CXF backend's credential-rejection path — callers (and their // operators) match on it. - throw new IllegalStateException( + throw new WinRMAuthenticationException( String.format("Authentication error on %s with user name \"%s\"", url, rawUsername) ); } diff --git a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java index c89a095..ab9c975 100644 --- a/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java +++ b/src/main/java/org/metricshub/winrm/service/WinRMExecutorFactory.java @@ -22,6 +22,7 @@ import java.nio.file.Path; import java.util.List; +import javax.net.ssl.SSLContext; import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.exceptions.WinRMException; import org.metricshub.winrm.light.LightWinRMService; @@ -53,4 +54,37 @@ public static WindowsRemoteExecutor createInstance( ) throws WinRMException { return LightWinRMService.createInstance(winRMEndpoint, timeout, ticketCache, authentications); } + + /** + * Create a {@link WindowsRemoteExecutor} with an explicit TLS configuration, overriding the + * {@code org.metricshub.winrm.tls.insecure} system property for this instance. + * + * @param winRMEndpoint endpoint with credentials (mandatory) + * @param timeout timeout in milliseconds (must be > 0) + * @param ticketCache Kerberos ticket cache path (may be {@code null}) + * @param authentications requested authentication schemes (may be {@code null}) + * @param sslContext the {@link SSLContext} providing the HTTPS socket factory (hostname + * verification stays on); {@code null} uses the default configuration + * @param trustAllCertificates when {@code true} (and no {@code sslContext} is given), trust every + * server certificate and skip hostname verification — insecure, testing only + * @return an executor backed by {@link LightWinRMService} + * @throws WinRMException for any problem creating the executor + */ + public static WindowsRemoteExecutor createInstance( + final WinRMEndpoint winRMEndpoint, + final long timeout, + final Path ticketCache, + final List authentications, + final SSLContext sslContext, + final boolean trustAllCertificates + ) throws WinRMException { + return LightWinRMService.createInstance( + winRMEndpoint, + timeout, + ticketCache, + authentications, + sslContext, + trustAllCertificates + ); + } } diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index cfd50ab..a9f3660 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -55,40 +55,49 @@ command-line jar. ## A first WQL query -Everything starts with the static -[`WinRMWqlExecutor.executeWql(...)`](apidocs/org/metricshub/winrm/wql/WinRMWqlExecutor.html) method: +Everything starts with the fluent +[`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html) builder — one client authenticates +once and can run any number of queries and commands over the same connection: ```java -import static java.util.Collections.singletonList; -import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTP; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; -import static org.metricshub.winrm.wql.WinRMWqlExecutor.executeWql; - -import org.metricshub.winrm.wql.WinRMWqlExecutor; +import java.time.Duration; +import org.metricshub.winrm.WinRMClient; +import org.metricshub.winrm.WqlResult; +import org.metricshub.winrm.WqlRow; public class Example { - public static void main(String[] args) throws Exception { - - WinRMWqlExecutor result = executeWql( - HTTP, // protocol (HTTP or HTTPS) - "server.example.com", // hostname (mandatory) - 5985, // port (null for the protocol default) - "DOMAIN\\Administrator", // username (DOMAIN\user or user) - "the-password".toCharArray(), // password - null, // namespace (null → ROOT\CIMV2) - "SELECT Name, State FROM Win32_Service", // WQL query - 30_000L, // timeout in milliseconds - null, // Kerberos ticket cache (null for NTLM) - singletonList(NTLM) // authentication schemes - ); - - System.out.println(result.getHeaders()); // [Name, State] - result.getRows().forEach(System.out::println); // one List per row + public static void main(String[] args) { + try (WinRMClient client = WinRMClient.builder("server.example.com") + .credentials("DOMAIN\\Administrator", "the-password".toCharArray()) + .timeout(Duration.ofSeconds(30)) + .build()) { + + WqlResult result = client.wql("SELECT Name, State FROM Win32_Service").execute(); + + System.out.println(result.columns()); // [Name, State] + for (WqlRow row : result) { + System.out.println(row.string("Name") + " is " + row.string("State")); + } + } } } ``` +Remote commands work the same way: + +```java +CommandResult result = client.command("ipconfig /all").execute(); +System.out.println(result.stdout()); +``` + +Failures are reported through the unchecked +[`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) +hierarchy. The static one-shot helpers that predate `WinRMClient` +([`WinRMWqlExecutor.executeWql(...)`](apidocs/org/metricshub/winrm/wql/WinRMWqlExecutor.html), +[`WinRMCommandExecutor.execute(...)`](apidocs/org/metricshub/winrm/command/WinRMCommandExecutor.html)) +remain available and unchanged, with their checked exceptions. + ## Where to go next * [Installation](installation.html) — coordinates, supported JDKs, and the standalone CLI jar diff --git a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java index c73a085..cec4ac9 100644 --- a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java +++ b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java @@ -591,4 +591,82 @@ void parsesCertutilDigestOutputs() { ShellFileCopy.digestProbe("C:\\f") ); } + + @Test + void copiesToExplicitRemotePathCreatingTheDirectory() throws Exception { + final byte[] content = "explicit destination\r\n".getBytes(UTF_8); + final Path localFile = tempDir.resolve("collect.ps1"); + Files.write(localFile, content); + + final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor() + .expectCommand("MKDIR", SUCCESS) + .expectCommand(" echo ", SUCCESS) + .expectCommand("certutil -f -decode", hashOutput("SHA256", sha256Hex(content))) + .expectCommand("MOVE /Y", hashOutput("SHA256", sha256Hex(content))) + // The initial destination probe: the file does not exist yet + .expectCommand("certutil -hashfile", FAILURE); + + ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "C:\\Deploy\\scripts\\collect.ps1", TIMEOUT); + + final List commands = executor.getExecutedCommands(); + assertTrue( + commands.get(0).contains("IF NOT EXIST \"C:\\Deploy\\scripts\" MKDIR \"C:\\Deploy\\scripts\""), + commands.get(0) + ); + // The transferred bytes round-trip through the echoed base64 payload + assertArrayEquals(content, echoedContent(commands)); + // And the verified staging copy is published at the exact requested destination + assertTrue( + commands.stream().anyMatch(c -> c.contains("MOVE /Y") && c.contains("\"C:\\Deploy\\scripts\\collect.ps1\"")), + commands.toString() + ); + } + + @Test + void explicitRemotePathTransferIsSkippedWhenDigestsMatch() throws Exception { + final byte[] content = "already there".getBytes(UTF_8); + final Path localFile = tempDir.resolve("same.ps1"); + Files.write(localFile, content); + + final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor() + .expectCommand("MKDIR", SUCCESS) + .expectCommand("certutil -hashfile", hashOutput("SHA256", sha256Hex(content))); + + ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "C:\\Deploy\\same.ps1", TIMEOUT); + + // Directory creation + destination probe only: no echo legs, no decode, no publish + assertEquals(2, executor.getExecutedCommands().size(), executor.getExecutedCommands().toString()); + assertFalse(executor.getExecutedCommands().stream().anyMatch(c -> c.contains(" echo "))); + } + + @Test + void explicitRemotePathIsValidated() throws Exception { + final Path localFile = tempDir.resolve("valid.ps1"); + Files.write(localFile, "x".getBytes(UTF_8)); + final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor(); + + // Not an absolute Windows path + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "collect.ps1", TIMEOUT) + ); + // cmd.exe would expand % even between quotes + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "C:\\Temp\\bad%name.ps1", TIMEOUT) + ); + // Directory part with a character Windows forbids in path components + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "C:\\Te|mp\\x.ps1", TIMEOUT) + ); + // Longer than MAX_PATH once the staging suffixes are added + final String tooLong = "C:\\Deploy\\" + "a".repeat(250) + "\\x.ps1"; + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, tooLong, TIMEOUT) + ); + // Nothing was executed remotely: validation happens before any command + assertTrue(executor.getExecutedCommands().isEmpty()); + } } diff --git a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java new file mode 100644 index 0000000..fd32d3a --- /dev/null +++ b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java @@ -0,0 +1,141 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +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.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.net.ssl.SSLContext; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.exceptions.WinRMClientException; + +/** + * Validation and defaults of {@link WinRMClient.Builder}, the per-operation request builders, + * and the result value objects — no network involved: {@code build()} does not connect. + */ +class WinRMClientBuilderTest { + + private static WinRMClient.Builder validBuilder() { + return WinRMClient.builder("host").credentials("DOMAIN\\user", "secret".toCharArray()); + } + + @Test + void hostnameIsRequired() { + assertThrows(IllegalArgumentException.class, () -> WinRMClient.builder(null)); + assertThrows(IllegalArgumentException.class, () -> WinRMClient.builder(" ")); + } + + @Test + void credentialsAreRequired() { + final WinRMClient.Builder builder = WinRMClient.builder("host"); + assertThrows(IllegalStateException.class, builder::build); + assertThrows(IllegalArgumentException.class, () -> builder.credentials(null, "x".toCharArray())); + assertThrows(IllegalArgumentException.class, () -> builder.credentials("user", null)); + } + + @Test + void portMustBeValid() { + assertThrows(IllegalArgumentException.class, () -> validBuilder().port(0)); + assertThrows(IllegalArgumentException.class, () -> validBuilder().port(65536)); + } + + @Test + void timeoutMustBePositive() { + assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(Duration.ofSeconds(-1))); + assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(null)); + } + + @Test + void authenticationMustNotBeEmpty() { + assertThrows(IllegalArgumentException.class, () -> validBuilder().authentication()); + assertThrows(IllegalArgumentException.class, () -> validBuilder().authentication((AuthScheme) null)); + } + + @Test + void sslContextAndTrustAllAreMutuallyExclusive() throws NoSuchAlgorithmException { + final WinRMClient.Builder builder = validBuilder() + .https() + .sslContext(SSLContext.getDefault()) + .trustAllCertificates(); + assertThrows(IllegalStateException.class, builder::build); + } + + @Test + void kerberosOverHttpIsRejectedAtBuildTime() { + final WinRMClient.Builder builder = validBuilder().authentication(AuthScheme.KERBEROS); + final WinRMClientException e = assertThrows(WinRMClientException.class, builder::build); + assertTrue(e.getMessage().contains("HTTPS"), e.getMessage()); + } + + @Test + void buildSucceedsWithoutConnecting() { + // The fluent one-liner shape: build() must not reach out to the (nonexistent) host. + try (WinRMClient client = validBuilder().https().port(5987).namespace("root\\custom").build()) { + assertEquals("host", client.hostname()); + } + } + + @Test + void wqlRequestValidatesItsOptions() { + try (WinRMClient client = validBuilder().build()) { + assertThrows(IllegalArgumentException.class, () -> client.wql(" ")); + final WqlRequest request = client.wql("SELECT Name FROM Win32_Service"); + assertThrows(IllegalArgumentException.class, () -> request.pageSize(0)); + assertThrows(IllegalArgumentException.class, () -> request.namespace(" ")); + assertThrows(IllegalArgumentException.class, () -> request.timeout(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> request.pullTimeout(Duration.ofSeconds(-3))); + } + } + + @Test + void commandRequestValidatesItsOptions() { + try (WinRMClient client = validBuilder().build()) { + assertThrows(IllegalArgumentException.class, () -> client.command(" ")); + final CommandRequest request = client.command("ipconfig"); + assertThrows(IllegalArgumentException.class, () -> request.workingDirectory(" ")); + assertThrows(IllegalArgumentException.class, () -> request.timeout(Duration.ZERO)); + assertThrows(IllegalArgumentException.class, () -> request.charset(null)); + assertThrows(IllegalArgumentException.class, () -> request.upload((java.nio.file.Path) null)); + } + } + + @Test + void wqlRowLooksUpPropertiesCaseInsensitively() { + final Map values = new LinkedHashMap<>(); + values.put("Name", "Spooler"); + values.put("State", null); + final WqlRow row = new WqlRow(values); + + assertEquals("Spooler", row.get("Name")); + assertEquals("Spooler", row.string("NAME")); + assertNull(row.string("State")); + assertNull(row.get("NoSuchProperty")); + assertThrows(UnsupportedOperationException.class, () -> row.asMap().put("x", "y")); + assertEquals("[Name, State]", row.asMap().keySet().toString()); + } +} diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java new file mode 100644 index 0000000..55ead57 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -0,0 +1,343 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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 static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.metricshub.winrm.light.FakeWsmanResponses.commandResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.done; +import static org.metricshub.winrm.light.FakeWsmanResponses.envelope; +import static org.metricshub.winrm.light.FakeWsmanResponses.enumerationDone; +import static org.metricshub.winrm.light.FakeWsmanResponses.fault; +import static org.metricshub.winrm.light.FakeWsmanResponses.instance; +import static org.metricshub.winrm.light.FakeWsmanResponses.receiveResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.resourceCreated; +import static org.metricshub.winrm.light.FakeWsmanResponses.signalResponse; +import static org.metricshub.winrm.light.FakeWsmanResponses.stream; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.exceptions.WinRMAuthenticationException; +import org.metricshub.winrm.exceptions.WinRMFaultException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WqlSyntaxException; +import org.metricshub.winrm.light.FakeWsmanServer; + +/** + * End-to-end tests of the fluent {@link WinRMClient} API against {@link FakeWsmanServer}: the + * full NTLM handshake and message encryption, the typed results, the wire effect of the + * builder options, and the unchecked exception mapping — all in-process, no Windows host. + */ +class WinRMClientTest { + + private static final String DOMAIN = "FAKE"; + private static final String USER = "user"; + private static final String PASSWORD = "s3cret-Passw0rd"; + + private static final String WSEN = "http://schemas.xmlsoap.org/ws/2004/09/enumeration"; + private static final String WSMAN = "http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private WinRMClient.Builder builder(final String password) { + return WinRMClient + .builder("127.0.0.1") + .port(server.port()) + .credentials(DOMAIN + "\\" + USER, password.toCharArray()); + } + + private static String service(final String name, final String state) { + return instance("Win32_Service", "Name", name, "State", state); + } + + @Test + void wqlReturnsTypedRowsAndColumnsOverEncryptedNtlm() throws Exception { + server + .enqueue( + 200, + envelope( + "" + + "uuid:CTX-1" + + "" + + service("Spooler", "Running") + + "" + + "" + ) + ) + .enqueue( + 200, + envelope( + "" + + "" + + service("WinRM", "Stopped") + + "" + + "" + + "" + ) + ); + + try (WinRMClient client = builder(PASSWORD).build()) { + final WqlResult result = client.wql("SELECT Name, State FROM Win32_Service").execute(); + + assertEquals(List.of("Name", "State"), result.columns()); + assertEquals(2, result.size()); + assertFalse(result.isEmpty()); + assertEquals("Spooler", result.rows().get(0).string("Name")); + // Property lookup is case-insensitive, like WMI itself. + assertEquals("Running", result.rows().get(0).string("state")); + assertNotNull(result.elapsed()); + + // The result is directly iterable. + int count = 0; + for (final WqlRow row : result) { + assertNotNull(row.string("Name")); + count++; + } + assertEquals(2, count); + } + + // Defaults pinned on the wire: MaxElements 32000, no MaxTime, ROOT/CIMV2 namespace. + final List requests = server.decryptedRequests(); + assertEquals(2, requests.size(), () -> String.join("\n---\n", requests)); + final String enumerate = requests.get(0); + assertTrue(enumerate.contains("32000"), enumerate); + assertTrue(enumerate.contains("http://schemas.microsoft.com/wbem/wsman/1/wmi/ROOT/CIMV2/*"), enumerate); + final String pull = requests.get(1); + assertTrue(pull.contains("32000"), pull); + assertFalse(pull.contains("MaxTime"), pull); + } + + @Test + void wqlOptionsReachTheWire() throws Exception { + server + .enqueue( + 200, + envelope( + "" + + "uuid:CTX-1" + + "" + ) + ) + .enqueue( + 200, + envelope( + "" + + "" + + service("Spooler", "Running") + + "" + + "" + + "" + ) + ); + + try (WinRMClient client = builder(PASSWORD).build()) { + final WqlResult result = client + .wql("SELECT Name, State FROM Win32_Service") + .namespace("root\\custom") + .pageSize(100) + .pullTimeout(Duration.ofSeconds(5)) + .timeout(Duration.ofSeconds(10)) + .execute(); + + assertEquals(1, result.size()); + } + + final List requests = server.decryptedRequests(); + assertEquals(2, requests.size(), () -> String.join("\n---\n", requests)); + final String enumerate = requests.get(0); + assertTrue(enumerate.contains("100"), enumerate); + assertTrue(enumerate.contains("http://schemas.microsoft.com/wbem/wsman/1/wmi/root/custom/*"), enumerate); + assertTrue(enumerate.contains("PT10S"), enumerate); + final String pull = requests.get(1); + assertTrue(pull.contains("PT5S"), pull); + assertTrue(pull.contains("100"), pull); + } + + @Test + void commandReturnsTypedResult() throws Exception { + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", "CMD-1", "output".getBytes(StandardCharsets.UTF_8)) + + stream("stderr", "CMD-1", "warn".getBytes(StandardCharsets.UTF_8)), + done("CMD-1", 3) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + final CommandResult result = client + .command("mycommand.exe") + .workingDirectory("C:\\Temp") + .charset(StandardCharsets.UTF_8) + .execute(); + + assertEquals("output", result.stdout()); + assertEquals("warn", result.stderr()); + assertEquals(3, result.exitCode()); + assertNotNull(result.elapsed()); + } + + final List requests = server.decryptedRequests(); + final String create = requests.get(0); + assertTrue(create.contains("C:\\Temp"), create); + assertTrue(requests.get(1).contains("mycommand.exe"), requests.get(1)); + } + + @Test + void commandDetectsTheOutputCharsetOnceAndCachesIt() throws Exception { + server + // First command: the client detects the remote code set with one WQL query... + .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "CodeSet", "1252")))) + // ...then runs the command in a fresh shell. + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "first".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue(200, envelope(signalResponse())) + // Second command: no second WQL — the charset is cached, and the shell is reused. + .enqueue(200, envelope(commandResponse("CMD-2"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-2", "second".getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + assertEquals("first", client.command("first.exe").execute().stdout()); + assertEquals("second", client.command("second.exe").execute().stdout()); + } + + final List requests = server.decryptedRequests(); + final long codeSetQueries = requests.stream().filter(r -> r.contains("SELECT CodeSet FROM Win32_OperatingSystem")) + .count(); + assertEquals(1, codeSetQueries, () -> String.join("\n---\n", requests)); + final long shellCreations = requests.stream().filter(r -> r.contains("")).count(); + assertEquals(1, shellCreations, "the second command must reuse the shell"); + } + + @Test + void wsmanFaultSurfacesAsTypedException() throws Exception { + server.enqueue( + 500, + fault( + "2150858778", + "The WS-Management service cannot process the request.", + "The WMI service or the WMI provider returned an unknown error: WBEM_E_INVALID_CLASS" + ) + ); + + try (WinRMClient client = builder(PASSWORD).build()) { + final WinRMFaultException e = assertThrows( + WinRMFaultException.class, + () -> client.wql("SELECT Name FROM No_Such_Class").execute() + ); + // The structured fields carry what callers previously had to extract with contains(). + assertEquals(500, e.getHttpStatus()); + assertEquals("2150858778", e.getFaultCode()); + assertEquals("The WS-Management service cannot process the request.", e.getFaultReason()); + assertTrue(e.getFaultDetail().contains("WBEM_E_INVALID_CLASS"), e.getFaultDetail()); + // And the message keeps the legacy format. + assertTrue(e.getMessage().contains("Enumerate failed"), e.getMessage()); + assertTrue(e.getMessage().contains("WSManFault 2150858778"), e.getMessage()); + } + } + + @Test + void wrongPasswordSurfacesAsTypedAuthenticationException() { + try (WinRMClient client = builder("wrong-password").build()) { + final WinRMAuthenticationException e = assertThrows( + WinRMAuthenticationException.class, + () -> client.wql("SELECT Name FROM Win32_Service").execute() + ); + assertEquals( + "Authentication error on http://127.0.0.1:" + server.port() + "/wsman with user name \"FAKE\\user\"", + e.getMessage() + ); + } + } + + @Test + void invalidWqlIsRejectedBeforeAnythingIsSent() { + try (WinRMClient client = builder(PASSWORD).build()) { + assertThrows(WqlSyntaxException.class, () -> client.wql("HELLO WORLD").execute()); + } + assertTrue(server.decryptedRequests().isEmpty()); + } + + @Test + void slowServerSurfacesAsTypedTimeoutException() { + // The response is scripted to arrive after the client's whole-operation deadline. + server.enqueueDelayed(200, envelope(enumerationDone(service("Spooler", "Running"))), 5_000); + + try (WinRMClient client = builder(PASSWORD).timeout(Duration.ofMillis(500)).build()) { + assertThrows(WinRMTimeoutException.class, () -> client.wql("SELECT Name FROM Win32_Service").execute()); + } + } + + @Test + void closedClientRejectsOperationsAndCloseIsIdempotent() { + final WinRMClient client = builder(PASSWORD).build(); + client.close(); + client.close(); + assertThrows(IllegalStateException.class, () -> client.wql("SELECT Name FROM Win32_Service").execute()); + } +} diff --git a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java index 1274719..fe9e157 100644 --- a/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java +++ b/src/test/java/org/metricshub/winrm/light/FakeWsmanServer.java @@ -58,10 +58,16 @@ static final class Scripted { final int status; final String soapBody; + final long delayMillis; Scripted(final int status, final String soapBody) { + this(status, soapBody, 0L); + } + + Scripted(final int status, final String soapBody, final long delayMillis) { this.status = status; this.soapBody = soapBody; + this.delayMillis = delayMillis; } } @@ -142,6 +148,22 @@ public FakeWsmanServer enqueue(final int status, final String soapBody) { return this; } + /** + * Queue the next scripted response with an artificial delay before it is served — to test + * client-side timeouts deterministically. + * + * @param status the HTTP status code to respond with + * @param soapBody the plaintext SOAP body to encrypt and serve + * @param delayMillis how long to wait before serving the response + * @return this server, for chaining + */ + public FakeWsmanServer enqueueDelayed(final int status, final String soapBody, final long delayMillis) { + synchronized (script) { + script.addLast(new Scripted(status, soapBody, delayMillis)); + } + return this; + } + /** * Serve the scripted bodies with {@code Transfer-Encoding: chunked} — several chunks, a chunk * extension, and trailer fields after the terminating chunk — instead of {@code Content-Length}, @@ -260,6 +282,14 @@ private void serveScripted(final OutputStream out, final WinRMSession session, f "" ); } + if (next.delayMillis > 0) { + try { + Thread.sleep(next.delayMillis); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + return; // test shutdown + } + } final byte[] sealed = NtlmCrypto.encryptAndSign(session, next.soapBody.getBytes(StandardCharsets.UTF_8)); respond(out, next.status, null, NtlmCrypto.ENCRYPTED_CONTENT_TYPE, sealed); } diff --git a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java index 66d1f09..a1d6a79 100644 --- a/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java +++ b/src/test/java/org/metricshub/winrm/light/WsmanClientParityTest.java @@ -224,7 +224,8 @@ private static String timeoutOf(final long timeoutMs) { "http://host:5985/wsman", "root/cimv2", "SELECT * FROM Win32_OperatingSystem", - timeoutMs + timeoutMs, + 32000 ); } } From f4df143823b86aac7a05cf373aa64688186c74ad Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 19:13:41 +0200 Subject: [PATCH 02/10] Address Codex review: abort timed-out queued operations, stricter validation - P1: a worker whose wall-clock timeout fired while its operation was still queued behind another one on the serial connection no longer executes the operation after the caller was told it timed out. WsmanClient acquires the operation lock interruptibly and re-checks the interrupt flag right after acquisition (Utils.execute cancels the worker with an interrupt on timeout). Regression test verified to fail without the fix: the abandoned command reached the wire. - P2: uploadFile destinations must be drive-rooted (C:\...) or UNC (\server\share\...); relative and drive-relative (C:x.ps1) paths are rejected instead of resolving against the remote shell's current directory. - P2: Duration options now require at least one millisecond; a positive sub-millisecond duration used to truncate to 0 and be rejected later (timeouts) or silently ignored (pullTimeout). Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/CommandRequest.java | 2 +- .../org/metricshub/winrm/ShellFileCopy.java | 11 ++++- .../org/metricshub/winrm/WinRMClient.java | 12 +++-- .../java/org/metricshub/winrm/WqlRequest.java | 4 +- .../metricshub/winrm/light/WsmanClient.java | 21 ++++++++- .../metricshub/winrm/ShellFileCopyTest.java | 33 ++++++++++++++ .../winrm/WinRMClientBuilderTest.java | 5 ++- .../org/metricshub/winrm/WinRMClientTest.java | 44 +++++++++++++++++++ 8 files changed, 120 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 305457a..76164e9 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -78,7 +78,7 @@ public CommandRequest workingDirectory(final String workingDirectory) { * Set the timeout of this command — a wall-clock deadline covering file uploads, encoding * detection, and the command itself. Default: the client's timeout. * - * @param timeout the timeout (must be positive) + * @param timeout the timeout (at least one millisecond) * @return this request */ public CommandRequest timeout(final Duration timeout) { diff --git a/src/main/java/org/metricshub/winrm/ShellFileCopy.java b/src/main/java/org/metricshub/winrm/ShellFileCopy.java index 79b4f1e..a3f477c 100644 --- a/src/main/java/org/metricshub/winrm/ShellFileCopy.java +++ b/src/main/java/org/metricshub/winrm/ShellFileCopy.java @@ -105,6 +105,10 @@ private ShellFileCopy() {} private static final Pattern RESERVED_DEVICE_NAME = Pattern .compile("(?i)(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\\..*)?"); + /** Absolute Windows file path: drive-rooted ({@code C:\...}) or UNC ({@code \\server\share\...}). */ + private static final Pattern ABSOLUTE_REMOTE_PATH = Pattern + .compile("(?:[A-Za-z]:|\\\\\\\\[^\\\\]+\\\\[^\\\\]+)\\\\.+"); + /** WSManFault code for "the maximum number of concurrent operations for this user has been exceeded". */ private static final String FAULT_OPERATION_QUOTA = "2150859174"; @@ -253,10 +257,13 @@ public static void copyLocalFileToRemoteFile( Utils.checkNonBlank(remoteFile, "remoteFile"); Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + // Only drive-rooted (C:\...) or UNC (\\server\share\...) destinations: a relative path + // (scripts\x.ps1) or a drive-relative one (C:x.ps1) would resolve against the remote + // shell's current directory and land the file somewhere the caller did not intend. final int separator = remoteFile.lastIndexOf('\\'); - if (separator <= 0 || separator == remoteFile.length() - 1) { + if (!ABSOLUTE_REMOTE_PATH.matcher(remoteFile).matches() || separator == remoteFile.length() - 1) { throw new IllegalArgumentException( - String.format("Remote path %s must be an absolute Windows file path.", remoteFile) + String.format("Remote path %s must be an absolute Windows file path (drive-rooted or UNC).", remoteFile) ); } final String remoteDirectory = remoteFile.substring(0, separator); diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 6c21344..d245e69 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -220,11 +220,15 @@ static WinRMClientException translate(final Exception exception) { ); } - /** Validate that a duration is non-null and positive, and return it. */ + /** + * Validate that a duration is non-null and at least one millisecond (the wire granularity of + * every timeout in this API — a positive sub-millisecond duration would silently become 0), + * and return it. + */ static Duration checkPositive(final Duration duration, final String name) { Utils.checkNonNull(duration, name); - if (duration.isZero() || duration.isNegative()) { - throw new IllegalArgumentException(name + " must be positive."); + if (toMillis(duration) < 1) { + throw new IllegalArgumentException(name + " must be at least one millisecond."); } return duration; } @@ -404,7 +408,7 @@ public Builder sslContext(final SSLContext sslContext) { * authentication, every WSMan round trip, and result collection. Default: 30 seconds. * Each operation can override it. * - * @param timeout the timeout (must be positive) + * @param timeout the timeout (at least one millisecond) * @return this builder */ public Builder timeout(final Duration timeout) { diff --git a/src/main/java/org/metricshub/winrm/WqlRequest.java b/src/main/java/org/metricshub/winrm/WqlRequest.java index a42d9d3..3dd52e5 100644 --- a/src/main/java/org/metricshub/winrm/WqlRequest.java +++ b/src/main/java/org/metricshub/winrm/WqlRequest.java @@ -75,7 +75,7 @@ public WqlRequest namespace(final String namespace) { * Set the timeout of this query — a wall-clock deadline covering every WSMan round trip and * result collection. Default: the client's timeout. * - * @param timeout the timeout (must be positive) + * @param timeout the timeout (at least one millisecond) * @return this request */ public WqlRequest timeout(final Duration timeout) { @@ -101,7 +101,7 @@ public WqlRequest pageSize(final int pageSize) { * Set the maximum time the server may hold a single Pull request open before answering with * the rows it has ({@code MaxTime}). Default: none — the server decides. * - * @param pullTimeout the per-Pull timeout (must be positive) + * @param pullTimeout the per-Pull timeout (at least one millisecond) * @return this request */ public WqlRequest pullTimeout(final Duration pullTimeout) { diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index fea22dd..e1a0d34 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -78,6 +78,23 @@ final class WsmanClient implements AutoCloseable { // tries it, so it can still hard-close the transport to unblock an abandoned, timed-out worker. private final ReentrantLock operationLock = new ReentrantLock(); + /** + * Acquire {@link #operationLock}, aborting when this task has been cancelled. A caller's + * wall-clock timeout can fire while its operation is still QUEUED behind another one on this + * serial connection; the timeout path then cancels (interrupts) the worker thread, which must + * NOT go on to acquire the lock and execute the operation the caller was already told timed + * out — a command would run its side effects after the failure was reported. Interruption + * while waiting aborts the acquisition; an interrupt that arrived just before or during the + * acquisition is detected right after it, before anything is sent. + */ + private void lockAbortably() throws InterruptedException { + operationLock.lockInterruptibly(); + if (Thread.interrupted()) { + operationLock.unlock(); + throw new InterruptedException("Operation abandoned: cancelled while waiting for the connection."); + } + } + WsmanClient( final String host, final int port, @@ -136,7 +153,7 @@ List> wql( ) throws Exception { // Serialize the whole enumeration (Enumerate + all Pulls) against any other operation sharing // this connection; see operationLock. - operationLock.lock(); + lockAbortably(); try { transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); // WMI namespaces are case-insensitive, but preserve the caller's case to match the CXF backend. @@ -193,7 +210,7 @@ CommandOutput executeCommand( ) throws Exception { // Serialize the whole shell lifecycle (Create + Command + Receive loop + Signal) against any // other operation sharing this connection and the shellId field; see operationLock. - operationLock.lock(); + lockAbortably(); try { transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); if (shellId == null) { diff --git a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java index cec4ac9..b91be90 100644 --- a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java +++ b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java @@ -639,6 +639,24 @@ void explicitRemotePathTransferIsSkippedWhenDigestsMatch() throws Exception { assertFalse(executor.getExecutedCommands().stream().anyMatch(c -> c.contains(" echo "))); } + @Test + void uncRemotePathsAreAccepted() throws Exception { + final byte[] content = "unc destination".getBytes(UTF_8); + final Path localFile = tempDir.resolve("unc.ps1"); + Files.write(localFile, content); + + final ScriptedWindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor() + .expectCommand("MKDIR", SUCCESS) + .expectCommand("certutil -hashfile", hashOutput("SHA256", sha256Hex(content))); + + ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "\\\\server\\share\\deploy\\unc.ps1", TIMEOUT); + + assertTrue( + executor.getExecutedCommands().get(0).contains("\"\\\\server\\share\\deploy\""), + executor.getExecutedCommands().get(0) + ); + } + @Test void explicitRemotePathIsValidated() throws Exception { final Path localFile = tempDir.resolve("valid.ps1"); @@ -650,6 +668,21 @@ void explicitRemotePathIsValidated() throws Exception { IllegalArgumentException.class, () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "collect.ps1", TIMEOUT) ); + // Relative path with a directory: would resolve against the remote shell's current directory + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "scripts\\collect.ps1", TIMEOUT) + ); + // Drive-relative path (no backslash after the drive letter): same problem + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "C:Temp\\collect.ps1", TIMEOUT) + ); + // Trailing backslash: no file name + assertThrows( + IllegalArgumentException.class, + () -> ShellFileCopy.copyLocalFileToRemoteFile(executor, localFile, "C:\\Temp\\", TIMEOUT) + ); // cmd.exe would expand % even between quotes assertThrows( IllegalArgumentException.class, diff --git a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java index fd32d3a..9e35ae6 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java @@ -64,10 +64,12 @@ void portMustBeValid() { } @Test - void timeoutMustBePositive() { + void timeoutMustBeAtLeastOneMillisecond() { assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(Duration.ZERO)); assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(Duration.ofSeconds(-1))); assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(null)); + // A positive sub-millisecond duration truncates to 0 ms and must be rejected, not silently dropped. + assertThrows(IllegalArgumentException.class, () -> validBuilder().timeout(Duration.ofNanos(1))); } @Test @@ -109,6 +111,7 @@ void wqlRequestValidatesItsOptions() { assertThrows(IllegalArgumentException.class, () -> request.namespace(" ")); assertThrows(IllegalArgumentException.class, () -> request.timeout(Duration.ZERO)); assertThrows(IllegalArgumentException.class, () -> request.pullTimeout(Duration.ofSeconds(-3))); + assertThrows(IllegalArgumentException.class, () -> request.pullTimeout(Duration.ofNanos(500))); } } diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 55ead57..d8cdf69 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -333,6 +333,50 @@ void slowServerSurfacesAsTypedTimeoutException() { } } + @Test + void queuedOperationThatTimesOutIsNeverSent() throws Exception { + // Thread A holds the serial connection with a slow command; thread B's command times out + // while QUEUED behind it. B's worker must abort instead of executing the command "later" — + // side effects must never run after the caller was already told the operation timed out. + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueueDelayed( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "slow".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))), + 2_500 + ) + .enqueue(200, envelope(signalResponse())); + + final java.util.concurrent.atomic.AtomicReference slowOutcome = new java.util.concurrent.atomic.AtomicReference<>(); + try (WinRMClient client = builder(PASSWORD).build()) { + final Thread slow = new Thread(() -> { + try { + slowOutcome.set(client.command("slow.exe").charset(StandardCharsets.UTF_8).execute().stdout()); + } catch (final RuntimeException e) { + slowOutcome.set(e); + } + }); + slow.start(); + Thread.sleep(500); // let the slow command acquire the connection + + assertThrows( + WinRMTimeoutException.class, + () -> client.command("never.exe").charset(StandardCharsets.UTF_8).timeout(Duration.ofMillis(300)).execute() + ); + + slow.join(30_000); + } + + // The slow command was unaffected by the abandoned one... + assertEquals("slow", slowOutcome.get()); + // ...and the timed-out command never reached the wire. + assertTrue( + server.decryptedRequests().stream().noneMatch(r -> r.contains("never.exe")), + () -> String.join("\n---\n", server.decryptedRequests()) + ); + } + @Test void closedClientRejectsOperationsAndCloseIsIdempotent() { final WinRMClient client = builder(PASSWORD).build(); From b07b3cc6fe83d7ff6dccc46a0cb19ce28d657e52 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 19:27:06 +0200 Subject: [PATCH 03/10] Address Codex round 2: abort between protocol legs, fix mangled Javadoc - P1: a worker blocked in a socket read does not observe the cancellation interrupt, so a first command whose timeout fired during shell creation went on to START the command once the late Create response arrived. WsmanClient now re-checks the interrupt flag between protocol steps (before Command, and before each WQL Pull). Regression test: the Create response arrives after the caller's timeout, the abandoned worker must not start the command, and the client stays usable for the next command on the now-existing shell. - P1: restored the credentials(...) Javadoc that a formatter pass had mangled (parameter text split, stray asterisks, @return concatenated into the @param text). Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/WinRMClient.java | 18 +++------- .../metricshub/winrm/light/WsmanClient.java | 18 ++++++++++ .../org/metricshub/winrm/WinRMClientTest.java | 34 +++++++++++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index d245e69..87e4761 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -305,21 +305,11 @@ public Builder port(final int port) { /** * Set the credentials (mandatory). * - * @param username the user name, plain ({@code user}) or domain-qualified ({@code DOMAIN\\user})* - * @param - * password the password; - * the array - * is deliberately - * not copied, so - * the caller - * can wipe* - * the single - * authoritative copy - * of the - * secret after - * closing the client*@return this builder + * @param username the user name, plain ({@code user}) or domain-qualified ({@code DOMAIN\\user}) + * @param password the password; the array is deliberately not copied, so the caller can + * wipe the single authoritative copy of the secret after closing the client + * @return this builder */ - @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "The password char[] is deliberately shared, not copied, so the caller " + "can wipe the single authoritative copy of the secret") diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index e1a0d34..e0ff124 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -95,6 +95,19 @@ private void lockAbortably() throws InterruptedException { } } + /** + * Abort between protocol steps when this task has been cancelled. A classic socket read does + * not observe the interrupt the timeout path delivers: a worker blocked in (say) the Create + * shell response can outlive its caller's timeout and would otherwise go on to the next step — + * sending a command after the caller was already told the operation timed out. Checked before + * every step with side effects. + */ + private static void checkNotCancelled() throws InterruptedException { + if (Thread.interrupted()) { + throw new InterruptedException("Operation abandoned: cancelled after its timeout was reported."); + } + } + WsmanClient( final String host, final int port, @@ -168,6 +181,8 @@ List> wql( boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); while (!endOfSequence && context != null && !context.isEmpty()) { + // Stop pulling once the caller has been told the operation timed out. + checkNotCancelled(); doc = expectOk(Envelopes.pull(url, ns, context, operationTimeoutMs, maxElements, maxTimeMs), "Pull"); collectItems(doc, rows); endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); @@ -217,6 +232,9 @@ CommandOutput executeCommand( createShell(workingDirectory, operationTimeoutMs); } final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; + // The caller's timeout may have fired while the Create response was being awaited (socket + // reads do not observe interrupts): never START the command after the reported timeout. + checkNotCancelled(); final String commandId = startCommand(commandLine, operationTimeoutMs); try { return receiveLoop(commandId, cs, operationTimeoutMs); diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index d8cdf69..5a9d708 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -377,6 +377,40 @@ void queuedOperationThatTimesOutIsNeverSent() throws Exception { ); } + @Test + void commandIsNeverStartedWhenTheTimeoutFiresDuringShellCreation() throws Exception { + // The Create-shell response arrives AFTER the caller's timeout. A socket read does not + // observe the cancellation interrupt, so the worker outlives the timeout — but it must + // abort before STARTING the command, not run it after the caller was told it timed out. + server + .enqueueDelayed(200, envelope(resourceCreated("SHELL-1")), 2_000) + // Responses for the follow-up command, proving the client stays usable (shell reused). + .enqueue(200, envelope(commandResponse("CMD-2"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-2", "second".getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + assertThrows( + WinRMTimeoutException.class, + () -> client.command("first.exe").charset(StandardCharsets.UTF_8).timeout(Duration.ofMillis(500)).execute() + ); + + // Blocks until the abandoned worker receives the late Create response and aborts. + assertEquals("second", client.command("second.exe").charset(StandardCharsets.UTF_8).execute().stdout()); + } + + // The timed-out command was never started on the remote host. + assertTrue( + server.decryptedRequests().stream().noneMatch(r -> r.contains(">first.exe<")), + () -> String.join("\n---\n", server.decryptedRequests()) + ); + } + @Test void closedClientRejectsOperationsAndCloseIsIdempotent() { final WinRMClient client = builder(PASSWORD).build(); From 29b10e27541b39cf11c3050ce7d88104d219affa Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 19:34:44 +0200 Subject: [PATCH 04/10] Address Codex round 3: stop abandoned Receive polling, per-op connect timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: the Receive loop now re-checks the cancellation flag before every iteration, so a worker whose timeout fired mid-command stops polling when a late partial response (or op-timeout fault) arrives, terminates the command via the finally-block Signal, and releases the serial connection instead of holding it until the remote command ends. Regression test: partial Receive response arriving after the timeout — exactly one Receive and one Signal for the abandoned command, and the next command runs normally. - P2: HttpTransport.operationTimeout(...) now drives the connect timeout too, not just the read timeout, so a (re)connection made on behalf of an operation is bounded by that operation's own timeout. Co-Authored-By: Claude Fable 5 --- .../metricshub/winrm/light/HttpTransport.java | 18 ++++--- .../metricshub/winrm/light/WsmanClient.java | 4 ++ .../org/metricshub/winrm/WinRMClientTest.java | 51 +++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index 7dc1b85..b26a27d 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -53,7 +53,6 @@ final class HttpTransport implements AutoCloseable { private final String host; private final int port; - private final int timeoutMillis; // Non-null => HTTPS: the socket is wrapped in TLS. Null => plain HTTP. private final SSLSocketFactory sslSocketFactory; private final boolean verifyHostname; @@ -61,8 +60,9 @@ final class HttpTransport implements AutoCloseable { private OutputStream out; private BufferedInputStream in; private long lastActivityMillis; - // Read timeout for the current operation; starts at the connection default and follows the - // per-operation timeout (see operationTimeout(int)). + // Connect and read timeouts for the current operation; they start at the construction default + // and follow each operation's own timeout (see operationTimeout(int)). + private int connectTimeoutMillis; private int readTimeoutMillis; HttpTransport(final String host, final int port, final int timeoutMillis) { @@ -78,22 +78,24 @@ final class HttpTransport implements AutoCloseable { ) { this.host = host; this.port = port; - this.timeoutMillis = timeoutMillis; this.sslSocketFactory = sslSocketFactory; this.verifyHostname = verifyHostname; + this.connectTimeoutMillis = timeoutMillis; // Read timeout slightly above the caller's timeout so the WSMan OperationTimeout fault // (which the Receive loop retries) reliably arrives before a socket read times out. this.readTimeoutMillis = timeoutMillis + 10_000; } /** - * Align the socket read timeout with the current operation's timeout (plus headroom, so the - * WSMan OperationTimeout fault arrives before the socket read gives up). Applies to the live - * connection immediately and to any future reconnection. + * Align the socket timeouts with the current operation's timeout: the connect timeout for a + * (re)connection made on behalf of this operation, and the read timeout (plus headroom, so + * the WSMan OperationTimeout fault arrives before the socket read gives up). Applies to the + * live connection immediately and to any future reconnection. * * @param operationTimeoutMillis the current operation's timeout in milliseconds */ void operationTimeout(final int operationTimeoutMillis) { + connectTimeoutMillis = operationTimeoutMillis; readTimeoutMillis = operationTimeoutMillis + 10_000; if (socket != null && !socket.isClosed()) { try { @@ -223,7 +225,7 @@ private void ensureConnected() throws IOException { params.setEndpointIdentificationAlgorithm("HTTPS"); sslSocket.setSSLParameters(params); } - newSocket.connect(new InetSocketAddress(host, port), timeoutMillis); + newSocket.connect(new InetSocketAddress(host, port), connectTimeoutMillis); newSocket.setSoTimeout(readTimeoutMillis); if (newSocket instanceof SSLSocket) { // Force the TLS handshake now so certificate/hostname failures surface here, not on diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index e0ff124..9dda357 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -276,6 +276,10 @@ private CommandOutput receiveLoop(final String commandId, final Charset charset, final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); while (true) { + // A late non-final response (or an op-timeout fault) must not keep an abandoned worker + // re-issuing Receive — and holding the serial connection — until the remote command ends. + // Aborting here still runs the finally-block Signal, which terminates the remote command. + checkNotCancelled(); final Decoded resp = request(Envelopes.receive(url, shellId, commandId, timeoutMs)); if (resp.status != 200) { final String faultCode = wsmanFaultCode(resp.document); diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index 5a9d708..cddfd42 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -411,6 +411,57 @@ void commandIsNeverStartedWhenTheTimeoutFiresDuringShellCreation() throws Except ); } + @Test + void receivePollingStopsWhenTheTimeoutFiresMidCommand() throws Exception { + // The command times out while a Receive read is blocked; the late response carries only + // PARTIAL output (no Done state). The abandoned worker must not re-issue Receive until the + // remote command eventually ends — it must stop, terminate the command, and release the + // serial connection for the next operation. + server + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueueDelayed( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "partial".getBytes(StandardCharsets.UTF_8)), null)), + 2_000 + ) + // The abandoned worker's terminate Signal for CMD-1... + .enqueue(200, envelope(signalResponse())) + // ...then the follow-up command, proving the connection was released and stays usable. + .enqueue(200, envelope(commandResponse("CMD-2"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-2", "second".getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + assertThrows( + WinRMTimeoutException.class, + () -> client.command("first.exe").charset(StandardCharsets.UTF_8).timeout(Duration.ofMillis(500)).execute() + ); + + // Blocks until the abandoned worker sees the late partial response, aborts, and unlocks. + assertEquals("second", client.command("second.exe").charset(StandardCharsets.UTF_8).execute().stdout()); + } + + final List requests = server.decryptedRequests(); + // Exactly one Receive was issued for the abandoned command — no polling after the timeout — + // and its terminate Signal was still sent, so the remote command does not keep running. + assertEquals( + 1, + requests.stream().filter(r -> r.contains("CommandId=\"CMD-1\">stdout stderr")).count(), + () -> String.join("\n---\n", requests) + ); + assertEquals( + 1, + requests.stream().filter(r -> r.contains("Signal CommandId=\"CMD-1\"")).count(), + () -> String.join("\n---\n", requests) + ); + } + @Test void closedClientRejectsOperationsAndCloseIsIdempotent() { final WinRMClient client = builder(PASSWORD).build(); From bb5cf81b6b02a1a3e3e29fb016dd7edcc2d175c5 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 20:25:17 +0200 Subject: [PATCH 05/10] Document file transfers in detail (new site page) New "File Transfers" documentation page covering the full mechanics of copying files through the WinRM channel: the two entry points (upload() on the command builder / uploadFile() to an explicit path, plus the legacy localFileToCopyList), the transfer directory (\Temp\SEN_ShareFor_$) and how it is discovered, content-addressed remote names and MAX_PATH truncation, the transfer pipeline (skip-if-identical digest probe, chunked base64 echo legs, certutil decode into a staging file, verified publish), the temporary .part/.part.b64 files, the 30-day purge, case-insensitive command-line substitution and the CMD.EXE /C wrap, and the file-name/size/quota constraints. Linked from the site menu, the overview, Remote Commands (whose inline explanation is condensed into a pointer), and the README. Co-Authored-By: Claude Fable 5 --- README.md | 4 +- src/site/markdown/commands.md | 24 ++-- src/site/markdown/file-transfers.md | 164 ++++++++++++++++++++++++++++ src/site/markdown/index.md | 1 + src/site/site.xml | 1 + 5 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/site/markdown/file-transfers.md diff --git a/README.md b/README.md index 3e22741..c98fd4b 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,9 @@ remote host **through the WinRM channel itself** (chunked base64 through the com decoded with `certutil` and verified with a digest): no SMB, no TCP port 445, no administrative share — and it works from any client OS. A file already present on the remote host with an identical digest is not transferred again. This transport is designed for small script files, not -bulk data. Over HTTPS it validates the certificate and verifies the hostname by default (see the +bulk data. The full mechanics — destination directory, temporary files, integrity verification, +cleanup, and command-line substitution — are documented on the +[File Transfers](https://metricshub.org/winrm-java/file-transfers.html) page. Over HTTPS it validates the certificate and verifies the hostname by default (see the upgrade warning above); `trustAllCertificates()` on the builder or `-Dorg.metricshub.winrm.tls.insecure=true` trusts all certificates (insecure, testing only). Kerberos uses the ambient Kerberos configuration (`krb5.conf` / `-Djava.security.krb5.*`) unless diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index f14414f..502fcc8 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -104,18 +104,18 @@ copies `c:\MyScript.vbs` to the host and runs the equivalent of: CSCRIPT "C:\Windows\Temp\...\MyScript.vbs" ``` -How the transfer works, and what to keep in mind: - -* Files travel **through the WinRM command shell itself** — chunked base64, decoded on the host with - `certutil` and verified with a digest. There is **no SMB**: TCP port 445 does not need to be - reachable, no administrative share is created, and the copy works from any client OS. -* The transfer is **content-addressed**: a fragment of the content digest is inserted before the - file extension (for example `MyScript.1a2b3c4d.vbs`), so files with the same name but different - content never overwrite each other. A script that inspects its own name (`WScript.ScriptName`) - therefore sees the digest fragment. -* A file already present on the host with an identical digest is **not transferred again**. -* The mechanism is designed for **small script files**, not bulk data — base64 over SOAP is not a - fast bulk transport. +The fluent API does the same with `upload(...)` on the command builder, and can also copy a file +to an explicit destination with `WinRMClient.uploadFile(localPath, remotePath)`. + +In short: files travel **through the WinRM command shell itself** (chunked base64, decoded with +`certutil`, digest-verified — no SMB, no TCP port 445, no administrative share), land under a +**content-addressed name** (e.g. `MyScript.1a2b3c4d5e6f.vbs`), and a file already present with an +identical digest is **not transferred again**. The mechanism is designed for small script files, +not bulk data. + +See **[File Transfers](file-transfers.html)** for the full mechanics: the exact destination +directory, the temporary files, the integrity verification, the 30-day cleanup, and the +command-line substitution rules. ## Exceptions diff --git a/src/site/markdown/file-transfers.md b/src/site/markdown/file-transfers.md new file mode 100644 index 0000000..abe9924 --- /dev/null +++ b/src/site/markdown/file-transfers.md @@ -0,0 +1,164 @@ +keywords: file transfer, file copy, upload, certutil, base64, digest, content-addressed, temporary files +description: How the WinRM Java Client copies local files to the remote host through the WinRM channel itself — destination paths, temporary files, integrity verification, and command-line substitution. + +# File Transfers + + + +The client can copy local files to the remote host **through the WinRM connection itself** — no +SMB, no TCP port 445, no administrative share — so it works from any client OS and needs no port +beyond the WinRM one. This page explains exactly how the transfer works: where files land, which +temporary files are created, how integrity is guaranteed, and how the command line is rewritten. + +## The two entry points + +**Transfer-and-run** — copy script files and rewrite the command to reference the remote copies +(this is what `upload(...)` on the fluent command builder and `localFileToCopyList` in the legacy +[`WinRMCommandExecutor.execute(...)`](apidocs/org/metricshub/winrm/command/WinRMCommandExecutor.html) +do): + +```java +client.command("CSCRIPT c:\\scripts\\collect.vbs") + .upload(Path.of("c:\\scripts\\collect.vbs")) + .execute(); +``` + +**Explicit destination** — copy one file to a path you choose +([`WinRMClient.uploadFile(...)`](apidocs/org/metricshub/winrm/WinRMClient.html), or +[`ShellFileCopy.copyLocalFileToRemoteFile(...)`](apidocs/org/metricshub/winrm/ShellFileCopy.html) +with a legacy executor): + +```java +client.uploadFile(Path.of("collect.ps1"), "C:\\Windows\\Temp\\collect.ps1"); +``` + +Both use the same transfer engine described below; they differ only in where the file lands. + +## Where files land + +With **transfer-and-run**, files are copied to a per-client-machine transfer directory on the +remote host: + +```text +\Temp\SEN_ShareFor_$ +``` + +* `` is discovered on the remote host with the WQL query + `SELECT WindowsDirectory FROM Win32_OperatingSystem` — typically `C:\Windows`. +* `` is the name of the machine **running the client** (the `COMPUTERNAME` + environment variable, or the local host name), so different client machines never share a + directory. The name and the trailing `$` are kept from the pre-2.0.0 SMB implementation, which + used this directory as a hidden share — no share is created anymore. +* The directory is created if missing (`IF NOT EXIST ... MKDIR ...`). + +Inside that directory the remote file name is **content-addressed**: a 12-hex-digit fragment of +the file's SHA-256 digest is inserted before the extension: + +```text +collect.vbs → \Temp\SEN_ShareFor_MYHOST$\collect.1a2b3c4d5e6f.vbs +``` + +Because the name identifies the content, two files with the same name but different content get +different remote paths — concurrent clients (even ones whose computer names collide) can never +overwrite each other's payload between verification and execution. The flip side: a script that +inspects its own file name (e.g. `WScript.ScriptName`) sees the digest fragment. + +Overlong names are truncated (on Unicode code-point boundaries) so that the complete remote path — +including the temporary-file suffixes described below — stays within the traditional Windows +`MAX_PATH` limit (260 characters) that old hosts still enforce; the digest fragment keeps +truncated names unique. + +With an **explicit destination** (`uploadFile`), the file lands exactly at the path you give — +no content-addressing, no renaming. The destination must be an absolute Windows path, either +drive-rooted (`C:\...`) or UNC (`\\server\share\...`); relative and drive-relative (`C:x.ps1`) +paths are rejected, because they would resolve against the remote shell's current directory. The +destination directory is created if missing. + +## How the bytes travel + +The transfer rides the already-authenticated (and, over HTTP, NTLM-encrypted) WinRM command +shell — every step below is an ordinary remote command: + +1. **Skip check.** The destination is hashed on the host + (`certutil -hashfile SHA256`, with a `SHA1` fallback for pre-2012 hosts in the same + command). If it already carries the digest of the local file, the transfer is **skipped + entirely** — re-running the same script costs one round trip. A destination present with a + *different* digest (e.g. corrupted in place) is remembered and repaired by replacement in + step 4. +2. **Upload.** The file content is base64-encoded locally (76-character lines) and appended to a + remote **base64 sidecar file** with chunked `echo` commands, batched into as few command legs + as possible, each under cmd.exe's command-line length limit (~8 kB per leg). +3. **Decode and verify.** One command leg decodes the sidecar with `certutil -f -decode` into the + **staging file**, deletes the sidecar, and hashes the staging file — the digest is compared + against the locally computed one before going any further. +4. **Publish and verify again.** The verified staging file is moved onto the destination: + * if the destination did not exist, `MOVE` — and if a concurrent transfer of the *same + content* won the race, the staging copy is simply discarded (a destination that already + carries the right digest is **never rewritten**, so a copy verified by another operation + cannot be invalidated); + * if the destination pre-existed with a mismatched digest, `MOVE /Y` force-replaces it + (repair). + + The same command leg hashes the destination one last time: **the operation only succeeds if + the destination provably contains the local bytes**. On any failure the temporary files are + deleted (best effort) and the destination is left alone — never trusted unverified. + +An empty local file skips steps 2–4: the destination is created with `TYPE NUL` and verified the +same way. + +### Temporary files + +Two short-lived artifacts exist next to the destination during a transfer: + +```text +..part the staging file (decoded content, verified before publish) +..part.b64 the base64 sidecar consumed by certutil -decode +``` + +`` combines a process-wide counter with 64 random bits, so concurrent transfers — same +JVM or not — never collide. Both files are removed on success and best-effort deleted on failure. + +### Housekeeping + +Before each transfer-and-run, entries of the transfer directory **not modified for 30 days are +purged** (best effort, via `forfiles`). Content-addressing means every revision of a changing +script gets a new remote name, so without this lifecycle the directory would grow without bound; +the purge also reclaims staging files orphaned by an interrupted transfer. The only cost: a +script unused for 30 days is re-uploaded once. + +## Command-line substitution + +With transfer-and-run, after the files are copied, every occurrence of each local path in the +command string is replaced — literally and **case-insensitively** — by the corresponding remote +path, and the result is executed through `CMD.EXE /C (...)`: + +```text +given: CSCRIPT c:\scripts\collect.vbs /debug +uploads: c:\scripts\collect.vbs +executes: CMD.EXE /C (CSCRIPT C:\Windows\Temp\SEN_ShareFor_MYHOST$\collect.1a2b3c4d5e6f.vbs /debug) +``` + +Notes: + +* The match is a literal, case-insensitive substring match on the path exactly as you passed it + to `upload(...)`/`localFileToCopyList` — use the same spelling in the command (`C:\Scripts\X.vbs` + and `c:\scripts\x.vbs` both match, but `C:\SCRIPTS\..\SCRIPTS\X.VBS` does not). +* A listed file that the command never references is still uploaded; the command is unchanged. +* Because the transfer legs run first, they are what creates the remote shell — so a + `workingDirectory(...)` on the same request does not apply (the shell already exists, with its + default directory, when the actual command runs). + +## Constraints and safety checks + +* **File names** must be safely embeddable in a quoted cmd.exe argument and creatable on Windows. + Rejected: names containing `%` or `!` (cmd.exe expands them even between quotes), `"`, control + characters, the Windows-forbidden characters `< > : " / \ | ? *`, names ending with a dot or a + space, and reserved device names (`CON`, `NUL`, `COM1`…, with or without extension). +* **Size**: the mechanism is designed for small script files. Base64 over SOAP costs one WinRM + operation per ~8 kB leg — fine for scripts, wrong for bulk data. +* **Server operation quotas**: old hosts cap concurrent WinRM operations per user very low (15 on + Windows 2008 R2). Transfer steps are batched to minimize operations, and a command rejected by + the quota *before it could run* is retried with escalating delays (5/10/15/20 s). +* **Integrity**: every path through the transfer ends with a digest verification of the actual + destination; the digest is a transfer-integrity check (the channel itself is authenticated and, + over HTTP, encrypted). diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index a9f3660..552894e 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -103,6 +103,7 @@ remain available and unchanged, with their checked exceptions. * [Installation](installation.html) — coordinates, supported JDKs, and the standalone CLI jar * [WQL Queries](wql.html) — query WMI and read the result * [Remote Commands](commands.html) — run commands and copy files to the host +* [File Transfers](file-transfers.html) — how files are copied through the WinRM channel * [Authentication](authentication.html) — NTLM and Kerberos * [TLS / HTTPS](tls.html) — certificate validation and trust stores * [Timeouts and Errors](timeouts-and-errors.html) — timeout semantics and the exception surface diff --git a/src/site/site.xml b/src/site/site.xml index c4a25f5..46567b1 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -55,6 +55,7 @@ + From 104ac70162ec5ac7637613a9f40449f25b23cb50 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 20:32:13 +0200 Subject: [PATCH 06/10] Docs accuracy (Codex): shared transfer directories, cache-hit cost, repair-failure semantics Co-Authored-By: Claude Fable 5 --- src/site/markdown/file-transfers.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/site/markdown/file-transfers.md b/src/site/markdown/file-transfers.md index abe9924..05c8116 100644 --- a/src/site/markdown/file-transfers.md +++ b/src/site/markdown/file-transfers.md @@ -46,9 +46,12 @@ remote host: * `` is discovered on the remote host with the WQL query `SELECT WindowsDirectory FROM Win32_OperatingSystem` — typically `C:\Windows`. * `` is the name of the machine **running the client** (the `COMPUTERNAME` - environment variable, or the local host name), so different client machines never share a - directory. The name and the trailing `$` are kept from the pre-2.0.0 SMB implementation, which - used this directory as a hidden share — no share is created anymore. + environment variable, or the local host name), which usually gives each client machine its own + directory. Clients that report the same computer name (cloned machines, containers) share one — + which is safe, because the content-addressed file names below prevent them from ever + overwriting each other's payloads; they simply also share the cache and the 30-day cleanup. + The name and the trailing `$` are kept from the pre-2.0.0 SMB implementation, which used this + directory as a hidden share — no share is created anymore. * The directory is created if missing (`IF NOT EXIST ... MKDIR ...`). Inside that directory the remote file name is **content-addressed**: a 12-hex-digit fragment of @@ -81,10 +84,11 @@ shell — every step below is an ordinary remote command: 1. **Skip check.** The destination is hashed on the host (`certutil -hashfile SHA256`, with a `SHA1` fallback for pre-2012 hosts in the same - command). If it already carries the digest of the local file, the transfer is **skipped - entirely** — re-running the same script costs one round trip. A destination present with a - *different* digest (e.g. corrupted in place) is remembered and repaired by replacement in - step 4. + command). If it already carries the digest of the local file, the **upload is skipped + entirely** — re-running an identical script costs only the fixed bookkeeping (the + directory-discovery query, the cleanup/`MKDIR` leg, and this digest probe), never the upload + legs. A destination present with a *different* digest (e.g. corrupted in place) is remembered + and repaired by replacement in step 4. 2. **Upload.** The file content is base64-encoded locally (76-character lines) and appended to a remote **base64 sidecar file** with chunked `echo` commands, batched into as few command legs as possible, each under cmd.exe's command-line length limit (~8 kB per leg). @@ -101,7 +105,11 @@ shell — every step below is an ordinary remote command: The same command leg hashes the destination one last time: **the operation only succeeds if the destination provably contains the local bytes**. On any failure the temporary files are - deleted (best effort) and the destination is left alone — never trusted unverified. + deleted (best effort). A destination that already carried the correct content is never + touched; but when a *mismatched* destination is being repaired, the replacement happens before + the final verification — so a failure during a repair can leave the destination already + replaced (and still unverified). Either way an unverified destination is never silently + trusted: the failure is reported, and the next transfer detects the mismatch and repairs it. An empty local file skips steps 2–4: the destination is created with `TYPE NUL` and verified the same way. From 80d252e104d431f870bc624c2a7b94921a9e4494 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 20:45:06 +0200 Subject: [PATCH 07/10] Address Codex round 5: recreate reaped shells, housekeeping queries in CIMV2 - P1: a cached command shell reaped by the server between commands (e.g. IdleTimeout on a long-lived client) left the client permanently broken: every command faulted shell-not-found against the stale selector. WsmanClient now catches the shell-not-found fault on Command creation (safe: the command was rejected before it could run), clears the cached ID, recreates the shell, and retries once; a shell-not-found on the terminate Signal also drops the cached ID so the next command starts clean. - P1: the internal housekeeping WQL queries now explicitly target ROOT\CIMV2 via the new WmiHelper.executeWqlInCimv2 (with a fallback to the executor default when the extended executeWql is unsupported): encoding detection (Codex's finding) and Windows-directory discovery for file transfers (same bug, found by inspection) both faulted when the client was built with a custom default namespace. e2e regression tests for both, CHANGELOG updated. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 ++ .../winrm/WindowsRemoteProcessUtils.java | 5 +- .../metricshub/winrm/WindowsTempShare.java | 8 ++- .../java/org/metricshub/winrm/WmiHelper.java | 36 ++++++++++ .../metricshub/winrm/light/WsmanClient.java | 25 ++++++- .../org/metricshub/winrm/WinRMClientTest.java | 70 +++++++++++++++++++ 6 files changed, 144 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8765ddc..57af684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,13 @@ Consequences: - The WSMan `OperationTimeout` header and the socket read timeout now follow each operation's own timeout instead of the executor's creation timeout (they were always the same value through the legacy API; the fluent API can override the timeout per operation). +- A cached remote command shell reaped by the server between commands (e.g. its `IdleTimeout` + expired on a long-lived client) is transparently recreated and the rejected command retried + once — previously every later command on the same executor kept failing with the + shell-not-found fault. +- The library's internal housekeeping queries (output-encoding detection, Windows-directory + discovery for file transfers) now explicitly target `ROOT\CIMV2`, so a client configured with a + custom default WMI namespace can still run commands and transfer files. - Dependency-free WinRM client with no Apache CXF / JAX-WS / JAXB stack, immune by construction to JAXP `ServiceLoader` conflicts (it uses the JDK-default XML factories). Supports NTLM over HTTP (with message encryption) and HTTPS, and Kerberos (SPNEGO, via the JDK GSS-API) over HTTPS. diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java b/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java index adf145e..460d802 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteProcessUtils.java @@ -95,7 +95,10 @@ public static Charset getWindowsEncodingCharset( return DEFAULT_CHARSET; } - final List> result = windowsRemoteExecutor.executeWql( + // Explicitly in ROOT\CIMV2: the executor's default namespace may be a custom one, where + // Win32_OperatingSystem does not exist. + final List> result = WmiHelper.executeWqlInCimv2( + windowsRemoteExecutor, "SELECT CodeSet FROM Win32_OperatingSystem", timeout ); diff --git a/src/main/java/org/metricshub/winrm/WindowsTempShare.java b/src/main/java/org/metricshub/winrm/WindowsTempShare.java index 2057512..4dadf93 100644 --- a/src/main/java/org/metricshub/winrm/WindowsTempShare.java +++ b/src/main/java/org/metricshub/winrm/WindowsTempShare.java @@ -140,9 +140,11 @@ public static String getWindowsDirectory(final WindowsRemoteExecutor windowsRemo Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); try { - // Extract the WindowsDirectory property from the first instance and return it (or throw an exception) - return windowsRemoteExecutor - .executeWql("SELECT WindowsDirectory FROM Win32_OperatingSystem", timeout) + // Extract the WindowsDirectory property from the first instance and return it (or throw an + // exception). Explicitly in ROOT\CIMV2: the executor's default namespace may be a custom + // one, where Win32_OperatingSystem does not exist. + return WmiHelper + .executeWqlInCimv2(windowsRemoteExecutor, "SELECT WindowsDirectory FROM Win32_OperatingSystem", timeout) .stream() .limit(1) .map(row -> (String) row.get("WindowsDirectory")) diff --git a/src/main/java/org/metricshub/winrm/WmiHelper.java b/src/main/java/org/metricshub/winrm/WmiHelper.java index 7fcc922..054e036 100644 --- a/src/main/java/org/metricshub/winrm/WmiHelper.java +++ b/src/main/java/org/metricshub/winrm/WmiHelper.java @@ -23,8 +23,10 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeoutException; import java.util.regex.Pattern; import java.util.stream.Collectors; +import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; public abstract class WmiHelper { @@ -56,6 +58,40 @@ public static boolean isValidWql(final String wqlQuery) { return WQL_SIMPLE_SELECT_PATTERN.matcher(wqlQuery).find(); } + /** + * Execute one of the library's internal housekeeping WQL queries (encoding detection, Windows + * directory discovery) explicitly in the {@value #DEFAULT_NAMESPACE} namespace — where the + * standard {@code Win32_*} classes live — regardless of the executor's configured default + * namespace, which the caller may have pointed at a custom namespace. Executors that do not + * support an explicit per-query namespace fall back to their default namespace, preserving the + * historical behavior. + * + * @param windowsRemoteExecutor Executor connected to the remote host + * @param wqlQuery The WQL query to run + * @param timeout Timeout in milliseconds + * @return the query result rows + * @throws TimeoutException To notify userName of timeout + * @throws WqlQuerySyntaxException On WQL syntax errors + * @throws WindowsRemoteException For any problem encountered on the remote host + */ + public static List> executeWqlInCimv2( + final WindowsRemoteExecutor windowsRemoteExecutor, + final String wqlQuery, + final long timeout + ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + try { + return windowsRemoteExecutor.executeWql( + DEFAULT_NAMESPACE, + wqlQuery, + timeout, + WindowsRemoteExecutor.DEFAULT_WQL_MAX_ELEMENTS, + 0 + ); + } catch (final UnsupportedOperationException e) { + return windowsRemoteExecutor.executeWql(wqlQuery, timeout); + } + } + /** * The "network resource" is either just the namespace (for localhost), or \\hostname\\namespace. * diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 9dda357..7bbd5ae 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -235,7 +235,21 @@ CommandOutput executeCommand( // The caller's timeout may have fired while the Create response was being awaited (socket // reads do not observe interrupts): never START the command after the reported timeout. checkNotCancelled(); - final String commandId = startCommand(commandLine, operationTimeoutMs); + String commandId; + try { + commandId = startCommand(commandLine, operationTimeoutMs); + } catch (final WinRMFaultException e) { + if (!FAULT_SHELL_NOT_FOUND.equals(e.getFaultCode())) { + throw e; + } + // The server reaped the cached shell between commands (e.g. its IdleTimeout expired on a + // long-lived client). The Command was rejected before it could run, so it is safe to + // recreate the shell and retry once. + shellId = null; + createShell(workingDirectory, operationTimeoutMs); + checkNotCancelled(); + commandId = startCommand(commandLine, operationTimeoutMs); + } try { return receiveLoop(commandId, cs, operationTimeoutMs); } finally { @@ -304,8 +318,13 @@ private CommandOutput receiveLoop(final String commandId, final Charset charset, private void terminate(final String commandId, final long timeoutMs) throws Exception { final Decoded resp = request(Envelopes.signal(url, shellId, commandId, timeoutMs)); // A missing shell is fine here — the command already finished and the shell may be gone. - if (resp.status != 200 && !FAULT_SHELL_NOT_FOUND.equals(wsmanFaultCode(resp.document))) { - throw faultException("Signal", resp); + // But drop the cached ID so the next command creates a fresh shell up front instead of + // discovering the stale one the hard way. + if (resp.status != 200) { + if (!FAULT_SHELL_NOT_FOUND.equals(wsmanFaultCode(resp.document))) { + throw faultException("Signal", resp); + } + shellId = null; } } diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index cddfd42..e1aaf9f 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -462,6 +462,76 @@ void receivePollingStopsWhenTheTimeoutFiresMidCommand() throws Exception { ); } + @Test + void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception { + server + // First command: normal lifecycle in a fresh shell. + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "first".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue(200, envelope(signalResponse())) + // Second command: the server reaped SHELL-1 in the meantime (IdleTimeout) and rejects the + // Command with shell-not-found; the client must recreate the shell and retry once. + .enqueue( + 500, + fault("2150858843", "The WS-Management service cannot process the request because the resource is offline.") + ) + .enqueue(200, envelope(resourceCreated("SHELL-2"))) + .enqueue(200, envelope(commandResponse("CMD-2"))) + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", "CMD-2", "second".getBytes(StandardCharsets.UTF_8)), done("CMD-2", 0)) + ) + ) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder(PASSWORD).build()) { + assertEquals("first", client.command("first.exe").charset(StandardCharsets.UTF_8).execute().stdout()); + assertEquals("second", client.command("second.exe").charset(StandardCharsets.UTF_8).execute().stdout()); + } + + final List requests = server.decryptedRequests(); + // The retried Command rides the NEW shell. + assertTrue( + requests.stream().anyMatch(r -> r.contains(">second.exe<") && r.contains("Selector Name=\"ShellId\">SHELL-2<")), + () -> String.join("\n---\n", requests) + ); + // Exactly two shells were created, and second.exe was sent twice (rejected, then retried). + assertEquals(2, requests.stream().filter(r -> r.contains("")).count()); + assertEquals(2, requests.stream().filter(r -> r.contains(">second.exe<")).count()); + } + + @Test + void charsetDetectionQueriesCimv2EvenWithACustomDefaultNamespace() throws Exception { + server + .enqueue(200, envelope(enumerationDone(instance("Win32_OperatingSystem", "CodeSet", "1252")))) + .enqueue(200, envelope(resourceCreated("SHELL-1"))) + .enqueue(200, envelope(commandResponse("CMD-1"))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", "CMD-1", "ok".getBytes(StandardCharsets.UTF_8)), done("CMD-1", 0))) + ) + .enqueue(200, envelope(signalResponse())); + + // The client's default namespace points at a custom one, where Win32_OperatingSystem does + // not exist: the internal encoding-detection query must still target ROOT\CIMV2. + try (WinRMClient client = builder(PASSWORD).namespace("root\\custom").build()) { + assertEquals("ok", client.command("whoami").execute().stdout()); + } + + final String codeSetQuery = server + .decryptedRequests() + .stream() + .filter(r -> r.contains("SELECT CodeSet FROM Win32_OperatingSystem")) + .findFirst() + .orElseThrow(); + assertTrue(codeSetQuery.contains("http://schemas.microsoft.com/wbem/wsman/1/wmi/ROOT/CIMV2/*"), codeSetQuery); + } + @Test void closedClientRejectsOperationsAndCloseIsIdempotent() { final WinRMClient client = builder(PASSWORD).build(); From 39e35cc153e476cd0ef60952e4f2ac286432f036 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 20:54:41 +0200 Subject: [PATCH 08/10] Rewrite the doc site around the fluent API - wql.md, commands.md, authentication.md, tls.md, timeouts-and-errors.md now document the fluent WinRMClient API exclusively: builders, typed results (WqlResult/WqlRow with case-insensitive lookup, CommandResult), Duration timeouts as a single wall-clock deadline, the unchecked exception hierarchy with programmatic fault fields, per-client TLS (trustAllCertificates / sslContext with a worked trust-store example), ordered authentication fallback, and the pageSize/pullTimeout enumeration knobs. CLI sections unchanged. - New legacy.md summarizes the static one-shot helpers (signatures, results, checked exceptions, behavior notes) for reference; linked from the Reference menu and the overview. - migrating-from-1x.md gains a "Moving to the fluent API" section: before/after examples for WQL and commands, a 1.x-to-fluent option mapping table, and the checked-to-unchecked exception mapping (with the message-compatibility guarantee). Co-Authored-By: Claude Fable 5 --- src/site/markdown/authentication.md | 68 +++++++---- src/site/markdown/commands.md | 143 +++++++++++------------ src/site/markdown/index.md | 3 +- src/site/markdown/legacy.md | 91 +++++++++++++++ src/site/markdown/migrating-from-1x.md | 95 ++++++++++++++- src/site/markdown/timeouts-and-errors.md | 89 +++++++++----- src/site/markdown/tls.md | 61 ++++++++-- src/site/markdown/wql.md | 130 ++++++++++++--------- src/site/site.xml | 1 + 9 files changed, 476 insertions(+), 205 deletions(-) create mode 100644 src/site/markdown/legacy.md diff --git a/src/site/markdown/authentication.md b/src/site/markdown/authentication.md index 4cd6ecb..f0ef6ef 100644 --- a/src/site/markdown/authentication.md +++ b/src/site/markdown/authentication.md @@ -1,24 +1,33 @@ keywords: authentication, ntlm, kerberos, spnego, domain, realm, kdc, krb5, ticket cache -description: Authenticate to WinRM with NTLM or Kerberos (SPNEGO), including domain accounts and Kerberos configuration. +description: Authenticate to WinRM with NTLM or Kerberos (SPNEGO), including domain accounts, ordered fallback, and Kerberos configuration. # Authentication -The client authenticates with either **NTLM** or **Kerberos (SPNEGO)**. The scheme is chosen by the -`authentications` argument, a -`List<`[`AuthenticationEnum`](apidocs/org/metricshub/winrm/service/client/auth/AuthenticationEnum.html)`>` -that both `executeWql(...)` and `WinRMCommandExecutor.execute(...)` accept. +The client authenticates with either **NTLM** or **Kerberos (SPNEGO)**. The scheme is chosen with +`authentication(...)` on the [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html) +builder, which takes one or more [`AuthScheme`](apidocs/org/metricshub/winrm/AuthScheme.html) +values: ```java -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.KERBEROS; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; - -singletonList(NTLM); // NTLM only (also the default when null or empty) -singletonList(KERBEROS); // Kerberos only +import org.metricshub.winrm.AuthScheme; + +WinRMClient.builder("server.example.com") + .credentials("DOMAIN\\Administrator", password) + .authentication(AuthScheme.NTLM) // NTLM only (also the default) + // .authentication(AuthScheme.KERBEROS) // Kerberos only + // .authentication(AuthScheme.KERBEROS, AuthScheme.NTLM) // ordered fallback + .build(); ``` -If the list is `null` or empty, **NTLM** is used. +When `authentication(...)` is not called, **NTLM** is used. + +### Ordered fallback + +Several schemes form an **ordered fallback list**: each is tried in the given order until one +succeeds. `authentication(KERBEROS, NTLM)` attempts Kerberos first and falls back to NTLM — for +example when the KDC is unreachable or the clock skew is too large. ## User name and domain @@ -31,6 +40,9 @@ remember to escape the backslash in a string literal: "Administrator" // no domain ``` +The password is a `char[]`, and the builder deliberately does **not** copy it: after closing the +client you can wipe the single authoritative copy of the secret (`Arrays.fill(password, '\0')`). + ## NTLM NTLM is the default. It works over both transports: @@ -43,22 +55,23 @@ NTLM needs no extra configuration beyond the user name and password. ## Kerberos (SPNEGO) -Kerberos authentication uses SPNEGO through the JDK's GSS-API and **requires HTTPS**. +Kerberos authentication uses SPNEGO through the JDK's GSS-API and **requires HTTPS**. Connect by +the **FQDN the KDC knows** (the service principal is `HTTP/`), not by IP address: ```java -import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.KERBEROS; - -executeWql( - HTTPS, "server.example.com", null, - "DOMAIN\\Administrator", password, null, - "SELECT Name FROM Win32_ComputerSystem", - 30_000L, - ticketCache, // optional java.nio.file.Path to a ticket cache - singletonList(KERBEROS) -); +try (WinRMClient client = WinRMClient.builder("server.internal.example.com") + .https() + .credentials("DOMAIN\\Administrator", password) + .authentication(AuthScheme.KERBEROS) + // .ticketCache(Path.of("/tmp/krb5cc_1000")) // optional + .build()) { + ... +} ``` +Requesting Kerberos on a plain-HTTP client fails at `build()` with a clear message: there is no +Kerberos message encryption over HTTP. + ### Kerberos configuration By default, Kerberos relies on the **ambient JDK Kerberos configuration** — the platform `krb5.conf` @@ -71,7 +84,14 @@ java -Djava.security.krb5.realm=EXAMPLE.COM \ -cp ... MyApp ``` -The optional `ticketCache` parameter points to a Kerberos ticket cache to use for the connection. +The optional `ticketCache(Path)` builder option points at a Kerberos ticket cache to use for the +connection; without it, Kerberos logs in with the user name and password. + +## Authentication failures + +A rejected credential (after every scheme of the fallback list was tried) surfaces as a +[`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html) +whose message has the stable form `Authentication error on with user name ""`. ## Choosing the scheme on the command line diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index 502fcc8..f4039c3 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -1,115 +1,99 @@ keywords: command, execute, cmd, stdout, stderr, exit code, file copy, script -description: Execute remote commands with WinRMCommandExecutor, capture output and exit codes, and copy local files to the host. +description: Execute remote commands with the fluent WinRMClient API, capture output and exit codes, and copy local files to the host. # Remote Commands The client can run an arbitrary command on the remote host and hand back its standard output, -standard error, and exit code. It can also copy local script files to the host first and rewrite the -command so it references them. +standard error, and exit code. It can also copy local script files to the host first and rewrite +the command so it references them. -## `WinRMCommandExecutor.execute(...)` +## Running a command -Commands are run with the static method -[`WinRMCommandExecutor.execute(...)`](apidocs/org/metricshub/winrm/command/WinRMCommandExecutor.html), -which returns a -[`WindowsRemoteCommandResult`](apidocs/org/metricshub/winrm/WindowsRemoteCommandResult.html). +Build a [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html), then prepare the command +with `command(...)` and run it with `execute()`: ```java -import static java.util.Collections.singletonList; -import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; - -import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.command.WinRMCommandExecutor; - -WindowsRemoteCommandResult result = WinRMCommandExecutor.execute( - "ipconfig /all", // command (mandatory) - HTTPS, // protocol - "server.example.com", // hostname (mandatory) - null, // port (null → 5985 for HTTP, 5986 for HTTPS) - "DOMAIN\\Administrator", // username (mandatory) - "the-password".toCharArray(), // password - null, // working directory (nullable) - 30_000L, // timeout in milliseconds - null, // local files to copy (nullable) - null, // Kerberos ticket cache (null for NTLM) - singletonList(NTLM) // authentication schemes -); - -System.out.println("exit code: " + result.getStatusCode()); -System.out.print(result.getStdout()); -System.err.print(result.getStderr()); +import java.time.Duration; +import org.metricshub.winrm.CommandResult; +import org.metricshub.winrm.WinRMClient; + +try (WinRMClient client = WinRMClient.builder("server.example.com") + .https() + .credentials("DOMAIN\\Administrator", password) + .timeout(Duration.ofSeconds(30)) + .build()) { + + CommandResult result = client.command("ipconfig /all").execute(); + + System.out.println("exit code: " + result.exitCode()); + System.out.print(result.stdout()); + System.err.print(result.stderr()); +} ``` -### Parameters +The command line is run through `cmd.exe` by the remote shell. One client can run any number of +commands (and [WQL queries](wql.html)) over the same authenticated connection — see the +[Overview](index.html) for the builder options. -| Parameter | Type | Notes | +### Command options + +Everything between `command(...)` and `execute()` is optional: + +| Option | Default | Meaning | | --- | --- | --- | -| `command` | `String` | The command line to run. **Mandatory.** | -| `protocol` | [`WinRMHttpProtocolEnum`](apidocs/org/metricshub/winrm/WinRMHttpProtocolEnum.html) | `HTTP` or `HTTPS`. `null` defaults to `HTTP`. | -| `hostname` | `String` | Host name or IP address. **Mandatory.** | -| `port` | `Integer` | `null` uses the protocol default. | -| `username` | `String` | `DOMAIN\user` or `user`. **Mandatory.** | -| `password` | `char[]` | The password. | -| `workingDirectory` | `String` | Working directory of the spawned process on the remote host. May be `null`. | -| `timeout` | `long` | Timeout in milliseconds. Must be **greater than zero**. | -| `localFileToCopyList` | `List` | Local files to copy to the host before running (see below). May be `null`. | -| `ticketCache` | `java.nio.file.Path` | Kerberos ticket cache path. `null` for NTLM. | -| `authentications` | `List<`[`AuthenticationEnum`](apidocs/org/metricshub/winrm/service/client/auth/AuthenticationEnum.html)`>` | Requested schemes. `null` or empty means NTLM only. | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads, encoding detection, and the command itself. | +| `charset(Charset)` | detected from the remote code set | The charset used to decode the command output (see below). | +| `workingDirectory(String)` | remote default | Working directory of the remote process. The remote shell is created by the client's **first** command and reused afterward, so this only takes effect on that first command. | +| `upload(Path...)` | none | Local files to copy to the host before running (see below). | ## The result -[`WindowsRemoteCommandResult`](apidocs/org/metricshub/winrm/WindowsRemoteCommandResult.html) is an -immutable value: +[`CommandResult`](apidocs/org/metricshub/winrm/CommandResult.html) is an immutable value: | Method | Returns | Description | | --- | --- | --- | -| `getStdout()` | `String` | The command's standard output. | -| `getStderr()` | `String` | The command's standard error. | -| `getStatusCode()` | `int` | The process exit code. | -| `getExecutionTime()` | `float` | The measured execution time of the command. | +| `stdout()` | `String` | The command's standard output. | +| `stderr()` | `String` | The command's standard error. | +| `exitCode()` | `int` | The process exit code (Windows HRESULT codes reported as unsigned 32-bit values are narrowed to the equivalent signed `int`). | +| `elapsed()` | `java.time.Duration` | Wall-clock time of the operation. | ## Character set -The output character set does not need to be specified: the client detects the remote host's active -code page before the command runs and decodes standard output and standard error accordingly. +By default the output character set does not need to be specified: the client detects the remote +host's active code page (one WQL query, always in `ROOT\CIMV2`) before the first command runs, and +**caches the result for the lifetime of the client** — later commands pay nothing. Set an explicit +`charset(...)` to skip the detection entirely. ## Copying local files to the host -Pass one or more local paths in `localFileToCopyList` to have them copied to the remote host before -the command runs. Every reference to a listed file in the `command` string is rewritten to the path -where the file lands on the host — typically under `C:\Windows\Temp`. For example: +Pass one or more local files to `upload(...)` to have them copied to the remote host before the +command runs. Every reference to an uploaded file in the command line is rewritten to the path +where the file lands on the host: ```java -WinRMCommandExecutor.execute( - "CSCRIPT c:\\MyScript.vbs", - /* protocol */ HTTPS, - /* hostname */ "server.example.com", - /* port */ null, - /* username */ "DOMAIN\\Administrator", - /* password */ password, - /* workingDir */ null, - /* timeout */ 30_000L, - /* files */ java.util.List.of("c:\\MyScript.vbs"), - /* ticket */ null, - /* auth */ singletonList(NTLM) -); +CommandResult result = client.command("CSCRIPT c:\\scripts\\collect.vbs") + .upload(Path.of("c:\\scripts\\collect.vbs")) + .execute(); ``` -copies `c:\MyScript.vbs` to the host and runs the equivalent of: +copies `c:\scripts\collect.vbs` to the host and runs the equivalent of: ```text -CSCRIPT "C:\Windows\Temp\...\MyScript.vbs" +CSCRIPT "C:\Windows\Temp\...\collect.1a2b3c4d5e6f.vbs" ``` -The fluent API does the same with `upload(...)` on the command builder, and can also copy a file -to an explicit destination with `WinRMClient.uploadFile(localPath, remotePath)`. +The client can also copy a file to an explicit destination of your choice, independently of any +command: + +```java +client.uploadFile(Path.of("collect.ps1"), "C:\\Windows\\Temp\\collect.ps1"); +``` In short: files travel **through the WinRM command shell itself** (chunked base64, decoded with `certutil`, digest-verified — no SMB, no TCP port 445, no administrative share), land under a -**content-addressed name** (e.g. `MyScript.1a2b3c4d5e6f.vbs`), and a file already present with an +**content-addressed name** (e.g. `collect.1a2b3c4d5e6f.vbs`), and a file already present with an identical digest is **not transferred again**. The mechanism is designed for small script files, not bulk data. @@ -119,13 +103,16 @@ command-line substitution rules. ## Exceptions -`execute(...)` declares: +`execute()` reports failures through the unchecked +[`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) +hierarchy: | Exception | When | | --- | --- | -| `java.io.IOException` | An I/O error, including a copied-file problem. | -| `java.util.concurrent.TimeoutException` | The operation did not complete within `timeout`. | -| [`WindowsRemoteException`](apidocs/org/metricshub/winrm/exceptions/WindowsRemoteException.html) | Any problem on the remote host (in practice a [`WinRMException`](apidocs/org/metricshub/winrm/exceptions/WinRMException.html)). | +| [`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html) | The credentials were rejected. | +| [`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) | The remote service answered with a WSMan fault — the fault code and detail are available as fields. | +| [`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html) | The operation did not complete within its timeout. | +| [`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) | Any other failure (connection, TLS, protocol, unreadable local file). | See [Timeouts and Errors](timeouts-and-errors.html) for details. diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 552894e..13d87f0 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -107,4 +107,5 @@ remain available and unchanged, with their checked exceptions. * [Authentication](authentication.html) — NTLM and Kerberos * [TLS / HTTPS](tls.html) — certificate validation and trust stores * [Timeouts and Errors](timeouts-and-errors.html) — timeout semantics and the exception surface -* [Migrating from 1.x](migrating-from-1x.html) — the 2.0.0 breaking changes +* [Migrating from 1.x](migrating-from-1x.html) — the 2.0.0 breaking changes, and moving to the fluent API +* [Legacy API](legacy.html) — the static one-shot helpers that predate `WinRMClient` diff --git a/src/site/markdown/legacy.md b/src/site/markdown/legacy.md new file mode 100644 index 0000000..eea8bea --- /dev/null +++ b/src/site/markdown/legacy.md @@ -0,0 +1,91 @@ +keywords: legacy, static, executeWql, WinRMWqlExecutor, WinRMCommandExecutor, checked exceptions +description: The legacy static API that predates WinRMClient — still supported, summarized for reference. + +# Legacy API + + + +Before the fluent [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html), the library was +used through two static one-shot helpers. They remain **fully supported and unchanged** — existing +code keeps working — but each call opens a connection, authenticates, runs one operation, and +closes everything. New code should use the [fluent API](index.html); see +[Migrating from 1.x](migrating-from-1x.html) for the mapping. + +## WQL queries + +[`WinRMWqlExecutor.executeWql(...)`](apidocs/org/metricshub/winrm/wql/WinRMWqlExecutor.html): + +```java +WinRMWqlExecutor result = WinRMWqlExecutor.executeWql( + protocol, // WinRMHttpProtocolEnum.HTTP or HTTPS (null → HTTP) + hostname, // mandatory + port, // Integer, null → 5985 (HTTP) / 5986 (HTTPS) + username, // "DOMAIN\\user" or "user", mandatory + password, // char[] + namespace, // null → ROOT\CIMV2 + wqlQuery, // mandatory + timeout, // long, milliseconds, > 0 + ticketCache, // java.nio.file.Path, null for NTLM + authentications // List, null/empty → NTLM +); + +result.getHeaders(); // List — column names +result.getRows(); // List> — values in header order +result.getExecutionTime(); // long, milliseconds +``` + +Declared exceptions: +[`WinRMException`](apidocs/org/metricshub/winrm/exceptions/WinRMException.html), +[`WqlQuerySyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlQuerySyntaxException.html), +`java.util.concurrent.TimeoutException` — all **checked**. + +## Remote commands + +[`WinRMCommandExecutor.execute(...)`](apidocs/org/metricshub/winrm/command/WinRMCommandExecutor.html): + +```java +WindowsRemoteCommandResult result = WinRMCommandExecutor.execute( + command, // mandatory + protocol, // null → HTTP + hostname, // mandatory + port, // null → protocol default + username, // mandatory + password, // char[] + workingDirectory, // nullable + timeout, // long, milliseconds, > 0 + localFileToCopyList, // List of local files to copy first, nullable + ticketCache, // nullable + authentications // null/empty → NTLM +); + +result.getStdout(); +result.getStderr(); +result.getStatusCode(); // process exit code +result.getExecutionTime(); +``` + +Files listed in `localFileToCopyList` are copied to the host first, and each reference to them in +`command` is rewritten to the remote copy — the same engine as the fluent `upload(...)`; see +[File Transfers](file-transfers.html). + +Declared exceptions: `java.io.IOException`, `java.util.concurrent.TimeoutException`, +[`WindowsRemoteException`](apidocs/org/metricshub/winrm/exceptions/WindowsRemoteException.html) — +all **checked**. + +## Behavior notes + +* **One connection per call**: every invocation performs a full authentication handshake. Code + that polls the same host repeatedly pays that cost on every call — the main reason to move to + the reusable `WinRMClient`. +* The output character set of a command is detected before each call (one extra WQL query per + call; the fluent client caches it). +* TLS configuration is global only: the `org.metricshub.winrm.tls.insecure` system property (see + [TLS / HTTPS](tls.html)); there is no per-call trust store. +* Authentication schemes come from + [`AuthenticationEnum`](apidocs/org/metricshub/winrm/service/client/auth/AuthenticationEnum.html) + (`NTLM`, `KERBEROS`), with the same ordered-fallback semantics as the fluent + [`AuthScheme`](apidocs/org/metricshub/winrm/AuthScheme.html). +* For advanced use, the underlying reusable executor is also public: + [`WinRMExecutorFactory.createInstance(...)`](apidocs/org/metricshub/winrm/service/WinRMExecutorFactory.html) + returns a [`WindowsRemoteExecutor`](apidocs/org/metricshub/winrm/WindowsRemoteExecutor.html) — + but the fluent `WinRMClient` is the supported way to get connection reuse. diff --git a/src/site/markdown/migrating-from-1x.md b/src/site/markdown/migrating-from-1x.md index 797be58..6a314e9 100644 --- a/src/site/markdown/migrating-from-1x.md +++ b/src/site/markdown/migrating-from-1x.md @@ -6,10 +6,15 @@ description: What changed in WinRM Java Client 2.0.0 and how to upgrade from the Version 2.0.0 is a major cleanup: the legacy Apache CXF backend and the SMB-based file copy are -gone, leaving a **dependency-free** client. The **documented entry points are unchanged**, so typical +gone, leaving a **dependency-free** client. The **1.x entry points are unchanged**, so typical calling code is unaffected — but a few CXF/SMB-only public types were removed (see the *Removed types* section below), and two runtime behaviors changed. Read this page before upgrading. +Version 2.0.0 also introduces the **fluent [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html) +API**, which the documentation is now written around. Upgrading does not require adopting it — +the [legacy API](legacy.html) keeps working — but moving is straightforward and worthwhile; see +[Moving to the fluent API](#moving-to-the-fluent-api) below. + ## TL;DR * The Apache CXF backend was **removed**; the dependency-free client is the only implementation. @@ -18,6 +23,8 @@ section below), and two runtime behaviors changed. Read this page before upgradi the certificate or opt out. * File copy for `localFileToCopyList` now goes **through the WinRM channel** instead of SMB. * A few CXF/SMB-only classes were removed. +* A new **fluent API** (`WinRMClient`) is the recommended way to use the library; the 1.x static + helpers remain supported. ## TLS is validated by default @@ -73,5 +80,89 @@ will not compile against 2.0.0 (all were CXF- or SMB-specific): * The Apache CXF-based `WinRMService` and its `service.client` internals, along with the generated WSDL/XSD resources. -The documented entry points — `WinRMWqlExecutor`, `WinRMCommandExecutor`, `WinRMEndpoint`, +The 1.x entry points — `WinRMWqlExecutor`, `WinRMCommandExecutor`, `WinRMEndpoint`, `WindowsRemoteCommandResult`, the enums, and the exception types — are unchanged. + +## Moving to the fluent API + +The 1.x static helpers open a connection, authenticate, run **one** operation, and tear everything +down. The fluent [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html) authenticates once +and runs any number of operations over the same connection — a large win for anything that polls a +host — and replaces the 10-argument positional calls with builders. Migration is mechanical: + +### WQL queries + +```java +// 1.x style (still works) +WinRMWqlExecutor result = WinRMWqlExecutor.executeWql( + HTTP, "server", null, "DOMAIN\\user", password, + null, "SELECT Name, State FROM Win32_Service", + 30_000L, null, singletonList(NTLM)); +List headers = result.getHeaders(); +List> rows = result.getRows(); + +// Fluent +try (WinRMClient client = WinRMClient.builder("server") + .credentials("DOMAIN\\user", password) + .timeout(Duration.ofSeconds(30)) + .build()) { + WqlResult result = client.wql("SELECT Name, State FROM Win32_Service").execute(); + List columns = result.columns(); + for (WqlRow row : result) { + row.string("Name"); // by name, case-insensitive — no more index arithmetic + } +} +``` + +### Commands + +```java +// 1.x style (still works) +WindowsRemoteCommandResult result = WinRMCommandExecutor.execute( + "CSCRIPT c:\\collect.vbs", HTTPS, "server", null, "DOMAIN\\user", password, + null, 30_000L, List.of("c:\\collect.vbs"), null, singletonList(NTLM)); + +// Fluent +try (WinRMClient client = WinRMClient.builder("server").https() + .credentials("DOMAIN\\user", password) + .timeout(Duration.ofSeconds(30)) + .build()) { + CommandResult result = client.command("CSCRIPT c:\\collect.vbs") + .upload(Path.of("c:\\collect.vbs")) + .execute(); + result.stdout(); + result.exitCode(); +} +``` + +### What maps to what + +| 1.x | Fluent | +| --- | --- | +| `protocol` argument (`WinRMHttpProtocolEnum`) | `https()` / `http()` on the builder | +| `port` argument | `port(int)` | +| `namespace` argument | `namespace(String)` on the builder or per query | +| `timeout` in milliseconds | `timeout(Duration)` on the builder or per operation | +| `ticketCache` argument | `ticketCache(Path)` | +| `List` | `authentication(AuthScheme...)` — same ordered-fallback semantics | +| `localFileToCopyList` | `upload(Path...)` on the command | +| `-Dorg.metricshub.winrm.tls.insecure=true` | `trustAllCertificates()` per client (or `sslContext(...)` for a dedicated trust store) | +| `getHeaders()` / `getRows()` (parallel lists) | `WqlResult.columns()` / iterable `WqlRow` with lookup by property name | +| `getStatusCode()` | `CommandResult.exitCode()` | + +### Exceptions become unchecked + +The fluent API reports failures through the unchecked +[`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) +hierarchy instead of the 1.x checked exceptions — no more mandatory `try`/`catch` around every +call, and WSMan faults expose their code and detail as fields: + +| 1.x checked exception | Fluent unchecked exception | +| --- | --- | +| `WinRMException` with `Authentication error on ...` message | [`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html) | +| `WinRMException` carrying WSMan fault text | [`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) — `getFaultCode()`, `getFaultDetail()` instead of `contains()` on the message | +| `java.util.concurrent.TimeoutException` | [`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html) | +| `WqlQuerySyntaxException` | [`WqlSyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlSyntaxException.html) | + +The exception **messages are unchanged**, so code that matches on message text keeps working after +the switch. See [Timeouts and Errors](timeouts-and-errors.html) for the complete picture. diff --git a/src/site/markdown/timeouts-and-errors.md b/src/site/markdown/timeouts-and-errors.md index 3c8ac57..9348bd9 100644 --- a/src/site/markdown/timeouts-and-errors.md +++ b/src/site/markdown/timeouts-and-errors.md @@ -1,4 +1,4 @@ -keywords: timeout, exception, error, winrmexception, wsmanfault, exit code +keywords: timeout, exception, error, winrmclientexception, wsmanfault, exit code description: Timeout semantics and the exception surface of the WinRM Java Client, plus the command-line exit codes. # Timeouts and Errors @@ -7,51 +7,76 @@ description: Timeout semantics and the exception surface of the WinRM Java Clien ## Timeouts -Both `executeWql(...)` and `WinRMCommandExecutor.execute(...)` take a `timeout` in -**milliseconds**. The value must be **greater than zero** — passing `0` or a negative value throws -an `IllegalArgumentException` immediately. - -The `timeout` applies to the remote operation and to the preparatory steps the client performs — -opening the connection, detecting the remote code page, and (for commands) copying files. It is -enforced **per step**, not as a single cumulative deadline: each major step is given up to `timeout` -to complete, so a call that chains several slow steps can take longer than one `timeout` overall -before a step finally exceeds its own limit and throws `java.util.concurrent.TimeoutException`. (When -files are copied, the later stages are budgeted against the time already spent, so that path stays -close to a single overall deadline.) +Timeouts are `java.time.Duration` values and must be **at least one millisecond**. The builder's +`timeout(...)` sets the default for every operation (30 seconds when unset), and each operation +can override it: ```java -try { - executeWql(HTTP, host, null, user, password, null, query, 30_000L, null, singletonList(NTLM)); -} catch (java.util.concurrent.TimeoutException e) { - // the operation did not finish within 30 seconds +try (WinRMClient client = WinRMClient.builder("server.example.com") + .credentials("DOMAIN\\Administrator", password) + .timeout(Duration.ofSeconds(30)) // client default + .build()) { + + client.wql("SELECT * FROM Win32_NTLogEvent") + .timeout(Duration.ofMinutes(2)) // this query only + .execute(); } ``` +The timeout is a **wall-clock deadline for the whole operation**: it covers authentication (on the +first operation), every WSMan round trip, and — for commands — the file uploads and the +remote-encoding detection, all budgeted against the same deadline. When it elapses, the operation +fails with +[`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html) and +no part of it (in particular: the command itself) runs afterward. + +The timeout also drives the wire-level behavior: the WSMan `OperationTimeout` header and the +socket timeouts follow each operation's own deadline. + ## The exception surface -| Exception | Checked? | Meaning | -| --- | --- | --- | -| [`WindowsRemoteException`](apidocs/org/metricshub/winrm/exceptions/WindowsRemoteException.html) | yes | Base type for a problem on the remote host. | -| [`WinRMException`](apidocs/org/metricshub/winrm/exceptions/WinRMException.html) | yes | A WinRM/WSMan failure — authentication rejection, WMI error, protocol fault, connection or TLS problem. Extends `WindowsRemoteException`. | -| [`WqlQuerySyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlQuerySyntaxException.html) | yes | The WQL query does not match the supported `SELECT` syntax. | -| `java.util.concurrent.TimeoutException` | yes | The operation exceeded its `timeout`. | -| `java.io.IOException` | yes | Declared by `WinRMCommandExecutor.execute(...)` for I/O problems (including copied-file errors). | -| `IllegalArgumentException` | no | A mandatory argument is missing, or `timeout` is not greater than zero. | +The fluent API is **unchecked**: every failure is a +[`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html), with +subtypes for the cases worth catching specifically: + +| Exception | Meaning | +| --- | --- | +| [`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html) | The credentials were rejected (after every scheme of an ordered fallback list). | +| [`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) | The remote service answered with a WSMan fault. | +| [`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html) | The operation exceeded its timeout. | +| [`WqlSyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlSyntaxException.html) | The WQL query does not match the supported `SELECT` syntax. | +| [`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) | Base type: any other failure (connection, DNS, TLS, protocol, local I/O). | -`executeWql(...)` declares `WinRMException`, `WqlQuerySyntaxException`, and `TimeoutException`. -`WinRMCommandExecutor.execute(...)` declares `IOException`, `TimeoutException`, and -`WindowsRemoteException`. +`IllegalArgumentException` (invalid option values) and `IllegalStateException` (missing +credentials at `build()`, or an operation on a closed client) report programming errors +immediately, before anything touches the network. ### Fault detail -When the remote host returns a WSMan fault, the exception message carries the detailed `WSManFault` -text — including the provider-level detail such as WMI `WBEM_E_*` mnemonics — alongside the SOAP -reason text, so the underlying cause is visible in the message. +[`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) exposes +the fault **programmatically**, so no message parsing is needed: + +| Method | Returns | +| --- | --- | +| `getFaultCode()` | The numeric WSManFault code, e.g. `2150858778`. | +| `getFaultReason()` | The SOAP fault reason text. | +| `getFaultDetail()` | The provider-level detail — where WMI puts mnemonics such as `WBEM_E_INVALID_CLASS` or `WBEM_E_INVALID_NAMESPACE`. | +| `getHttpStatus()` | The HTTP status of the faulting response (typically 500). | + +The exception message still carries the same text as the legacy API, so message-based matching +keeps working. ### Authentication failures -A rejected credential surfaces as a `WinRMException` whose message is of the form -`Authentication error on with user name ""`. +A rejected credential surfaces as a +[`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html) +whose message has the stable form `Authentication error on with user name ""`. + +### The legacy API + +The [legacy static helpers](legacy.html) keep their historical **checked** exceptions +(`WinRMException`, `WqlQuerySyntaxException`, `TimeoutException`, `IOException`); they are +unaffected by the unchecked hierarchy above. ## Command-line exit codes diff --git a/src/site/markdown/tls.md b/src/site/markdown/tls.md index c79f841..6642cd0 100644 --- a/src/site/markdown/tls.md +++ b/src/site/markdown/tls.md @@ -1,17 +1,18 @@ -keywords: tls, https, certificate, trust store, hostname verification, insecure, self-signed -description: How the client validates TLS certificates over HTTPS, how to trust a certificate, and the insecure test-only opt-out. +keywords: tls, https, certificate, trust store, hostname verification, insecure, self-signed, sslcontext +description: How the client validates TLS certificates over HTTPS, how to trust a certificate, per-client TLS options, and the insecure test-only opt-out. # TLS / HTTPS -Use HTTPS by passing `HTTPS` as the protocol. HTTPS uses port **5986** by default (HTTP uses -**5985**); pass an explicit `port` to override either. +Use HTTPS with `https()` on the [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html) +builder. HTTPS uses port **5986** by default (HTTP uses **5985**); `port(int)` overrides either. ```java -import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTPS; - -executeWql(HTTPS, "server.example.com", null, /* ... */); +WinRMClient.builder("server.example.com") + .https() + .credentials("DOMAIN\\Administrator", password) + .build(); ``` ## Validation is on by default @@ -40,19 +41,55 @@ java -Djavax.net.ssl.trustStore=/path/to/truststore.jks \ Because the client uses the JDK default socket factory, any trust store configured this way (or the platform's default trust store) applies automatically. +### A dedicated trust store for one client + +To use a specific trust store for one client — without touching the JVM-wide configuration — pass +your own `SSLContext` to the builder. Hostname verification stays on: + +```java +KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); +try (InputStream in = Files.newInputStream(Path.of("winrm-truststore.jks"))) { + trustStore.load(in, "changeit".toCharArray()); +} +TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); +tmf.init(trustStore); +SSLContext sslContext = SSLContext.getInstance("TLS"); +sslContext.init(null, tmf.getTrustManagers(), null); + +WinRMClient client = WinRMClient.builder("server.example.com") + .https() + .credentials("DOMAIN\\Administrator", password) + .sslContext(sslContext) + .build(); +``` + ## Disabling validation (insecure — testing only) -For a self-signed test host where installing a trust store is not practical, set the system property -`org.metricshub.winrm.tls.insecure` to `true`. This trusts **all** certificates and skips hostname -verification: +For a self-signed test host where installing a trust store is not practical, +`trustAllCertificates()` on the builder trusts **all** certificates and skips hostname +verification — for that client only: + +```java +WinRMClient.builder("test-host.local") + .https() + .credentials("Administrator", password) + .trustAllCertificates() // insecure — testing only + .build(); +``` + +The JVM-wide system property `org.metricshub.winrm.tls.insecure=true` has the same effect for +every client that does not configure TLS explicitly (it is what the legacy API uses): ```bash java -Dorg.metricshub.winrm.tls.insecure=true -cp ... MyApp ``` > [!WARNING] -> This defeats the protection TLS provides against man-in-the-middle attacks. Use it only for -> testing or for isolated hosts, never in production. +> Both opt-outs defeat the protection TLS provides against man-in-the-middle attacks. Use them only +> for testing or for isolated hosts, never in production. + +`trustAllCertificates()` and `sslContext(...)` are mutually exclusive, and either one takes +precedence over the system property for that client. ## On the command line diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index 0f553ff..e3031fc 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -1,5 +1,5 @@ keywords: wql, wmi, query, win32, namespace, root cimv2, cim -description: Run WQL / WMI queries with WinRMWqlExecutor and read the result rows. +description: Run WQL / WMI queries with the fluent WinRMClient API and read the result rows. # WQL Queries @@ -8,73 +8,85 @@ description: Run WQL / WMI queries with WinRMWqlExecutor and read the result row WQL (WMI Query Language) is the SQL-like language used to query the Windows Management Instrumentation (WMI) repository. The client runs a query on the remote host and returns the rows. -## `WinRMWqlExecutor.executeWql(...)` +## Running a query -A query is executed with the static method -[`WinRMWqlExecutor.executeWql(...)`](apidocs/org/metricshub/winrm/wql/WinRMWqlExecutor.html). It -opens a connection, runs the query, collects every row, closes the connection, and returns a -[`WinRMWqlExecutor`](apidocs/org/metricshub/winrm/wql/WinRMWqlExecutor.html) holding the result. +Build a [`WinRMClient`](apidocs/org/metricshub/winrm/WinRMClient.html), then prepare the query +with `wql(...)` and run it with `execute()`: ```java -import static java.util.Collections.singletonList; -import static org.metricshub.winrm.WinRMHttpProtocolEnum.HTTP; -import static org.metricshub.winrm.service.client.auth.AuthenticationEnum.NTLM; -import static org.metricshub.winrm.wql.WinRMWqlExecutor.executeWql; - -import org.metricshub.winrm.wql.WinRMWqlExecutor; - -WinRMWqlExecutor result = executeWql( - HTTP, // protocol - "server.example.com", // hostname - null, // port (null → 5985 for HTTP, 5986 for HTTPS) - "DOMAIN\\Administrator", // username - "the-password".toCharArray(), // password - "ROOT\\CIMV2", // namespace (null → ROOT\CIMV2) - "SELECT Name, State FROM Win32_Service", // WQL query - 30_000L, // timeout in milliseconds - null, // Kerberos ticket cache (null for NTLM) - singletonList(NTLM) // authentication schemes -); +import java.time.Duration; +import org.metricshub.winrm.WinRMClient; +import org.metricshub.winrm.WqlResult; +import org.metricshub.winrm.WqlRow; + +try (WinRMClient client = WinRMClient.builder("server.example.com") + .credentials("DOMAIN\\Administrator", password) + .timeout(Duration.ofSeconds(30)) + .build()) { + + WqlResult result = client.wql("SELECT Name, State FROM Win32_Service").execute(); + + for (WqlRow row : result) { + System.out.println(row.string("Name") + " is " + row.string("State")); + } +} ``` -### Parameters +One client can run any number of queries (and [commands](commands.html)) over the same +authenticated connection — see the [Overview](index.html) for the builder options. + +### Query options -| Parameter | Type | Notes | +Everything between `wql(...)` and `execute()` is optional: + +| Option | Default | Meaning | | --- | --- | --- | -| `protocol` | [`WinRMHttpProtocolEnum`](apidocs/org/metricshub/winrm/WinRMHttpProtocolEnum.html) | `HTTP` or `HTTPS`. `null` defaults to `HTTP`. | -| `hostname` | `String` | Host name or IP address. **Mandatory.** | -| `port` | `Integer` | `null` uses the protocol default (5985 for HTTP, 5986 for HTTPS). | -| `username` | `String` | `DOMAIN\user` or `user`. **Mandatory.** See [Authentication](authentication.html). | -| `password` | `char[]` | The password. | -| `namespace` | `String` | WMI namespace. `null` or blank defaults to `ROOT\CIMV2`. Backslashes and forward slashes are both accepted. | -| `wqlQuery` | `String` | The WQL query. **Mandatory.** | -| `timeout` | `long` | Timeout in milliseconds. Must be **greater than zero** (an `IllegalArgumentException` is thrown otherwise). See [Timeouts and Errors](timeouts-and-errors.html). | -| `ticketCache` | `java.nio.file.Path` | Kerberos ticket cache path. `null` for NTLM. See [Authentication](authentication.html). | -| `authentications` | `List<`[`AuthenticationEnum`](apidocs/org/metricshub/winrm/service/client/auth/AuthenticationEnum.html)`>` | Requested schemes. `null` or empty means NTLM only. | +| `namespace(String)` | the client's namespace (`ROOT\CIMV2` unless set on the builder) | The WMI namespace to query. | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline for the whole query. See [Timeouts and Errors](timeouts-and-errors.html). | +| `pageSize(int)` | 32000 | WS-Enumeration `MaxElements`: how many rows the server may return per protocol round trip. | +| `pullTimeout(Duration)` | server default | WS-Enumeration `MaxTime`: how long the server may hold a single `Pull` open before answering with the rows it has. | + +```java +WqlResult events = client.wql("SELECT * FROM Win32_NTLogEvent") + .namespace("root\\cimv2") + .pageSize(5000) + .pullTimeout(Duration.ofSeconds(10)) + .timeout(Duration.ofMinutes(2)) + .execute(); +``` + +`pageSize` and `pullTimeout` matter for very large result sets (for example Windows event logs): +a smaller page bounds each response's size, and a pull timeout keeps the server from holding a +`Pull` open past your deadline while it gathers rows. ## Reading the result -The returned [`WinRMWqlExecutor`](apidocs/org/metricshub/winrm/wql/WinRMWqlExecutor.html) exposes: +[`WqlResult`](apidocs/org/metricshub/winrm/WqlResult.html) is immutable and iterable: | Method | Returns | Description | | --- | --- | --- | -| `getHeaders()` | `List` | The property (column) names. | -| `getRows()` | `List>` | One `List` per row, with values in the same order as the headers. | -| `getExecutionTime()` | `long` | Wall-clock time of the whole call, in milliseconds. | +| `columns()` | `List` | The property (column) names, in query order. | +| `rows()` | `List<`[`WqlRow`](apidocs/org/metricshub/winrm/WqlRow.html)`>` | The result rows. | +| `size()` / `isEmpty()` | `int` / `boolean` | Row count. | +| `elapsed()` | `java.time.Duration` | Wall-clock time of the query. | -```java -List headers = result.getHeaders(); // e.g. [Name, State] -for (List row : result.getRows()) { - System.out.println(headers + " = " + row); -} -``` +Each [`WqlRow`](apidocs/org/metricshub/winrm/WqlRow.html) exposes the instance properties: + +| Method | Returns | Description | +| --- | --- | --- | +| `string(String)` | `String` | The property value as a string, or `null`. | +| `get(String)` | `Object` | The raw property value, or `null`. | +| `asMap()` | `Map` | All properties, in server order (unmodifiable). | + +Property lookup is **case-insensitive**, matching WMI semantics: `row.string("name")` and +`row.string("Name")` return the same value. ### Property order and case -* When you select explicit properties (`SELECT Name, State FROM ...`), the headers keep the **order +* When you select explicit properties (`SELECT Name, State FROM ...`), the columns keep the **order of the query** and the **exact case reported by WMI**. * With `SELECT * FROM ...`, the properties are returned in **alphabetical order** (case-insensitive). -* If the query returns no rows, the headers fall back to the property names exactly as written in +* If the query returns no rows, the columns fall back to the property names exactly as written in the query (WMI's own casing cannot be recovered from an empty result set). ## Supported WQL syntax @@ -90,23 +102,29 @@ SELECT Name FROM Win32_Process WHERE Name = 'explorer.exe' The grammar is a single `SELECT` of either `*` or a comma-separated property list, a `FROM` clause, and an optional `WHERE` clause. Joins, sub-selects, and other advanced constructs are not part of the supported syntax. An invalid query raises a -[`WqlQuerySyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlQuerySyntaxException.html). +[`WqlSyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlSyntaxException.html) before +anything is sent to the host. ## Choosing a namespace Most Windows classes live under the default `ROOT\CIMV2` namespace. To query a different one — for -example `ROOT\Microsoft\SqlServer` or `ROOT\WMI` — pass it as the `namespace` argument. Both -`ROOT\WMI` and `ROOT/WMI` are accepted. +example `ROOT\Microsoft\SqlServer` or `ROOT\WMI` — set it per query with `namespace(...)`, or set a +client-wide default with `namespace(...)` on the builder. Both `ROOT\WMI` and `ROOT/WMI` are +accepted. ## Exceptions -`executeWql(...)` declares three checked exceptions: +`execute()` reports failures through the unchecked +[`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) +hierarchy: | Exception | When | | --- | --- | -| [`WqlQuerySyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlQuerySyntaxException.html) | The query does not match the supported `SELECT` syntax. | -| [`WinRMException`](apidocs/org/metricshub/winrm/exceptions/WinRMException.html) | Any WinRM/WSMan problem on the remote host (authentication, WMI error, protocol fault, ...). | -| `java.util.concurrent.TimeoutException` | The operation did not complete within `timeout`. | +| [`WqlSyntaxException`](apidocs/org/metricshub/winrm/exceptions/WqlSyntaxException.html) | The query does not match the supported `SELECT` syntax. | +| [`WinRMAuthenticationException`](apidocs/org/metricshub/winrm/exceptions/WinRMAuthenticationException.html) | The credentials were rejected. | +| [`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) | The remote service answered with a WSMan fault (e.g. an unknown class or namespace) — the fault code and detail are available as fields. | +| [`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html) | The query did not complete within its timeout. | +| [`WinRMClientException`](apidocs/org/metricshub/winrm/exceptions/WinRMClientException.html) | Any other failure (connection, TLS, protocol). | See [Timeouts and Errors](timeouts-and-errors.html) for the full exception surface. diff --git a/src/site/site.xml b/src/site/site.xml index 46567b1..a53060b 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -63,6 +63,7 @@ + From e21c2ea17367a9aec6a5579f3b74145b7ce2de35 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 21:02:53 +0200 Subject: [PATCH 09/10] Preserve the pinned working directory when recreating a reaped shell (Codex P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first command on a connection pins the shell's working directory; every (re)creation — including the transparent recreation after the server reaped the shell — now reuses it, instead of the current request's (usually null) value. Regression test asserts the recreated shell carries the original WorkingDirectory. Co-Authored-By: Claude Fable 5 --- .../org/metricshub/winrm/light/WsmanClient.java | 17 ++++++++++++++--- .../org/metricshub/winrm/WinRMClientTest.java | 14 ++++++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index 7bbd5ae..b2d4ac2 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -71,6 +71,13 @@ final class WsmanClient implements AutoCloseable { private String pendingAuthorization; private String shellId; + // The shell's working directory is pinned by the FIRST command on this connection and reused + // whenever the shell must be (re)created — e.g. after the server reaped it — so a recreation + // stays invisible to the caller instead of silently moving later commands to the default + // directory. Guarded by operationLock, like shellId. + private String shellWorkingDirectory; + private boolean shellWorkingDirectoryPinned; + // A single NTLM connection is a serial channel: one socket, stateful RC4 ciphers with sequence // numbers, and a single shellId. Concurrent callers (e.g. one executor shared across // threads) MUST NOT interleave, or they read each other's responses and desync the cipher streams. @@ -228,8 +235,12 @@ CommandOutput executeCommand( lockAbortably(); try { transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + if (!shellWorkingDirectoryPinned) { + shellWorkingDirectory = workingDirectory; + shellWorkingDirectoryPinned = true; + } if (shellId == null) { - createShell(workingDirectory, operationTimeoutMs); + createShell(shellWorkingDirectory, operationTimeoutMs); } final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; // The caller's timeout may have fired while the Create response was being awaited (socket @@ -244,9 +255,9 @@ CommandOutput executeCommand( } // The server reaped the cached shell between commands (e.g. its IdleTimeout expired on a // long-lived client). The Command was rejected before it could run, so it is safe to - // recreate the shell and retry once. + // recreate the shell — with its ORIGINAL working directory — and retry once. shellId = null; - createShell(workingDirectory, operationTimeoutMs); + createShell(shellWorkingDirectory, operationTimeoutMs); checkNotCancelled(); commandId = startCommand(commandLine, operationTimeoutMs); } diff --git a/src/test/java/org/metricshub/winrm/WinRMClientTest.java b/src/test/java/org/metricshub/winrm/WinRMClientTest.java index e1aaf9f..3299288 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -490,7 +490,10 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception { .enqueue(200, envelope(signalResponse())); try (WinRMClient client = builder(PASSWORD).build()) { - assertEquals("first", client.command("first.exe").charset(StandardCharsets.UTF_8).execute().stdout()); + assertEquals( + "first", + client.command("first.exe").workingDirectory("C:\\Work").charset(StandardCharsets.UTF_8).execute().stdout() + ); assertEquals("second", client.command("second.exe").charset(StandardCharsets.UTF_8).execute().stdout()); } @@ -501,8 +504,15 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception { () -> String.join("\n---\n", requests) ); // Exactly two shells were created, and second.exe was sent twice (rejected, then retried). - assertEquals(2, requests.stream().filter(r -> r.contains("")).count()); + final List creates = requests + .stream() + .filter(r -> r.contains("")) + .collect(java.util.stream.Collectors.toList()); + assertEquals(2, creates.size()); assertEquals(2, requests.stream().filter(r -> r.contains(">second.exe<")).count()); + // The recreated shell keeps the working directory pinned by the FIRST command, even though + // the retried command did not set one. + assertTrue(creates.get(1).contains("C:\\Work"), creates.get(1)); } @Test From 1a92d47cefb010163ca2c0adc81a06e0d24839b4 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Mon, 27 Jul 2026 21:10:27 +0200 Subject: [PATCH 10/10] Reject incomplete domain-qualified usernames at credentials() (Codex P2) A username with a leading or trailing backslash (empty domain or user part) now fails fast with a clear IllegalArgumentException at the setter, instead of an ArrayIndexOutOfBoundsException from the endpoint parser at build(). Co-Authored-By: Claude Fable 5 --- src/main/java/org/metricshub/winrm/WinRMClient.java | 7 +++++++ .../metricshub/winrm/WinRMClientBuilderTest.java | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index 87e4761..c04ea41 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -316,6 +316,13 @@ public Builder port(final int port) { public Builder credentials(final String username, final char[] password) { Utils.checkNonNull(username, "username"); Utils.checkNonNull(password, "password"); + // Validate the shape now (on the whitespace-stripped form the endpoint actually parses): + // a lone or edge backslash would otherwise surface as an obscure parsing error at build(). + final String cleaned = username.replaceAll("\\s", ""); + final int backslash = cleaned.indexOf('\\'); + if (cleaned.isEmpty() || backslash == 0 || backslash == cleaned.length() - 1) { + throw new IllegalArgumentException("username must be \"user\" or \"DOMAIN\\user\"."); + } this.username = username; this.password = password; return this; diff --git a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java index 9e35ae6..45882f2 100644 --- a/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java +++ b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java @@ -57,6 +57,19 @@ void credentialsAreRequired() { assertThrows(IllegalArgumentException.class, () -> builder.credentials("user", null)); } + @Test + void incompleteDomainQualifiedUsernamesAreRejected() { + final char[] password = "x".toCharArray(); + final WinRMClient.Builder builder = WinRMClient.builder("host"); + // A trailing or leading backslash means an empty user or domain part. + assertThrows(IllegalArgumentException.class, () -> builder.credentials("DOMAIN\\", password)); + assertThrows(IllegalArgumentException.class, () -> builder.credentials("\\user", password)); + assertThrows(IllegalArgumentException.class, () -> builder.credentials(" ", password)); + // The valid forms still pass. + builder.credentials("user", password); + builder.credentials("DOMAIN\\user", password); + } + @Test void portMustBeValid() { assertThrows(IllegalArgumentException.class, () -> validBuilder().port(0));