From d4b0347ac40ed55a54854fc5b5eb229d731d0c82 Mon Sep 17 00:00:00 2001 From: crschnick Date: Thu, 25 Jun 2026 13:57:45 +0000 Subject: [PATCH] Rework RDP --- .../xpipe/app/cred/CustomAgentStrategy.java | 3 +- .../xpipe/app/ext/ProcessControlProvider.java | 3 + .../xpipe/app/hub/comp/StoreCreationMenu.java | 20 + .../java/io/xpipe/app/prefs/AppPrefs.java | 1 + .../java/io/xpipe/app/rdp/FreeRdpClient.java | 12 + .../java/io/xpipe/app/rdp/MstscRdpClient.java | 89 ++-- .../io/xpipe/app/rdp/RdpLaunchConfig.java | 18 + .../xpipe/app/rdp/RdpLaunchConfigGateway.java | 18 + .../io/xpipe/app/rdp/RemminaRdpClient.java | 29 +- .../app/terminal/OhMyPoshTerminalPrompt.java | 4 +- .../app/terminal/StarshipTerminalPrompt.java | 4 +- .../java/io/xpipe/app/util/CredAdvapi32.java | 407 ++++++++++++++++++ .../app/util/NativeWinWindowControl.java | 4 +- .../java/io/xpipe/app/util/RemminaHelper.java | 42 +- .../java/io/xpipe/app/util/Validators.java | 10 + .../main/java/io/xpipe/app/util/WinCred.java | 107 +++++ lang/strings/translations_en.properties | 20 +- 17 files changed, 694 insertions(+), 97 deletions(-) create mode 100644 app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfigGateway.java create mode 100644 app/src/main/java/io/xpipe/app/util/CredAdvapi32.java create mode 100644 app/src/main/java/io/xpipe/app/util/WinCred.java diff --git a/app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java b/app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java index 8ee1ebb4b..e20b64ddf 100644 --- a/app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java +++ b/app/src/main/java/io/xpipe/app/cred/CustomAgentStrategy.java @@ -9,6 +9,7 @@ import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.platform.Validator; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.process.CommandBuilder; +import io.xpipe.app.process.LocalShell; import io.xpipe.app.process.ShellControl; import io.xpipe.app.util.DocumentationLink; import io.xpipe.app.util.Validators; @@ -129,7 +130,7 @@ public class CustomAgentStrategy implements SshIdentityAgentStrategy { agent = AppPrefs.get().defaultSshAgentSocket().getValue(); } if (agent != null) { - return agent.resolveTildeHome(sc.view().userHome()); + return FilePath.of(LocalShell.getShell().getShellDialect().evaluateExpression(LocalShell.getShell(), agent.toString()).readStdoutOrThrow()); } } diff --git a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java index 11c6b1e80..8c0fe02a5 100644 --- a/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java +++ b/app/src/main/java/io/xpipe/app/ext/ProcessControlProvider.java @@ -16,6 +16,7 @@ import javafx.beans.property.Property; import io.modelcontextprotocol.spec.McpSchema; +import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.Optional; @@ -91,4 +92,6 @@ public abstract class ProcessControlProvider { public abstract void refreshWsl(); public abstract void showWebtopDeploymentDialog(); + + public abstract void importRdpFile(Path file) throws Exception; } diff --git a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java index 6e3606275..f993e86d7 100644 --- a/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java +++ b/app/src/main/java/io/xpipe/app/hub/comp/StoreCreationMenu.java @@ -1,11 +1,16 @@ package io.xpipe.app.hub.comp; import io.xpipe.app.action.AbstractAction; +import io.xpipe.app.browser.BrowserFileChooserSessionComp; import io.xpipe.app.comp.base.PrettyImageHelper; import io.xpipe.app.core.AppI18n; import io.xpipe.app.ext.*; +import io.xpipe.app.platform.LabelGraphic; +import io.xpipe.app.platform.MenuHelper; +import io.xpipe.app.storage.DataStorage; import io.xpipe.app.util.ScanDialog; +import io.xpipe.app.util.ThreadHelper; import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.collections.ObservableList; @@ -169,6 +174,21 @@ public class StoreCreationMenu { item.setDisable(!dataStoreProvider.allowCreation()); menu.getItems().add(item); } + + if (categories[0].equals(DataStoreCreationCategory.DESKTOP)) { + var rdpFile = MenuHelper.createMenuItem(new LabelGraphic.ImageGraphic("rdpFile_icon.svg", 16), "rdpFile"); + rdpFile.setOnAction(event -> { + BrowserFileChooserSessionComp.open(() -> DataStorage.get().local().ref(), () -> null, fileReference -> { + var file = fileReference.getPath().asLocalPath(); + ThreadHelper.runFailableAsync(() -> { + ProcessControlProvider.get().importRdpFile(file); + }); + }, false, false, entry -> entry.equals(DataStorage.get().local()), null); + event.consume(); + }); + menu.getItems().add(menu.getItems().size() - 2, rdpFile); + } + return menu; } diff --git a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java index c39171fb0..fee32b2cd 100644 --- a/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java +++ b/app/src/main/java/io/xpipe/app/prefs/AppPrefs.java @@ -883,6 +883,7 @@ public final class AppPrefs { var env = System.getenv("XPIPE_API_KEY"); if (env != null && !env.isEmpty()) { enableHttpApi.set(true); + allowExternalApiRequests.set(true); apiKey.setValue(env); } } diff --git a/app/src/main/java/io/xpipe/app/rdp/FreeRdpClient.java b/app/src/main/java/io/xpipe/app/rdp/FreeRdpClient.java index 36414681d..abccaa916 100644 --- a/app/src/main/java/io/xpipe/app/rdp/FreeRdpClient.java +++ b/app/src/main/java/io/xpipe/app/rdp/FreeRdpClient.java @@ -54,6 +54,18 @@ public class FreeRdpClient implements ExternalRdpClient { .add("-themes") .add("/size:100%"); + var gateway = configuration.getGateway(); + if (gateway != null) { + b.add("/g").addQuoted(gateway.getHost()); + if (gateway.getUsername() != null) { + b.add("/gu").addQuoted(gateway.getUsername()); + } + if (gateway.getPassword() != null) { + var escapedPw = gateway.getPassword().getSecretValue().replaceAll("'", "\\\\'"); + b.add("/gp:'" + escapedPw + "'"); + } + } + if (configuration.getPassword() != null) { var escapedPw = configuration.getPassword().getSecretValue().replaceAll("'", "\\\\'"); b.add("/p:'" + escapedPw + "'"); diff --git a/app/src/main/java/io/xpipe/app/rdp/MstscRdpClient.java b/app/src/main/java/io/xpipe/app/rdp/MstscRdpClient.java index 4df37324b..2aebd589d 100644 --- a/app/src/main/java/io/xpipe/app/rdp/MstscRdpClient.java +++ b/app/src/main/java/io/xpipe/app/rdp/MstscRdpClient.java @@ -1,24 +1,22 @@ package io.xpipe.app.rdp; +import com.sun.jna.LastErrorException; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; import io.xpipe.app.comp.base.ModalButton; import io.xpipe.app.comp.base.ModalOverlay; import io.xpipe.app.core.AppCache; import io.xpipe.app.core.AppDisplayScale; import io.xpipe.app.core.window.AppDialog; +import io.xpipe.app.platform.ClipboardHelper; import io.xpipe.app.platform.OptionsBuilder; import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.prefs.ExternalApplicationType; import io.xpipe.app.process.CommandBuilder; import io.xpipe.app.process.LocalShell; import io.xpipe.app.storage.DataStorage; -import io.xpipe.app.util.GlobalTimer; -import io.xpipe.app.util.LocalExec; -import io.xpipe.app.util.RdpConfig; -import io.xpipe.app.util.RemoteDesktopWindow; -import io.xpipe.app.util.ThreadHelper; -import io.xpipe.app.util.WindowsRegistry; -import io.xpipe.app.util.OsType; -import io.xpipe.app.util.SecretValue; +import io.xpipe.app.util.*; import javafx.application.Platform; import javafx.beans.property.Property; @@ -31,12 +29,16 @@ import lombok.Value; import lombok.extern.jackson.Jacksonized; import org.apache.commons.io.FileUtils; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import static io.xpipe.app.util.CredAdvapi32.*; + @JsonTypeName("mstsc") @Value @Jacksonized @@ -186,9 +188,17 @@ public class MstscRdpClient implements ExternalApplicationType.PathApplication, } var setCache = prepareLocalhostRegistryCache(configuration); - var fullAddress = configuration.getConfig().get("full address"); - if (fullAddress.isPresent()) { - disableSignatureWarning(fullAddress.get().getValue()); + disableSignatureWarning(configuration); + + if (configuration.getPassword() != null) { + WinCred.setCredential("TERMSRV/" + configuration.getHost(), CRED_TYPE_DOMAIN_PASSWORD, CRED_PERSIST_SESSION, + configuration.getUsername(), configuration.getPassword()); + } + + var gateway = configuration.getGateway(); + if (gateway != null && gateway.getPassword() != null) { + WinCred.setCredential(gateway.getHost(), CRED_TYPE_DOMAIN_PASSWORD, CRED_PERSIST_SESSION, + gateway.getUsername(), gateway.getPassword()); } var file = writeRdpConfigFile(configuration.getTitle(), adaptedRdpConfig); @@ -204,26 +214,15 @@ public class MstscRdpClient implements ExternalApplicationType.PathApplication, window.getDockBounds().getW(), window.getDockBounds().getH(), process, - Duration.ofSeconds(60), + Duration.ofSeconds(120), p -> { return !p.isDialog(); }); } - GlobalTimer.delay( - () -> { - FileUtils.deleteQuietly(file.toFile()); - }, - Duration.ofSeconds(1)); - - if (!setCache && configuration - .getConfig() - .get("full address").isPresent()) { + if (!setCache) { var localhost = configuration - .getConfig() - .get("full address") - .orElseThrow() - .getValue() + .getHost() .startsWith("localhost"); if (localhost) { saveLocalhostRegistryCache(configuration.getStoreId()); @@ -233,7 +232,7 @@ public class MstscRdpClient implements ExternalApplicationType.PathApplication, @Override public boolean supportsPasswordPassing(RdpLaunchConfig config) { - return LocalShell.getLocalPowershell().isPresent(); + return true; } @Override @@ -267,31 +266,32 @@ public class MstscRdpClient implements ExternalApplicationType.PathApplication, return input; } - private RdpConfig getAdaptedConfig(RdpLaunchConfig configuration) throws Exception { + private RdpConfig getAdaptedConfig(RdpLaunchConfig configuration) { var input = configuration.getConfig(); var pass = configuration.getPassword(); - if (input.get("password 51").isPresent() || !supportsPasswordPassing(configuration) || pass == null) { - return input.overlay(Map.of("smart sizing", new RdpConfig.TypedValue("i", smartSizing ? "1" : "0"))); - } - var adapted = input.overlay(Map.of( - "password 51", - new RdpConfig.TypedValue("b", encrypt(pass)), "prompt for credentials", - new RdpConfig.TypedValue("i", "0"), + new RdpConfig.TypedValue("i", pass != null ? "0" : "1"), "smart sizing", new RdpConfig.TypedValue("i", smartSizing ? "1" : "0"))); return adapted; } - private void disableSignatureWarning(String fullAddress) { - var hostname = fullAddress.split(":")[0]; + private void disableSignatureWarning(RdpLaunchConfig config) { WindowsRegistry.local() .setIntegerValue( WindowsRegistry.HKEY_CURRENT_USER, "Software\\Microsoft\\Terminal Server Client\\LocalDevices", - hostname, + config.getHost(), 0x4c); + if (config.getGateway() != null) { + WindowsRegistry.local() + .setIntegerValue( + WindowsRegistry.HKEY_CURRENT_USER, + "Software\\Microsoft\\Terminal Server Client\\LocalDevices", + config.getHost() + ";" + config.getGateway().getHost(), + 0x4c); + } } private void saveLocalhostRegistryCache(UUID entry) { @@ -344,12 +344,8 @@ public class MstscRdpClient implements ExternalApplicationType.PathApplication, WindowsRegistry.HKEY_CURRENT_USER, "Software\\Microsoft\\Terminal Server Client\\Servers\\localhost"); - var fullAddress = configuration.getConfig().get("full address"); - if (fullAddress.isEmpty()) { - return false; - } - var localhost = fullAddress.get().getValue().startsWith("localhost"); + var localhost = configuration.getHost().startsWith("localhost"); if (localhost) { var found = getLocalhostRegistryCache(configuration.getStoreId()); if (found.isPresent()) { @@ -380,15 +376,6 @@ public class MstscRdpClient implements ExternalApplicationType.PathApplication, return false; } - private String encrypt(SecretValue password) throws Exception { - var ps = LocalShell.getLocalPowershell().orElseThrow(); - var cmd = ps.command(CommandBuilder.of() - .add(sc -> "(" + sc.getShellDialect().literalArgument(password.getSecretValue()) - + " | ConvertTo-SecureString -AsPlainText -Force) | ConvertFrom-SecureString")); - cmd.sensitive(); - return cmd.readStdoutOrThrow(); - } - @Override public String getExecutable() { return "mstsc.exe"; diff --git a/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfig.java b/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfig.java index ef14d1eed..452eac70d 100644 --- a/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfig.java +++ b/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfig.java @@ -6,15 +6,33 @@ import io.xpipe.app.util.SecretValue; import lombok.Value; +import java.util.Optional; import java.util.UUID; @Value public class RdpLaunchConfig { String title; + String host; + int port; DataStoreEntry entry; RdpConfig config; UUID storeId; + String username; SecretValue password; + RdpLaunchConfigGateway gateway; + + public Optional getDomain() { + var domain = username.contains("\\") ? username.split("\\\\")[0] : null; + return Optional.ofNullable(domain); + } + + public String getUserWithoutDomain() { + if (username.contains("\\")) { + return username.split("\\\\")[1]; + } else { + return username; + } + } public boolean isRemoteApp() { return config.get("remoteapplicationprogram").isPresent(); diff --git a/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfigGateway.java b/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfigGateway.java new file mode 100644 index 000000000..f3b95fec3 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/rdp/RdpLaunchConfigGateway.java @@ -0,0 +1,18 @@ +package io.xpipe.app.rdp; + +import io.xpipe.app.storage.DataStoreEntry; +import io.xpipe.app.util.RdpConfig; +import io.xpipe.app.util.SecretValue; + +import lombok.Value; + +import java.util.UUID; + +@Value +public class RdpLaunchConfigGateway { + + String host; + String username; + SecretValue password; + boolean reused; +} diff --git a/app/src/main/java/io/xpipe/app/rdp/RemminaRdpClient.java b/app/src/main/java/io/xpipe/app/rdp/RemminaRdpClient.java index e83a3f562..140449afa 100644 --- a/app/src/main/java/io/xpipe/app/rdp/RemminaRdpClient.java +++ b/app/src/main/java/io/xpipe/app/rdp/RemminaRdpClient.java @@ -17,16 +17,6 @@ import java.util.*; @Builder public class RemminaRdpClient implements ExternalApplicationType.LinuxApplication, ExternalRdpClient { - private List toStrip() { - return List.of("auto connect", "password 51", "prompt for credentials", "smart sizing"); - } - - private boolean containsOnlyRecognizedOptions(RdpLaunchConfig configuration) { - var l = new HashSet<>(configuration.getConfig().getContent().keySet()); - toStrip().forEach(l::remove); - return l.size() == 2 && l.contains("username") && l.contains("full address"); - } - @Override public void launch(RdpLaunchConfig configuration) throws Exception { // Remmina does not support RemoteApps @@ -38,24 +28,17 @@ public class RemminaRdpClient implements ExternalApplicationType.LinuxApplicatio } } - if (containsOnlyRecognizedOptions(configuration)) { - var encrypted = RemminaHelper.encryptPassword(configuration.getPassword()); - if (encrypted.isPresent()) { - var file = RemminaHelper.writeRemminaRdpConfigFile(configuration, encrypted.get()); - launch(CommandBuilder.of().add("-c").addFile(file.toString())); - return; - } + var encrypted = RemminaHelper.encryptPassword(configuration.getPassword()); + if (encrypted.isPresent()) { + var file = RemminaHelper.writeRemminaRdpConfigFile(configuration); + launch(CommandBuilder.of().add("-c").addFile(file.toString())); + return; } - - RdpConfig c = configuration.getConfig(); - var file = writeRdpConfigFile(configuration.getTitle(), c); - launch(CommandBuilder.of().add("-c").addFile(file.toString())); - LocalFileTracker.deleteOnExit(file); } @Override public boolean supportsPasswordPassing(RdpLaunchConfig config) { - return config.isRemoteApp() || containsOnlyRecognizedOptions(config); + return config.isRemoteApp(); } @Override diff --git a/app/src/main/java/io/xpipe/app/terminal/OhMyPoshTerminalPrompt.java b/app/src/main/java/io/xpipe/app/terminal/OhMyPoshTerminalPrompt.java index 16b042f7a..dd86f3b7f 100644 --- a/app/src/main/java/io/xpipe/app/terminal/OhMyPoshTerminalPrompt.java +++ b/app/src/main/java/io/xpipe/app/terminal/OhMyPoshTerminalPrompt.java @@ -213,9 +213,9 @@ public class OhMyPoshTerminalPrompt extends ConfigFileTerminalPrompt { lines.add("& ([ScriptBlock]::Create((oh-my-posh init $(oh-my-posh get shell) --print" + configArg + ") -join \"`n\"))"); } else if (dialect == ShellDialects.FISH) { - lines.add("oh-my-posh init fish" + configArg + " | source"); + lines.add("sh -c \"oh-my-posh init fish" + configArg + "\" | source"); } else { - lines.add("eval \"$(oh-my-posh init " + dialect.getId() + configArg + ")\""); + lines.add("eval \"$(sh -c \"oh-my-posh init " + dialect.getId() + configArg + "\")\""); } } return ShellScript.lines(lines); diff --git a/app/src/main/java/io/xpipe/app/terminal/StarshipTerminalPrompt.java b/app/src/main/java/io/xpipe/app/terminal/StarshipTerminalPrompt.java index 07e135d11..19d7ac3cf 100644 --- a/app/src/main/java/io/xpipe/app/terminal/StarshipTerminalPrompt.java +++ b/app/src/main/java/io/xpipe/app/terminal/StarshipTerminalPrompt.java @@ -77,9 +77,9 @@ public class StarshipTerminalPrompt extends ConfigFileTerminalPrompt { if (ShellDialects.isPowershell(shellControl)) { lines.add("Invoke-Expression (&starship init powershell)"); } else if (dialect == ShellDialects.FISH) { - lines.add("starship init fish | source"); + lines.add("sh -c \"starship init fish\" | source"); } else { - lines.add("eval \"$(starship init " + dialect.getId() + ")\""); + lines.add("eval \"$(sh -c \"starship init " + dialect.getId() + "\")\""); } } return ShellScript.lines(lines); diff --git a/app/src/main/java/io/xpipe/app/util/CredAdvapi32.java b/app/src/main/java/io/xpipe/app/util/CredAdvapi32.java new file mode 100644 index 000000000..73c5e4c5d --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/CredAdvapi32.java @@ -0,0 +1,407 @@ +package io.xpipe.app.util; + +import com.sun.jna.LastErrorException; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.platform.win32.WinBase; +import com.sun.jna.win32.StdCallLibrary; +import com.sun.jna.win32.W32APIOptions; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * This class exposes functions from credential manager on Windows platform + * via JNA. + * + * Please refer to MSDN documentations for each method usage pattern + */ +public interface CredAdvapi32 extends StdCallLibrary { + + CredAdvapi32 INSTANCE = Native.load("Advapi32", CredAdvapi32.class, W32APIOptions.UNICODE_OPTIONS); + /** + * CredRead flag + */ + int CRED_FLAGS_PROMPT_NOW = 0x0002; + int CRED_FLAGS_USERNAME_TARGET = 0x0004; + + /** + * Type of Credential + */ + int CRED_TYPE_GENERIC = 1; + int CRED_TYPE_DOMAIN_PASSWORD = 2; + int CRED_TYPE_DOMAIN_CERTIFICATE = 3; + int CRED_TYPE_DOMAIN_VISIBLE_PASSWORD = 4; + int CRED_TYPE_GENERIC_CERTIFICATE = 5; + int CRED_TYPE_DOMAIN_EXTENDED = 6; + int CRED_TYPE_MAXIMUM = 7; // Maximum supported cred type + int CRED_TYPE_MAXIMUM_EX = CRED_TYPE_MAXIMUM + 1000; + + /** + * CredWrite flag + */ + int CRED_PRESERVE_CREDENTIAL_BLOB = 0x1; + + /** + * Values of the Credential Persist field + */ + int CRED_PERSIST_NONE = 0; + int CRED_PERSIST_SESSION = 1; + int CRED_PERSIST_LOCAL_MACHINE = 2; + int CRED_PERSIST_ENTERPRISE = 3; + + /** + * Credential attributes + * + * https://msdn.microsoft.com/en-us/library/windows/desktop/aa374790(v=vs.85).aspx + * + * typedef struct _CREDENTIAL_ATTRIBUTE { + * LPTSTR Keyword; + * DWORD Flags; + * DWORD ValueSize; + * LPBYTE Value; + * } CREDENTIAL_ATTRIBUTE, *PCREDENTIAL_ATTRIBUTE; + * + */ + class CREDENTIAL_ATTRIBUTE extends Structure { + + public static class ByReference extends CREDENTIAL_ATTRIBUTE implements Structure.ByReference { } + + @Override + protected List getFieldOrder() { + return Arrays.asList("Keyword", + "Flags", + "ValueSize", + "Value"); + } + + /** + * Name of the application-specific attribute. Names should be of the form _. + * This member cannot be longer than CRED_MAX_STRING_LENGTH (256) characters. + */ + public String Keyword; + + /** + * Identifies characteristics of the credential attribute. This member is reserved and should be originally + * initialized as zero and not otherwise altered to permit future enhancement. + */ + public int Flags; + + /** + * Length of Value in bytes. This member cannot be larger than CRED_MAX_VALUE_SIZE (256). + */ + public int ValueSize; + + /** + * Data associated with the attribute. By convention, if Value is a text string, then Value should not + * include the trailing zero character and should be in UNICODE. + * + * Credentials are expected to be portable. The application should take care to ensure that the data in + * value is portable. It is the responsibility of the application to define the byte-endian and alignment + * of the data in Value. + */ + public Pointer Value; + } + + /** + * Pointer to {@See CREDENTIAL_ATTRIBUTE} struct + */ + class PCREDENTIAL_ATTRIBUTE extends Structure { + + @Override + protected List getFieldOrder() { + return Collections.singletonList("credential_attribute"); + } + + public PCREDENTIAL_ATTRIBUTE() { + super(); + } + + public PCREDENTIAL_ATTRIBUTE(byte[] data) { + super(new Memory(data.length)); + getPointer().write(0, data, 0, data.length); + read(); + } + + public PCREDENTIAL_ATTRIBUTE(Pointer memory) { + super(memory); + read(); + } + + public Pointer credential_attribute; + } + + /** + * The CREDENTIAL structure contains an individual credential + * + * https://msdn.microsoft.com/en-us/library/windows/desktop/aa374788(v=vs.85).aspx + * + * typedef struct _CREDENTIAL { + * DWORD Flags; + * DWORD Type; + * LPTSTR TargetName; + * LPTSTR Comment; + * FILETIME LastWritten; + * DWORD CredentialBlobSize; + * LPBYTE CredentialBlob; + * DWORD Persist; + * DWORD AttributeCount; + * PCREDENTIAL_ATTRIBUTE Attributes; + * LPTSTR TargetAlias; + * LPTSTR UserName; + * } CREDENTIAL, *PCREDENTIAL; + */ + class CREDENTIAL extends Structure { + + @Override + protected List getFieldOrder() { + return Arrays.asList("Flags", + "Type", + "TargetName", + "Comment", + "LastWritten", + "CredentialBlobSize", + "CredentialBlob", + "Persist", + "AttributeCount", + "Attributes", + "TargetAlias", + "UserName"); + } + + public CREDENTIAL() { + super(); + } + + public CREDENTIAL(final int size) { + super(new Memory(size)); + } + + public CREDENTIAL(Pointer memory) { + super(memory); + read(); + } + + /** + * A bit member that identifies characteristics of the credential. Undefined bits should be initialized + * as zero and not otherwise altered to permit future enhancement. + * + * See MSDN doc for all possible flags + */ + public int Flags; + + /** + * The type of the credential. This member cannot be changed after the credential is created. + * + * See MSDN doc for all possible types + */ + public int Type; + + /** + * The name of the credential. The TargetName and Type members uniquely identify the credential. + * This member cannot be changed after the credential is created. Instead, the credential with the old + * name should be deleted and the credential with the new name created. + * + * See MSDN doc for additional requirement + */ + public String TargetName; + + /** + * A string comment from the user that describes this credential. This member cannot be longer than + * CRED_MAX_STRING_LENGTH (256) characters. + */ + public String Comment; + + /** + * The time, in Coordinated Universal Time (Greenwich Mean Time), of the last modification of the credential. + * For write operations, the value of this member is ignored. + */ + public WinBase.FILETIME LastWritten; + + /** + * The size, in bytes, of the CredentialBlob member. This member cannot be larger than + * CRED_MAX_CREDENTIAL_BLOB_SIZE (512) bytes. + */ + public int CredentialBlobSize; + + /** + * Secret data for the credential. The CredentialBlob member can be both read and written. + * If the Type member is CRED_TYPE_DOMAIN_PASSWORD, this member contains the plaintext Unicode password + * for UserName. The CredentialBlob and CredentialBlobSize members do not include a trailing zero character. + * Also, for CRED_TYPE_DOMAIN_PASSWORD, this member can only be read by the authentication packages. + * + * If the Type member is CRED_TYPE_DOMAIN_CERTIFICATE, this member contains the clear test + * Unicode PIN for UserName. The CredentialBlob and CredentialBlobSize members do not include a trailing + * zero character. Also, this member can only be read by the authentication packages. + * + * If the Type member is CRED_TYPE_GENERIC, this member is defined by the application. + * Credentials are expected to be portable. Applications should ensure that the data in CredentialBlob is + * portable. The application defines the byte-endian and alignment of the data in CredentialBlob. + */ + public Pointer CredentialBlob; + + /** + * Defines the persistence of this credential. This member can be read and written. + * + * See MSDN doc for all possible values + */ + public int Persist; + + /** + * The number of application-defined attributes to be associated with the credential. This member can be + * read and written. Its value cannot be greater than CRED_MAX_ATTRIBUTES (64). + */ + public int AttributeCount; + + /** + * Application-defined attributes that are associated with the credential. This member can be read + * and written. + */ + //notTODO: Need to make this into array + public CREDENTIAL_ATTRIBUTE.ByReference Attributes; + + /** + * Alias for the TargetName member. This member can be read and written. It cannot be longer than + * CRED_MAX_STRING_LENGTH (256) characters. + * + * If the credential Type is CRED_TYPE_GENERIC, this member can be non-NULL, but the credential manager + * ignores the member. + */ + public String TargetAlias; + + /** + * The user name of the account used to connect to TargetName. + * If the credential Type is CRED_TYPE_DOMAIN_PASSWORD, this member can be either a DomainName\UserName + * or a UPN. + * + * If the credential Type is CRED_TYPE_DOMAIN_CERTIFICATE, this member must be a marshaled certificate + * reference created by calling CredMarshalCredential with a CertCredential. + * + * If the credential Type is CRED_TYPE_GENERIC, this member can be non-NULL, but the credential manager + * ignores the member. + * + * This member cannot be longer than CRED_MAX_USERNAME_LENGTH (513) characters. + */ + public String UserName; + } + + /** + * Pointer to {@see CREDENTIAL} struct + */ + class PCREDENTIAL extends Structure { + + @Override + protected List getFieldOrder() { + return Collections.singletonList("credential"); + } + + public PCREDENTIAL() { + super(); + } + + public PCREDENTIAL(byte[] data) { + super(new Memory(data.length)); + getPointer().write(0, data, 0, data.length); + read(); + } + + public PCREDENTIAL(Pointer memory) { + super(memory); + read(); + } + + public Pointer credential; + } + + /** + * The CredRead function reads a credential from the user's credential set. + * + * The credential set used is the one associated with the logon session of the current token. + * The token must not have the user's SID disabled. + * + * https://msdn.microsoft.com/en-us/library/windows/desktop/aa374804(v=vs.85).aspx + * + * @param targetName + * String that contains the name of the credential to read. + * @param type + * Type of the credential to read. Type must be one of the CRED_TYPE_* defined types. + * @param flags + * Currently reserved and must be zero. + * @param pcredential + * Out - Pointer to a single allocated block buffer to return the credential. + * Any pointers contained within the buffer are pointers to locations within this single allocated block. + * The single returned buffer must be freed by calling CredFree. + * + * @return + * True if CredRead succeeded, false otherwise + * + * @throws LastErrorException + * GetLastError + */ + boolean CredRead(String targetName, int type, int flags, PCREDENTIAL pcredential) throws LastErrorException; + + /** + * The CredWrite function creates a new credential or modifies an existing credential in the user's credential set. + * The new credential is associated with the logon session of the current token. The token must not have the + * user's security identifier (SID) disabled. + * + * https://msdn.microsoft.com/en-us/library/windows/desktop/aa375187(v=vs.85).aspx + * + * @param credential + * A CREDENTIAL structure to be written. + * @param flags + * Flags that control the function's operation. The following flag is defined. + * CRED_PRESERVE_CREDENTIAL_BLOB: + * The credential BLOB from an existing credential is preserved with the same + * credential name and credential type. The CredentialBlobSize of the passed + * in Credential structure must be zero. + * + * @return + * True if CredWrite succeeded, false otherwise + * + * @throws LastErrorException + * GetLastError + */ + boolean CredWrite(CREDENTIAL credential, int flags) throws LastErrorException; + + /** + * The CredDelete function deletes a credential from the user's credential set. The credential set used is the one + * associated with the logon session of the current token. The token must not have the user's SID disabled. + * + * https://msdn.microsoft.com/en-us/library/windows/desktop/aa374787(v=vs.85).aspx + * + * @param targetName + * String that contains the name of the credential to read. + * @param type + * Type of the credential to delete. Must be one of the CRED_TYPE_* defined types. For a list of the + * defined types, see the Type member of the CREDENTIAL structure. + * If the value of this parameter is CRED_TYPE_DOMAIN_EXTENDED, this function can delete a credential that + * specifies a user name when there are multiple credentials for the same target. The value of the TargetName + * parameter must specify the user name as Target|UserName. + * @param flags + * Reserved and must be zero. + * + * @return + * True if CredDelete succeeded, false otherwise + * + * @throws LastErrorException + * GetLastError + */ + boolean CredDelete(String targetName, int type, int flags) throws LastErrorException; + + /** + * The CredFree function frees a buffer returned by any of the credentials management functions. + * + * https://msdn.microsoft.com/en-us/library/windows/desktop/aa374796(v=vs.85).aspx + * + * @param credential + * Pointer to CREDENTIAL to be freed + * + * @throws LastErrorException + * GetLastError + */ + void CredFree(Pointer credential) throws LastErrorException; +} diff --git a/app/src/main/java/io/xpipe/app/util/NativeWinWindowControl.java b/app/src/main/java/io/xpipe/app/util/NativeWinWindowControl.java index a8f623f33..31e300eb5 100644 --- a/app/src/main/java/io/xpipe/app/util/NativeWinWindowControl.java +++ b/app/src/main/java/io/xpipe/app/util/NativeWinWindowControl.java @@ -17,6 +17,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import static com.sun.jna.win32.W32APIOptions.DEFAULT_OPTIONS; + @Getter @EqualsAndHashCode public class NativeWinWindowControl { @@ -268,7 +270,7 @@ public class NativeWinWindowControl { public interface Dwm extends Library { - Dwm INSTANCE = Native.load("dwmapi", Dwm.class); + Dwm INSTANCE = Native.load("dwmapi", Dwm.class, DEFAULT_OPTIONS); WinNT.HRESULT DwmSetWindowAttribute( WinDef.HWND hwnd, int dwAttribute, PointerType pvAttribute, int cbAttribute); diff --git a/app/src/main/java/io/xpipe/app/util/RemminaHelper.java b/app/src/main/java/io/xpipe/app/util/RemminaHelper.java index 246f7d47d..ffa4c982c 100644 --- a/app/src/main/java/io/xpipe/app/util/RemminaHelper.java +++ b/app/src/main/java/io/xpipe/app/util/RemminaHelper.java @@ -50,13 +50,7 @@ public class RemminaHelper { } } - public static Path writeRemminaRdpConfigFile(RdpLaunchConfig configuration, String password) throws Exception { - var user = configuration.getConfig().get("username").orElseThrow().getValue(); - var domain = user.contains("\\") ? user.split("\\\\")[0] : null; - if (domain != null) { - user = user.split("\\\\")[1]; - } - + public static Path writeRemminaRdpConfigFile(RdpLaunchConfig configuration) throws Exception { var w = Math.round(AppMainWindow.get().getStage().getWidth()); // Remmina's height calculation does not take the titlebar into account var h = Math.round(AppMainWindow.get().getStage().getHeight()) - 38; @@ -65,6 +59,27 @@ public class RemminaHelper { var name = OsFileSystem.ofLocal().makeFileSystemCompatible(configuration.getTitle()); var file = AppLocalTemp.getLocalTempDataDirectory("remmina").resolve("xpipe-" + name + ".remmina"); + + var gateway = ""; + if (configuration.getGateway() != null) { + gateway += "gateway_server=" + configuration.getGateway().getHost(); + if (configuration.getGateway().getUsername() != null) { + var gatewayUser = configuration.getGateway().getUsername(); + var gatewayDomain = gatewayUser.contains("\\") ? gatewayUser.split("\\\\")[0] : null; + if (gatewayDomain != null) { + gatewayUser = gatewayUser.split("\\\\")[1]; + } + + gateway += "gateway_username=" + gatewayUser; + if (gatewayDomain != null) { + gateway += "gateway_domain=" + gatewayDomain; + } + } + if (configuration.getGateway().getPassword() != null) { + gateway += "gateway_password=" + RemminaHelper.encryptPassword(configuration.getGateway().getPassword()); + } + } + var string = """ [remmina] protocol=RDP @@ -77,16 +92,17 @@ public class RemminaHelper { scale=2 window_width=%s window_height=%s - window_maximize=%s + window_maximize=%s%s """.formatted( configuration.getTitle(), - user, - domain != null ? domain : "", - configuration.getConfig().get("full address").orElseThrow().getValue(), - password != null ? password : "", + configuration.getUsername(), + configuration.getDomain().orElse(""), + configuration.getHost(), + configuration.getPassword() != null ? encryptPassword(configuration.getPassword()) : "", w, h, - maximize); + maximize, + !gateway.isEmpty() ? "\n" + gateway : ""); Files.createDirectories(file.getParent()); Files.writeString(file, string); return file; diff --git a/app/src/main/java/io/xpipe/app/util/Validators.java b/app/src/main/java/io/xpipe/app/util/Validators.java index 24bc01d78..9ef8e98ef 100644 --- a/app/src/main/java/io/xpipe/app/util/Validators.java +++ b/app/src/main/java/io/xpipe/app/util/Validators.java @@ -5,6 +5,7 @@ import io.xpipe.app.ext.DataStore; import io.xpipe.app.ext.ValidationException; import io.xpipe.app.storage.DataStoreEntryRef; +import java.util.Arrays; import java.util.List; public class Validators { @@ -18,6 +19,15 @@ public class Validators { } } + public static void isAnyType(DataStoreEntryRef ref, Class... cs) + throws ValidationException { + if (ref == null + || ref.getStore() == null + || Arrays.stream(cs).noneMatch(c -> c.isAssignableFrom(ref.getStore().getClass()))) { + throw new ValidationException("Value must be an instance of " + Arrays.stream(cs).map(Class::getSimpleName).toList()); + } + } + public static void nonNull(Object object) throws ValidationException { if (object == null) { throw new ValidationException(AppI18n.get("valueMustNotBeEmpty")); diff --git a/app/src/main/java/io/xpipe/app/util/WinCred.java b/app/src/main/java/io/xpipe/app/util/WinCred.java new file mode 100644 index 000000000..cbb7e143f --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/WinCred.java @@ -0,0 +1,107 @@ +package io.xpipe.app.util; + + +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +import com.sun.jna.LastErrorException; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.platform.win32.Advapi32; +import com.sun.jna.win32.W32APIOptions; +import io.xpipe.app.issue.ErrorEventFactory; +import lombok.Value; + +public class WinCred { + + @Value + public static class Credential { + String target; + String username; + String password; + } + + private static Boolean libraryLoaded; + private static CredAdvapi32 INSTANCE; + + private static synchronized boolean isLibrarySupported() { + if (libraryLoaded != null) { + return libraryLoaded; + } + + try { + INSTANCE = Native.load("Advapi32", CredAdvapi32.class, W32APIOptions.UNICODE_OPTIONS); + return (libraryLoaded = true); + } catch (Throwable t) { + libraryLoaded = false; + ErrorEventFactory.fromThrowable(t) + .description("Unable to load native library Advapi32.dll for Windows credentials queries." + + " Credential operations will fail and some functionality will be unavailable") + .handle(); + return false; + } + } + + public static synchronized Optional getCredential(String target, int type) { + if (!isLibrarySupported()) { + return Optional.empty(); + } + + CredAdvapi32.PCREDENTIAL pcredMem = new CredAdvapi32.PCREDENTIAL(); + + try { + if (INSTANCE.CredRead(target, type, 0, pcredMem)) { + CredAdvapi32.CREDENTIAL credMem = new CredAdvapi32.CREDENTIAL(pcredMem.credential); + if (credMem.CredentialBlob == null) { + return Optional.empty(); + } + + byte[] passwordBytes = credMem.CredentialBlob.getByteArray(0, credMem.CredentialBlobSize); + String password = new String(passwordBytes, StandardCharsets.UTF_16LE); + Credential cred = new Credential(credMem.TargetName, credMem.UserName, password); + return Optional.of(cred); + } else { + return Optional.empty(); + } + } finally { + INSTANCE.CredFree(pcredMem.credential); + } + } + + public static void setCredential(String target, int type, int persist, String userName, SecretValue password) { + if (!isLibrarySupported()) { + return; + } + + CredAdvapi32.CREDENTIAL credMem = new CredAdvapi32.CREDENTIAL(); + credMem.Flags = 0; + credMem.TargetName = target; + credMem.Type = type; + credMem.UserName = userName; + credMem.AttributeCount = 0; + credMem.Persist = persist; + byte[] bpassword = password.getSecretValue().getBytes(StandardCharsets.UTF_16LE); + credMem.CredentialBlobSize = bpassword.length; + credMem.CredentialBlob = getPointer(bpassword); + if (!INSTANCE.CredWrite(credMem, 0)) { + int err = Native.getLastError(); + throw new LastErrorException(err); + } + } + + public static void deleteCredential(String target, int type) { + if (!INSTANCE.CredDelete(target, type, 0)) { + int err = Native.getLastError(); + throw new LastErrorException(err); + } + } + + private static Pointer getPointer(byte[] array) { + Pointer p = new Memory(array.length); + p.write(0, array, 0, array.length); + return p; + } +} diff --git a/lang/strings/translations_en.properties b/lang/strings/translations_en.properties index 196de3778..5ececc957 100644 --- a/lang/strings/translations_en.properties +++ b/lang/strings/translations_en.properties @@ -1116,7 +1116,8 @@ configLocationDescription=The file path of the config file gateway=Gateway gatewayDescription=The optional gateway to use when connecting rdpGateway=Connection gateway -rdpGatewayDescription=The optional gateway through which to tunnel the connection to the RDP port +#force +rdpGatewayDescription=The optional gateway. Supports both SSH hosts for tunneling or RD gateway servers connectionInformation=Connection target #context: title connectionInformationDescription=Which system to connect to @@ -1783,7 +1784,9 @@ hasHost=$COUNT$ available hosts largeFileWarningTitle=Large file edit largeFileWarningContent=The file you want to edit is quite large with $SIZE$. Do you really want to open this file in your text editor? rdpAskpassUser=RDP username for host $HOST$ -rdpAskpassPassword=Password for user $USER$ +#force +rdpAskpassPassword=RDP password for user $USER$ +rdpGatewayAskpassPassword=RDP gateway password for user $USER$ inPlaceKey=Key inPlaceKeyText=Private key content inPlaceKeyTextDescription=The private key contents @@ -2229,7 +2232,6 @@ enpassUnlock=Enter Enpass vault master password keeperSmsUnlock=Enter Keeper Commander SMS Code keeper2faUnlock=Enter Keeper 2FA Code keeperUnlock=Enter your Keeper master password to unlock -wakeOnLanHost=Host wakeOnLanHost=Target host wakeOnLanHostDescription=The system to wake wakeOnLanGateway=Broadcast host @@ -2313,5 +2315,15 @@ webtopAccessVpnTypeDescription=If you already use one of the supported VPNs, aut webtopDeploymentTarget=Target webtopDeploymentAccess=Access control webtopDeploymentConfiguration=Container configuration - +rdpGatewayHost=Gateway host +rdpGatewayHostDescription=The address of the RDS gateway server +rdpGatewayPassword=Use password +rdpGatewayPasswordDescription=Whether the gateway requires a password to authenticate +rdpGatewayReusePassword=Reuse credentials +rdpGatewayReusePasswordDescription=Reuse credentials of the target RDP server for gateway +rdpGatewayIdentity=Gateway credentials +rdpGatewayIdentityDescription=The credentials to authenticate with the gateway +rdpGateway.displayName=RD gateway +rdpGateway.displayDescription=Create a Remote Desktop Gateway to use with RDP connections +rdpFile=RDP file