diff --git a/README.md b/README.md index c98fd4b..fb86fb5 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,44 @@ Per-operation options: `namespace(...)`, `timeout(...)`, and for WQL enumeration from the remote code set by default), and `upload(Path...)` to copy local script files and rewrite the command to reference the remote copies. +### Streaming + +Both operations also have a streaming terminal for large result sets and long-running commands — +everything upstream (authentication, TLS, namespace, options) is shared with the blocking +terminals: + +```java +// WQL rows are pulled from the server page by page as the stream advances: +// memory stays bounded by one page (pageSize(int)), not by the whole result set. +try (Stream rows = client.wql("SELECT * FROM Win32_NTLogEvent").stream()) { + rows.filter(r -> "Error".equals(r.string("Type"))) + .limit(100) + .forEach(System.out::println); +} + +// Commands can be consumed while they run, java.lang.Process-style: +try (RemoteProcess p = client.command("wevtutil qe System /f:text").start()) { + try (BufferedReader out = p.stdout()) { + out.lines().forEach(System.out::println); + } + int exitCode = p.waitFor(); // or waitFor(Duration) for a deadline +} + +// Middle ground: tail the output live, keep the blocking terminal and its full result. +client.command("longRunningThing.exe") + .onStdout(chunk -> log.info(chunk)) + .onStderr(chunk -> log.warn(chunk)) + .execute(); +``` + +Streams and processes **must be closed** (try-with-resources): they hold the client's serial +connection while open, and closing early releases the server-side enumeration (WS-Enumeration +`Release`) or terminates the remote command (WinRM terminate `Signal`). For the streaming +terminals the configured timeout is an **inactivity** timeout — the longest silence tolerated from +the server between two responses — not an overall deadline, so long tails can stream indefinitely. +Output is decoded incrementally: a multibyte character split across protocol chunks is decoded +correctly. + The pre-existing static helpers (`WinRMWqlExecutor.executeWql(...)`, `WinRMCommandExecutor.execute(...)`) keep working unchanged — see **Legacy API** below. @@ -129,56 +167,15 @@ java -jar target/winrm-java--standalone.jar \ exec ipconfig /all ``` -Use `--help` for the complete option list and `--version` for the build version. HTTP is the -default transport and uses port 5985; `--https` uses port 5986. `-P`/`--port` overrides either -default, and `-t`/`--timeout` sets the operation timeout in milliseconds (60,000 by default). - -NTLM is used when neither authentication flag is supplied. `--ntlm` and `--kerberos` are mutually -exclusive. Kerberos requires HTTPS. By default it uses the ambient JDK Kerberos configuration. The -CLI can instead configure the JDK for the current invocation with `--kerberos-kdc `. If no -`--kerberos-realm ` is supplied, the realm is inferred by removing the KDC hostname's first -DNS label and uppercasing the remaining suffix. For example: - -```bash -java -jar target/winrm-java--standalone.jar \ - -h server.internal.sentrysoftware.net -u 'DOMAIN\user' -pf password.txt \ - --https --kerberos --kerberos-kdc camus.internal.sentrysoftware.net \ - command whoami -``` +Use `--help` for the option list and `--version` for the build version. The CLI is built on the +streaming API: WQL rows are written to stdout as UTF-8 [JSON Lines](https://jsonlines.org/) **as +the enumeration pages arrive**, and remote command stdout and stderr are forwarded **live** to the +corresponding local streams while the command runs. Diagnostics go only to stderr, and the exit +codes are stable for scripting. -This infers `INTERNAL.SENTRYSOFTWARE.NET`. The inference follows a common Active Directory DNS -naming convention; it is not guaranteed by Kerberos. Specify `--kerberos-realm` when the realm does -not match the KDC's DNS suffix or when the KDC is not a fully qualified DNS name. Both options are -valid only with `--kerberos`, and `--kerberos-realm` requires `--kerberos-kdc`. - -HTTPS validates the certificate and hostname by default. `--https-permissive` trusts any -certificate and hostname; it is intentionally insecure and should only be used for testing or -isolated hosts. - -`-p`/`--password` is convenient for interactive use, but command-line arguments may be visible to -other local processes. Prefer `-pf`/`--password-file` for automation. Password files are decoded as -UTF-8. Exactly one final LF, CRLF, or CR is removed; all other bytes, including whitespace and -earlier line endings, are part of the password. The two password options are mutually exclusive. -If neither is supplied, the CLI securely requests the password from the interactive console without -echoing it. Non-interactive runs must use `--password-file` (or, less securely, `--password`). - -WQL writes one compact UTF-8 JSON object per row to stdout -([JSON Lines](https://jsonlines.org/)); property order follows the WinRM response. Diagnostics go -only to stderr. Remote command stdout and stderr are forwarded to the corresponding local streams. -The current backend buffers an operation's result; the CLI output boundary is ready to consume the -streaming API when it becomes available. - -Exit behavior is stable: - -| Exit code | Meaning | -| ---: | --- | -| `0` | Successful WQL query or remote command | -| `0`–`255` | Remote command exit code, when representable | -| `64` | Invalid CLI usage | -| `69` | Connection, DNS, socket, or TLS failure | -| `70` | WinRM protocol or other remote failure | -| `77` | Authentication failure | -| `124` | Operation timeout | +The full manual — options, password handling, Kerberos configuration, streaming and timeout +semantics, exit codes — is the +[Command-Line Client](https://metricshub.org/winrm-java/cli.html) page. ## Build instructions diff --git a/src/main/java/org/metricshub/winrm/AuthScheme.java b/src/main/java/org/metricshub/winrm/AuthScheme.java index cd9a99f..46b3c97 100644 --- a/src/main/java/org/metricshub/winrm/AuthScheme.java +++ b/src/main/java/org/metricshub/winrm/AuthScheme.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/ChunkDecoder.java b/src/main/java/org/metricshub/winrm/ChunkDecoder.java new file mode 100644 index 0000000..c5b7a34 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/ChunkDecoder.java @@ -0,0 +1,109 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 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.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.Charset; +import java.nio.charset.CharsetDecoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; + +/** + * Incremental, stateful charset decoding for streamed command output. A multibyte character (e.g. + * UTF-8) can be split across WSMan Stream elements or Receive responses, so each chunk is decoded + * with a decoder that carries the partial-character bytes over to the next chunk — never a + * per-chunk {@code new String(bytes)}, which would corrupt the boundary bytes into replacement + * characters. Malformed and unmappable input is replaced, matching + * {@link String#String(byte[], Charset)}, so incrementally decoding a byte sequence yields the + * same text as decoding it in one piece. + *

