diff --git a/CHANGELOG.md b/CHANGELOG.md index c9211ac..57af684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -72,6 +72,35 @@ 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). +- 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/README.md b/README.md index e4dc2a5..c98fd4b 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,26 @@ 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. 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 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..76164e9 --- /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 (at least one millisecond) + * @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..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"; @@ -223,12 +227,120 @@ 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"); + + // 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 (!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 (drive-rooted or UNC).", 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 +367,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 +392,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..c04ea41 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -0,0 +1,459 @@ +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 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 (toMillis(duration) < 1) { + throw new IllegalArgumentException(name + " must be at least one millisecond."); + } + 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"); + // 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; + } + + /** + * 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 (at least one millisecond) + * @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/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/WqlRequest.java b/src/main/java/org/metricshub/winrm/WqlRequest.java new file mode 100644 index 0000000..3dd52e5 --- /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 (at least one millisecond) + * @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 (at least one millisecond) + * @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..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,6 +60,10 @@ final class HttpTransport implements AutoCloseable { private OutputStream out; private BufferedInputStream in; private long lastActivityMillis; + // 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) { this(host, port, timeoutMillis, null, false); @@ -75,9 +78,32 @@ 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 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 { + socket.setSoTimeout(readTimeoutMillis); + } catch (final IOException ignored) { + // the next read fails and request() re-establishes the connection + } + } } static final class Response { @@ -199,10 +225,8 @@ private void ensureConnected() throws IOException { params.setEndpointIdentificationAlgorithm("HTTPS"); 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.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 // 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..b2d4ac2 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; @@ -69,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. @@ -76,6 +85,36 @@ 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."); + } + } + + /** + * 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, @@ -115,17 +154,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(); + lockAbortably(); 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 +188,9 @@ 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"); + // 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"); context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); @@ -158,29 +215,63 @@ 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(); + lockAbortably(); try { + transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + if (!shellWorkingDirectoryPinned) { + shellWorkingDirectory = workingDirectory; + shellWorkingDirectoryPinned = true; + } if (shellId == null) { - createShell(workingDirectory); + createShell(shellWorkingDirectory, operationTimeoutMs); } final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; - final String commandId = startCommand(commandLine); + // 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(); + 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 — with its ORIGINAL working directory — and retry once. + shellId = null; + createShell(shellWorkingDirectory, operationTimeoutMs); + checkNotCancelled(); + 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 +284,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,13 +293,18 @@ 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. 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); @@ -216,7 +312,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 +326,16 @@ 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)); + // 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; } } @@ -244,11 +345,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 +410,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/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 f14414f..f4039c3 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -1,131 +1,118 @@ 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 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"); ``` -How the transfer works, and what to keep in mind: +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. `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. -* 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. +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 -`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/file-transfers.md b/src/site/markdown/file-transfers.md new file mode 100644 index 0000000..05c8116 --- /dev/null +++ b/src/site/markdown/file-transfers.md @@ -0,0 +1,172 @@ +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), 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 +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 **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). +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). 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. + +### 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 cfd50ab..13d87f0 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -55,46 +55,57 @@ 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 * [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 -* [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 c4a25f5..a53060b 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -55,6 +55,7 @@ + @@ -62,6 +63,7 @@ + diff --git a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java index c73a085..b91be90 100644 --- a/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java +++ b/src/test/java/org/metricshub/winrm/ShellFileCopyTest.java @@ -591,4 +591,115 @@ 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 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"); + 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) + ); + // 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, + () -> 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..45882f2 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/WinRMClientBuilderTest.java @@ -0,0 +1,157 @@ +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 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)); + assertThrows(IllegalArgumentException.class, () -> validBuilder().port(65536)); + } + + @Test + 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 + 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))); + assertThrows(IllegalArgumentException.class, () -> request.pullTimeout(Duration.ofNanos(500))); + } + } + + @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..3299288 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/WinRMClientTest.java @@ -0,0 +1,552 @@ +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 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 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 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 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").workingDirectory("C:\\Work").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). + 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 + 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(); + 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 ); } }