Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 64 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,23 +28,81 @@ 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
SLF4J — problems are reported through exceptions only) and is immune by construction to JAXP
`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:
Expand Down
33 changes: 33 additions & 0 deletions src/main/java/org/metricshub/winrm/AuthScheme.java
Original file line number Diff line number Diff line change
@@ -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
}
187 changes: 187 additions & 0 deletions src/main/java/org/metricshub/winrm/CommandRequest.java
Original file line number Diff line number Diff line change
@@ -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<Path> 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}:
*
* <pre>{@code
* client.command("CSCRIPT c:\\scripts\\collect.vbs")
* .upload(Path.of("c:\\scripts\\collect.vbs"))
* .execute();
* }</pre>
*
* transfers the script and executes {@code CSCRIPT <remote copy>}.
*
* @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<String> 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);
}
}
}
Loading
Loading