+ * This is a thin push-style convenience over the JDK's own {@link CharsetDecoder}, which does all + * the actual decoding. The JDK's ready-made incremental decoders ({@code InputStreamReader} and + * its underlying {@code StreamDecoder}) only fit pull-based streams — they block the caller + * until input arrives — whereas output chunks here are pushed by the protocol loop as each + * Receive response is processed; there is no public JDK type for that direction, only the + * {@link CharsetDecoder}/{@link ByteBuffer} primitives this class packages. + */ +final class ChunkDecoder { + + private final CharsetDecoder decoder; + + // Undecoded tail bytes of the previous chunk — an incomplete multibyte character — replayed in + // front of the next chunk. + private byte[] pending = new byte[0]; + + ChunkDecoder(final Charset charset) { + this.decoder = charset + .newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE); + } + + /** + * Decode the next chunk, returning the characters that are complete so far. An incomplete + * multibyte character at the end of the chunk is withheld until the next call completes it. + */ + String decode(final byte[] chunk) { + return decode(chunk, false); + } + + /** + * Flush the decoder at the end of the stream: a trailing incomplete character becomes a + * replacement character, exactly as a whole-buffer {@code new String(bytes)} would render it. + */ + String finish() { + return decode(new byte[0], true); + } + + private String decode(final byte[] chunk, final boolean endOfInput) { + final ByteBuffer in = ByteBuffer.allocate(pending.length + chunk.length); + in.put(pending); + in.put(chunk); + in.flip(); + + final StringBuilder text = new StringBuilder(); + final CharBuffer out = CharBuffer.allocate(Math.max(16, in.remaining() * 2)); + CoderResult result; + do { + result = decoder.decode(in, out, endOfInput); + out.flip(); + text.append(out); + out.clear(); + // With REPLACE in force the only non-underflow result is overflow: loop for more room. + } while (result.isOverflow()); + + if (endOfInput) { + do { + result = decoder.flush(out); + out.flip(); + text.append(out); + out.clear(); + } while (result.isOverflow()); + pending = new byte[0]; + } else { + // Whatever the decoder left in the input is an incomplete character: carry it over. + pending = new byte[in.remaining()]; + in.get(pending); + } + return text.toString(); + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandCursor.java b/src/main/java/org/metricshub/winrm/CommandCursor.java new file mode 100644 index 0000000..6445d88 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/CommandCursor.java @@ -0,0 +1,139 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 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.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; + +/** + * A cursor over the raw output of a running remote command, returned by + * {@link WindowsRemoteExecutor#startCommand(String, String, long)}. Each {@link #next()} is one + * WSMan Receive round trip yielding the output bytes exactly as the server handed them out — + * undecoded, because a multibyte character can be split across chunks; decode with a stateful + * {@link java.nio.charset.CharsetDecoder} (or accumulate the bytes and decode once at the end). + *

+ * The cursor owns the executor's serial connection until the command completes or the cursor is + * closed: no other operation can run on the same executor while the cursor is open. Completion + * (a {@code null} return from {@link #next()}) sends the protocol's terminate Signal and releases + * the connection on its own; closing earlier sends the same Signal, which actually stops the + * still-running remote command. Always close the cursor — use try-with-resources. + *

+ * A cursor is not thread-safe: advance and close it from one thread at a time. + */ +public interface CommandCursor extends AutoCloseable { + /** + * Block until the remote command produces output (or completes), for at most one + * per-round-trip timeout. + * + * @return the next chunk of raw output — possibly empty — or {@code null} once the command has + * completed; the exit code is then available from {@link #exitCode()} + * @throws TimeoutException when the command produces no output for a whole per-round-trip + * timeout (the inactivity timeout of the stream) + * @throws WindowsRemoteException for any other failure while receiving + */ + Chunk next() throws TimeoutException, WindowsRemoteException; + + /** + * Bounded variant of {@link #next()}: block at most the given wait for output. When the + * command produces nothing in that window, an empty chunk is returned — a bounded poll + * expiring is not a failure, and the cursor remains fully usable — unlike {@link #next()}, + * whose whole per-round-trip timeout counts as the stream's inactivity limit. Deadline-bounded + * waits (e.g. {@code RemoteProcess.waitFor(Duration)}) are built on this. + *

+ * The default implementation does not bound the wait: it delegates to {@link #next()}. + * + * @param maxWaitMillis how long to block at most, capped by the cursor's per-round-trip timeout + * @return the next chunk of raw output — empty when the wait elapsed first — or {@code null} + * once the command has completed + * @throws TimeoutException when the server does not even answer the bounded request + * @throws WindowsRemoteException for any other failure while receiving + */ + default Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return next(); + } + + /** + * Get the command's exit code. + * + * @return the exit code + * @throws IllegalStateException when the command has not completed yet — completion is + * observed as a {@code null} return from {@link #next()} + */ + int exitCode(); + + /** + * Terminate the command (when it is still running) with the WinRM terminate Signal and release + * the executor's connection. Idempotent; a no-op when the command already completed. After an + * early close, {@link #next()} returns {@code null} without touching the connection again (and + * no exit code is available, since the command never completed). May throw an unchecked + * {@link org.metricshub.winrm.exceptions.WinRMClientException} when the Signal itself fails — + * the remote command may then still be running. + */ + @Override + void close(); + + /** One Receive response's worth of raw output bytes, split by stream. */ + final class Chunk { + + private final byte[] stdout; + private final byte[] stderr; + + /** + * Create a chunk over the given stream bytes (not copied: a chunk is a transient carrier + * between the protocol loop and the decoder, not a retained value). + * + * @param stdout the raw stdout bytes of this chunk (possibly empty, never null) + * @param stderr the raw stderr bytes of this chunk (possibly empty, never null) + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP2", justification = "Chunks are transient carriers on the output hot path; defensive copies " + + + "would double the allocation for no benefit") + public Chunk(final byte[] stdout, final byte[] stderr) { + this.stdout = stdout; + this.stderr = stderr; + } + + /** + * Get the raw stdout bytes of this chunk. + * + * @return the stdout bytes, possibly empty + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "Chunks are transient carriers on the output hot path; defensive copies " + + + "would double the allocation for no benefit") + public byte[] stdout() { + return stdout; + } + + /** + * Get the raw stderr bytes of this chunk. + * + * @return the stderr bytes, possibly empty + */ + @SuppressFBWarnings(value = "EI_EXPOSE_REP", justification = "Chunks are transient carriers on the output hot path; defensive copies " + + + "would double the allocation for no benefit") + public byte[] stderr() { + return stderr; + } + } +} diff --git a/src/main/java/org/metricshub/winrm/CommandRequest.java b/src/main/java/org/metricshub/winrm/CommandRequest.java index 76164e9..8ab808e 100644 --- a/src/main/java/org/metricshub/winrm/CommandRequest.java +++ b/src/main/java/org/metricshub/winrm/CommandRequest.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. @@ -26,7 +26,9 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import java.util.stream.Collectors; import org.metricshub.winrm.exceptions.WinRMClientException; import org.metricshub.winrm.exceptions.WinRMTimeoutException; @@ -36,7 +38,8 @@ /** * 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. + * output and exit code, {@link #start()} returns a {@link RemoteProcess} whose output can be + * consumed while the command is still running. */ public final class CommandRequest { @@ -46,6 +49,8 @@ public final class CommandRequest { private Duration timeout; private Charset charset; private final List uploads = new ArrayList<>(); + private Consumer stdoutConsumer; + private Consumer stderrConsumer; /** * Create the request. @@ -75,8 +80,10 @@ public CommandRequest workingDirectory(final String workingDirectory) { } /** - * Set the timeout of this command — a wall-clock deadline covering file uploads, encoding - * detection, and the command itself. Default: the client's timeout. + * Set the timeout of this command. For {@link #execute()} it is a wall-clock deadline covering + * file uploads, encoding detection, and the command itself; for {@link #start()} it is an + * inactivity timeout — the longest silence tolerated from the server between two + * responses, with no overall deadline. Default: the client's timeout. * * @param timeout the timeout (at least one millisecond) * @return this request @@ -126,7 +133,48 @@ public CommandRequest upload(final Path... files) { } /** - * Execute the command and collect its complete output. + * Register a callback receiving each chunk of standard output as it arrives, while + * {@link #execute()} is still running — a middle ground between collecting everything and + * managing a {@link RemoteProcess}: tail the output live, but keep the blocking terminal and + * its complete {@link CommandResult}. + * + *

{@code
+	 * client.command("longRunningThing.exe")
+	 * 	.onStdout(chunk -> log.info(chunk))
+	 * 	.onStderr(chunk -> log.warn(chunk))
+	 * 	.execute();
+	 * }
+ * + * The callback is invoked on an internal worker thread (never concurrently), with output + * decoded incrementally: a chunk is a run of characters as the server delivered them, not + * necessarily whole lines. + * + * @param consumer the standard output consumer + * @return this request + */ + public CommandRequest onStdout(final Consumer consumer) { + Utils.checkNonNull(consumer, "consumer"); + this.stdoutConsumer = consumer; + return this; + } + + /** + * Register a callback receiving each chunk of standard error as it arrives, while + * {@link #execute()} is still running. Same contract as {@link #onStdout(Consumer)}. + * + * @param consumer the standard error consumer + * @return this request + */ + public CommandRequest onStderr(final Consumer consumer) { + Utils.checkNonNull(consumer, "consumer"); + this.stderrConsumer = consumer; + return this; + } + + /** + * Execute the command and collect its complete output. When {@link #onStdout(Consumer)} or + * {@link #onStderr(Consumer)} callbacks are registered, they additionally receive the output + * chunk by chunk while the command runs; the returned result is complete either way. * * @return the command result: stdout, stderr, exit code, and execution time * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the timeout elapses first @@ -138,50 +186,194 @@ 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") + final Prepared prepared = prepare(timeoutMillis, start); + final long remaining = TimeoutHelper.getRemainingTime( + timeoutMillis, + start, + "No time left to execute the command" + ); + + if (stdoutConsumer == null && stderrConsumer == null) { + final WindowsRemoteCommandResult result = client + .executor() + .executeCommand(prepared.command, prepared.workingDirectory, prepared.charset, remaining); + + return new CommandResult( + result.getStdout(), + result.getStderr(), + result.getStatusCode(), + Duration.ofMillis(Utils.getCurrentTimeMillis() - start) ); - actualCommand = String.format("CMD.EXE /C (%s)", updatedCommand); - actualWorkingDirectory = null; } - final Charset actualCharset = charset != null ? charset : client.detectCharset(timeoutMillis, start); + // Callback variant: drain the streaming cursor, delivering each chunk as it arrives. + // The same wall-clock deadline governs, enforced the way the blocking path enforces + // it — a worker runs the exchange and is cancelled when the deadline fires. + return Utils.execute(() -> drainWithCallbacks(prepared, remaining, start), remaining); + } catch (final TimeoutException e) { + throw timeoutException(e); + } catch (final IOException | WqlQuerySyntaxException e) { + throw new WinRMClientException(e.getMessage(), e); + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WinRMClientException(e.getMessage(), e); + } catch (final ExecutionException e) { + throw translateExecutionFailure(e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } - final WindowsRemoteCommandResult result = client + /** + * Start the command and return a {@link RemoteProcess} handle over it — the streaming + * counterpart of {@link #execute()}: stdout and stderr can be consumed while the command is + * still running, and the handle exposes the eventual exit code. + * + *
{@code
+	 * try (RemoteProcess process = client.command("wevtutil qe System /f:text").start()) {
+	 * 	try (BufferedReader out = process.stdout()) {
+	 * 		out.lines().forEach(this::process);
+	 * 	}
+	 * 	int exitCode = process.waitFor();
+	 * }
+	 * }
+ *

+ * The process must be closed — use try-with-resources. It holds the client's serial + * connection until the command completes or the handle is closed; closing early terminates + * the remote command (WinRM terminate {@code Signal}). The timeout acts as an + * inactivity timeout — see {@link RemoteProcess}. File uploads and encoding detection + * run here, before the command starts. + * + * @return the running process handle, to use with try-with-resources + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the command startup times out + * @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 RemoteProcess start() { + final long start = Utils.getCurrentTimeMillis(); + final long timeoutMillis = WinRMClient.toMillis(timeout); + try { + final Prepared prepared = prepare(timeoutMillis, start); + // The full timeout, not the remaining time: for a streaming consumer it bounds each + // round trip (inactivity), not the overall exchange the preparation steps count against. + final CommandCursor cursor = 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) - ); + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis); + return new RemoteProcess(cursor, prepared.charset, client.hostname(), timeout); } catch (final TimeoutException e) { - throw new WinRMTimeoutException( - String.format("Command timed out after %s on %s", timeout, client.hostname()), - e - ); + throw timeoutException(e); } catch (final IOException | WqlQuerySyntaxException e) { throw new WinRMClientException(e.getMessage(), e); } catch (final WindowsRemoteException e) { throw WinRMClient.translate(e); } } + + /** The command, working directory and charset actually sent, after the preparation steps. */ + private static final class Prepared { + + final String command; + final String workingDirectory; + final Charset charset; + + Prepared(final String command, final String workingDirectory, final Charset charset) { + this.command = command; + this.workingDirectory = workingDirectory; + this.charset = charset; + } + } + + /** + * Run the preparation steps shared by {@link #execute()} and {@link #start()}: copy the local + * files to the remote host (rewriting the command line to reference the remote copies) and + * resolve the output charset. + */ + private Prepared prepare(final long timeoutMillis, final long start) + throws IOException, TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + 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); + return new Prepared(actualCommand, actualWorkingDirectory, actualCharset); + } + + /** + * Drain the streaming cursor on the worker thread {@link Utils#execute} provides, delivering + * each decoded chunk to the registered callbacks and accumulating the complete output for the + * final result. Incremental decoding with a carried-over decoder state yields exactly the text + * a whole-buffer decode would. + */ + private CommandResult drainWithCallbacks(final Prepared prepared, final long timeoutMillis, final long start) + throws Exception { + final ChunkDecoder stdoutDecoder = new ChunkDecoder(prepared.charset); + final ChunkDecoder stderrDecoder = new ChunkDecoder(prepared.charset); + final StringBuilder stdout = new StringBuilder(); + final StringBuilder stderr = new StringBuilder(); + try ( + CommandCursor cursor = client.executor() + .startCommand(prepared.command, prepared.workingDirectory, timeoutMillis)) { + CommandCursor.Chunk chunk; + while ((chunk = cursor.next()) != null) { + deliver(stdoutDecoder.decode(chunk.stdout()), stdout, stdoutConsumer); + deliver(stderrDecoder.decode(chunk.stderr()), stderr, stderrConsumer); + } + deliver(stdoutDecoder.finish(), stdout, stdoutConsumer); + deliver(stderrDecoder.finish(), stderr, stderrConsumer); + return new CommandResult( + stdout.toString(), + stderr.toString(), + cursor.exitCode(), + Duration.ofMillis(Utils.getCurrentTimeMillis() - start) + ); + } + } + + /** Append a decoded chunk to the accumulated output and hand it to the callback, when any. */ + private static void deliver(final String text, final StringBuilder accumulator, final Consumer consumer) { + if (text.isEmpty()) { + return; + } + accumulator.append(text); + if (consumer != null) { + consumer.accept(text); + } + } + + /** Unwrap a worker failure from the callback variant into the documented unchecked hierarchy. */ + private RuntimeException translateExecutionFailure(final ExecutionException e) { + final Throwable cause = e.getCause() != null ? e.getCause() : e; + if (cause instanceof TimeoutException) { + return timeoutException((TimeoutException) cause); + } + if (cause instanceof RuntimeException) { + return (RuntimeException) cause; + } + if (cause instanceof Exception) { + return WinRMClient.translate((Exception) cause); + } + return new WinRMClientException(cause.getMessage(), cause); + } + + private WinRMTimeoutException timeoutException(final TimeoutException cause) { + return new WinRMTimeoutException( + String.format("Command timed out after %s on %s", timeout, client.hostname()), + cause + ); + } } diff --git a/src/main/java/org/metricshub/winrm/CommandResult.java b/src/main/java/org/metricshub/winrm/CommandResult.java index 31ca87c..4cc0f7a 100644 --- a/src/main/java/org/metricshub/winrm/CommandResult.java +++ b/src/main/java/org/metricshub/winrm/CommandResult.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/RemoteProcess.java b/src/main/java/org/metricshub/winrm/RemoteProcess.java new file mode 100644 index 0000000..a50ff41 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/RemoteProcess.java @@ -0,0 +1,306 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 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.BufferedReader; +import java.io.Reader; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; + +/** + * A running remote command, created by {@link CommandRequest#start()} — the streaming counterpart + * of {@link CommandRequest#execute()}, shaped like {@link java.lang.Process}. Output can be + * consumed while the command is still running: + * + *

{@code
+ * try (RemoteProcess process = client.command("wevtutil qe System /f:text").start()) {
+ * 	try (BufferedReader out = process.stdout()) {
+ * 		out.lines().forEach(System.out::println);
+ * 	}
+ * 	int exitCode = process.waitFor();
+ * }
+ * }
+ *

+ * Lifecycle. The process owns the client's serial connection until the command completes or + * the process is closed: other operations on the same client block in the meantime (the same + * contract as a JDBC {@code ResultSet} on its connection). Closing before completion sends the + * WinRM terminate Signal, which stops the remote command. Always close the process — use + * try-with-resources. Closing the readers returned by {@link #stdout()}/{@link #stderr()} does + * not close the process. + *

+ * Reading. Both channels are fed by the same WSMan Receive loop: reading either channel (or + * calling {@link #waitFor()}) advances the loop, and output that arrives for the channel not being + * read is buffered in memory until it is read — so memory is bounded by the unread channel, not by + * the total output. Output is decoded incrementally with the request's charset; a multibyte + * character split across protocol chunks is decoded correctly. + *

+ * Timeout. The request timeout acts as an inactivity timeout: the longest silence + * tolerated from the server between two responses, not an overall deadline — a command may run + * (and stream) far longer than the timeout as long as it keeps producing output. Reads and waits + * throw {@link WinRMTimeoutException} when the command stays silent for a whole timeout; use + * {@link #waitFor(Duration)} for an overall deadline. + *

+ * Threading. A process is not thread-safe: read, wait and close from one thread at a time. + *

+ * Failures during consumption are reported through the unchecked + * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy, including from the + * readers' {@code read()} methods. + */ +public final class RemoteProcess implements AutoCloseable { + + private final CommandCursor cursor; + private final String hostname; + private final Duration timeout; + + private final ChunkDecoder stdoutDecoder; + private final ChunkDecoder stderrDecoder; + + // Decoded output that has arrived but has not been read yet, per channel. + private final StringBuilder stdoutPending = new StringBuilder(); + private final StringBuilder stderrPending = new StringBuilder(); + + private final BufferedReader stdout; + private final BufferedReader stderr; + + // finished = no more protocol fetches may happen: the command completed OR the process was + // closed early. exitCode is non-null only when completion was actually observed. + private boolean finished; + private Integer exitCode; + + RemoteProcess(final CommandCursor cursor, final Charset charset, final String hostname, final Duration timeout) { + this.cursor = cursor; + this.hostname = hostname; + this.timeout = timeout; + this.stdoutDecoder = new ChunkDecoder(charset); + this.stderrDecoder = new ChunkDecoder(charset); + this.stdout = new BufferedReader(new ChannelReader(stdoutPending)); + this.stderr = new BufferedReader(new ChannelReader(stderrPending)); + } + + /** + * Get the standard output of the remote command, decoded incrementally: lines can be read + * while the command is still running. Always the same reader instance; closing it does not + * affect the process. + * + * @return the standard output reader + */ + public BufferedReader stdout() { + return stdout; + } + + /** + * Get the standard error of the remote command, decoded incrementally: lines can be read + * while the command is still running. Always the same reader instance; closing it does not + * affect the process. + * + * @return the standard error reader + */ + public BufferedReader stderr() { + return stderr; + } + + /** + * Wait for the command to complete, buffering any unread output in the meantime (read it + * afterward from {@link #stdout()}/{@link #stderr()}). + * + * @return the command's exit code + * @throws WinRMTimeoutException when the command stays silent for a whole inactivity timeout + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized int waitFor() { + while (!finished) { + fetchOnce(); + } + return exitCodeValue(); + } + + /** + * Wait at most the given duration for the command to complete — an overall deadline, on top of + * the per-response inactivity timeout. The remaining wait is a hard bound on the active + * protocol round trip: the server is asked to answer early enough for its reply to arrive + * within it, and a wait too short for any network round trip is waited out locally without + * touching the wire. Expiry does not affect the command: it keeps running, the process stays + * fully usable, and the caller decides whether to keep waiting or {@link #close()}. + * + * @param deadline how long to wait (at least one millisecond) + * @return {@code true} when the command completed within the given duration — the exit code is + * then available from {@link #exitCode()} — {@code false} when the wait expired first + * @throws WinRMTimeoutException when the server does not answer the bounded requests within + * the remaining wait — a peer that stopped answering cannot hold the wait past its + * deadline + * @throws org.metricshub.winrm.exceptions.WinRMClientException for any other failure + */ + public synchronized boolean waitFor(final Duration deadline) { + WinRMClient.checkPositive(deadline, "deadline"); + final long deadlineMillis = WinRMClient.toMillis(deadline); + final long start = Utils.getCurrentTimeMillis(); + long remaining = deadlineMillis; + while (!finished && remaining > 0) { + absorb(advance(remaining)); + remaining = deadlineMillis - (Utils.getCurrentTimeMillis() - start); + } + if (!finished) { + try { + // The last absorbed chunk may have carried completion right as the deadline ran out: + // the command DID complete within the wait, so report that rather than a spurious + // expiry. exitCode() answers from local state; the follow-up advance then completes + // without waiting (its bounded cleanup cannot block on a 1 ms budget). + cursor.exitCode(); + absorb(advance(1)); + } catch (final IllegalStateException ignored) { + // Genuinely still running: the wait expired. + } + } + if (finished && exitCode == null) { + throw new IllegalStateException("The process was closed before the command completed."); + } + return finished; + } + + /** + * Get the exit code of the completed command. + * + * @return the exit code + * @throws IllegalStateException when the command has not completed yet — wait for completion + * with {@link #waitFor()}, or read the output streams to their end first — or when the + * process was closed before the command completed + */ + public synchronized int exitCode() { + return exitCodeValue(); + } + + /** + * Terminate the command (when it is still running) and release the client's connection. + * Idempotent; a no-op when the command already completed. Output buffered before the close + * remains readable, then the readers report end of stream; {@link #waitFor()} and + * {@link #exitCode()} throw {@link IllegalStateException} when the close preceded completion — + * a terminated command has no exit code. + */ + @Override + public synchronized void close() { + if (!finished) { + // No protocol fetch may happen after the close: the cursor signals the command and + // releases the connection, so this handle must never touch it again. The decoders are + // flushed so a trailing partial character surfaces (as a replacement) instead of vanishing. + finished = true; + try { + // The command may in fact have completed (its final chunk was received) without this + // handle having observed the end-of-stream fetch: the exit code is then already known. + exitCode = cursor.exitCode(); + } catch (final IllegalStateException ignored) { + // Genuinely closed before completion: there is no exit code. + } + stdoutPending.append(stdoutDecoder.finish()); + stderrPending.append(stderrDecoder.finish()); + } + cursor.close(); + } + + /** The observed exit code, or the explanation of why there is none. */ + private int exitCodeValue() { + if (exitCode == null) { + throw new IllegalStateException( + finished ? "The process was closed before the command completed." : "The command has not completed yet." + ); + } + return exitCode; + } + + /** One Receive round trip: decode what arrived into the per-channel buffers. Holds the monitor. */ + private void fetchOnce() { + absorb(advance(-1)); + } + + /** + * One protocol round trip: unbounded ({@code maxWaitMillis < 0}, the inactivity timeout + * governs) or bounded to the given wait (a deadline-driven poll whose expiry is an empty + * chunk, not a failure). Holds the monitor. + */ + private CommandCursor.Chunk advance(final long maxWaitMillis) { + try { + return maxWaitMillis < 0 ? cursor.next() : cursor.poll(maxWaitMillis); + } catch (final TimeoutException e) { + throw new WinRMTimeoutException( + String.format("Command produced no output within %s on %s", timeout, hostname), + e + ); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + } + + /** Absorb one round trip's outcome into the process state. Holds the monitor. */ + private void absorb(final CommandCursor.Chunk chunk) { + if (chunk == null) { + finished = true; + exitCode = cursor.exitCode(); + stdoutPending.append(stdoutDecoder.finish()); + stderrPending.append(stderrDecoder.finish()); + } else { + stdoutPending.append(stdoutDecoder.decode(chunk.stdout())); + stderrPending.append(stderrDecoder.decode(chunk.stderr())); + } + } + + /** Serve a read from the channel's buffer, advancing the Receive loop while it is empty. */ + private synchronized int read(final StringBuilder pending, final char[] cbuf, final int off, final int len) { + while (pending.length() == 0 && !finished) { + fetchOnce(); + } + if (pending.length() == 0) { + return -1; + } + final int count = Math.min(len, pending.length()); + pending.getChars(0, count, cbuf, off); + pending.delete(0, count); + return count; + } + + /** + * One output channel as a {@link Reader}. Reading drives the shared Receive loop; whatever + * arrives for the other channel in the meantime is buffered there. + */ + private final class ChannelReader extends Reader { + + private final StringBuilder pending; + + private ChannelReader(final StringBuilder pending) { + this.pending = pending; + } + + @Override + public int read(final char[] cbuf, final int off, final int len) { + if (len == 0) { + return 0; + } + return RemoteProcess.this.read(pending, cbuf, off, len); + } + + @Override + public void close() { + // Closing a reader does not close the process: the RemoteProcess owns the lifecycle, so + // each reader can sit in its own try-with-resources while the process lives on. + } + } +} diff --git a/src/main/java/org/metricshub/winrm/WinRMClient.java b/src/main/java/org/metricshub/winrm/WinRMClient.java index c04ea41..ee3e845 100644 --- a/src/main/java/org/metricshub/winrm/WinRMClient.java +++ b/src/main/java/org/metricshub/winrm/WinRMClient.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. @@ -60,8 +60,14 @@ * } * } *

+ * Besides the blocking {@code execute()} terminals, both operations can stream: + * {@link WqlRequest#stream()} yields WQL rows lazily page by page, and + * {@link CommandRequest#start()} returns a {@link RemoteProcess} whose output is consumed while + * the command is still running. + *

* 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. + * channel — concurrent operations are executed one at a time, and an open stream or process + * holds the connection until it is closed. *

* Failures are reported through the unchecked * {@link org.metricshub.winrm.exceptions.WinRMClientException} hierarchy; the legacy static diff --git a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java index 8da56f4..32dfd34 100644 --- a/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java +++ b/src/main/java/org/metricshub/winrm/WindowsRemoteExecutor.java @@ -86,6 +86,66 @@ default List> executeWql( ); } + /** + *

+ * Start a WQL enumeration and return a lazy {@link WqlCursor} over its rows: rows can be + * consumed as the WS-Enumeration pages arrive, and memory stays bounded by one page. + *

+ *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support streaming (such as the built-in lightweight backend) implement this method. + *

+ * + * @param namespace the WMI namespace to query, e.g. {@code ROOT\CIMV2} (required) + * @param wqlQuery the WQL query (required) + * @param timeout timeout in milliseconds of each WSMan round trip — the inactivity timeout of + * the stream, not an overall deadline (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 cursor over the result rows, owning the executor's connection until exhausted or + * closed — always close it (try-with-resources) + * @throws TimeoutException when the server does not answer the initial Enumerate in time + * @throws WqlQuerySyntaxException if WQL query syntax is invalid + * @throws WindowsRemoteException For any problem encountered + */ + default WqlCursor streamWql( + 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 streaming WQL enumeration."); + } + + /** + *

+ * Start a command on the remote host and return a {@link CommandCursor} over its raw output: + * chunks can be consumed as the WSMan Receive responses arrive, before the command exits. + *

+ *

+ * The default implementation throws {@link UnsupportedOperationException}: only executors that + * support streaming (such as the built-in lightweight backend) implement this method. + *

+ * + * @param command The command to execute + * @param workingDirectory Path of the directory for the spawned process on the remote system (can be null) + * @param timeout timeout in milliseconds of each WSMan round trip — the inactivity timeout of + * the stream, not an overall deadline (throws an IllegalArgumentException if negative + * or zero) + * @return a cursor over the command output, owning the executor's connection until the command + * completes or the cursor is closed — always close it (try-with-resources) + * @throws TimeoutException when the server does not answer the command startup in time + * @throws WindowsRemoteException For any problem encountered + */ + default CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) + throws TimeoutException, WindowsRemoteException { + throw new UnsupportedOperationException(getClass().getName() + " does not support streaming command execution."); + } + /** * Execute the command on the remote * diff --git a/src/main/java/org/metricshub/winrm/WqlCursor.java b/src/main/java/org/metricshub/winrm/WqlCursor.java new file mode 100644 index 0000000..c8936d3 --- /dev/null +++ b/src/main/java/org/metricshub/winrm/WqlCursor.java @@ -0,0 +1,62 @@ +package org.metricshub.winrm; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * WinRM Java Client + * ჻჻჻჻჻჻ + * Copyright (C) 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.Map; +import java.util.concurrent.TimeoutException; +import org.metricshub.winrm.exceptions.WindowsRemoteException; + +/** + * A lazily-advancing cursor over the rows of a WQL enumeration, returned by + * {@link WindowsRemoteExecutor#streamWql(String, String, long, int, long)}. Rows are parsed and + * served page by page: advancing past the current WS-Enumeration page issues the next Pull + * request, so memory stays bounded by one page rather than the whole result set. + *

+ * The cursor owns the executor's serial connection until it is exhausted or closed: no other + * operation can run on the same executor while the cursor is open (the same contract as a JDBC + * {@code ResultSet} on its connection). Exhaustion releases the connection on its own; closing + * before the end additionally sends a WS-Enumeration Release so the server frees the enumeration + * context immediately. Always close the cursor — use try-with-resources. + *

+ * A cursor is not thread-safe: advance and close it from one thread at a time. + */ +public interface WqlCursor extends AutoCloseable { + /** + * Advance to the next row, issuing the next WS-Enumeration Pull when the current page is + * exhausted. + * + * @return the next row as an ordered property map, or {@code null} once the enumeration is + * exhausted + * @throws TimeoutException when the server stays silent for a whole per-round-trip timeout + * (the inactivity timeout of the stream) + * @throws WindowsRemoteException for any other failure while pulling + */ + Map next() throws TimeoutException, WindowsRemoteException; + + /** + * Release the enumeration and the executor's connection. When the enumeration is not + * exhausted, a best-effort WS-Enumeration Release tells the server to free the enumeration + * context. Idempotent, and never throws: releasing the context is a courtesy the server can + * also handle on its own timeout. + */ + @Override + void close(); +} diff --git a/src/main/java/org/metricshub/winrm/WqlRequest.java b/src/main/java/org/metricshub/winrm/WqlRequest.java index 3dd52e5..0d35ebe 100644 --- a/src/main/java/org/metricshub/winrm/WqlRequest.java +++ b/src/main/java/org/metricshub/winrm/WqlRequest.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. @@ -23,8 +23,13 @@ import java.time.Duration; import java.util.List; import java.util.Map; +import java.util.Spliterator; +import java.util.Spliterators; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; import org.metricshub.winrm.exceptions.WinRMTimeoutException; import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; @@ -33,7 +38,7 @@ /** * 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. + * complete result, {@link #stream()} yields the rows lazily as they arrive. */ public final class WqlRequest { @@ -72,8 +77,10 @@ public WqlRequest namespace(final String namespace) { } /** - * Set the timeout of this query — a wall-clock deadline covering every WSMan round trip and - * result collection. Default: the client's timeout. + * Set the timeout of this query. For {@link #execute()} it is a wall-clock deadline covering + * every WSMan round trip and result collection; for {@link #stream()} it is an + * inactivity timeout — the longest silence tolerated from the server between two + * responses, with no overall deadline. Default: the client's timeout. * * @param timeout the timeout (at least one millisecond) * @return this request @@ -144,4 +151,79 @@ public WqlResult execute() { throw WinRMClient.translate(e); } } + + /** + * Execute the query and stream the rows lazily: each row is yielded as soon as it is parsed, + * and the next WS-Enumeration page is pulled from the server only as the stream advances — + * memory stays bounded by one page ({@link #pageSize(int)}) instead of the whole result set. + * + *

{@code
+	 * try (Stream rows = client.wql("SELECT * FROM Win32_NTLogEvent").stream()) {
+	 * 	rows.filter(row -> "Error".equals(row.string("Type"))).limit(100).forEach(this::process);
+	 * }
+	 * }
+ *

+ * The stream must be closed (same contract as {@link java.nio.file.Files#lines}) — use + * try-with-resources. It holds the client's serial connection while open: other operations on + * the same client block until it is closed or exhausted. Closing before the last row tells the + * server to release the enumeration immediately (WS-Enumeration {@code Release}). + *

+ * The timeout acts as an inactivity timeout — the longest silence tolerated from the + * server between two responses — not an overall deadline: consuming a large result can take + * arbitrarily long as long as the server keeps answering. The initial request is sent here; + * later pages are fetched during consumption, so the exceptions below can also be thrown from + * the stream's operations while iterating. + * + * @return a lazy, sequential stream of rows, to use with try-with-resources + * @throws WqlSyntaxException when the WQL query is invalid + * @throws org.metricshub.winrm.exceptions.WinRMTimeoutException when the inactivity timeout + * elapses + * @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 Stream stream() { + final long timeoutMillis = WinRMClient.toMillis(timeout); + final long pullTimeoutMillis = pullTimeout != null ? WinRMClient.toMillis(pullTimeout) : 0; + final WqlCursor cursor; + try { + cursor = client.executor().streamWql(namespace, query, timeoutMillis, pageSize, pullTimeoutMillis); + } catch (final TimeoutException e) { + throw timeoutException(e); + } catch (final WqlQuerySyntaxException e) { + throw new WqlSyntaxException(e.getMessage(), e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + + final Spliterator spliterator = new Spliterators.AbstractSpliterator( + Long.MAX_VALUE, + Spliterator.ORDERED | Spliterator.NONNULL | Spliterator.IMMUTABLE + ) { + @Override + public boolean tryAdvance(final Consumer action) { + final Map row; + try { + row = cursor.next(); + } catch (final TimeoutException e) { + throw timeoutException(e); + } catch (final WindowsRemoteException e) { + throw WinRMClient.translate(e); + } + if (row == null) { + return false; + } + action.accept(new WqlRow(row)); + return true; + } + }; + return StreamSupport.stream(spliterator, false).onClose(cursor::close); + } + + private WinRMTimeoutException timeoutException(final TimeoutException cause) { + return new WinRMTimeoutException( + String.format("WQL query timed out after %s on %s", timeout, client.hostname()), + cause + ); + } } diff --git a/src/main/java/org/metricshub/winrm/WqlResult.java b/src/main/java/org/metricshub/winrm/WqlResult.java index e02787c..bf001a7 100644 --- a/src/main/java/org/metricshub/winrm/WqlResult.java +++ b/src/main/java/org/metricshub/winrm/WqlResult.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/WqlRow.java b/src/main/java/org/metricshub/winrm/WqlRow.java index 1582114..db24afb 100644 --- a/src/main/java/org/metricshub/winrm/WqlRow.java +++ b/src/main/java/org/metricshub/winrm/WqlRow.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java index 41bee70..de89e65 100644 --- a/src/main/java/org/metricshub/winrm/cli/WinRmCli.java +++ b/src/main/java/org/metricshub/winrm/cli/WinRmCli.java @@ -27,23 +27,30 @@ import java.net.NoRouteToHostException; import java.net.SocketException; import java.net.UnknownHostException; -import java.nio.charset.Charset; +import java.time.Duration; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.stream.Stream; import javax.net.ssl.SSLException; -import org.metricshub.winrm.WindowsRemoteCommandResult; -import org.metricshub.winrm.WindowsRemoteProcessUtils; -import org.metricshub.winrm.exceptions.WindowsRemoteException; -import org.metricshub.winrm.light.LightWinRMService; -import org.metricshub.winrm.service.WinRMEndpoint; +import org.metricshub.winrm.AuthScheme; +import org.metricshub.winrm.WinRMClient; +import org.metricshub.winrm.WinRMHttpProtocolEnum; +import org.metricshub.winrm.WqlRow; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.service.client.auth.AuthenticationEnum; /** - * Command-line interface for WQL queries and remote command execution through WinRM. + * Command-line interface for WQL queries and remote command execution through WinRM, built on the + * streaming terminals of the fluent {@link WinRMClient} API. *

- * WQL results are emitted as UTF-8 JSON Lines on standard output. Remote command output is - * forwarded to the matching local output stream. Diagnostics are written only to standard error. + * WQL results are emitted as UTF-8 JSON Lines on standard output, row by row as the + * WS-Enumeration pages arrive — a large query starts producing output immediately and memory + * stays bounded, but a mid-stream failure can leave partial output on standard output (with a + * nonzero exit code). Remote command output is forwarded live to the matching local output + * stream while the command runs. Diagnostics are written only to standard error. *

* NTLM is the default authentication scheme. Kerberos requires HTTPS. HTTPS validates certificates * and hostnames unless the explicitly insecure {@code --https-permissive} option is used. @@ -63,7 +70,6 @@ public final class WinRmCli { static final int EXIT_AUTHENTICATION = 77; static final int EXIT_TIMEOUT = 124; - private static final String INSECURE_TLS_PROPERTY = "org.metricshub.winrm.tls.insecure"; private static final String KERBEROS_KDC_PROPERTY = "java.security.krb5.kdc"; private static final String KERBEROS_REALM_PROPERTY = "java.security.krb5.realm"; @@ -140,29 +146,40 @@ private static int execute( final PrintStream standardError, final RemoteFactory remoteFactory ) { - final String previousInsecureTls = System.getProperty(INSECURE_TLS_PROPERTY); final String previousKerberosKdc = System.getProperty(KERBEROS_KDC_PROPERTY); final String previousKerberosRealm = System.getProperty(KERBEROS_REALM_PROPERTY); try { - setPermissiveHttps(arguments.permissiveHttps()); setKerberosConfiguration(arguments, standardError); try (RemoteOperations remote = remoteFactory.connect(arguments)) { if (arguments.operation() == CliArguments.Operation.WQL) { - final List> rows = remote.executeWql(arguments.input(), arguments.timeout()); - for (final Map row : rows) { - JsonLinesWriter.write(row, standardOutput); - } - standardOutput.flush(); + // Flush after every row so a downstream pipe sees each row as soon as the server + // hands it out, not when the enumeration ends. + remote.streamWql( + arguments.input(), + arguments.timeout(), + row -> { + JsonLinesWriter.write(row, standardOutput); + standardOutput.flush(); + } + ); return 0; } - final WindowsRemoteCommandResult result = remote.executeCommand(arguments.input(), arguments.timeout()); - standardOutput.print(result.getStdout()); - standardError.print(result.getStderr()); - standardOutput.flush(); - standardError.flush(); - return remoteExitCode(result.getStatusCode(), standardError); + // Forward each output chunk as it arrives, so a long-running command can be followed live. + final int exitCode = remote.executeCommand( + arguments.input(), + arguments.timeout(), + chunk -> { + standardOutput.print(chunk); + standardOutput.flush(); + }, + chunk -> { + standardError.print(chunk); + standardError.flush(); + } + ); + return remoteExitCode(exitCode, standardError); } - } catch (final TimeoutException e) { + } catch (final TimeoutException | WinRMTimeoutException e) { diagnostic(standardError, "operation timed out"); return EXIT_TIMEOUT; } catch (final Exception e) { @@ -170,24 +187,35 @@ private static int execute( diagnostic(standardError, safeMessage(e)); return exitCode; } finally { - restoreProperty(INSECURE_TLS_PROPERTY, previousInsecureTls); restoreProperty(KERBEROS_KDC_PROPERTY, previousKerberosKdc); restoreProperty(KERBEROS_REALM_PROPERTY, previousKerberosRealm); } } - private static RemoteOperations connect(final CliArguments arguments) throws WindowsRemoteException { - final WinRMEndpoint endpoint = new WinRMEndpoint( - arguments.protocol(), - arguments.hostname(), - arguments.port(), - arguments.username(), - arguments.password(), - null - ); - return new LightRemoteOperations( - LightWinRMService.createInstance(endpoint, arguments.timeout(), null, arguments.authentications()) - ); + static RemoteOperations connect(final CliArguments arguments) { + final WinRMClient.Builder builder = WinRMClient + .builder(arguments.hostname()) + .port(arguments.port()) + .credentials(arguments.username(), arguments.password()) + .timeout(Duration.ofMillis(arguments.timeout())); + if (arguments.protocol() == WinRMHttpProtocolEnum.HTTPS) { + builder.https(); + } + if (arguments.permissiveHttps()) { + // Per-client setting: unlike the legacy org.metricshub.winrm.tls.insecure system + // property, it does not leak to (or race with) anything else in the JVM. + builder.trustAllCertificates(); + } + final List authentications = arguments.authentications(); + if (authentications != null && !authentications.isEmpty()) { + builder.authentication( + authentications + .stream() + .map(scheme -> scheme == AuthenticationEnum.KERBEROS ? AuthScheme.KERBEROS : AuthScheme.NTLM) + .toArray(AuthScheme[]::new) + ); + } + return new FluentRemoteOperations(builder.build()); } private static int remoteExitCode(final int exitCode, final PrintStream standardError) { @@ -238,12 +266,6 @@ private static void diagnostic(final PrintStream standardError, final String mes standardError.println("winrm-java: " + message); } - private static void setPermissiveHttps(final boolean permissive) { - if (permissive) { - System.setProperty(INSECURE_TLS_PROPERTY, Boolean.TRUE.toString()); - } - } - private static void setKerberosConfiguration( final CliArguments arguments, final PrintStream standardError @@ -300,15 +322,9 @@ private static String help() { " --version Show the project version\n" + "\n" + "If neither password option is given, the password is requested from the interactive console.\n" + - "The password options are mutually exclusive. Password files have one final\n" + - "LF, CRLF, or CR removed; every other byte is part of the UTF-8 password.\n" + - "Kerberos KDC/realm options set the JDK Kerberos configuration for this invocation.\n" + - "Without them, the ambient JDK Kerberos configuration is used.\n" + - "WQL rows are written to stdout as UTF-8 JSON Lines. Command stdout and stderr are forwarded\n" + - "to the corresponding local streams.\n" + "\n" + - "Exit codes: 0 success; remote command code 0..255 when available; 64 usage;\n" + - "69 connection/TLS; 70 WinRM protocol; 77 authentication; 124 timeout.\n"; + "Full manual - streaming behavior, password files, Kerberos, exit codes:\n" + + " https://metricshub.org/winrm-java/cli.html\n"; } @FunctionalInterface @@ -322,36 +338,55 @@ interface PasswordReader { } interface RemoteOperations extends AutoCloseable { - List> executeWql(String query, long timeout) throws Exception; + /** Run the WQL query, handing each row to the consumer as it arrives. */ + void streamWql(String query, long timeout, Consumer> rowConsumer) throws Exception; - WindowsRemoteCommandResult executeCommand(String command, long timeout) throws Exception; + /** + * Run the command, forwarding each decoded output chunk to the matching consumer as it + * arrives, and return the remote exit code. + */ + int executeCommand(String command, long timeout, Consumer stdoutConsumer, Consumer stderrConsumer) + throws Exception; @Override void close(); } - static final class LightRemoteOperations implements RemoteOperations { + /** The real remote operations: the streaming terminals of the fluent {@link WinRMClient}. */ + static final class FluentRemoteOperations implements RemoteOperations { - private final LightWinRMService service; + private final WinRMClient client; - LightRemoteOperations(final LightWinRMService service) { - this.service = service; + FluentRemoteOperations(final WinRMClient client) { + this.client = client; } @Override - public List> executeWql(final String query, final long timeout) throws Exception { - return service.executeWql(query, timeout); + public void streamWql(final String query, final long timeout, final Consumer> rowConsumer) { + try (Stream rows = client.wql(query).timeout(Duration.ofMillis(timeout)).stream()) { + rows.forEach(row -> rowConsumer.accept(row.asMap())); + } } @Override - public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { - final Charset charset = WindowsRemoteProcessUtils.getWindowsEncodingCharset(service, timeout); - return service.executeCommand(command, null, charset, timeout); + public int executeCommand( + final String command, + final long timeout, + final Consumer stdoutConsumer, + final Consumer stderrConsumer + ) { + return client + .command(command) + .timeout(Duration.ofMillis(timeout)) + .onStdout(stdoutConsumer) + .onStderr(stderrConsumer) + .execute() + .exitCode(); } @Override public void close() { - service.close(); + client.close(); } } } diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java index 4bdaf1b..d155cdd 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMAuthenticationException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java index dbc2115..2758de3 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMClientException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java index 45d393e..e72a00b 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMFaultException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java b/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java index 6656d73..357de45 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WinRMTimeoutException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java b/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java index 781bf6c..da1c84d 100644 --- a/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java +++ b/src/main/java/org/metricshub/winrm/exceptions/WqlSyntaxException.java @@ -4,7 +4,7 @@ * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ * WinRM Java Client * ჻჻჻჻჻჻ - * Copyright 2023 - 2026 MetricsHub + * Copyright (C) 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. diff --git a/src/main/java/org/metricshub/winrm/light/Envelopes.java b/src/main/java/org/metricshub/winrm/light/Envelopes.java index 8332c4b..11a650f 100644 --- a/src/main/java/org/metricshub/winrm/light/Envelopes.java +++ b/src/main/java/org/metricshub/winrm/light/Envelopes.java @@ -28,7 +28,7 @@ /** * WS-Management SOAP envelope templates — the only "WSDL" the light client needs. - * Covers Identify, WQL Enumerate/Pull, and the command shell lifecycle + * Covers Identify, WQL enumeration (Enumerate / Pull / Release), and the command shell lifecycle * (Create / Command / Receive / Signal / Delete). */ final class Envelopes { @@ -42,6 +42,7 @@ final class Envelopes { private static final String ACTION_ENUMERATE = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Enumerate"; private static final String ACTION_PULL = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Pull"; + private static final String ACTION_RELEASE = "http://schemas.xmlsoap.org/ws/2004/09/enumeration/Release"; private static final String ACTION_CREATE = "http://schemas.xmlsoap.org/ws/2004/09/transfer/Create"; private static final String ACTION_DELETE = "http://schemas.xmlsoap.org/ws/2004/09/transfer/Delete"; private static final String ACTION_COMMAND = RSP + "/Command"; @@ -102,6 +103,19 @@ static String pull( ""; } + static String release(final String url, final String namespace, final String context, final long timeoutMs) { + // WS-Enumeration Release: tells the server to discard an enumeration context that will not be + // pulled to its end, freeing the server-side operation slot immediately instead of waiting for + // its idle timeout. + return envelopeOpen(false) + + header(url, wmiResourceUri(namespace), ACTION_RELEASE, timeoutMs, null, null) + + "" + + "" + + escape(context) + + "" + + ""; + } + // --- Command shell ----------------------------------------------------- static String createShell(final String url, final String workingDirectory, final long timeoutMs) { diff --git a/src/main/java/org/metricshub/winrm/light/HttpTransport.java b/src/main/java/org/metricshub/winrm/light/HttpTransport.java index b26a27d..91339c0 100644 --- a/src/main/java/org/metricshub/winrm/light/HttpTransport.java +++ b/src/main/java/org/metricshub/winrm/light/HttpTransport.java @@ -65,6 +65,16 @@ final class HttpTransport implements AutoCloseable { private int connectTimeoutMillis; private int readTimeoutMillis; + // Absolute bound (epoch ms, 0 = none) on every socket wait while a deadline-bounded poll is + // active — see pollTimeout(int). Cleared by the other timeout modes. + private long deadlineEpochMillis; + + // Streaming (inactivity) mode: every request leg (re)arms the absolute deadline for itself, so + // one WHOLE response — not each of its reads — is bounded by the inactivity timeout. SO_TIMEOUT + // alone restarts on every byte, and a peer trickling an endless incomplete response would + // otherwise hold a streaming fetch forever. + private boolean deadlinePerLeg; + HttpTransport(final String host, final int port, final int timeoutMillis) { this(host, port, timeoutMillis, null, false); } @@ -87,25 +97,82 @@ final class HttpTransport implements AutoCloseable { } /** - * 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. + * Align the socket timeouts with the current blocking 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 the Receive loop retries on reliably arrives before the + * socket read gives up — the blocking paths are bounded by their caller's wall-clock deadline, + * not by the socket). 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; + applyTimeouts(operationTimeoutMillis, operationTimeoutMillis + 10_000, 0, false); + } + + /** + * Socket timeouts for one deadline-bounded poll round trip: the budget is the deadline itself + * — no headroom on top, or a peer that stopped answering could hold a deadline-bounded wait + * past its advertised bound (the caller carves the fault-transit slack out of the INSIDE of + * the budget instead, by asking the server to answer earlier than the budget). + *

+ * The budget also becomes an ABSOLUTE deadline shared by every socket operation until the + * next timeout-mode switch: one poll may span several HTTP round trips (a dropped connection + * forces a reconnect and a whole re-authentication exchange), and each leg must only get what + * is left of the budget — not a fresh full timeout each, which would let a slow peer stretch + * a deadline-bounded wait to several multiples of the requested duration. + * + * @param budgetMillis the poll's whole budget in milliseconds + */ + void pollTimeout(final int budgetMillis) { + applyTimeouts(budgetMillis, budgetMillis, Utils.getCurrentTimeMillis() + budgetMillis, false); + } + + /** + * Align the socket timeouts with a STREAMING operation's inactivity timeout. Unlike + * {@link #operationTimeout(int)} the read timeout gets NO headroom: the streaming paths have + * no outer wall-clock timer, and a read timeout there means "the server stayed silent too + * long" — so the socket must give up at the inactivity bound itself, not ten seconds later. + * A server that enforces the WSMan OperationTimeout by answering with the op-timeout fault + * reaches the caller through that fault instead; both surface as the same timeout. + *

+ * The bound is absolute per request leg (armed at the start of each {@link #post}): one whole + * response must arrive within the inactivity timeout — a peer trickling bytes must not restart + * the clock with every byte and hold a streaming fetch forever. + * + * @param inactivityTimeoutMillis the longest tolerated silence in milliseconds + */ + void inactivityTimeout(final int inactivityTimeoutMillis) { + applyTimeouts(inactivityTimeoutMillis, inactivityTimeoutMillis, 0, true); + } + + private void applyTimeouts(final int connectMillis, final int readMillis, final long deadline, final boolean perLeg) { + deadlineEpochMillis = deadline; + deadlinePerLeg = perLeg; + connectTimeoutMillis = connectMillis; + readTimeoutMillis = readMillis; if (socket != null && !socket.isClosed()) { try { - socket.setSoTimeout(readTimeoutMillis); + socket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); } catch (final IOException ignored) { // the next read fails and request() re-establishes the connection } } } + /** + * Cap a configured timeout by what is left of the poll deadline, when one is active. The 1 ms + * floor keeps an already-expired deadline from disabling the timeout (0 would mean "infinite" + * to a socket): the next blocking operation then fails almost immediately instead. + */ + private int boundedByDeadline(final int timeoutMillis) { + if (deadlineEpochMillis == 0) { + return timeoutMillis; + } + final long remaining = deadlineEpochMillis - Utils.getCurrentTimeMillis(); + return (int) Math.max(1, Math.min(timeoutMillis, remaining)); + } + static final class Response { final int status; @@ -225,8 +292,8 @@ private void ensureConnected() throws IOException { params.setEndpointIdentificationAlgorithm("HTTPS"); sslSocket.setSSLParameters(params); } - newSocket.connect(new InetSocketAddress(host, port), connectTimeoutMillis); - newSocket.setSoTimeout(readTimeoutMillis); + newSocket.connect(new InetSocketAddress(host, port), boundedByDeadline(connectTimeoutMillis)); + newSocket.setSoTimeout(boundedByDeadline(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. @@ -254,8 +321,19 @@ private void ensureConnected() throws IOException { Response post(final String path, final byte[] body, final String contentType, final String authorization) throws IOException { + if (deadlinePerLeg) { + // Streaming mode: this whole leg — a reconnect included — must complete within the + // inactivity timeout, however many reads it takes (see inactivityTimeout(int)). + deadlineEpochMillis = Utils.getCurrentTimeMillis() + readTimeoutMillis; + } ensureConnected(); try { + if (deadlineEpochMillis != 0) { + // Several HTTP legs can run under one poll deadline (reconnect, authentication + // exchange, the request itself): re-cap the read wait to what is left of the budget + // at the start of every leg. + socket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); + } final StringBuilder head = new StringBuilder(); head.append("POST ").append(path).append(" HTTP/1.1\r\n"); head.append("Accept: */*\r\n"); @@ -343,7 +421,7 @@ private String readLine() throws IOException { final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); int b; int prev = -1; - while ((b = in.read()) != -1) { + while ((b = readByte()) != -1) { if (prev == '\r' && b == '\n') { final byte[] raw = buffer.toByteArray(); return new String(raw, 0, raw.length - 1, StandardCharsets.ISO_8859_1); @@ -358,6 +436,7 @@ private byte[] readFixed(final int length) throws IOException { final byte[] buffer = new byte[length]; int read = 0; while (read < length) { + beforeBlockingRead(); final int n = in.read(buffer, read, length - read); if (n < 0) { throw new IOException("Unexpected EOF: got " + read + " of " + length + " body bytes"); @@ -367,6 +446,25 @@ private byte[] readFixed(final int length) throws IOException { return buffer; } + /** One byte of the response, its blocking wait re-capped by the poll deadline. */ + private int readByte() throws IOException { + beforeBlockingRead(); + return in.read(); + } + + /** + * Re-cap the socket timeout by what is left of the poll deadline before a blocking read. + * {@code SO_TIMEOUT} applies to EACH read independently: without this, a peer trickling a + * response one byte at a time would reset its clock with every byte and stretch a + * deadline-bounded poll arbitrarily past the deadline. Costs nothing outside poll mode, and + * skips the syscall while buffered data makes the next read non-blocking. + */ + private void beforeBlockingRead() throws IOException { + if (deadlineEpochMillis != 0 && in.available() == 0) { + socket.setSoTimeout(boundedByDeadline(readTimeoutMillis)); + } + } + private byte[] readChunked() throws IOException { final ByteArrayOutputStream body = new ByteArrayOutputStream(); while (true) { diff --git a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java index 2bc7fb0..25894ac 100644 --- a/src/main/java/org/metricshub/winrm/light/LightWinRMService.java +++ b/src/main/java/org/metricshub/winrm/light/LightWinRMService.java @@ -31,11 +31,14 @@ import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLSocketFactory; +import org.metricshub.winrm.CommandCursor; import org.metricshub.winrm.Utils; import org.metricshub.winrm.WinRMHttpProtocolEnum; import org.metricshub.winrm.WindowsRemoteCommandResult; import org.metricshub.winrm.WindowsRemoteExecutor; import org.metricshub.winrm.WmiHelper; +import org.metricshub.winrm.WqlCursor; +import org.metricshub.winrm.exceptions.WinRMClientException; import org.metricshub.winrm.exceptions.WinRMException; import org.metricshub.winrm.exceptions.WindowsRemoteException; import org.metricshub.winrm.exceptions.WqlQuerySyntaxException; @@ -214,17 +217,7 @@ public List> executeWql( 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."); - } + checkWqlArguments(namespace, wqlQuery, timeout, maxElements, pullTimeout); // Enforce the caller's timeout as a wall-clock deadline (throwing TimeoutException), matching // the CXF WinRMService and bounding the WSMan Pull loop. @@ -241,6 +234,132 @@ public List> executeWql( ); } + @Override + public WqlCursor streamWql( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws TimeoutException, WqlQuerySyntaxException, WindowsRemoteException { + checkWqlArguments(namespace, wqlQuery, timeout, maxElements, pullTimeout); + + // The initial Enumerate is sent here, on the caller's thread, so configuration and + // authentication failures surface immediately rather than on the first row. + final WsmanClient.WqlEnumeration enumeration = callStreaming( + () -> client.openWql(namespace, wqlQuery, timeout, maxElements, pullTimeout, true) + ); + return new WqlCursor() { + @Override + public Map next() throws TimeoutException, WindowsRemoteException { + final Map row = callStreaming(enumeration::next); + return row == null ? null : new LinkedHashMap<>(row); + } + + @Override + public void close() { + enumeration.close(); + } + }; + } + + /** Validate the arguments shared by the blocking and streaming WQL entry points. */ + private void checkWqlArguments( + final String namespace, + final String wqlQuery, + final long timeout, + final int maxElements, + final long pullTimeout + ) throws WqlQuerySyntaxException { + 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."); + } + } + + @Override + public CommandCursor startCommand(final String command, final String workingDirectory, final long timeout) + throws TimeoutException, WindowsRemoteException { + checkNotClosed(); + Utils.checkNonNull(command, "command"); + Utils.checkArgumentNotZeroOrNegative(timeout, "timeout"); + + // Shell creation and command startup happen here, on the caller's thread, so failures + // surface immediately rather than on the first output chunk. + final WsmanClient.RemoteCommand remoteCommand = callStreaming( + () -> client.startCommand(command, workingDirectory, timeout, true) + ); + return new CommandCursor() { + @Override + public Chunk next() throws TimeoutException, WindowsRemoteException { + return adapt(callStreaming(remoteCommand::nextChunk)); + } + + @Override + public Chunk poll(final long maxWaitMillis) throws TimeoutException, WindowsRemoteException { + return adapt(callStreaming(() -> remoteCommand.pollChunk(maxWaitMillis))); + } + + private Chunk adapt(final WsmanClient.RemoteCommand.Chunk chunk) { + return chunk == null ? null : new Chunk(chunk.stdout, chunk.stderr); + } + + @Override + public int exitCode() { + return remoteCommand.exitCode(); + } + + @Override + public void close() { + try { + remoteCommand.close(); + } catch (final RuntimeException e) { + // Typed protocol failures (e.g. a fault answering the terminate Signal) pass through. + throw e; + } catch (final InterruptedException e) { + // Closing on an already-cancelled thread: restore the flag, the connection permit + // has been released and the transport is torn down with the executor. + Thread.currentThread().interrupt(); + } catch (final Exception e) { + throw new WinRMClientException(e.getMessage(), e); + } + } + }; + } + + /** + * Run one streaming protocol step on the caller's thread, translating the raw client failures + * the way the blocking operations do. Unlike the blocking operations there is no worker thread + * and no wall-clock deadline: each round trip is bounded by the operation timeout (the WSMan + * OperationTimeout header and the socket read timeout), which acts as the inactivity timeout + * of the stream and surfaces as the {@link TimeoutException} this method lets through. + * + * @param step the protocol step to run + * @param the step's result type + * @return the step's result + * @throws TimeoutException when the step exceeds the inactivity timeout + * @throws WinRMException when the step fails with a checked failure + */ + private static T callStreaming(final Callable step) throws TimeoutException, WinRMException { + try { + return step.call(); + } catch (final TimeoutException | RuntimeException e) { + throw e; + } catch (final InterruptedException e) { + Thread.currentThread().interrupt(); + throw new WinRMException(e); + } catch (final Exception e) { + throw new WinRMException(e, e.getMessage()); + } + } + @Override public WindowsRemoteCommandResult executeCommand( final String command, diff --git a/src/main/java/org/metricshub/winrm/light/WsmanClient.java b/src/main/java/org/metricshub/winrm/light/WsmanClient.java index b2d4ac2..2276e56 100644 --- a/src/main/java/org/metricshub/winrm/light/WsmanClient.java +++ b/src/main/java/org/metricshub/winrm/light/WsmanClient.java @@ -22,6 +22,7 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.net.SocketTimeoutException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; @@ -29,7 +30,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.locks.ReentrantLock; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeoutException; import javax.net.ssl.SSLSocketFactory; import javax.xml.XMLConstants; import javax.xml.parsers.DocumentBuilder; @@ -53,6 +55,10 @@ final class WsmanClient implements AutoCloseable { private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; private static final String FAULT_SHELL_NOT_FOUND = "2150858843"; + // A bounded poll shorter than this cannot be honored by a network round trip (the answer could + // not come back in time): it is waited out locally instead of going to the wire. + private static final long MIN_WIRE_POLL_MS = 100; + // WS-Enumeration namespace: the EndOfSequence / EnumerationContext markers live here. Match them by // namespace, never by local name alone, so a WMI property that happens to be named "EndOfSequence" // or "EnumerationContext" inside cannot be mistaken for the enumeration control element. @@ -74,30 +80,40 @@ final class WsmanClient implements AutoCloseable { // 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. + // directory. Guarded by connectionPermit, 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. - // Every high-level operation (wql/executeCommand) runs while holding this lock; close() only - // tries it, so it can still hard-close the transport to unblock an abandoned, timed-out worker. - private final ReentrantLock operationLock = new ReentrantLock(); + // Every high-level operation (wql/executeCommand) and every open streaming handle + // (WqlEnumeration/RemoteCommand) runs while holding this single permit; close() only tries it, + // so it can still hard-close the transport to unblock an abandoned, timed-out worker. A + // Semaphore rather than a ReentrantLock because a streaming handle may legitimately be advanced + // and closed by a different thread than the one that opened it (a lock could then not be + // released at all — unlock is owner-only). + private final Semaphore connectionPermit = new Semaphore(1); + + // Set (before anything else) by close(): a straggler — an abandoned worker or a streaming + // handle outliving the client — must never send another request, because request() would + // happily reconnect and re-authenticate the hard-closed transport, reviving a connection + // nothing will ever close again. Volatile: close() may run on another thread. + private volatile boolean closed; /** - * Acquire {@link #operationLock}, aborting when this task has been cancelled. A caller's + * Acquire {@link #connectionPermit}, 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 + * NOT go on to acquire the permit 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(); + connectionPermit.acquire(); if (Thread.interrupted()) { - operationLock.unlock(); + connectionPermit.release(); throw new InterruptedException("Operation abandoned: cancelled while waiting for the connection."); } } @@ -142,6 +158,22 @@ private static int toSocketTimeoutMillis(final long millis) { return (int) Math.min(millis, Integer.MAX_VALUE - 10_000L); } + /** + * Align the transport's socket timeouts with the operation being opened. Blocking operations + * keep the read-timeout headroom (their caller's wall-clock deadline governs, and the WSMan + * op-timeout fault must arrive before the socket gives up so the Receive loop can retry); + * streaming operations must observe the configured inactivity timeout on the socket itself — + * a server that stops answering entirely would otherwise be detected ten seconds late. + */ + private void configureTimeouts(final long operationTimeoutMs, final boolean failOnQuietTimeout) { + final int millis = toSocketTimeoutMillis(operationTimeoutMs); + if (failOnQuietTimeout) { + transport.inactivityTimeout(millis); + } else { + transport.operationTimeout(millis); + } + } + /** A decrypted WSMan response: HTTP status plus the (decrypted) SOAP body. */ private static final class Decoded { @@ -155,7 +187,8 @@ private static final class Decoded { } /** - * Run a WQL query and return the rows as ordered property maps. + * Run a WQL query and return the rows as ordered property maps. Implemented as "drain the + * stream" over {@link #openWql} so the blocking and streaming paths cannot drift apart. * * @param namespace the WMI namespace * @param query the WQL query @@ -171,33 +204,188 @@ List> wql( final int maxElements, final long maxTimeMs ) throws Exception { - // Serialize the whole enumeration (Enumerate + all Pulls) against any other operation sharing - // this connection; see operationLock. + final List> rows = new ArrayList<>(); + try (WqlEnumeration enumeration = openWql(namespace, query, operationTimeoutMs, maxElements, maxTimeMs, false)) { + Map row; + while ((row = enumeration.next()) != null) { + rows.add(row); + } + } + return rows; + } + + /** + * Start a WQL enumeration and return a lazy handle over its rows. The handle owns the + * connection (see {@link #connectionPermit}) until it is exhausted or closed: no other + * operation can run on this client while it is open. + * + * @param namespace the WMI namespace + * @param query the WQL query + * @param operationTimeoutMs each WSMan round trip's timeout, driving the OperationTimeout + * header and the socket read timeout — for a streaming consumer this is the inactivity + * timeout: the longest silence tolerated between two responses + * @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 + * @param failOnQuietTimeout streaming mode: convert a server "no result yet" operation-timeout + * fault or a socket read timeout on Pull into a {@link TimeoutException} instead of + * letting the raw fault/IO failure surface (the blocking path is bounded by the caller's + * wall-clock deadline instead) + */ + WqlEnumeration openWql( + final String namespace, + final String query, + final long operationTimeoutMs, + final int maxElements, + final long maxTimeMs, + final boolean failOnQuietTimeout + ) throws Exception { + // Serialize the whole enumeration (Enumerate + all Pulls + Release) against any other + // operation sharing this connection; see connectionPermit. lockAbortably(); + boolean opened = false; try { - transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + configureTimeouts(operationTimeoutMs, failOnQuietTimeout); // 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<>(); + final WqlEnumeration enumeration = new WqlEnumeration( + ns, + operationTimeoutMs, + maxElements, + maxTimeMs, + failOnQuietTimeout + ); + enumeration.ingest( + exchange( + Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), + "Enumerate", + operationTimeoutMs, + failOnQuietTimeout + ) + ); + opened = true; + return enumeration; + } finally { + if (!opened) { + connectionPermit.release(); + } + } + } + + /** + * A lazily-advancing WQL enumeration: rows are served from the current WS-Enumeration page and + * the next Pull is issued only when the page runs out, so memory stays bounded by one page. + * Holds {@link #connectionPermit} from creation until exhaustion or {@link #close()}; closing + * before the end sends a WS-Enumeration Release so the server frees the enumeration context. + */ + final class WqlEnumeration implements AutoCloseable { + + private final String namespace; + private final long operationTimeoutMs; + private final int maxElements; + private final long maxTimeMs; + private final boolean failOnQuietTimeout; - Document doc = expectOk(Envelopes.enumerateWql(url, ns, query, operationTimeoutMs, maxElements), "Enumerate"); - collectItems(doc, rows); + // The current page only: previous pages (rows and DOM) are unreachable once served. + private List> page = new ArrayList<>(); + private int cursor; + private String context; + private boolean endOfSequence; + private boolean finished; + + // Set when an advance failed: the connection state is then unknown (a fault, a half-read + // response, a cancellation), so close() must not push a Release into it — it releases the + // permit only, exactly like the pre-streaming code did on its error paths. + private boolean broken; + + private WqlEnumeration( + final String namespace, + final long operationTimeoutMs, + final int maxElements, + final long maxTimeMs, + final boolean failOnQuietTimeout + ) { + this.namespace = namespace; + this.operationTimeoutMs = operationTimeoutMs; + this.maxElements = maxElements; + this.maxTimeMs = maxTimeMs; + this.failOnQuietTimeout = failOnQuietTimeout; + } - // Pull until the server signals EndOfSequence (matching the CXF backend). The aggregate - // timeout in LightWinRMService bounds a misbehaving server that never ends the sequence. - boolean endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); - String context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); - while (!endOfSequence && context != null && !context.isEmpty()) { + /** Absorb one Enumerate/Pull response: its rows become the current page. */ + private void ingest(final Document doc) { + page = new ArrayList<>(); + cursor = 0; + collectItems(doc, page); + endOfSequence = hasEnumerationElement(doc, "EndOfSequence"); + // Pull only while the server hands back a context (matching the CXF backend). + context = endOfSequence ? null : textNS(doc, WS_ENUMERATION_NS, "EnumerationContext"); + if (context == null || context.isEmpty()) { + endOfSequence = true; + } + } + + /** + * The next row, or {@code null} once the enumeration is exhausted. Exhaustion releases the + * connection immediately (no Release is needed — the server discarded the context when it + * sent EndOfSequence), so a fully-consumed enumeration does not depend on {@link #close()}. + */ + Map next() throws Exception { + try { + return advance(); + } catch (final Exception e) { + broken = true; + throw e; + } + } + + private Map advance() throws Exception { + if (finished) { + return null; + } + while (cursor >= page.size()) { + if (endOfSequence) { + finished = true; + connectionPermit.release(); + return null; + } // 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"); + ingest( + exchange( + Envelopes.pull(url, namespace, context, operationTimeoutMs, maxElements, maxTimeMs), + "Pull", + operationTimeoutMs, + failOnQuietTimeout + ) + ); + } + return page.get(cursor++); + } + + /** + * Release the enumeration: when the server still holds an enumeration context, a + * best-effort WS-Enumeration Release lets it free the context (and the operation slot it + * counts against server-side quotas) immediately. Always releases the connection; idempotent. + */ + @Override + public void close() { + if (finished) { + return; + } + finished = true; + try { + // No Release when the whole client was closed while this handle was open: its transport + // is gone, and the request would reconnect and re-authenticate just to be thrown away. + if (!broken && !closed && !endOfSequence && context != null && !context.isEmpty()) { + try { + request(Envelopes.release(url, namespace, context, operationTimeoutMs)); + } catch (final Exception ignored) { + // Best-effort cleanup: the server reaps an unreleased context on its own timeout. + } + } + } finally { + connectionPermit.release(); } - return rows; - } finally { - operationLock.unlock(); } } @@ -216,7 +404,12 @@ static final class CommandOutput { } /** - * Execute a command in the remote command shell, creating the shell on first use. + * Execute a command in the remote command shell, creating the shell on first use. Implemented + * as "drain the stream" over {@link #startCommand} so the blocking and streaming paths cannot + * drift apart: the raw stream BYTES are accumulated and decoded once at the end, because 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. * * @param commandLine the command line to run * @param workingDirectory working directory of the shell (only honored when the shell is created) @@ -229,26 +422,65 @@ CommandOutput executeCommand( final String workingDirectory, final Charset charset, final long operationTimeoutMs + ) throws Exception { + final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + try (RemoteCommand command = startCommand(commandLine, workingDirectory, operationTimeoutMs, false)) { + RemoteCommand.Chunk chunk; + while ((chunk = command.nextChunk()) != null) { + stdout.write(chunk.stdout, 0, chunk.stdout.length); + stderr.write(chunk.stderr, 0, chunk.stderr.length); + } + return new CommandOutput( + new String(stdout.toByteArray(), cs), + new String(stderr.toByteArray(), cs), + command.exitCode() + ); + } + } + + /** + * Start a command in the remote command shell (creating the shell on first use) and return a + * handle over its raw output chunks. The handle owns the connection (see + * {@link #connectionPermit}) until the command completes or the handle is closed: no other + * operation can run on this client while it is open. + * + * @param commandLine the command line to run + * @param workingDirectory working directory of the shell (only honored when the shell is created) + * @param operationTimeoutMs each WSMan round trip's timeout, driving the OperationTimeout + * header and the socket read timeout — for a streaming consumer this is the inactivity + * timeout: the longest silence tolerated between two responses + * @param failOnQuietTimeout streaming mode: convert a server "no output yet" operation-timeout + * fault or a socket read timeout into a {@link TimeoutException} instead of re-issuing + * the Receive forever (the blocking path is bounded by the caller's wall-clock deadline + * instead) + */ + RemoteCommand startCommand( + final String commandLine, + final String workingDirectory, + final long operationTimeoutMs, + final boolean failOnQuietTimeout ) 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. + // other operation sharing this connection and the shellId field; see connectionPermit. lockAbortably(); + boolean opened = false; try { - transport.operationTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + configureTimeouts(operationTimeoutMs, failOnQuietTimeout); if (!shellWorkingDirectoryPinned) { shellWorkingDirectory = workingDirectory; shellWorkingDirectoryPinned = true; } if (shellId == null) { - createShell(shellWorkingDirectory, operationTimeoutMs); + createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); } - final Charset cs = charset != null ? charset : StandardCharsets.UTF_8; // The caller's timeout may have fired while the Create response was being awaited (socket // reads do not observe interrupts): never START the command after the reported timeout. checkNotCancelled(); String commandId; try { - commandId = startCommand(commandLine, operationTimeoutMs); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); } catch (final WinRMFaultException e) { if (!FAULT_SHELL_NOT_FOUND.equals(e.getFaultCode())) { throw e; @@ -257,22 +489,279 @@ CommandOutput executeCommand( // 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); + createShell(shellWorkingDirectory, operationTimeoutMs, failOnQuietTimeout); checkNotCancelled(); - commandId = startCommand(commandLine, operationTimeoutMs); + commandId = sendCommand(commandLine, operationTimeoutMs, failOnQuietTimeout); + } + opened = true; + return new RemoteCommand(commandId, operationTimeoutMs, failOnQuietTimeout); + } finally { + if (!opened) { + connectionPermit.release(); + } + } + } + + /** + * A running remote command: each {@link #nextChunk()} is one WSMan Receive round trip yielding + * the raw output bytes as the server handed them out, so memory stays bounded by one response. + * Holds {@link #connectionPermit} from creation until completion or {@link #close()}; both + * paths send the terminate Signal, exactly like the pre-streaming receive loop did — Signal + * after completion is part of the shell protocol, and Signal on early close is what actually + * stops the remote command. The one exception is completion discovered inside a bounded poll + * (see {@link #finishBounded}), whose Signal is bounded by — or skipped for — the caller's + * remaining wait. + */ + final class RemoteCommand implements AutoCloseable { + + /** One Receive response's worth of raw output bytes, split by stream. */ + final class Chunk { + + final byte[] stdout; + final byte[] stderr; + + Chunk(final byte[] stdout, final byte[] stderr) { + this.stdout = stdout; + this.stderr = stderr; + } + } + + private final String commandId; + private final long operationTimeoutMs; + private final boolean failOnQuietTimeout; + private Integer exitCode; + private boolean finished; + + private RemoteCommand(final String commandId, final long operationTimeoutMs, final boolean failOnQuietTimeout) { + this.commandId = commandId; + this.operationTimeoutMs = operationTimeoutMs; + this.failOnQuietTimeout = failOnQuietTimeout; + } + + /** + * The next chunk of raw output — one Receive round trip, possibly empty — or {@code null} + * once the command has completed. The {@code null} return has already sent the terminate + * Signal and released the connection, so a fully-drained command does not depend on + * {@link #close()}; the exit code is then available from {@link #exitCode()}. + */ + Chunk nextChunk() throws Exception { + if (finished) { + // Already signaled — normally after completion, but also after an early close(): the + // connection was released either way, so never touch it again from this handle. + return null; + } + if (exitCode != null) { + // The command completed with the previous chunk: Signal it and release the connection. + finish(); + return null; } + return toChunk(receiveOutput()); + } + + /** + * Bounded variant of {@link #nextChunk()}: block at most the given wait — a hard bound. A + * wire poll asks the server to answer EARLIER than the wait (the difference is transit + * slack for its "nothing yet" op-timeout fault to reach us before the socket cuts at the + * full wait); that fault is returned as an EMPTY chunk instead of failing the handle — + * the protocol's clean expiry, leaving the command and this handle fully usable. A wait + * too short for any network round trip is waited out locally instead. Returns {@code null} + * exactly like {@link #nextChunk()} once the command has completed. + * + * @param maxWaitMs how long to block at most, capped by the handle's own per-round-trip + * timeout + */ + Chunk pollChunk(final long maxWaitMs) throws Exception { + if (finished) { + return null; + } + if (exitCode != null) { + // The command completed with the previous chunk: Signal it — but under the poll's + // budget, never the full inactivity timeout of the plain fetches. + finishBounded(maxWaitMs); + return null; + } + final long budget = Math.max(1, Math.min(maxWaitMs, operationTimeoutMs)); + if (budget < MIN_WIRE_POLL_MS) { + // No answer could come back in time: waiting the budget out locally is the only way + // to honor it. The protocol advances on the next full-size fetch or poll. + Thread.sleep(budget); + return new Chunk(new byte[0], new byte[0]); + } + // Split the budget: the server may hold the Receive for the first part, and the rest is + // transit slack for its "nothing yet" op-timeout fault to arrive BEFORE the socket cuts + // at the full budget — the expected expiry of a bounded poll is that fault, and it must + // win the race or the poll would desync the connection it is supposed to leave intact. + final long transit = Math.min(1_000, budget / 2); + transport.pollTimeout(toSocketTimeoutMillis(budget)); try { - return receiveLoop(commandId, cs, operationTimeoutMs); + checkNotCancelled(); + final Decoded resp; + try { + resp = request(Envelopes.receive(url, shellId, commandId, budget - transit)); + } catch (final SocketTimeoutException e) { + // The peer answered neither within its shortened hold nor within the transit + // slack. The Receive is abandoned mid-flight, so drop the connection outright: a + // late response must not be readable as the answer to a LATER request. + transport.close(); + throw quietTimeout("No response from the WinRM service", budget, e); + } + if (resp.status != 200) { + if (FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + // Nothing yet: the bounded wait elapsed server-side. + return new Chunk(new byte[0], new byte[0]); + } + throw faultException("Receive", resp); + } + return toChunk(resp); } finally { - terminate(commandId, operationTimeoutMs); + // Back to the strict streaming bound for the ordinary (unbounded) fetches. + transport.inactivityTimeout(toSocketTimeoutMillis(operationTimeoutMs)); + } + } + + /** Turn one 200 Receive response into a chunk, recording the exit code when it says Done. */ + private Chunk toChunk(final Decoded resp) { + final ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + final ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + collectStreams(resp.document, stdout, stderr); + exitCode = doneExitCode(resp.document); + return new Chunk(stdout.toByteArray(), stderr.toByteArray()); + } + + /** Issue Receive until a usable response arrives, honoring the timeout mode. */ + private Decoded receiveOutput() throws Exception { + 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 sends the Signal (via close), which terminates the remote command. + checkNotCancelled(); + final Decoded resp; + try { + resp = request(Envelopes.receive(url, shellId, commandId, operationTimeoutMs)); + } catch (final SocketTimeoutException e) { + if (failOnQuietTimeout) { + throw quietTimeout("No response from the WinRM service", operationTimeoutMs, e); + } + throw e; + } + if (resp.status == 200) { + return resp; + } + if (!FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + throw faultException("Receive", resp); + } + // No output before OperationTimeout expired. The blocking path re-issues the Receive + // immediately (its caller's wall-clock deadline governs); for a streaming consumer + // that silence IS the inactivity timeout. + if (failOnQuietTimeout) { + throw quietTimeout("The command produced no output", operationTimeoutMs, null); + } + } + } + + /** + * The command's exit code, once {@link #nextChunk()} has returned {@code null}. + */ + int exitCode() { + if (exitCode == null) { + throw new IllegalStateException("The command has not completed yet."); + } + return exitCode; + } + + /** + * Signal the command (terminate) and release the connection; runs at most once. For a + * still-running command (an early close) the Signal is what actually stops it, so its + * failures are reported; once the command has COMPLETED the Signal is best-effort cleanup + * (see {@link #terminateCompleted}) — a completed command with a known exit code must + * never turn into a failure because its acknowledgement hiccuped. + */ + private void finish() throws Exception { + if (finished) { + return; + } + finished = true; + try { + // No Signal when the whole client was closed while this handle was open: its transport + // is gone, and the request would reconnect and re-authenticate just to be thrown away — + // the server reaps the shell (and its commands) on its own IdleTimeout instead. + if (!closed) { + if (exitCode != null) { + terminateCompleted(operationTimeoutMs); + } else { + terminate(commandId, operationTimeoutMs); + } + } + } finally { + connectionPermit.release(); + } + } + + /** + * Completion cleanup under a poll budget: like {@link #finish()} after completion, but the + * Signal must not outlive the caller's remaining wait either. Runs at most once. + */ + private void finishBounded(final long budgetMs) { + if (finished) { + return; + } + finished = true; + try { + if (!closed) { + terminateCompleted(budgetMs); + } + } finally { + connectionPermit.release(); + } + } + + /** + * Best-effort Signal for an ALREADY-COMPLETED command, bounded by the given budget. No + * failure of it may be reported: the command completed and its exit code is known, and + * that must never be hidden behind a cleanup hiccup. A fault answering the Signal is a + * complete, in-sync exchange and is simply ignored; any other failure (a timeout, a reset, + * a half-read response) leaves the connection in an unknown state, so it is dropped — a + * late response must not desync a later request. A budget too small for any round trip + * skips the Signal outright, leaving the healthy connection untouched; the server reaps + * the completed command's state with the shell. + */ + private void terminateCompleted(final long budgetMs) { + final long budget = Math.max(1, Math.min(budgetMs, operationTimeoutMs)); + if (budget < MIN_WIRE_POLL_MS) { + return; + } + transport.pollTimeout(toSocketTimeoutMillis(budget)); + try { + terminate(commandId, budget); + } catch (final WinRMFaultException ignored) { + // The Signal was answered with a fault: the exchange completed, the connection is in + // sync — and the command's completion is what matters. + } catch (final Exception e) { + transport.close(); + } finally { + transport.inactivityTimeout(toSocketTimeoutMillis(operationTimeoutMs)); } - } finally { - operationLock.unlock(); + } + + /** + * Send the terminate Signal (stopping the remote command when it is still running) and + * release the connection. Idempotent; a no-op when the command already completed and was + * signaled by the final {@link #nextChunk()}. + */ + @Override + public void close() throws Exception { + finish(); } } - private void createShell(final String workingDirectory, final long timeoutMs) throws Exception { - final Document doc = expectOk(Envelopes.createShell(url, workingDirectory, timeoutMs), "Create shell"); + private void createShell(final String workingDirectory, final long timeoutMs, final boolean failOnQuietTimeout) + throws Exception { + final Document doc = exchange( + Envelopes.createShell(url, workingDirectory, timeoutMs), + "Create shell", + timeoutMs, + failOnQuietTimeout + ); final NodeList selectors = doc.getElementsByTagNameNS("*", "Selector"); for (int i = 0; i < selectors.getLength(); i++) { final Element selector = (Element) selectors.item(i); @@ -284,8 +773,14 @@ private void createShell(final String workingDirectory, final long timeoutMs) th throw new IllegalStateException("Shell ID not found in Create response"); } - private String startCommand(final String commandLine, final long timeoutMs) throws Exception { - final Document doc = expectOk(Envelopes.command(url, shellId, commandLine, timeoutMs), "Command"); + private String sendCommand(final String commandLine, final long timeoutMs, final boolean failOnQuietTimeout) + throws Exception { + final Document doc = exchange( + Envelopes.command(url, shellId, commandLine, timeoutMs), + "Command", + timeoutMs, + failOnQuietTimeout + ); final String commandId = text(doc, "CommandId"); if (commandId == null) { throw new IllegalStateException("No CommandId in Command response"); @@ -293,39 +788,6 @@ private String startCommand(final String commandLine, final long timeoutMs) thro return commandId; } - 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); - // No output before OperationTimeout → re-issue Receive immediately. - if (FAULT_OPERATION_TIMEOUT.equals(faultCode)) { - continue; - } - throw faultException("Receive", resp); - } - collectStreams(resp.document, stdout, stderr); - final Integer exitCode = doneExitCode(resp.document); - if (exitCode != null) { - return new CommandOutput( - new String(stdout.toByteArray(), charset), - new String(stderr.toByteArray(), charset), - exitCode - ); - } - } - } - 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. @@ -350,6 +812,48 @@ private Document expectOk(final String soap, final String operation) throws Exce return resp.document; } + /** + * Send one request of a streaming-capable operation, expecting HTTP 200. In streaming mode + * ({@code failOnQuietTimeout}) the two "server stayed quiet for a whole timeout" signals — a + * socket read timeout and the WSMan operation-timeout fault — are translated into the + * {@link TimeoutException} the streaming contract documents, on EVERY round trip (startup + * included), so the caller sees one consistent inactivity failure regardless of which request + * exceeded the limit first. In blocking mode this is exactly {@link #expectOk}: the raw + * failures surface and the caller's wall-clock deadline governs. + */ + private Document exchange( + final String soap, + final String operation, + final long operationTimeoutMs, + final boolean failOnQuietTimeout + ) throws Exception { + if (!failOnQuietTimeout) { + return expectOk(soap, operation); + } + final Decoded resp; + try { + resp = request(soap); + } catch (final SocketTimeoutException e) { + throw quietTimeout("No response from the WinRM service", operationTimeoutMs, e); + } + if (resp.status != 200) { + if (FAULT_OPERATION_TIMEOUT.equals(wsmanFaultCode(resp.document))) { + throw quietTimeout(operation + " produced no result", operationTimeoutMs, null); + } + throw faultException(operation, resp); + } + return resp.document; + } + + /** The streaming inactivity timeout: the server produced nothing for a whole timeout. */ + private static TimeoutException quietTimeout(final String what, final long timeoutMs, final Throwable cause) { + final TimeoutException timeout = new TimeoutException(what + " within the " + timeoutMs + " ms timeout."); + if (cause != null) { + timeout.initCause(cause); + } + return timeout; + } + /** * Build the exception for a faulting response: the message keeps the historical * {@code failed:

} format (part of the exception-message contract inherited @@ -368,10 +872,22 @@ private static WinRMFaultException faultException(final String operation, final /** * 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 - * from a locked wql/executeCommand/close, so requests never interleave on the stateful connection. + * and decode the response. The caller must hold {@link #connectionPermit}; every path here is + * reached from an open enumeration/command handle (which owns the permit) or a permit-holding + * close, so requests never interleave on the stateful connection. */ private Decoded request(final String soap) throws Exception { + // A straggler outliving close() must fail here rather than transparently reconnect and + // re-authenticate the hard-closed transport — that revived connection would leak, since + // nothing will ever close this client again. Same message as the executor's own guard. + if (closed) { + throw new IllegalStateException("This instance has been closed and a new one must be created."); + } + return send(soap); + } + + /** The body of {@link #request(String)}, also reachable from close() itself. */ + private Decoded send(final String soap) throws Exception { // If the connection was dropped (e.g. the server sent "Connection: close"), the session bound // to it is dead — re-handshake on the fresh connection rather than sending unauthenticated. if (auth.isAuthenticated() && !transport.isConnected()) { @@ -601,19 +1117,23 @@ private static String trimToNull(final String s) { @Override public void close() { + // Fence stragglers first: any in-flight or later request() from a worker or streaming handle + // that outlives this close must fail instead of reviving the connection (see request()). + closed = true; // Only attempt a graceful shell Delete if no operation is currently using the connection: a - // blocking tryLock (never a lock()) keeps close() from waiting on an abandoned, timed-out worker - // still holding operationLock while blocked on a socket read. When we cannot acquire the lock, - // or a request would otherwise race the worker, we skip the Delete and just hard-close the - // transport below — which unblocks that worker's read; the shell is reaped by the server IdleTimeout. - final boolean locked = operationLock.tryLock(); + // non-blocking tryAcquire (never an acquire()) keeps close() from waiting on an abandoned, + // timed-out worker — or an open streaming handle — still holding the permit while blocked on + // a socket read. When we cannot acquire the permit, or a request would otherwise race the + // worker, we skip the Delete and just hard-close the transport below — which unblocks that + // worker's read; the shell is reaped by the server IdleTimeout. + final boolean locked = connectionPermit.tryAcquire(); try { final String shell = shellId; shellId = null; if (locked) { if (shell != null) { try { - request(Envelopes.deleteShell(url, shell, timeoutMs)); + send(Envelopes.deleteShell(url, shell, timeoutMs)); } catch (final Exception ignored) { // best-effort shell cleanup } @@ -625,7 +1145,7 @@ public void close() { } } finally { if (locked) { - operationLock.unlock(); + connectionPermit.release(); } transport.close(); } diff --git a/src/site/markdown/cli.md b/src/site/markdown/cli.md new file mode 100644 index 0000000..dc2e04c --- /dev/null +++ b/src/site/markdown/cli.md @@ -0,0 +1,152 @@ +keywords: cli, command line, standalone, jar, wql, exec, exit codes, manual +description: Manual page of the winrm-java standalone command-line client - subcommands, options, passwords, Kerberos, streaming output, and exit codes. + +# Command-Line Client + + + +Every release ships a self-contained executable jar that bundles the client and a small CLI: +download `${project.artifactId}-${project.version}-standalone.jar` from the +[latest release](https://github.com/metricshub/winrm-java/releases/latest) and run it with Java. +This page is its manual. + +## Synopsis + +```text +java -jar winrm-java-standalone.jar [options] wql +java -jar winrm-java-standalone.jar [options] command|cmd|exec|run +java -jar winrm-java-standalone.jar --help | --version +``` + +## Subcommands + +| Subcommand | Description | +| --- | --- | +| `wql ` | Run a WQL query and print the rows to stdout as UTF-8 [JSON Lines](https://jsonlines.org/). | +| `command ` | Run a command on the remote host, forwarding its output. `cmd`, `exec`, and `run` are aliases. | + +Everything after the subcommand is the query or the command line; quoting follows your local +shell's rules, and multi-word command lines are reassembled for the remote `cmd.exe`. + +## Options + +| Option | Description | +| --- | --- | +| `-h, --hostname ` | Target hostname or IP address (required). | +| `-u, --username ` | User name, optionally `DOMAIN\user` (required). | +| `-p, --password ` | Password. Command-line arguments may be visible to other local processes: avoid in automation. | +| `-pf, --password-file ` | Read the password from a UTF-8 file (preferred for automation, see below). | +| `-P, --port ` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. | +| `-t, --timeout ` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#Timeout_semantics). | +| `--https` | Connect over HTTPS. | +| `--https-permissive` | Trust any HTTPS certificate and hostname. Intentionally insecure: testing and isolated hosts only. Requires `--https`. | +| `--ntlm` | Authenticate with NTLM (the default). | +| `--kerberos` | Authenticate with Kerberos. Requires `--https`. | +| `--kerberos-kdc ` | Set the Kerberos KDC for this invocation; the realm is inferred from its DNS suffix (see below). | +| `--kerberos-realm ` | Override the realm inferred from `--kerberos-kdc`. | +| `--help` | Print the usage summary. | +| `--version` | Print the build version. | + +`--ntlm` and `--kerberos` are mutually exclusive, as are the two password options. + +## Passwords + +If neither `-p` nor `-pf` is supplied, the CLI securely requests the password from the interactive +console without echoing it. Non-interactive runs must use `--password-file` (or, less securely, +`--password`). + +Password files are decoded as UTF-8. Exactly one final LF, CRLF, or CR is removed; every other +byte — including whitespace and earlier line endings — is part of the password. + +## Kerberos + +By default, Kerberos uses the ambient JDK configuration (`krb5.conf` / +`-Djava.security.krb5.*`). The CLI can instead configure the JDK for the current invocation with +`--kerberos-kdc `. If no `--kerberos-realm` is supplied, the realm is inferred by removing +the KDC hostname's first DNS label and uppercasing the remaining suffix — for example, a KDC of +`camus.internal.example.net` infers the realm `INTERNAL.EXAMPLE.NET`. The inference follows a +common Active Directory DNS naming convention; it is not guaranteed by Kerberos, so specify +`--kerberos-realm` when the realm does not match the KDC's DNS suffix or when the KDC is not a +fully qualified DNS name. Both options are valid only with `--kerberos`, and `--kerberos-realm` +requires `--kerberos-kdc`. + +See [Authentication](authentication.html) for how NTLM and Kerberos work on the wire. + +## Output + +Diagnostics go **only to standard error**, so standard output can always be piped or parsed. + +### `wql` + +Each result row is printed as one compact UTF-8 JSON object per line +([JSON Lines](https://jsonlines.org/)); property order follows the WinRM response. The rows are +**streamed**: each one is written and flushed as it arrives from the host, so a downstream pipe +starts working immediately and memory stays bounded regardless of the result size. A mid-stream +failure can therefore leave partial output on standard output, signalled by the nonzero exit code. + +### `command` + +Remote stdout and stderr are forwarded **live** to the corresponding local streams while the +command runs — each chunk is flushed as it arrives, so a long-running command can be followed in +real time. The output is decoded with the remote host's active code page, detected automatically +before the command starts. + +## Timeout semantics + +`-t`/`--timeout` follows the operation: + +* For `wql`, it is the **inactivity timeout** of the stream — the longest tolerated silence + between two server responses. A large result can stream for longer than the timeout, as long as + the server keeps answering. +* For `command`, it is the **overall deadline** covering the encoding detection and the command + itself. + +See [Timeouts and Errors](timeouts-and-errors.html) for the underlying semantics. + +## Exit codes + +| Exit code | Meaning | +| ---: | --- | +| `0` | Successful WQL query or remote command. | +| `0`–`255` | Remote command exit code, when it fits in that range. | +| `64` | Invalid CLI usage. | +| `69` | Connection, DNS, socket, or TLS failure. | +| `70` | WinRM protocol or other remote failure (including a remote exit code not representable in 0–255). | +| `77` | Authentication failure. | +| `124` | Operation timeout. | + +## Examples + +Run a WQL query over NTLM and HTTP, reading the password from a file: + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + --hostname server.example.net --username 'DOMAIN\user' \ + --password-file password.txt --ntlm \ + wql 'SELECT Name,State FROM Win32_Service' +``` + +Run a remote command over HTTPS: + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.example.net -u Administrator -pf password.txt --https \ + exec ipconfig /all +``` + +Kerberos with an explicit KDC (realm inferred as `INTERNAL.EXAMPLE.NET`): + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.internal.example.net -u 'DOMAIN\user' -pf password.txt \ + --https --kerberos --kerberos-kdc camus.internal.example.net \ + command whoami +``` + +Follow a long-running command live and capture the streamed WQL rows with `jq`: + +```bash +java -jar ${project.artifactId}-${project.version}-standalone.jar \ + -h server.example.net -u 'DOMAIN\user' -pf password.txt \ + wql 'SELECT * FROM Win32_NTLogEvent' | jq -r .Message +``` diff --git a/src/site/markdown/commands.md b/src/site/markdown/commands.md index f4039c3..56ac550 100644 --- a/src/site/markdown/commands.md +++ b/src/site/markdown/commands.md @@ -43,10 +43,11 @@ Everything between `command(...)` and `execute()` is optional: | Option | Default | Meaning | | --- | --- | --- | -| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads, encoding detection, and the command itself. | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline covering file uploads, encoding detection, and the command itself with `execute()`; inactivity timeout with `start()`. | | `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). | +| `onStdout(Consumer)` / `onStderr(Consumer)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). | ## The result @@ -59,6 +60,56 @@ Everything between `command(...)` and `execute()` is optional: | `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. | +## Streaming the output + +`execute()` collects the complete output in memory and returns only when the command has exited. +For long-running or verbose commands, end the same request with `start()` instead: it returns a +[`RemoteProcess`](apidocs/org/metricshub/winrm/RemoteProcess.html) — shaped like +`java.lang.Process` — whose output can be consumed **while the command is still running**: + +```java +try (RemoteProcess process = client.command("wevtutil qe System /f:text").start()) { + try (BufferedReader out = process.stdout()) { + out.lines().forEach(this::process); + } + int exitCode = process.waitFor(); // or waitFor(Duration) for an overall deadline +} +``` + +Points to know: + +* **Close the process** — use try-with-resources. Closing before completion sends the WinRM + terminate `Signal`, which actually stops the remote command; a command drained to its end cleans + up on its own. Closing the readers does *not* close the process. +* `stdout()` and `stderr()` are fed by the same protocol loop: reading either channel (or calling + `waitFor()`) advances it, and output arriving for the channel not being read is buffered until + read — memory is bounded by the *unread* channel, not by the total output. +* Output is **decoded incrementally** with the request's charset; a multibyte character split + across protocol chunks is decoded correctly. +* The process **holds the client's serial connection** until completion or close: other operations + on the same client wait in the meantime. +* The timeout is an **inactivity** timeout — the longest silence tolerated from the server — not + an overall deadline: a command may run (and stream) far longer than the timeout as long as it + keeps producing output. Use `waitFor(Duration)` when you need a hard deadline. See + [Timeouts and Errors](timeouts-and-errors.html). + +### Tailing the output of a blocking execution + +When you only want to *observe* the output live — logging, progress reporting — but still want the +blocking call and its complete [`CommandResult`](apidocs/org/metricshub/winrm/CommandResult.html), +register `onStdout(...)` / `onStderr(...)` callbacks and keep `execute()` as the terminal: + +```java +CommandResult result = client.command("longRunningThing.exe") + .onStdout(chunk -> log.info(chunk)) + .onStderr(chunk -> log.warn(chunk)) + .execute(); +``` + +Each callback receives the output chunk by chunk as the server delivers it (not necessarily whole +lines), on an internal worker thread, never concurrently. The wall-clock timeout of `execute()` +applies unchanged. + ## Character set By default the output character set does not need to be specified: the client detects the remote @@ -118,12 +169,5 @@ See [Timeouts and Errors](timeouts-and-errors.html) for details. ## From the command line -The standalone jar runs a command with the `command` subcommand (aliases: `cmd`, `exec`, `run`). -Standard output and standard error are forwarded to the corresponding local streams, and the -process exits with the remote exit code when it fits in 0–255: - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - -h server.example.com -u 'DOMAIN\user' -pf password.txt --https \ - exec ipconfig /all -``` +The standalone jar runs a command with its `command` subcommand, forwarding the output live and +propagating the exit code — see the [Command-Line Client](cli.html) manual. diff --git a/src/site/markdown/index.md b/src/site/markdown/index.md index 13d87f0..7425ff3 100644 --- a/src/site/markdown/index.md +++ b/src/site/markdown/index.md @@ -15,6 +15,11 @@ The **WinRM Java Client** is a small library that talks to the Windows Remote Ma * **execute remote commands**, capturing standard output, standard error and the exit code — optionally copying local script files to the host first ([Remote Commands](commands.html)). +Both operations can also **stream**: WQL rows are consumed page by page as they arrive +(`stream()`), and command output is consumed while the command is still running (`start()`, +returning a `java.lang.Process`-like handle) — memory stays bounded regardless of the result +size. + It supports **NTLM** over HTTP (with message encryption) and HTTPS, and **Kerberos (SPNEGO)** over HTTPS ([Authentication](authentication.html)). @@ -104,6 +109,7 @@ remain available and unchanged, with their checked exceptions. * [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 +* [Command-Line Client](cli.html) — the standalone jar's manual page * [Authentication](authentication.html) — NTLM and Kerberos * [TLS / HTTPS](tls.html) — certificate validation and trust stores * [Timeouts and Errors](timeouts-and-errors.html) — timeout semantics and the exception surface diff --git a/src/site/markdown/installation.md b/src/site/markdown/installation.md index 4a7461e..2412427 100644 --- a/src/site/markdown/installation.md +++ b/src/site/markdown/installation.md @@ -61,28 +61,12 @@ Download `${project.artifactId}-${project.version}-standalone.jar` from the java -jar ${project.artifactId}-${project.version}-standalone.jar --help ``` -Run a WQL query: - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - --hostname server.example.com --username 'DOMAIN\user' \ - --password-file password.txt --ntlm \ - wql 'SELECT Name, State FROM Win32_Service' -``` - -Run a remote command (`cmd`, `exec`, and `run` are aliases for `command`): - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - -h server.example.com -u Administrator -pf password.txt --https \ - exec ipconfig /all -``` - -The CLI is covered in more detail throughout the [Usage](wql.html) pages; `--version` prints the -build version. +Subcommands, options, password handling, streaming behavior, and exit codes are documented in the +[Command-Line Client](cli.html) manual. ## Where to go next * [WQL Queries](wql.html) * [Remote Commands](commands.html) +* [Command-Line Client](cli.html) * [Authentication](authentication.html) diff --git a/src/site/markdown/timeouts-and-errors.md b/src/site/markdown/timeouts-and-errors.md index 9348bd9..bb4e739 100644 --- a/src/site/markdown/timeouts-and-errors.md +++ b/src/site/markdown/timeouts-and-errors.md @@ -1,5 +1,5 @@ -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. +keywords: timeout, exception, error, winrmclientexception, wsmanfault +description: Timeout semantics and the exception surface of the WinRM Java Client. # Timeouts and Errors @@ -33,6 +33,18 @@ 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. +### Streaming terminals: inactivity timeout + +The streaming terminals — `stream()` on a WQL request and `start()` on a command (see +[WQL Queries](wql.html) and [Remote Commands](commands.html)) — interpret the same `timeout(...)` +value differently, because an overall deadline would make long-running streams impossible: there +it is an **inactivity timeout**, the longest silence tolerated from the server between two +responses. A query result can be consumed, or a command can keep streaming output, for arbitrarily +long — but as soon as the server stays silent for a whole timeout, the operation fails with +[`WinRMTimeoutException`](apidocs/org/metricshub/winrm/exceptions/WinRMTimeoutException.html). +For commands, `RemoteProcess.waitFor(Duration)` provides an overall deadline on top when one is +needed. + ## The exception surface The fluent API is **unchecked**: every failure is a @@ -80,17 +92,5 @@ unaffected by the unchecked hierarchy above. ## Command-line exit codes -The standalone jar maps outcomes to stable process exit codes: - -| Exit code | Meaning | -| ---: | --- | -| `0` | Successful WQL query or remote command. | -| `0`–`255` | Remote command exit code, when it fits in that range. | -| `64` | Invalid CLI usage. | -| `69` | Connection, DNS, socket, or TLS failure. | -| `70` | WinRM protocol or other remote failure. | -| `77` | Authentication failure. | -| `124` | Operation timeout. | - -Diagnostics are written only to standard error, so a WQL query's JSON Lines output on standard -output is never mixed with error messages. +The standalone jar maps these outcomes to stable process exit codes — see the +[Command-Line Client](cli.html) manual. diff --git a/src/site/markdown/wql.md b/src/site/markdown/wql.md index e3031fc..6c8830e 100644 --- a/src/site/markdown/wql.md +++ b/src/site/markdown/wql.md @@ -42,7 +42,7 @@ Everything between `wql(...)` and `execute()` is optional: | Option | Default | Meaning | | --- | --- | --- | | `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). | +| `timeout(Duration)` | the client's timeout | Wall-clock deadline for the whole query with `execute()`; inactivity timeout with `stream()`. 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. | @@ -59,6 +59,40 @@ WqlResult events = client.wql("SELECT * FROM Win32_NTLogEvent") 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. +## Streaming the rows + +`execute()` collects the complete result in memory. For very large result sets — Windows event +logs, software inventories — end the same request with `stream()` instead: it returns a lazy +`java.util.stream.Stream` of [`WqlRow`](apidocs/org/metricshub/winrm/WqlRow.html)s that yields +each row as soon as it is parsed and pulls the next WS-Enumeration page from the server only as +the stream advances. Memory stays bounded by one page (`pageSize(int)`), not by the whole result +set. + +```java +try (Stream rows = client.wql("SELECT * FROM Win32_NTLogEvent") + .pageSize(5000) + .stream()) { + + rows.filter(row -> "Error".equals(row.string("Type"))) + .limit(100) + .forEach(this::process); +} +``` + +Points to know: + +* **Close the stream** — use try-with-resources, exactly like `Files.lines(...)`. Closing before + the last row tells the server to free the enumeration immediately (WS-Enumeration `Release`); + an exhausted stream cleans up on its own. +* The stream **holds the client's serial connection** while open: other operations on the same + client wait until it is closed or exhausted (the same contract as a JDBC `ResultSet` on its + connection). +* The timeout is an **inactivity** timeout — the longest silence tolerated from the server between + two responses — not an overall deadline: consuming a huge result can take arbitrarily long as + long as the server keeps answering. See [Timeouts and Errors](timeouts-and-errors.html). +* The stream is sequential and ordered; failures while iterating are reported through the same + unchecked exceptions as `execute()` (see below). + ## Reading the result [`WqlResult`](apidocs/org/metricshub/winrm/WqlResult.html) is immutable and iterable: @@ -130,13 +164,5 @@ See [Timeouts and Errors](timeouts-and-errors.html) for the full exception surfa ## From the command line -The standalone jar exposes the same capability through the `wql` subcommand, printing one compact -UTF-8 JSON object per row ([JSON Lines](https://jsonlines.org/)): - -```bash -java -jar ${project.artifactId}-${project.version}-standalone.jar \ - -h server.example.com -u 'DOMAIN\user' -pf password.txt --ntlm \ - wql 'SELECT Name, State FROM Win32_Service' -``` - -Property order follows the WinRM response; diagnostics go only to standard error. +The standalone jar exposes the same capability through its `wql` subcommand, streaming the rows +to stdout as JSON Lines — see the [Command-Line Client](cli.html) manual. diff --git a/src/site/site.xml b/src/site/site.xml index a53060b..a2864f1 100644 --- a/src/site/site.xml +++ b/src/site/site.xml @@ -56,6 +56,7 @@ + diff --git a/src/test/java/org/metricshub/winrm/ChunkDecoderTest.java b/src/test/java/org/metricshub/winrm/ChunkDecoderTest.java new file mode 100644 index 0000000..921db42 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/ChunkDecoderTest.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 static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +/** + * Unit tests of {@link ChunkDecoder}: incrementally decoding a byte sequence — split at any + * boundary, including inside multibyte characters — must yield exactly the text a whole-buffer + * {@code new String(bytes, charset)} yields. + */ +class ChunkDecoderTest { + + /** Decode the bytes in two chunks split at the given position, plus the final flush. */ + private static String decodeSplit(final byte[] bytes, final int split, final Charset charset) { + final ChunkDecoder decoder = new ChunkDecoder(charset); + return (decoder.decode(Arrays.copyOfRange(bytes, 0, split)) + + decoder.decode(Arrays.copyOfRange(bytes, split, bytes.length)) + + decoder.finish()); + } + + @Test + void anySplitOfMultibyteUtf8MatchesWholeBufferDecoding() { + // 2-byte (é), 3-byte (€) and 4-byte (🙂) UTF-8 sequences. + final String text = "aé€🙂z"; + final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + for (int split = 0; split <= bytes.length; split++) { + assertEquals(text, decodeSplit(bytes, split, StandardCharsets.UTF_8), "split at " + split); + } + } + + @Test + void oneByteAtATimeMatchesWholeBufferDecoding() { + final String text = "é€🙂"; + final byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + final ChunkDecoder decoder = new ChunkDecoder(StandardCharsets.UTF_8); + final StringBuilder decoded = new StringBuilder(); + for (final byte b : bytes) { + decoded.append(decoder.decode(new byte[] { b })); + } + decoded.append(decoder.finish()); + assertEquals(text, decoded.toString()); + } + + @Test + void malformedInputIsReplacedLikeStringConstructor() { + // A stray continuation byte and a truncated 2-byte sequence at the very end. + final byte[] bytes = { 'a', (byte) 0xA9, 'b', (byte) 0xC3 }; + final String expected = new String(bytes, StandardCharsets.UTF_8); + for (int split = 0; split <= bytes.length; split++) { + assertEquals(expected, decodeSplit(bytes, split, StandardCharsets.UTF_8), "split at " + split); + } + } + + @Test + void singleByteCharsetsPassThrough() { + final Charset cp1252 = Charset.forName("windows-1252"); + final String text = "café au lait"; + final byte[] bytes = text.getBytes(cp1252); + assertEquals(text, decodeSplit(bytes, bytes.length / 2, cp1252)); + } + + @Test + void emptyChunksProduceNoOutput() { + final ChunkDecoder decoder = new ChunkDecoder(StandardCharsets.UTF_8); + assertEquals("", decoder.decode(new byte[0])); + assertEquals("", decoder.decode(new byte[0])); + assertEquals("", decoder.finish()); + } +} diff --git a/src/test/java/org/metricshub/winrm/StreamingApiTest.java b/src/test/java/org/metricshub/winrm/StreamingApiTest.java new file mode 100644 index 0000000..dd07b45 --- /dev/null +++ b/src/test/java/org/metricshub/winrm/StreamingApiTest.java @@ -0,0 +1,726 @@ +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.assertNull; +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.io.BufferedReader; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.metricshub.winrm.exceptions.WinRMTimeoutException; +import org.metricshub.winrm.exceptions.WqlSyntaxException; +import org.metricshub.winrm.light.FakeWsmanServer; + +/** + * End-to-end tests of the streaming API (issue #111) against {@link FakeWsmanServer}: + * {@code WqlRequest.stream()}, {@code CommandRequest.start()} / {@code RemoteProcess}, and the + * {@code onStdout}/{@code onStderr} callbacks — laziness, resource cleanup (WS-Enumeration + * Release, terminate Signal), incremental decoding, and the inactivity-timeout semantics. + */ +class StreamingApiTest { + + 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"; + + /** The WSMan fault code the server answers with when no result is ready before OperationTimeout. */ + private static final String FAULT_OPERATION_TIMEOUT = "2150858793"; + + private static final String COMMAND_ID = "CMD-1"; + + private FakeWsmanServer server; + + @BeforeEach + void startServer() throws Exception { + server = new FakeWsmanServer(DOMAIN, USER, PASSWORD); + } + + @AfterEach + void stopServer() { + server.close(); + } + + private WinRMClient.Builder builder() { + return WinRMClient + .builder("127.0.0.1") + .port(server.port()) + .credentials(DOMAIN + "\\" + USER, PASSWORD.toCharArray()) + .timeout(Duration.ofSeconds(10)); + } + + private static String service(final String name, final String state) { + return instance("Win32_Service", "Name", name, "State", state); + } + + /** An EnumerateResponse carrying rows and an open enumeration context (more pages follow). */ + private static String enumeratePage(final String context, final String... instances) { + final StringBuilder xml = new StringBuilder(); + xml + .append("") + .append("") + .append(context) + .append("") + .append(""); + for (final String item : instances) { + xml.append(item); + } + return xml.append("").toString(); + } + + /** A final PullResponse: the last rows and the end-of-sequence marker. */ + private static String pullDone(final String... instances) { + final StringBuilder xml = new StringBuilder(); + xml + .append(""); + for (final String item : instances) { + xml.append(item); + } + return xml.append("").toString(); + } + + private static String releaseResponse() { + return ""; + } + + // --- WQL streaming ------------------------------------------------------- + + @Test + void wqlStreamYieldsRowsBeforeLaterPagesAreFetched() throws Exception { + server + .enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running"), service("W32Time", "Stopped")))) + .enqueue(200, envelope(pullDone(service("WinRM", "Running")))); + + try (WinRMClient client = builder().build()) { + try (Stream rows = client.wql("SELECT Name, State FROM Win32_Service").stream()) { + final Iterator iterator = rows.iterator(); + + assertEquals("Spooler", iterator.next().string("Name")); + assertEquals("W32Time", iterator.next().string("Name")); + // Both first-page rows were served from the Enumerate response alone: no Pull yet. + assertEquals(1, server.decryptedRequests().size()); + + assertEquals("WinRM", iterator.next().string("Name")); + assertEquals(2, server.decryptedRequests().size()); + assertTrue(server.decryptedRequests().get(1).contains("enumeration/Pull")); + + assertFalse(iterator.hasNext()); + } + // The enumeration completed with EndOfSequence: nothing to release. + assertEquals(2, server.decryptedRequests().size()); + } + } + + @Test + void closingWqlStreamEarlySendsRelease() throws Exception { + server + .enqueue( + 200, + envelope(enumeratePage("uuid:CTX-42", service("Spooler", "Running"), service("W32Time", "Stopped"))) + ) + .enqueue(200, envelope(releaseResponse())); + + try (WinRMClient client = builder().build()) { + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + assertEquals("Spooler", rows.findFirst().orElseThrow().string("Name")); + } + + final List requests = server.decryptedRequests(); + assertEquals(2, requests.size()); + assertTrue(requests.get(1).contains("enumeration/Release"), "early close must send a Release"); + assertTrue(requests.get(1).contains("uuid:CTX-42"), "the Release must carry the enumeration context"); + + // The connection is free again: a follow-up query runs on the same client. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void exhaustedWqlStreamReleasesTheConnectionWithoutRelease() throws Exception { + server.enqueue(200, envelope(enumerationDone(service("Spooler", "Running")))); + + try (WinRMClient client = builder().build()) { + final List names = new ArrayList<>(); + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + rows.map(row -> row.string("Name")).forEach(names::add); + } + assertEquals(List.of("Spooler"), names); + assertEquals(1, server.decryptedRequests().size()); + + // The permit was released on exhaustion: the client is immediately reusable. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void wqlStreamMapsOperationTimeoutFaultToInactivityTimeout() throws Exception { + server + .enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running")))) + .enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + final Iterator iterator = rows.iterator(); + assertEquals("Spooler", iterator.next().string("Name")); + + final WinRMTimeoutException e = assertThrows(WinRMTimeoutException.class, iterator::next); + assertTrue(e.getMessage().contains("timed out"), e.getMessage()); + } + // The enumeration state is unknown after the failure: no Release is pushed into it. + assertEquals(2, server.decryptedRequests().size()); + } + } + + @Test + void totalServerSilenceIsBoundedByTheInactivityTimeout() throws Exception { + server + .enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running")))) + // The Pull response arrives way past the inactivity timeout — a server that stopped + // answering entirely (no op-timeout fault). The socket read itself must give up at the + // inactivity bound, not 10 seconds later (the headroom the blocking paths keep). + .enqueueDelayed(200, envelope(pullDone(service("WinRM", "Running"))), 5_000); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(300)).build()) { + try (Stream rows = client.wql("SELECT Name FROM Win32_Service").stream()) { + final Iterator iterator = rows.iterator(); + assertEquals("Spooler", iterator.next().string("Name")); + + final long start = System.nanoTime(); + assertThrows(WinRMTimeoutException.class, iterator::next); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 4_000, + "silence must be detected at the inactivity timeout, not " + elapsedMillis + " ms later" + ); + } + } + } + + @Test + void quietTimeoutOnTheInitialEnumerateSurfacesAsInactivityTimeout() throws Exception { + // The op-timeout fault can answer the very first request too: stream() must report the + // documented timeout, not a generic WSMan fault. + server.enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + assertThrows(WinRMTimeoutException.class, () -> client.wql("SELECT Name FROM Win32_Service").stream()); + } + } + + @Test + void closingTheClientWhileAWqlStreamIsOpenLeavesTheStreamInert() throws Exception { + server.enqueue(200, envelope(enumeratePage("uuid:CTX-1", service("Spooler", "Running")))); + + try (WinRMClient client = builder().build()) { + final Stream rows = client.wql("SELECT Name FROM Win32_Service").stream(); + final Iterator iterator = rows.iterator(); + assertEquals("Spooler", iterator.next().string("Name")); + + // The client goes away while the stream still owns the connection: closing the stream + // afterward must not resurrect the transport (reconnect + re-authenticate) for a Release. + client.close(); + rows.close(); + + assertEquals(1, server.decryptedRequests().size()); + } + } + + @Test + void wqlStreamRejectsInvalidQueryBeforeSendingAnything() throws Exception { + try (WinRMClient client = builder().build()) { + assertThrows(WqlSyntaxException.class, () -> client.wql("Not a WQL query").stream()); + assertEquals(0, server.decryptedRequests().size()); + } + } + + // --- Command streaming --------------------------------------------------- + + /** Script the shell creation and command startup that precede every command exchange. */ + private void enqueueCommandStartup() { + server.enqueue(200, envelope(resourceCreated("SHELL-1"))).enqueue(200, envelope(commandResponse(COMMAND_ID))); + } + + private static String stdoutChunk(final String text) { + return stream("stdout", COMMAND_ID, text.getBytes(StandardCharsets.UTF_8)); + } + + private static String stderrChunk(final String text) { + return stream("stderr", COMMAND_ID, text.getBytes(StandardCharsets.UTF_8)); + } + + @Test + void startStreamsOutputWhileTheCommandIsStillRunning() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("line1\n"), null))) + .enqueue(200, envelope(receiveResponse(stdoutChunk("line2\n"), done(COMMAND_ID, 7)))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("dir /s").charset(StandardCharsets.UTF_8).start()) { + final BufferedReader stdout = process.stdout(); + + assertEquals("line1", stdout.readLine()); + // The first line was consumed while only one Receive had been answered: the command + // is still running from the client's point of view. + assertEquals(3, server.decryptedRequests().size()); + + assertEquals("line2", stdout.readLine()); + assertNull(stdout.readLine()); + assertEquals(7, process.waitFor()); + } + + final List requests = server.decryptedRequests(); + // Create, Command, Receive, Receive, Signal — and the close() after completion adds nothing. + assertEquals(5, requests.size()); + assertTrue(requests.get(4).contains("signal/terminate")); + } + } + + @Test + void interleavedChannelsAreSplitAndOrderIsPreservedPerChannel() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("out1\n") + stderrChunk("err1\n"), null))) + .enqueue(200, envelope(receiveResponse(stderrChunk("err2\n") + stdoutChunk("out2\n"), done(COMMAND_ID, 0)))) + .enqueue(200, envelope(signalResponse())); + + try ( + WinRMClient client = builder().build(); + RemoteProcess process = client.command("run").charset(StandardCharsets.UTF_8).start()) { + // Draining stdout first buffers whatever arrives on stderr in the meantime. + assertEquals(List.of("out1", "out2"), process.stdout().lines().collect(java.util.stream.Collectors.toList())); + assertEquals(List.of("err1", "err2"), process.stderr().lines().collect(java.util.stream.Collectors.toList())); + assertEquals(0, process.waitFor()); + } + } + + @Test + void multibyteCharacterSplitAcrossReceiveResponsesIsDecodedCorrectly() throws Exception { + final byte[] eAcute = "é".getBytes(StandardCharsets.UTF_8); // 0xC3 0xA9 + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stream("stdout", COMMAND_ID, new byte[] + { 'a', eAcute[0] }), null))) + .enqueue( + 200, + envelope(receiveResponse(stream("stdout", COMMAND_ID, new byte[] + { eAcute[1], 'b' }), done(COMMAND_ID, 0))) + ) + .enqueue(200, envelope(signalResponse())); + + try ( + WinRMClient client = builder().build(); + RemoteProcess process = client.command("type utf8.txt").charset(StandardCharsets.UTF_8).start()) { + assertEquals("aéb", process.stdout().readLine()); + assertEquals(0, process.waitFor()); + } + } + + @Test + void closingTheProcessEarlyTerminatesTheRemoteCommand() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("tick\n"), null))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("ping -t localhost").charset(StandardCharsets.UTF_8).start(); + assertEquals("tick", process.stdout().readLine()); + assertThrows(IllegalStateException.class, process::exitCode); + + process.close(); + + // The handle is inert after closing: buffered output only, then end of stream — reads + // and waits must not issue any further protocol request on a connection they no longer own. + assertNull(process.stdout().readLine()); + assertThrows(IllegalStateException.class, process::waitFor); + assertThrows(IllegalStateException.class, process::exitCode); + + final List requests = server.decryptedRequests(); + assertEquals(4, requests.size()); + assertTrue(requests.get(3).contains("signal/terminate"), "early close must Signal the command"); + + // The connection is free again after the early termination. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void closingAfterTheFinalChunkStillExposesTheExitCode() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("all\n"), done(COMMAND_ID, 5)))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("quick.exe").charset(StandardCharsets.UTF_8).start(); + // The final chunk (carrying the exit state) was received, but the end-of-stream fetch + // never ran: closing must still expose the exit code the command actually reported. + assertEquals("all", process.stdout().readLine()); + process.close(); + + assertEquals(5, process.exitCode()); + assertEquals(5, process.waitFor()); + assertNull(process.stdout().readLine()); + assertEquals(4, server.decryptedRequests().size()); + } + } + + @Test + void waitForDeadlineExpiresWhileTheCommandKeepsRunning() throws Exception { + enqueueCommandStartup(); + server + .enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("slow\n"), null)), 300) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("slow.exe").charset(StandardCharsets.UTF_8).start()) { + // A wait too short for any network round trip is waited out locally: no request goes + // to the wire, and the process is untouched. + assertFalse(process.waitFor(Duration.ofMillis(50)), "the command must still be running"); + assertEquals(2, server.decryptedRequests().size(), "a sub-round-trip wait must not touch the wire"); + + // The process remains fully usable: reading advances the protocol as usual. + assertEquals("slow", process.stdout().readLine()); + } + assertTrue(server.decryptedRequests().get(3).contains("signal/terminate")); + } + } + + @Test + void quietTimeoutDuringCommandStartupSurfacesAsInactivityTimeout() throws Exception { + // The op-timeout fault can answer the shell Create too: start() must report the documented + // timeout, not a generic WSMan fault. + server.enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + assertThrows( + WinRMTimeoutException.class, + () -> client.command("slow-start.exe").charset(StandardCharsets.UTF_8).start() + ); + } + } + + @Test + void closingTheClientWhileAProcessIsOpenLeavesTheProcessInert() throws Exception { + enqueueCommandStartup(); + server.enqueue(200, envelope(receiveResponse(stdoutChunk("tick\n"), null))); + + try (WinRMClient client = builder().build()) { + final RemoteProcess process = client.command("ping -t localhost").charset(StandardCharsets.UTF_8).start(); + assertEquals("tick", process.stdout().readLine()); + + // The client goes away while the process still owns the connection: closing the process + // afterward must not resurrect the transport (reconnect + re-authenticate) for a Signal. + client.close(); + process.close(); + + assertEquals(3, server.decryptedRequests().size()); + } + } + + @Test + void boundedPollTreatsAFaultWithinBudgetAsNothingYet() throws Exception { + enqueueCommandStartup(); + server + // The "nothing yet" op-timeout fault answering the bounded poll, arriving well within + // the poll's budget — a compliant server answering at the shortened OperationTimeout. + .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 150) + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 3)))) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + // The fault reads as an expired poll (empty chunk), not as a failure. + final CommandCursor.Chunk nothingYet = cursor.poll(2_000); + assertEquals(0, nothingYet.stdout().length + nothingYet.stderr().length); + + // The bounded Receive asked the server to answer EARLY: its OperationTimeout is the + // budget minus the fault-transit slack, so the answer arrives within the budget. + final String boundedReceive = server.decryptedRequests().get(2); + assertTrue(boundedReceive.contains("/Receive"), boundedReceive); + assertTrue( + boundedReceive.contains("PT1S<"), + "a 2 s poll must ask the server to answer within 1 s" + ); + + // The expired poll was non-destructive: the cursor completes normally afterward. + final CommandCursor.Chunk chunk = cursor.next(); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + assertNull(cursor.next()); + assertEquals(3, cursor.exitCode()); + } + } + } + + @Test + void deadPeerCannotHoldABoundedWaitHostage() throws Exception { + enqueueCommandStartup(); + server + // The peer answers the bounded Receive long after the wait: a peer that stopped + // answering. The wait must fail AT its deadline — the socket cuts at the poll budget + // itself, with no headroom a dead peer could hide behind. + .enqueueDelayed(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out."), 4_000) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("dead.exe").charset(StandardCharsets.UTF_8).start()) { + final long start = System.nanoTime(); + assertThrows(WinRMTimeoutException.class, () -> process.waitFor(Duration.ofMillis(200))); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "a dead peer must be detected at the bounded wait, not " + elapsedMillis + " ms later" + ); + // The server was asked to answer within half the 200 ms budget. + assertTrue(server.decryptedRequests().get(2).contains("PT0.1S<")); + } + // close() terminated the command over a fresh connection (the abandoned one was dropped). + final List requests = server.decryptedRequests(); + assertTrue(requests.get(requests.size() - 1).contains("signal/terminate")); + } + } + + @Test + void completionSignalIsBoundedByThePollBudget() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))) + // The Signal acknowledging the ALREADY-COMPLETED command stalls far past the poll + // budget: completion (and the known exit code) must win over the cleanup hiccup. + .enqueueDelayed(200, envelope(signalResponse()), 3_000); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.poll(5_000); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + + final long start = System.nanoTime(); + assertNull(cursor.poll(1_000), "completion must be reported"); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 2_500, + "the completion Signal must not outlive the poll budget; took " + elapsedMillis + " ms" + ); + assertEquals(5, cursor.exitCode()); + } + } + } + + @Test + void completionArrivingNearTheDeadlineIsStillReported() throws Exception { + enqueueCommandStartup(); + // The final Done-carrying response lands close to the wait's deadline: too little budget is + // left for a wire Signal, but the completion happened WITHIN the wait and must be reported + // as such — never as a spurious expiry. + server.enqueueDelayed(200, envelope(receiveResponse(stdoutChunk("late\n"), done(COMMAND_ID, 9))), 520); + + try (WinRMClient client = builder().build()) { + try (RemoteProcess process = client.command("barely.exe").charset(StandardCharsets.UTF_8).start()) { + assertTrue(process.waitFor(Duration.ofMillis(600)), "completion within the wait must be reported"); + assertEquals(9, process.exitCode()); + assertEquals("late", process.stdout().readLine()); + // The leftover budget could not fit a Signal round trip: none was sent. + assertEquals(3, server.decryptedRequests().size()); + } + } + } + + @Test + void faultAnsweringTheCompletionSignalDoesNotHideCompletion() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))) + // The Signal acknowledging the ALREADY-COMPLETED command is answered with a fault: pure + // cleanup noise — the completion and its exit code must win. + .enqueue(500, fault("999", "Signal rejected")); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.poll(5_000); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + assertNull(cursor.poll(5_000), "completion must be reported despite the Signal fault"); + assertEquals(5, cursor.exitCode()); + } + + // The fault was a complete, in-sync exchange: the connection remains usable. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void faultAnsweringTheCompletionSignalDoesNotFailThePlainFetchEither() throws Exception { + enqueueCommandStartup(); + server + .enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))) + // Same as the bounded-poll case, but on the ordinary next() path: reading EOF from a + // COMPLETED command must not fail because the cleanup Signal was answered with a fault. + .enqueue(500, fault("999", "Signal rejected")); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.next(); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + assertNull(cursor.next(), "completion must be reported despite the Signal fault"); + assertEquals(5, cursor.exitCode()); + } + + // The fault was a complete, in-sync exchange: the connection remains usable. + server.enqueue(200, envelope(enumerationDone(service("WinRM", "Running")))); + assertEquals(1, client.wql("SELECT Name FROM Win32_Service").execute().size()); + } + } + + @Test + void completionInsideATinyPollSkipsTheSignal() throws Exception { + enqueueCommandStartup(); + server.enqueue(200, envelope(receiveResponse(stdoutChunk("done\n"), done(COMMAND_ID, 5)))); + + try (WinRMClient client = builder().build()) { + try (CommandCursor cursor = client.executor().startCommand("run.exe", null, 10_000)) { + final CommandCursor.Chunk chunk = cursor.poll(5_000); + assertEquals("done\n", new String(chunk.stdout(), StandardCharsets.UTF_8)); + + // No round trip fits in the remaining budget: completion is reported without a wire + // Signal, and the healthy connection is left untouched. + assertNull(cursor.poll(20)); + assertEquals(5, cursor.exitCode()); + assertEquals(3, server.decryptedRequests().size(), "a tiny-budget completion must not touch the wire"); + } + } + } + + @Test + void commandSilenceBeyondTheTimeoutSurfacesAsInactivityTimeout() throws Exception { + enqueueCommandStartup(); + server + .enqueue(500, fault(FAULT_OPERATION_TIMEOUT, "The operation timed out.")) + .enqueue(200, envelope(signalResponse())); + + try (WinRMClient client = builder().timeout(Duration.ofMillis(500)).build()) { + try (RemoteProcess process = client.command("silent.exe").charset(StandardCharsets.UTF_8).start()) { + final WinRMTimeoutException e = assertThrows(WinRMTimeoutException.class, process::waitFor); + assertTrue(e.getMessage().contains("no output"), e.getMessage()); + } + // Closing after the failure still terminates the remote command. + assertTrue(server.decryptedRequests().get(3).contains("signal/terminate")); + } + } + + // --- onStdout / onStderr callbacks ---------------------------------------- + + @Test + void callbacksReceiveChunksAsTheyArriveAndTheResultIsComplete() throws Exception { + final byte[] eAcute = "é".getBytes(StandardCharsets.UTF_8); + enqueueCommandStartup(); + server + // The first chunk ends with half of a UTF-8 character: the callback must not see it + // until the second chunk completes it. + .enqueue( + 200, + envelope( + receiveResponse(stream("stdout", COMMAND_ID, concat("first".getBytes(StandardCharsets.UTF_8), new byte[] + { eAcute[0] })), null) + ) + ) + .enqueue( + 200, + envelope( + receiveResponse( + stream("stdout", COMMAND_ID, concat(new byte[] + { eAcute[1] }, "second".getBytes(StandardCharsets.UTF_8))) + + stderrChunk("warning"), + done(COMMAND_ID, 3) + ) + ) + ) + .enqueue(200, envelope(signalResponse())); + + final List stdoutChunks = new ArrayList<>(); + final List stderrChunks = new ArrayList<>(); + try (WinRMClient client = builder().build()) { + final CommandResult result = client + .command("chatty.exe") + .charset(StandardCharsets.UTF_8) + .onStdout(stdoutChunks::add) + .onStderr(stderrChunks::add) + .execute(); + + assertEquals(List.of("first", "ésecond"), stdoutChunks); + assertEquals(List.of("warning"), stderrChunks); + assertEquals("firstésecond", result.stdout()); + assertEquals("warning", result.stderr()); + assertEquals(3, result.exitCode()); + } + } + + // --- SPI defaults ---------------------------------------------------------- + + @Test + void executorsWithoutStreamingSupportRejectTheStreamingEntryPoints() { + final WindowsRemoteExecutor executor = new ScriptedWindowsRemoteExecutor(); + assertThrows(UnsupportedOperationException.class, () -> executor.streamWql("ROOT\\CIMV2", "SELECT 1", 1000, 10, 0)); + assertThrows(UnsupportedOperationException.class, () -> executor.startCommand("dir", null, 1000)); + } + + private static byte[] concat(final byte[] a, final byte[] b) { + final byte[] result = new byte[a.length + b.length]; + System.arraycopy(a, 0, result, 0, a.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } +} diff --git a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java index 698c31f..24e35dd 100644 --- a/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java +++ b/src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java @@ -39,12 +39,9 @@ import java.util.List; import java.util.Map; import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; -import org.metricshub.winrm.WinRMHttpProtocolEnum; -import org.metricshub.winrm.WindowsRemoteCommandResult; import org.metricshub.winrm.light.FakeWsmanServer; -import org.metricshub.winrm.light.LightWinRMService; -import org.metricshub.winrm.service.WinRMEndpoint; class WinRmCliTest { @@ -61,6 +58,8 @@ void helpAndVersionDoNotConnect() throws Exception { assertTrue(help.stdout.contains("-P, --port")); assertTrue(help.stdout.contains("--kerberos-kdc")); assertTrue(help.stdout.contains("--kerberos-realm")); + // The details (streaming behavior, password files, exit codes) live in the online manual. + assertTrue(help.stdout.contains("https://metricshub.org/winrm-java/cli.html")); assertEquals("", help.stderr); final Invocation version = invoke(new String[] { "--version" }, arguments -> failingRemote()); @@ -99,7 +98,9 @@ void writesWqlAsJsonLines() throws Exception { @Test void forwardsCommandStreamsAndExitCode() throws Exception { final FakeRemote remote = new FakeRemote(); - remote.commandResult = new WindowsRemoteCommandResult("output", "warning", 0.1f, 7); + remote.stdoutChunks = List.of("out", "put"); + remote.stderrChunks = List.of("warning"); + remote.commandExitCode = 7; final Invocation invocation = invoke(concat(REQUIRED, "exec", "echo", "hello world"), args -> remote); @@ -258,35 +259,39 @@ void honorsAnAmbientInsecureTlsProperty() throws Exception { @Test void decodesCommandOutputUsingTheRemoteWindowsCodePage() throws Exception { final Charset windowsCharset = Charset.forName("windows-1251"); - final long timeout = 30_000L; - // End to end against the in-process WSMan server: the remote reports Windows code page - // 1251 and the command output arrives in that encoding — the CLI must query the code page - // and decode the stream bytes with it, or the Cyrillic output turns into mojibake. + // Full stack against the in-process WSMan server, through the CLI's real connect factory + // and its streaming forwarders: the remote reports Windows code page 1251 and the command + // output arrives in that encoding — the CLI must query the code page and decode the stream + // bytes with it, or the Cyrillic output turns into mojibake. try (FakeWsmanServer server = new FakeWsmanServer("FAKE", "user", "secret")) { enqueueEnumeration(server, instance("Win32_OperatingSystem", "CodeSet", "1251")); enqueueShellCreation(server); enqueueCommandExchange(server, "Результат".getBytes(windowsCharset), new byte[0], 0); enqueueShellDeletion(server); - final WinRMEndpoint endpoint = new WinRMEndpoint( - WinRMHttpProtocolEnum.HTTP, - "127.0.0.1", - server.port(), - "FAKE\\user", - "secret".toCharArray(), - null + final Invocation invocation = invoke( + new String[] + { + "-h", + "127.0.0.1", + "-P", + String.valueOf(server.port()), + "-u", + "FAKE\\user", + "-p", + "secret", + "-t", + "30000", + "exec", + "whoami" + }, + WinRmCli::connect ); - final WindowsRemoteCommandResult result; - try ( - WinRmCli.LightRemoteOperations remote = new WinRmCli.LightRemoteOperations( - LightWinRMService.createInstance(endpoint, timeout, null, null) - )) { - result = remote.executeCommand("whoami", timeout); - } - assertEquals("Результат", result.getStdout()); - assertEquals(0, result.getStatusCode()); + assertEquals(0, invocation.exitCode); + assertEquals("Результат", invocation.stdout); + assertEquals("", invocation.stderr); // The decoding charset really came from the remote code-page query assertTrue( @@ -362,22 +367,32 @@ private Invocation(final int exitCode, final String stdout, final String stderr) private static final class FakeRemote implements WinRmCli.RemoteOperations { private List> rows = List.of(); - private WindowsRemoteCommandResult commandResult = new WindowsRemoteCommandResult("", "", 0.0f, 0); + private List stdoutChunks = List.of(); + private List stderrChunks = List.of(); + private int commandExitCode; private Exception failure; private String command; private boolean closed; @Override - public List> executeWql(final String query, final long timeout) throws Exception { + public void streamWql(final String query, final long timeout, final Consumer> rowConsumer) + throws Exception { failIfConfigured(); - return rows; + rows.forEach(rowConsumer); } @Override - public WindowsRemoteCommandResult executeCommand(final String command, final long timeout) throws Exception { + public int executeCommand( + final String command, + final long timeout, + final Consumer stdoutConsumer, + final Consumer stderrConsumer + ) throws Exception { this.command = command; failIfConfigured(); - return commandResult; + stdoutChunks.forEach(stdoutConsumer); + stderrChunks.forEach(stderrConsumer); + return commandExitCode; } @Override diff --git a/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java new file mode 100644 index 0000000..96590ea --- /dev/null +++ b/src/test/java/org/metricshub/winrm/light/HttpTransportDeadlineTest.java @@ -0,0 +1,195 @@ +package org.metricshub.winrm.light; + +/*- + * ╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲╱╲ + * 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +/** + * Unit test of the {@link HttpTransport#pollTimeout(int)} deadline: one deadline-bounded poll may + * span several HTTP round trips (a reconnect plus a re-authentication exchange), and every leg + * must be capped by what is LEFT of the poll's budget — a peer answering each leg just fast enough + * must not be able to stretch the poll to several multiples of the requested wait. + */ +class HttpTransportDeadlineTest { + + @Test + void everyLegOfABoundedPollSharesOneDeadline() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + final Thread handler = new Thread(() -> serveSlowly(server), "slow-http-server"); + handler.setDaemon(true); + handler.start(); + + final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); + try { + // One 2 s budget for EVERY leg together. The server answers each leg after 600 ms — + // fast enough for any single leg, so only the shared deadline can stop the sequence + // (legs 1-3 complete by ~1.8 s, leg 4 runs out of budget at 2 s). + transport.pollTimeout(2_000); + final long start = System.nanoTime(); + assertThrows( + SocketTimeoutException.class, + () -> { + for (int leg = 0; leg < 8; leg++) { + transport.post("/wsman", new byte[0], null, null); + } + } + ); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "the legs must share the poll deadline, not get a fresh timeout each; took " + elapsedMillis + " ms" + ); + } finally { + transport.close(); + } + } // closing the ServerSocket unblocks the handler thread + } + + @Test + void aTricklingPeerCannotStretchABoundedPollPastItsDeadline() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + startTricklingServer(server); + + final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); + try { + transport.pollTimeout(2_000); + final long start = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> transport.post("/wsman", new byte[0], null, null)); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "each response byte must be capped by the shared deadline; took " + elapsedMillis + " ms" + ); + } finally { + transport.close(); + } + } + } + + @Test + void aTricklingPeerCannotStretchAStreamingRoundTrip() throws Exception { + try (ServerSocket server = new ServerSocket(0)) { + startTricklingServer(server); + + final HttpTransport transport = new HttpTransport("127.0.0.1", server.getLocalPort(), 60_000); + try { + // Streaming (inactivity) mode: one WHOLE response must arrive within the timeout — + // a peer trickling one byte per 300 ms must not restart the clock with every byte. + transport.inactivityTimeout(2_000); + final long start = System.nanoTime(); + assertThrows(SocketTimeoutException.class, () -> transport.post("/wsman", new byte[0], null, null)); + final long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + assertTrue( + elapsedMillis < 3_000, + "the whole response must be bounded by the inactivity timeout; took " + elapsedMillis + " ms" + ); + } finally { + transport.close(); + } + } + } + + /** + * One connection: read the request head, then trickle the response one byte every 300 ms. + * {@code SO_TIMEOUT} applies per read, so without an absolute bound every byte would reset the + * clock and the ~40-byte response would take ~12 s. + */ + private static void startTricklingServer(final ServerSocket server) { + final Thread handler = new Thread( + () -> { + try (Socket socket = server.accept()) { + final InputStream in = new BufferedInputStream(socket.getInputStream()); + final OutputStream out = socket.getOutputStream(); + if (readRequestHead(in)) { + for (final byte b : "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".getBytes( + StandardCharsets.ISO_8859_1 + )) { + Thread.sleep(300); + out.write(b); + out.flush(); + } + } + } catch (final IOException | InterruptedException ignored) { + // client timed out or test over + } + }, + "trickle-http-server" + ); + handler.setDaemon(true); + handler.start(); + } + + /** Serve every request of every connection with a minimal 200 response, 600 ms late. */ + private static void serveSlowly(final ServerSocket server) { + try { + while (true) { + final Socket socket = server.accept(); + final Thread connection = new Thread(() -> serveConnection(socket), "slow-http-conn"); + connection.setDaemon(true); + connection.start(); + } + } catch (final IOException ignored) { + // server socket closed: test over + } + } + + private static void serveConnection(final Socket socket) { + try (socket) { + final InputStream in = new BufferedInputStream(socket.getInputStream()); + final OutputStream out = socket.getOutputStream(); + while (readRequestHead(in)) { + Thread.sleep(600); + out.write("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1)); + out.flush(); + } + } catch (final IOException | InterruptedException ignored) { + // connection torn down: client timed out or test over + } + } + + /** Consume one request head (the posts of this test carry no body). */ + private static boolean readRequestHead(final InputStream in) throws IOException { + int matched = 0; + int b; + while ((b = in.read()) != -1) { + // A request head ends with CRLFCRLF. + if ((matched % 2 == 0 && b == '\r') || (matched % 2 == 1 && b == '\n')) { + if (++matched == 4) { + return true; + } + } else { + matched = b == '\r' ? 1 : 0; + } + } + return false; + } +}