diff --git a/README.md b/README.md index 0d980b726..58a50a92d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ It currently supports: - [Hyper-V](https://docs.xpipe.io/guide/hyperv), [KVM](https://docs.xpipe.io/guide/kvm), [VMware Player/Workstation/Fusion](https://docs.xpipe.io/guide/vmware) virtual machines - [Kubernetes](https://docs.xpipe.io/guide/kubernetes) clusters, pods, and containers - [Tailscale](https://docs.xpipe.io/guide/tailscale) and [Teleport](https://docs.xpipe.io/guide/teleport) connections -- Windows Subsystem for Linux, Cygwin, and MSYS2 instances +- Windows Subsystem for Linux, Cygwin, and MSYS2 environments - Powershell Remote Sessions - RDP and VNC connections @@ -170,7 +170,9 @@ as it is not a perfect standalone version. It should however run on most systems ## Docker container -XPipe is a desktop application first and foremost. It requires a full desktop environment to function with various installed applications such as terminals, editors, shells, CLI tools, and more. So there is no true web-based interface for XPipe. Since it might make sense however to access your XPipe environment from the web, there is also a so-called webtop docker container image for XPipe. [XPipe Webtop](https://github.com/xpipe-io/xpipe-webtop) is a web-based desktop environment that can be run in a container and accessed from a browser via KasmVNC. The desktop environment comes with XPipe and various terminals and editors preinstalled and configured. +XPipe is a desktop application first and foremost. It requires a full desktop environment to function with various installed applications such as terminals, editors, shells, CLI tools, and more. So there is no true web-based interface for XPipe. + +Since it might make sense however to access your XPipe environment from the web, there is also a so-called webtop docker container image for XPipe. [XPipe Webtop](https://github.com/xpipe-io/xpipe-webtop) is a web-based desktop environment that can be run in a container and accessed from a browser via KasmVNC. The desktop environment comes with XPipe and various terminals and editors preinstalled and configured. # Further information diff --git a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java index aac5156aa..db92fdbba 100644 --- a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java @@ -20,7 +20,9 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermissions; import java.util.*; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; public class AppBeaconServer { @@ -34,6 +36,7 @@ public class AppBeaconServer { private final boolean propertyPort; private boolean running; + private ExecutorService executor; private HttpServer server; @Getter @@ -105,7 +108,11 @@ public class AppBeaconServer { } running = false; - server.stop(1); + server.stop(0); + executor.shutdown(); + try { + executor.awaitTermination(30, TimeUnit.SECONDS); + } catch (InterruptedException ignored) {} } private void initAuthSecret() throws IOException { @@ -127,12 +134,7 @@ public class AppBeaconServer { } private void start() throws IOException { - server = HttpServer.create( - new InetSocketAddress(Inet4Address.getByAddress(new byte[] {0x7f, 0x00, 0x00, 0x01}), port), 10); - BeaconInterface.getAll().forEach(beaconInterface -> { - server.createContext(beaconInterface.getPath(), new BeaconRequestHandler<>(beaconInterface)); - }); - server.setExecutor(Executors.newFixedThreadPool(5, r -> { + executor = Executors.newFixedThreadPool(5, r -> { Thread t = Executors.defaultThreadFactory().newThread(r); t.setDaemon(true); t.setName("http handler"); @@ -140,7 +142,13 @@ public class AppBeaconServer { ErrorEvent.fromThrowable(e).handle(); }); return t; - })); + }); + server = HttpServer.create( + new InetSocketAddress(Inet4Address.getByAddress(new byte[] {0x7f, 0x00, 0x00, 0x01}), port), 10); + BeaconInterface.getAll().forEach(beaconInterface -> { + server.createContext(beaconInterface.getPath(), new BeaconRequestHandler<>(beaconInterface)); + }); + server.setExecutor(executor); var resourceMap = Map.of( "openapi.yaml", "misc/openapi.yaml", diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionQueryExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionQueryExchangeImpl.java index 58df33e30..dfcc96adb 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionQueryExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/ConnectionQueryExchangeImpl.java @@ -1,6 +1,7 @@ package io.xpipe.app.beacon.impl; import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStorageQuery; import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.beacon.api.ConnectionQueryExchange; @@ -14,43 +15,7 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange { @Override public Object handle(HttpExchange exchange, Request msg) { - var catMatcher = Pattern.compile( - toRegex("all connections/" + msg.getCategoryFilter().toLowerCase())); - var conMatcher = Pattern.compile(toRegex(msg.getConnectionFilter().toLowerCase())); - var typeMatcher = Pattern.compile(toRegex(msg.getTypeFilter().toLowerCase())); - - List found = new ArrayList<>(); - for (DataStoreEntry storeEntry : DataStorage.get().getStoreEntries()) { - if (!storeEntry.getValidity().isUsable()) { - continue; - } - - var name = DataStorage.get().getStorePath(storeEntry).toString(); - if (!conMatcher.matcher(name).matches()) { - continue; - } - - var cat = DataStorage.get() - .getStoreCategoryIfPresent(storeEntry.getCategoryUuid()) - .orElse(null); - if (cat == null) { - continue; - } - - var c = DataStorage.get().getStorePath(cat).toString(); - if (!catMatcher.matcher(c).matches()) { - continue; - } - - if (!typeMatcher - .matcher(storeEntry.getProvider().getId().toLowerCase()) - .matches()) { - continue; - } - - found.add(storeEntry); - } - + var found = DataStorageQuery.query(msg.getCategoryFilter(), msg.getConnectionFilter(), msg.getTypeFilter()); return Response.builder() .found(found.stream().map(entry -> entry.getUuid()).toList()) .build(); @@ -60,85 +25,4 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange { public Object getSynchronizationObject() { return DataStorage.get(); } - - private String toRegex(String pattern) { - // https://stackoverflow.com/a/17369948/6477761 - StringBuilder sb = new StringBuilder(pattern.length()); - int inGroup = 0; - int inClass = 0; - int firstIndexInClass = -1; - char[] arr = pattern.toCharArray(); - for (int i = 0; i < arr.length; i++) { - char ch = arr[i]; - switch (ch) { - case '\\': - if (++i >= arr.length) { - sb.append('\\'); - } else { - char next = arr[i]; - switch (next) { - case ',': - // escape not needed - break; - case 'Q': - case 'E': - // extra escape needed - sb.append('\\'); - default: - sb.append('\\'); - } - sb.append(next); - } - break; - case '*': - if (inClass == 0) sb.append(".*"); - else sb.append('*'); - break; - case '?': - if (inClass == 0) sb.append('.'); - else sb.append('?'); - break; - case '[': - inClass++; - firstIndexInClass = i + 1; - sb.append('['); - break; - case ']': - inClass--; - sb.append(']'); - break; - case '.': - case '(': - case ')': - case '+': - case '|': - case '^': - case '$': - case '@': - case '%': - if (inClass == 0 || (firstIndexInClass == i && ch == '^')) sb.append('\\'); - sb.append(ch); - break; - case '!': - if (firstIndexInClass == i) sb.append('^'); - else sb.append('!'); - break; - case '{': - inGroup++; - sb.append('('); - break; - case '}': - inGroup--; - sb.append(')'); - break; - case ',': - if (inGroup > 0) sb.append('|'); - else sb.append(','); - break; - default: - sb.append(ch); - } - } - return sb.toString(); - } } diff --git a/app/src/main/java/io/xpipe/app/beacon/impl/TerminalExternalLaunchExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalExternalLaunchExchangeImpl.java new file mode 100644 index 000000000..cc7b34f56 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/beacon/impl/TerminalExternalLaunchExchangeImpl.java @@ -0,0 +1,45 @@ +package io.xpipe.app.beacon.impl; + +import com.sun.net.httpserver.HttpExchange; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.storage.DataStorage; +import io.xpipe.app.storage.DataStorageQuery; +import io.xpipe.app.storage.DataStoreEntry; +import io.xpipe.app.terminal.TerminalLauncherManager; +import io.xpipe.beacon.BeaconClientException; +import io.xpipe.beacon.BeaconServerException; +import io.xpipe.beacon.api.TerminalExternalLaunchExchange; + +public class TerminalExternalLaunchExchangeImpl extends TerminalExternalLaunchExchange { + + @Override + public Object handle(HttpExchange exchange, Request msg) throws BeaconClientException, BeaconServerException { + var found = DataStorageQuery.queryUserInput(msg.getConnection()); + if (found.isEmpty()) { + throw new BeaconClientException("No connection found for input " + msg.getConnection()); + } + + if (found.size() > 1) { + throw new BeaconServerException("Multiple connections found: " + found.stream().map(DataStoreEntry::getName).toList()); + } + + var e = found.getFirst(); + var isShell = e.getStore() instanceof ShellStore; + if (!isShell) { + throw new BeaconClientException("Connection " + DataStorage.get().getStorePath(e).toString() + " is not a shell connection"); + } + + var r = TerminalLauncherManager.externalExchange(e.ref()); + return Response.builder().command(r).build(); + } + + @Override + public boolean requiresEnabledApi() { + return false; + } + + @Override + public Object getSynchronizationObject() { + return DataStorage.get(); + } +} diff --git a/app/src/main/java/io/xpipe/app/storage/DataStorageQuery.java b/app/src/main/java/io/xpipe/app/storage/DataStorageQuery.java new file mode 100644 index 000000000..8e5812846 --- /dev/null +++ b/app/src/main/java/io/xpipe/app/storage/DataStorageQuery.java @@ -0,0 +1,148 @@ +package io.xpipe.app.storage; + +import com.sun.net.httpserver.HttpExchange; +import io.xpipe.beacon.api.ConnectionQueryExchange; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +public class DataStorageQuery { + + public static List queryUserInput(String connection) { + var found = query("*", "*" + connection + "*", "*"); + if (found.size() > 1) { + var narrow = found.stream().filter(dataStoreEntry -> dataStoreEntry.getName().equalsIgnoreCase(connection)).toList(); + if (narrow.size() == 1) { + return narrow; + } + } + return found; + } + + public static List query(String categoryFilter, String connectionFilter, String typeFilter) { + var catMatcher = Pattern.compile( + toRegex("all connections/" + categoryFilter.toLowerCase())); + var conMatcher = Pattern.compile(toRegex(connectionFilter.toLowerCase())); + var typeMatcher = Pattern.compile(toRegex(typeFilter.toLowerCase())); + + List found = new ArrayList<>(); + for (DataStoreEntry storeEntry : DataStorage.get().getStoreEntries()) { + if (!storeEntry.getValidity().isUsable()) { + continue; + } + + var name = DataStorage.get().getStorePath(storeEntry).toString(); + if (!conMatcher.matcher(name).matches()) { + continue; + } + + var cat = DataStorage.get() + .getStoreCategoryIfPresent(storeEntry.getCategoryUuid()) + .orElse(null); + if (cat == null) { + continue; + } + + var c = DataStorage.get().getStorePath(cat).toString(); + if (!catMatcher.matcher(c).matches()) { + continue; + } + + if (!typeMatcher + .matcher(storeEntry.getProvider().getId().toLowerCase()) + .matches()) { + continue; + } + + found.add(storeEntry); + } + return found; + } + + private static String toRegex(String pattern) { + pattern = pattern.replaceAll("\\*\\*", "#"); + // https://stackoverflow.com/a/17369948/6477761 + StringBuilder sb = new StringBuilder(pattern.length()); + int inGroup = 0; + int inClass = 0; + int firstIndexInClass = -1; + char[] arr = pattern.toCharArray(); + for (int i = 0; i < arr.length; i++) { + char ch = arr[i]; + switch (ch) { + case '\\': + if (++i >= arr.length) { + sb.append('\\'); + } else { + char next = arr[i]; + switch (next) { + case ',': + // escape not needed + break; + case 'Q': + case 'E': + // extra escape needed + sb.append('\\'); + default: + sb.append('\\'); + } + sb.append(next); + } + break; + case '*': + if (inClass == 0) sb.append("[^/]*"); + else sb.append('*'); + break; + case '#': + if (inClass == 0) sb.append(".*"); + else sb.append('*'); + break; + case '?': + if (inClass == 0) sb.append('.'); + else sb.append('?'); + break; + case '[': + inClass++; + firstIndexInClass = i + 1; + sb.append('['); + break; + case ']': + inClass--; + sb.append(']'); + break; + case '.': + case '(': + case ')': + case '+': + case '|': + case '^': + case '$': + case '@': + case '%': + if (inClass == 0 || (firstIndexInClass == i && ch == '^')) sb.append('\\'); + sb.append(ch); + break; + case '!': + if (firstIndexInClass == i) sb.append('^'); + else sb.append('!'); + break; + case '{': + inGroup++; + sb.append('('); + break; + case '}': + inGroup--; + sb.append(')'); + break; + case ',': + if (inGroup > 0) sb.append('|'); + else sb.append(','); + break; + default: + sb.append(ch); + } + } + return sb.toString(); + } +} diff --git a/app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java index 8e871add5..6a1b842ce 100644 --- a/app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java +++ b/app/src/main/java/io/xpipe/app/terminal/TerminalLauncherManager.java @@ -1,17 +1,19 @@ package io.xpipe.app.terminal; +import io.xpipe.app.ext.ProcessControlProvider; +import io.xpipe.app.ext.ShellStore; +import io.xpipe.app.storage.DataStoreEntryRef; +import io.xpipe.app.util.LocalShell; import io.xpipe.app.util.SecretManager; import io.xpipe.app.util.SecretQueryProgress; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconServerException; import io.xpipe.core.process.ProcessControl; +import io.xpipe.core.process.ShellControl; import io.xpipe.core.process.TerminalInitScriptConfig; import java.nio.file.Path; -import java.util.LinkedHashMap; -import java.util.Optional; -import java.util.SequencedMap; -import java.util.UUID; +import java.util.*; import java.util.concurrent.CountDownLatch; public class TerminalLauncherManager { @@ -126,4 +128,33 @@ public class TerminalLauncherManager { return ((TerminalLaunchResult.ResultSuccess) e.getResult()).getTargetScript(); } } + + + public static List externalExchange(DataStoreEntryRef ref) throws BeaconClientException, BeaconServerException { + var request = UUID.randomUUID(); + ShellControl session; + try { + session = ref.getStore().getOrStartSession(); + } catch (Exception e) { + throw new BeaconServerException(e); + } + var config = TerminalInitScriptConfig.ofName(ref.get().getName()); + submitAsync(request, session, config, null); + waitExchange(request); + var script = launchExchange(request); + try (var sc = LocalShell.getShell().start()) { + var runCommand = ProcessControlProvider.get().getEffectiveLocalDialect().getOpenScriptCommand(script.toString()).buildBaseParts(sc); + var cleaned = runCommand.stream().map(s -> { + if (s.startsWith("\"") && s.endsWith("\"")) { + s = s.substring(1, s.length() - 1); + } else if (s.startsWith("'") && s.endsWith("'")) { + s = s.substring(1, s.length() - 1); + } + return s; + }).toList(); + return cleaned; + } catch (Exception e) { + throw new BeaconServerException(e); + } + } } diff --git a/app/src/main/java/module-info.java b/app/src/main/java/module-info.java index c000ae695..f8497a9b9 100644 --- a/app/src/main/java/module-info.java +++ b/app/src/main/java/module-info.java @@ -151,6 +151,7 @@ open module io.xpipe.app { TerminalPrepareExchangeImpl, TerminalWaitExchangeImpl, TerminalLaunchExchangeImpl, + TerminalExternalLaunchExchangeImpl, SshLaunchExchangeImpl, DaemonVersionExchangeImpl; } diff --git a/beacon/src/main/java/io/xpipe/beacon/api/TerminalExternalLaunchExchange.java b/beacon/src/main/java/io/xpipe/beacon/api/TerminalExternalLaunchExchange.java new file mode 100644 index 000000000..5fc3a1c94 --- /dev/null +++ b/beacon/src/main/java/io/xpipe/beacon/api/TerminalExternalLaunchExchange.java @@ -0,0 +1,36 @@ +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 TerminalExternalLaunchExchange extends BeaconInterface { + + @Override + public String getPath() { + return "/terminal/externalLaunch"; + } + + @Jacksonized + @Builder + @Value + public static class Request { + @NonNull + String connection; + + @NonNull + List arguments; + } + + @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 99e3c734f..3c62651b9 100644 --- a/beacon/src/main/java/module-info.java +++ b/beacon/src/main/java/module-info.java @@ -49,6 +49,7 @@ open module io.xpipe.beacon { TerminalPrepareExchange, TerminalWaitExchange, TerminalLaunchExchange, + TerminalExternalLaunchExchange, SshLaunchExchange, FsReadExchange, FsBlobExchange,