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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions src/main/java/org/metricshub/winrm/WinRMClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ public static final class Builder {
private boolean allowDelegation;
private boolean trustAllCertificates;
private int consoleCodePage;
private boolean loadUserProfile;
private SSLContext sslContext;
private Duration timeout = DEFAULT_TIMEOUT;
private int retries;
Expand Down Expand Up @@ -587,6 +588,27 @@ public Builder consoleCodePage(final int consoleCodePage) {
return this;
}

/**
* Load the user profile in the remote command shell — the opposite of
* {@code winrs -noprofile}. Default: the profile is not loaded, and unless something else
* already loaded it on the host (an interactive session, for example), commands run with
* the default profile: {@code %USERPROFILE%} is {@code C:\Users\Default}, {@code %APPDATA%}
* is not set, and {@code HKEY_CURRENT_USER} is not the user's own registry hive.
* <p>
* The setting applies to every shell this client creates — for commands, file transfers and
* remote file operations — including a shell silently recreated after the server reaped the
* previous one. {@code winrs} documents that loading the profile requires the user to be a
* local administrator on the host; when the host refuses it, the shell creation fails with a
* {@link org.metricshub.winrm.exceptions.WinRMFaultException} carrying the fault code and
* detail.
*
* @return this builder
*/
public Builder loadUserProfile() {
this.loadUserProfile = true;
return this;
}

/**
* Build the client. This does not connect yet: the connection is established and
* authenticated by the first operation.
Expand Down Expand Up @@ -633,6 +655,7 @@ public WinRMClient build() {
sslContext,
trustAllCertificates,
consoleCodePage,
loadUserProfile,
retries,
toMillis(retryDelay)
);
Expand Down
13 changes: 13 additions & 0 deletions src/main/java/org/metricshub/winrm/cli/CliArguments.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ enum Operation {
private final boolean forwardStdin;
private final String directory;
private final Map<String, String> environment;
private final boolean loadUserProfile;
private final String input;
private final Path localFile;
private final String glob;
Expand Down Expand Up @@ -118,6 +119,7 @@ private CliArguments(final Builder builder) {
forwardStdin = builder.forwardStdin;
directory = builder.directory;
environment = builder.environment;
loadUserProfile = builder.loadUserProfile;
input = builder.input;
localFile = builder.localFile;
glob = builder.glob;
Expand Down Expand Up @@ -206,6 +208,9 @@ private static int parseOption(final Builder builder, final String[] arguments,
case "--env":
parseEnvironmentVariable(builder, optionValue(arguments, index, option), option);
return nextIndex(argument, index);
case "--profile":
builder.loadUserProfile = true;
return index + 1;
case "--ntlm":
builder.ntlm = true;
return index + 1;
Expand Down Expand Up @@ -470,6 +475,9 @@ private static void validate(final Builder builder) throws CliUsageException {
if (!builder.environment.isEmpty() && !runsInShell) {
throw new CliUsageException("--env requires the command or shell subcommand");
}
if (builder.loadUserProfile && !runsInShell) {
throw new CliUsageException("--profile requires the command or shell subcommand");
}
if (builder.filesOnly && builder.directoriesOnly) {
throw new CliUsageException("--files-only and --directories-only are mutually exclusive");
}
Expand Down Expand Up @@ -731,6 +739,10 @@ Map<String, String> environment() {
return environment;
}

boolean loadUserProfile() {
return loadUserProfile;
}

/** The query, the command line, or the remote path of a file subcommand. */
String input() {
return input;
Expand Down Expand Up @@ -815,6 +827,7 @@ private static final class Builder {
private boolean forwardStdin;
private String directory;
private final Map<String, String> environment = new LinkedHashMap<>();
private boolean loadUserProfile;
private Integer port;
private long timeout = DEFAULT_TIMEOUT;
private String input;
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/org/metricshub/winrm/cli/WinRmCli.java
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,9 @@ static FluentRemoteOperations connect(final CliArguments arguments, final int co
if (arguments.allowDelegate()) {
builder.allowDelegation();
}
if (arguments.loadUserProfile()) {
builder.loadUserProfile();
}
final List<AuthenticationEnum> authentications = arguments.authentications();
if (authentications != null && !authentications.isEmpty()) {
builder.authentication(
Expand Down Expand Up @@ -734,6 +737,7 @@ private static String help() {
" -t, --timeout <ms> Operation timeout in milliseconds (default: 60000)\n" +
" -d, --directory <path> Working directory of the remote command or shell\n" +
" --env <NAME=VALUE> Environment variable of the remote command or shell (repeatable)\n" +
" --profile Load the user profile in the remote command or shell\n" +
" -i, --stdin Forward the local standard input to the remote command\n" +
" --https Use HTTPS\n" +
" --https-permissive Trust any HTTPS certificate and hostname (insecure)\n" +
Expand Down
7 changes: 5 additions & 2 deletions src/main/java/org/metricshub/winrm/light/Envelopes.java
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,19 @@ static String release(final String url, final String namespace, final String con
* empty omits the {@code rsp:Environment} block
* @param codePage the console code page of the shell ({@code WINRS_CODEPAGE}); 0 uses
* {@link #CODEPAGE_UTF8}, the default that makes every command's output UTF-8
* @param loadUserProfile whether the shell loads the user profile ({@code WINRS_NOPROFILE} is
* its negation)
*/
static String createShell(
final String url,
final String workingDirectory,
final Map<String, String> environment,
final long timeoutMs,
final int codePage
final int codePage,
final boolean loadUserProfile
) {
final String optionSet = "<wsman:OptionSet>" +
"<wsman:Option Name=\"WINRS_NOPROFILE\">TRUE</wsman:Option>" +
"<wsman:Option Name=\"WINRS_NOPROFILE\">" + (loadUserProfile ? "FALSE" : "TRUE") + "</wsman:Option>" +
"<wsman:Option Name=\"WINRS_CODEPAGE\">" + (codePage > 0 ? String.valueOf(codePage) : CODEPAGE_UTF8)
+ "</wsman:Option>" +
"</wsman:OptionSet>";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,16 @@ public static LightWinRMService createInstance(
sslContext,
trustAllCertificates,
consoleCodePage,
false,
connectRetries,
retryDelay
);
}

/**
* Create a light WinRM executor that may delegate the caller's Kerberos credentials to the host,
* so remote commands can authenticate onward as the caller (the second hop).
* so remote commands can authenticate onward as the caller (the second hop), and may load the
* user profile in the command shell.
*
* @param winRMEndpoint endpoint with credentials (mandatory)
* @param timeout timeout in milliseconds (must be &gt; 0)
Expand All @@ -225,6 +227,8 @@ public static LightWinRMService createInstance(
* server certificate and skip hostname verification — insecure, testing only
* @param consoleCodePage the console code page of the command shell; 0 keeps the default 65001,
* which makes command output UTF-8 whatever the remote locale
* @param loadUserProfile whether the command shell loads the user profile (registry hive,
* per-user environment variables); {@code false} is the historical behavior
* @param connectRetries how many times one round trip may re-attempt to connect and authenticate
* (must be &gt;= 0); 0 keeps the historical fail-fast behavior
* @param retryDelay the pause in milliseconds before each retry (must be &gt;= 0)
Expand All @@ -240,6 +244,7 @@ public static LightWinRMService createInstance(
final SSLContext sslContext,
final boolean trustAllCertificates,
final int consoleCodePage,
final boolean loadUserProfile,
Comment thread
bertysentry marked this conversation as resolved.
final int connectRetries,
final long retryDelay
) throws WinRMException {
Expand Down Expand Up @@ -292,6 +297,7 @@ public static LightWinRMService createInstance(
authScheme,
winRMEndpoint.getRawUsername(),
consoleCodePage,
loadUserProfile,
connectRetries,
retryDelay
);
Expand Down
5 changes: 4 additions & 1 deletion src/main/java/org/metricshub/winrm/light/WsmanClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ final class WsmanClient implements AutoCloseable {

private final long timeoutMs;
private final int consoleCodePage;
private final boolean loadUserProfile;
private final String url;
private final String rawUsername;
private final AuthScheme auth;
Expand Down Expand Up @@ -183,11 +184,13 @@ private static void checkNotCancelled() throws InterruptedException {
final AuthScheme auth,
final String rawUsername,
final int consoleCodePage,
final boolean loadUserProfile,
final int connectRetries,
final long retryDelayMs
) {
this.timeoutMs = timeoutMs;
this.consoleCodePage = consoleCodePage;
this.loadUserProfile = loadUserProfile;
this.connectRetries = connectRetries;
this.retryDelayMs = retryDelayMs;
// A non-null socket factory selects HTTPS: TLS wraps the transport and the SOAP travels plaintext.
Expand Down Expand Up @@ -962,7 +965,7 @@ private void createShell(
final boolean failOnQuietTimeout
) throws Exception {
final Document doc = exchange(
Envelopes.createShell(url, workingDirectory, environment, timeoutMs, consoleCodePage),
Envelopes.createShell(url, workingDirectory, environment, timeoutMs, consoleCodePage, loadUserProfile),
"Create shell",
timeoutMs,
failOnQuietTimeout
Expand Down
3 changes: 2 additions & 1 deletion src/site/markdown/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ own options, in any order.
| `-pf, --password-file <file>` | Read the password from a UTF-8 file (preferred for automation, see below). |
| `-P, --port <port>` | Target port. Default: 5985 for HTTP, 5986 for HTTPS. |
| `-t, --timeout <ms>` | Operation timeout in milliseconds. Default: 60000. See [Timeout semantics](#timeout-semantics). |
| `-d, --directory <path>` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the remote user's profile directory. |
| `-d, --directory <path>` | Working directory the remote command or interactive shell starts in, like `winrs -d` (only with `command` and `shell`). Default: the user's profile directory, or `C:\Users\Default` when that profile is not loaded (see `--profile`). |
| `--env <NAME=VALUE>` | Environment variable set in the remote shell, like `winrs -env` (only with `command` and `shell`). Repeatable — one occurrence per variable; the value is split on the first `=`, so it may itself contain `=`. |
| `--profile` | Load the user profile in the remote shell (only with `command` and `shell`): `%APPDATA%`, the user's `HKEY_CURRENT_USER` hive. Not loaded by default, the reverse of `winrs`, where `-noprofile` turns it off; see [Loading the user profile](commands.html#loading-the-user-profile). |
| `-i, --stdin` | Forward the local standard input to the remote command (only with `command`); see below. |
| `--https` | Connect over HTTPS. |
| `--https-permissive` | Trust any HTTPS certificate and hostname. Intentionally insecure: testing and isolated hosts only. Requires `--https`. |
Expand Down
27 changes: 27 additions & 0 deletions src/site/markdown/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ Everything between `command(...)` and `execute()` is optional:
| `stdinCharset(Charset)` | the output charset | The charset used to *encode* standard input, when it differs from the output charset (see below). |
| `onStdout(Consumer<String>)` / `onStderr(Consumer<String>)` | none | Callbacks receiving each chunk of output live while `execute()` runs (see below). |

### Loading the user profile

By default the remote shell does **not** load the user profile, the equivalent of
`winrs -noprofile`. A command then sees the user's own profile only if something else already
loaded it on the host, such as an interactive or disconnected session. Otherwise the shell runs
with the default profile: `%USERPROFILE%` and the starting directory are `C:\Users\Default`,
`%APPDATA%` and `%LOCALAPPDATA%` are not set, `%TEMP%` is `C:\Windows\Temp`, and
`HKEY_CURRENT_USER` is not the user's registry hive. When a command needs them, build the client
with `loadUserProfile()`:

```java
try (WinRMClient client = WinRMClient.builder("server.example.com")
.credentials("DOMAIN\\user", password)
.loadUserProfile()
.build()) {
client.command("reg query HKCU\\Software\\Vendor").execute();
}
```

The profile is loaded when the remote shell is created, so this is a client setting. It applies
to every shell the client creates, for commands, file transfers and remote file operations alike,
including a shell recreated after the server reaped the previous one. Microsoft's `winrs`
documentation warns that loading the profile fails for a user who is not a local administrator on
the host: the command then fails with a
[`WinRMFaultException`](apidocs/org/metricshub/winrm/exceptions/WinRMFaultException.html) carrying
the fault code and detail. The CLI's `--profile` option does the same.

## Running PowerShell

`powerShell(...)` prepares a PowerShell script execution the same way `command(...)` prepares a
Expand Down
4 changes: 4 additions & 0 deletions src/site/markdown/migrating-from-winrm4j.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@ the switch:
which mangles any non-ASCII output on non-English hosts. This client creates the remote shell
with code page **65001 (UTF-8)**, so accented and non-Latin output decodes correctly whatever
the remote locale — no configuration needed ([Character encoding](commands.html#character-encoding)).
* **The user profile is not loaded by default.** winrm4j hardcodes `WINRS_NOPROFILE=FALSE`, so its
commands see the user's profile: `%APPDATA%`, the user's `HKEY_CURRENT_USER` hive. This client
does not load it unless the builder calls `loadUserProfile()`
([Loading the user profile](commands.html#loading-the-user-profile)).
* **Timeout semantics.** winrm4j's `operationTimeout` is the WSMan `Receive` polling timeout (how
long each poll waits for output), and separate CXF settings govern connect/receive at the HTTP
level. Here a single `timeout(Duration)` (default 30&nbsp;s) is a **wall-clock deadline for the
Expand Down
6 changes: 5 additions & 1 deletion src/test/java/org/metricshub/winrm/WinRMClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,7 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception {
)
.enqueue(200, envelope(signalResponse()));

try (WinRMClient client = builder(PASSWORD).build()) {
try (WinRMClient client = builder(PASSWORD).loadUserProfile().build()) {
assertEquals(
"first",
client
Expand Down Expand Up @@ -868,6 +868,10 @@ void expiredCachedShellIsRecreatedAndTheCommandRetried() throws Exception {
.contains("<rsp:Environment><rsp:Variable Name=\"BUILD_NUMBER\">42</rsp:Variable></rsp:Environment>"),
creates.get(1)
);
// The client's loadUserProfile() rides every shell it creates, the recreated one included.
for (final String create : creates) {
assertTrue(create.contains("<wsman:Option Name=\"WINRS_NOPROFILE\">FALSE</wsman:Option>"), create);
}
}

@Test
Expand Down
5 changes: 5 additions & 0 deletions src/test/java/org/metricshub/winrm/cli/CliArgumentsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ void parsesDefaultsAndClearsDirectPassword() throws Exception {
assertEquals(CliArguments.DEFAULT_TIMEOUT, parsed.timeout());
assertEquals(List.of(AuthenticationEnum.NTLM), parsed.authentications());
assertFalse(parsed.allowDelegate());
assertFalse(parsed.loadUserProfile());

final char[] password = parsed.password();
parsed.close();
Expand Down Expand Up @@ -422,6 +423,10 @@ void rejectsInvalidArguments() {
"--env requires the command or shell subcommand",
concat(base, "--env", "A=b", "wql", "SELECT Name FROM Win32_Service")
},
{
"--profile requires the command or shell subcommand",
concat(base, "--profile", "wql", "SELECT Name FROM Win32_Service")
},
{ "-P must be between 1 and 65535", concat(base, "-P", "65536", "command", "whoami") },
{ "-t must be greater than zero", concat(base, "-t", "0", "command", "whoami") },
{ "missing subcommand (wql, command, shell, ls, stat, cat, or get)", base },
Expand Down
7 changes: 7 additions & 0 deletions src/test/java/org/metricshub/winrm/cli/WinRmCliTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ void helpAndVersionDoNotConnect() throws Exception {
assertTrue(help.stdout.contains("-P, --port"));
assertTrue(help.stdout.contains("-d, --directory"));
assertTrue(help.stdout.contains("--env <NAME=VALUE>"));
assertTrue(help.stdout.contains("--profile"));
assertTrue(help.stdout.contains("--kerberos-kdc"));
assertTrue(help.stdout.contains("--kerberos-realm"));
assertTrue(help.stdout.contains("--allow-delegate"));
Expand Down Expand Up @@ -201,6 +202,7 @@ void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception {
"C:\\build",
"--env",
"BUILD_NUMBER=42",
"--profile",
"exec",
"build.cmd"
},
Expand All @@ -216,6 +218,8 @@ void sendsTheWorkingDirectoryInTheCreateShellRequest() throws Exception {
create.contains("<rsp:Environment><rsp:Variable Name=\"BUILD_NUMBER\">42</rsp:Variable></rsp:Environment>"),
create
);
// And --profile, as the WINRS_NOPROFILE option.
assertTrue(create.contains("<wsman:Option Name=\"WINRS_NOPROFILE\">FALSE</wsman:Option>"), create);
}
}

Expand Down Expand Up @@ -288,6 +292,7 @@ public int read() {
"C:\\build",
"--env",
"CONFIG=release",
"--profile",
"shell"
},
WinRmCli::connect,
Expand All @@ -304,6 +309,8 @@ public int read() {
create.contains("<rsp:Environment><rsp:Variable Name=\"CONFIG\">release</rsp:Variable></rsp:Environment>"),
create
);
// The session's own connection carries --profile too.
assertTrue(create.contains("<wsman:Option Name=\"WINRS_NOPROFILE\">FALSE</wsman:Option>"), create);
final String command = requests.get(2);
assertTrue(command.contains("<rsp:Command>cmd.exe /Q</rsp:Command>"), command);
assertTrue(command.contains("<wsman:Option Name=\"WINRS_CONSOLEMODE_STDIN\">FALSE</wsman:Option>"), command);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ void closeWhileOperationInFlightStillErasesTheBasicCredential() throws Exception
scheme,
USERNAME,
65001,
false,
0,
0L
);
Expand Down
Loading