diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java new file mode 100644 index 000000000..4a548c06e --- /dev/null +++ b/app/src/main/java/io/xpipe/app/beacon/impl/SshLaunchExchangeImpl.java @@ -0,0 +1,32 @@ +package io.xpipe.app.beacon.impl; + +import com.sun.net.httpserver.HttpExchange; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.util.TerminalLauncherManager; +import io.xpipe.beacon.api.SshLaunchExchange; +import io.xpipe.core.process.ProcessControlProvider; +import io.xpipe.core.process.TerminalInitScriptConfig; +import io.xpipe.core.store.ShellStore; +import io.xpipe.core.store.StorePath; + +import java.util.UUID; + +public class SshLaunchExchangeImpl extends SshLaunchExchange { + + @Override + public Object handle(HttpExchange exchange, Request msg) throws Exception { + if (msg.getStorePath() != null && !msg.getStorePath().contains("SSH_ORIGINAL_COMMAND")) { + var storePath = StorePath.create(msg.getStorePath()); + var found = DataStorage.get().getStoreEntries().stream().filter(entry -> DataStorage.get().getStorePath(entry).equals(storePath)).findFirst(); + if (found.isPresent() && found.get().getStore() instanceof ShellStore shellStore) { + TerminalLauncherManager.submitAsync(UUID.randomUUID(), shellStore.control(), + TerminalInitScriptConfig.ofName(DataStorage.get().getStoreEntryDisplayName(found.get())),null); + } + } + TerminalLauncherManager.submitAsync(UUID.randomUUID(), ((ShellStore) DataStorage.get().local().getStore()).control(), + TerminalInitScriptConfig.ofName("abc"),null); + var r = TerminalLauncherManager.waitForFirstLaunch(); + var c = ProcessControlProvider.get().getEffectiveLocalDialect().getOpenScriptCommand(r.toString()).buildBaseParts(null); + return Response.builder().command(c).build(); + } +} diff --git a/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java b/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java index 7e3d12647..ae4d3bac6 100644 --- a/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java +++ b/app/src/main/java/io/xpipe/app/core/mode/BaseMode.java @@ -16,10 +16,7 @@ import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.storage.DataStorage; import io.xpipe.app.storage.GitStorageHandler; import io.xpipe.app.update.XPipeDistributionType; -import io.xpipe.app.util.FileBridge; -import io.xpipe.app.util.LicenseProvider; -import io.xpipe.app.util.LocalShell; -import io.xpipe.app.util.UnlockAlert; +import io.xpipe.app.util.*; public class BaseMode extends OperationMode { @@ -78,6 +75,7 @@ public class BaseMode extends OperationMode { public void finalTeardown() { TrackEvent.info("Background mode shutdown started"); BrowserSessionModel.DEFAULT.reset(); + SshLocalBridge.reset(); StoreViewState.reset(); DataStoreProviders.reset(); DataStorage.reset(); diff --git a/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java b/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java index e281f21a2..7cb26561c 100644 --- a/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java +++ b/app/src/main/java/io/xpipe/app/terminal/ExternalTerminalType.java @@ -1,10 +1,10 @@ package io.xpipe.app.terminal; import io.xpipe.app.ext.PrefsChoiceValue; +import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.prefs.ExternalApplicationType; import io.xpipe.app.storage.DataStoreColor; -import io.xpipe.app.util.CommandSupport; -import io.xpipe.app.util.LocalShell; +import io.xpipe.app.util.*; import io.xpipe.core.process.*; import io.xpipe.core.store.FilePath; import io.xpipe.core.util.FailableFunction; @@ -16,13 +16,108 @@ import lombok.With; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Comparator; -import java.util.List; +import java.util.*; public interface ExternalTerminalType extends PrefsChoiceValue { + ExternalTerminalType MOBAXTERM = new WindowsType("app.mobaXterm","MobaXterm") { + + @Override + protected Optional determineInstallation() { + try { + var r = WindowsRegistry.local().readValue(WindowsRegistry.HKEY_LOCAL_MACHINE, "SOFTWARE\\Classes\\mobaxterm\\DefaultIcon"); + return r.map(Path::of); + } catch (Exception e) { + ErrorEvent.fromThrowable(e).omit().handle(); + return Optional.empty(); + } + } + + @Override + public boolean supportsTabs() { + return true; + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public boolean supportsColoredTitle() { + return true; + } + + @Override + protected void execute(Path file, LaunchConfiguration configuration) throws Exception { + try (var sc = LocalShell.getShell()) { + var fixedFile = configuration.getScriptFile().toString() + .replaceAll("\\\\", "/") + .replaceAll("\\s","\\$0"); + var command = sc.getShellDialect() == ShellDialects.CMD ? + CommandBuilder.of().addQuoted("cmd /c " + fixedFile) : + CommandBuilder.of().addQuoted("powershell -NoProfile -ExecutionPolicy Bypass -File " + fixedFile); + sc.command(CommandBuilder.of().addFile(file.toString()).add("-newtab").add(command)).execute(); + } + } + }; + + ExternalTerminalType TERMIUS = new ExternalTerminalType() { + + @Override + public String getId() { + return "app.termius"; + } + + @Override + public boolean isAvailable() { + try (var sc = LocalShell.getShell()) { + return switch (OsType.getLocal()) { + case OsType.Linux linux -> { + yield CommandSupport.isInPathSilent(sc, "termius"); + } + case OsType.MacOs macOs -> { + yield CommandSupport.isInPathSilent(sc, "termius"); + } + case OsType.Windows windows -> { + var r = WindowsRegistry.local().readValue(WindowsRegistry.HKEY_CURRENT_USER, "SOFTWARE\\Classes\\termius"); + yield r.isPresent(); + } + }; + } catch (Exception e) { + ErrorEvent.fromThrowable(e).omit().handle(); + return false; + } + } + + @Override + public boolean supportsTabs() { + return true; + } + + @Override + public boolean isRecommended() { + return false; + } + + @Override + public boolean supportsColoredTitle() { + return true; + } + + @Override + public void launch(LaunchConfiguration configuration) throws Exception { + SshLocalBridge.init(); + var name = "xpipe_bridge"; + var host = "localhost"; + var port = 21722; + var user = System.getProperty("user.name"); + Hyperlinks.open("termius://app/host-sharing#label=" + name + "&ip=" + host + "&port=" + port + "&username=" + + user + "&os=windows"); + } + }; + + ExternalTerminalType CMD = new SimplePathType("app.cmd", "cmd.exe", true) { @Override @@ -652,6 +747,8 @@ public interface ExternalTerminalType extends PrefsChoiceValue { TabbyTerminalType.TABBY_WINDOWS, AlacrittyTerminalType.ALACRITTY_WINDOWS, WezTerminalType.WEZTERM_WINDOWS, + TERMIUS, + MOBAXTERM, CMD, PWSH, POWERSHELL); diff --git a/app/src/main/java/io/xpipe/app/util/SshLocalBridge.java b/app/src/main/java/io/xpipe/app/util/SshLocalBridge.java new file mode 100644 index 000000000..bec294c78 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/util/SshLocalBridge.java @@ -0,0 +1,148 @@ +package io.xpipe.app.util; + +import io.xpipe.app.beacon.AppBeaconServer; +import io.xpipe.app.core.AppProperties; +import io.xpipe.app.issue.ErrorEvent; +import io.xpipe.core.process.CommandBuilder; +import io.xpipe.core.process.OsType; +import io.xpipe.core.process.ProcessControlProvider; +import io.xpipe.core.process.ShellControl; +import io.xpipe.core.util.XPipeInstallation; +import lombok.Getter; +import lombok.Setter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +@Getter +public class SshLocalBridge { + + private static SshLocalBridge INSTANCE; + + private final Path directory; + private final int port; + private final String user; + @Setter + private ShellControl runningShell; + + public SshLocalBridge(Path directory, int port, String user) { + this.directory = directory; + this.port = port; + this.user = user; + } + + public Path getPubHostKey() { + return directory.resolve("host_key.pub"); + } + + public Path getHostKey() { + return directory.resolve("host_key"); + } + + public Path getPubIdentityKey() { + return directory.resolve("identity.pub"); + } + + public Path getIdentityKey() { + return directory.resolve("identity"); + } + + public Path getConfig() { + return directory.resolve("sshd_config"); + } + + public static void init() throws Exception { + if (INSTANCE != null) { + return; + } + + try (var sc = LocalShell.getShell().start()) { + var bridgeDir = AppProperties.get().getDataDir().resolve("ssh_bridge"); + Files.createDirectories(bridgeDir); + var port = AppBeaconServer.get().getPort() + 1; + var user = sc.getShellDialect().printUsernameCommand(sc).readStdoutOrThrow(); + INSTANCE = new SshLocalBridge(bridgeDir, port, user); + + var hostKey = INSTANCE.getHostKey(); + if (!sc.getShellDialect().createFileExistsCommand(sc, hostKey.toString()).executeAndCheck()) { + sc.executeSimpleCommand("ssh-keygen -q -N \"\" -t ed25519 -f \"" + hostKey + "\""); + } + + var idKey = INSTANCE.getIdentityKey(); + if (!sc.getShellDialect().createFileExistsCommand(sc, idKey.toString()).executeAndCheck()) { + sc.executeSimpleCommand("ssh-keygen -q -N \"\" -t ed25519 -f \"" + idKey + "\""); + } + + var config = INSTANCE.getConfig(); + var command = "\"" + XPipeInstallation.getLocalDefaultCliExecutable() + "\" ssh-launch " + sc.getShellDialect().environmentVariable("SSH_ORIGINAL_COMMAND"); + var pidFile = bridgeDir.resolve("sshd.pid"); + var content = """ + ForceCommand %s + PidFile "%s" + StrictModes no + SyslogFacility USER + LogLevel Debug3 + Port %s + PasswordAuthentication no + HostKey "%s" + PubkeyAuthentication yes + AuthorizedKeysFile "%s" + """ + .formatted(command, pidFile.toString(), "" + port, INSTANCE.getHostKey().toString(), INSTANCE.getPubIdentityKey());; + Files.writeString(config, content); + + INSTANCE.updateConfig(); + + var exec = getSshd(sc); + var launchCommand = CommandBuilder.of().addFile(exec).add("-f").addFile(INSTANCE.getConfig().toString()).add("-p", "" + port); + var control = ProcessControlProvider.get().createLocalProcessControl(true).start(); + control.writeLine(launchCommand.buildFull(control)); + INSTANCE.setRunningShell(control); + } + } + + private void updateConfig() throws IOException { + var file = Path.of(System.getProperty("user.home"), ".ssh", "config"); + if (!Files.exists(file)) { + return; + } + + var content = Files.readString(file); + if (content.contains("xpipe_bridge")) { + return; + } + + var updated = content + "\n\n" + """ + Host xpipe_bridge + HostName localhost + User "%s" + Port %s + IdentityFile "%s" + """.formatted(port, user, getIdentityKey()); + Files.writeString(file, updated); + } + + private static String getSshd(ShellControl sc) throws Exception { + if (OsType.getLocal() == OsType.WINDOWS) { + return XPipeInstallation.getLocalBundledToolsDirectory().resolve("openssh").resolve("sshd").toString(); + } else { + var exec = sc.executeSimpleStringCommand(sc.getShellDialect().getWhichCommand("sshd")); + return exec; + } + } + + public static void reset() { + if (INSTANCE == null) { + return; + } + + try { + INSTANCE.getRunningShell().closeStdin(); + } catch (IOException e) { + ErrorEvent.fromThrowable(e).omit().handle(); + } + INSTANCE.getRunningShell().kill(); + INSTANCE = null; + } +} diff --git a/app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java b/app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java index bf318d0ad..4bec639dc 100644 --- a/app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java +++ b/app/src/main/java/io/xpipe/app/util/TerminalLauncherManager.java @@ -7,20 +7,19 @@ import io.xpipe.core.process.ShellControl; import io.xpipe.core.process.TerminalInitScriptConfig; import io.xpipe.core.process.WorkingDirectoryFunction; import io.xpipe.core.store.FilePath; - import lombok.Setter; import lombok.Value; import lombok.experimental.NonFinal; import java.nio.file.Path; -import java.util.Map; +import java.util.LinkedHashMap; +import java.util.SequencedMap; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; public class TerminalLauncherManager { - private static final Map entries = new ConcurrentHashMap<>(); + private static final SequencedMap entries = new LinkedHashMap<>(); private static void prepare( ProcessControl processControl, TerminalInitScriptConfig config, String directory, Entry entry) { @@ -73,6 +72,15 @@ public class TerminalLauncherManager { return latch; } + public static Path waitForFirstLaunch() throws BeaconClientException, BeaconServerException { + if (entries.isEmpty()) { + throw new BeaconClientException("Unknown launch request"); + } + + var first = entries.firstEntry(); + return waitForCompletion(first.getKey()); + } + public static Path waitForCompletion(UUID request) throws BeaconClientException, BeaconServerException { var e = entries.get(request); if (e == null) { @@ -86,8 +94,8 @@ public class TerminalLauncherManager { } var r = e.getResult(); + entries.remove(request); if (r instanceof ResultFailure failure) { - entries.remove(request); var t = failure.getThrowable(); throw new BeaconServerException(t); } diff --git a/app/src/main/java/module-info.java b/app/src/main/java/module-info.java index 2923838ea..f55981ef6 100644 --- a/app/src/main/java/module-info.java +++ b/app/src/main/java/module-info.java @@ -154,5 +154,6 @@ open module io.xpipe.app { AskpassExchangeImpl, TerminalWaitExchangeImpl, TerminalLaunchExchangeImpl, + SshLaunchExchangeImpl, DaemonVersionExchangeImpl; } diff --git a/beacon/src/main/java/io/xpipe/beacon/api/SshLaunchExchange.java b/beacon/src/main/java/io/xpipe/beacon/api/SshLaunchExchange.java new file mode 100644 index 000000000..6cf11e9fe --- /dev/null +++ b/beacon/src/main/java/io/xpipe/beacon/api/SshLaunchExchange.java @@ -0,0 +1,32 @@ +package io.xpipe.beacon.api; + +import io.xpipe.beacon.BeaconInterface; +import lombok.Builder; +import lombok.NonNull; +import lombok.Value; +import lombok.extern.jackson.Jacksonized; + +import java.util.List; + +public class SshLaunchExchange extends BeaconInterface { + + @Override + public String getPath() { + return "/sshLaunch"; + } + + @Jacksonized + @Builder + @Value + public static class Request { + String storePath; + } + + @Jacksonized + @Builder + @Value + public static class Response { + @NonNull + List command; + } +} diff --git a/beacon/src/main/java/module-info.java b/beacon/src/main/java/module-info.java index 30a277cb6..d70ed28d1 100644 --- a/beacon/src/main/java/module-info.java +++ b/beacon/src/main/java/module-info.java @@ -47,6 +47,7 @@ open module io.xpipe.beacon { AskpassExchange, TerminalWaitExchange, TerminalLaunchExchange, + SshLaunchExchange, FsReadExchange, FsBlobExchange, FsWriteExchange,