diff --git a/app/build.gradle b/app/build.gradle index e82d29704..319f9fcd1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -150,6 +150,13 @@ processResources { into resourcesDir } } + + doLast { + copy { + from file("$rootDir/openapi.yaml") + into file("${sourceSets.main.output.resourcesDir}/io/xpipe/app/resources/misc"); + } + } } distTar { 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 adc0ea3c2..e3d0a31e1 100644 --- a/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java +++ b/app/src/main/java/io/xpipe/app/beacon/AppBeaconServer.java @@ -119,6 +119,7 @@ public class AppBeaconServer { })); var resourceMap = Map.of( + "openapi.yaml", "misc/openapi.yaml", "markdown.css", "misc/github-markdown-dark.css", "highlight.min.js", "misc/highlight.min.js", "github-dark.min.css", "misc/github-dark.min.css" 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 729194f49..2fca86f76 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 @@ -6,6 +6,7 @@ import io.xpipe.app.storage.DataStoreEntry; import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.api.ConnectionQueryExchange; +import io.xpipe.core.store.StorePath; import java.io.IOException; import java.util.ArrayList; @@ -16,7 +17,7 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange { @Override public Object handle(HttpExchange exchange, Request msg) throws IOException, BeaconClientException, BeaconServerException { - var catMatcher = Pattern.compile(toRegex(msg.getCategoryFilter())); + var catMatcher = Pattern.compile(toRegex("all connections/" + msg.getCategoryFilter())); var conMatcher = Pattern.compile(toRegex(msg.getConnectionFilter())); List found = new ArrayList<>(); @@ -45,7 +46,8 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange { var mapped = new ArrayList(); for (DataStoreEntry e : found) { - var cat = DataStorage.get().getStorePath(DataStorage.get().getStoreCategoryIfPresent(e.getCategoryUuid()).orElseThrow()); + var names = DataStorage.get().getStorePath(DataStorage.get().getStoreCategoryIfPresent(e.getCategoryUuid()).orElseThrow()).getNames(); + var cat = new StorePath(names.subList(1, names.size())); var obj = ConnectionQueryExchange.QueryResponse.builder() .uuid(e.getUuid()).category(cat).connection(DataStorage.get() .getStorePath(e)).type(e.getProvider().getId()).build(); @@ -55,6 +57,92 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange { } private String toRegex(String pattern) { - return pattern.replaceAll("\\*\\*", ".*?").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 '[': + 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/HandshakeExchangeImpl.java b/app/src/main/java/io/xpipe/app/beacon/impl/HandshakeExchangeImpl.java index a99ed993f..2e10a773c 100644 --- a/app/src/main/java/io/xpipe/app/beacon/impl/HandshakeExchangeImpl.java +++ b/app/src/main/java/io/xpipe/app/beacon/impl/HandshakeExchangeImpl.java @@ -23,7 +23,7 @@ public class HandshakeExchangeImpl extends HandshakeExchange { var session = new BeaconSession(body.getClient(), UUID.randomUUID().toString()); AppBeaconServer.get().addSession(session); - return Response.builder().token(session.getToken()).build(); + return Response.builder().sessionToken(session.getToken()).build(); } private boolean checkAuth(BeaconAuthMethod authMethod) { diff --git a/app/src/main/java/io/xpipe/app/core/AppDesktopIntegration.java b/app/src/main/java/io/xpipe/app/core/AppDesktopIntegration.java index c6900b53e..5ad9b00ee 100644 --- a/app/src/main/java/io/xpipe/app/core/AppDesktopIntegration.java +++ b/app/src/main/java/io/xpipe/app/core/AppDesktopIntegration.java @@ -5,6 +5,7 @@ import io.xpipe.app.core.mode.OperationMode; import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.launcher.LauncherInput; import io.xpipe.app.prefs.AppPrefs; +import io.xpipe.app.util.PlatformState; import io.xpipe.app.util.ThreadHelper; import io.xpipe.core.process.OsType; @@ -16,6 +17,10 @@ import java.util.List; public class AppDesktopIntegration { public static void setupDesktopIntegrations() { + if (PlatformState.getCurrent() != PlatformState.RUNNING) { + return; + } + try { if (Desktop.isDesktopSupported()) { Desktop.getDesktop().addAppEventListener(new SystemSleepListener() { diff --git a/app/src/main/java/io/xpipe/app/storage/DataStorage.java b/app/src/main/java/io/xpipe/app/storage/DataStorage.java index f1f50317f..2d3a7b303 100644 --- a/app/src/main/java/io/xpipe/app/storage/DataStorage.java +++ b/app/src/main/java/io/xpipe/app/storage/DataStorage.java @@ -763,13 +763,13 @@ public abstract class DataStorage { public StorePath getStorePath(DataStoreEntry entry) { return StorePath.create(getStoreParentHierarchy(entry).stream() .filter(e -> !(e.getStore() instanceof LocalStore)) - .map(e -> e.getName().replaceAll("/", "_")) + .map(e -> e.getName().toLowerCase().replaceAll("/", "_")) .toArray(String[]::new)); } public StorePath getStorePath(DataStoreCategory entry) { return StorePath.create(getCategoryParentHierarchy(entry).stream() - .map(e -> e.getName().replaceAll("/", "_")) + .map(e -> e.getName().toLowerCase().replaceAll("/", "_")) .toArray(String[]::new)); } diff --git a/app/src/main/resources/io/xpipe/app/resources/misc/api.md b/app/src/main/resources/io/xpipe/app/resources/misc/api.md index 29ed0a901..0f59f86de 100644 --- a/app/src/main/resources/io/xpipe/app/resources/misc/api.md +++ b/app/src/main/resources/io/xpipe/app/resources/misc/api.md @@ -1,14 +1,17 @@ --- title: XPipe API Documentation v10.0 language_tabs: - - shell: Shell - - http: HTTP - javascript: JavaScript - - ruby: Ruby - python: Python - - php: PHP - java: Java - go: Go + - shell: Shell +language_clients: + - javascript: "" + - python: "" + - java: "" + - go: "" + - shell: "" toc_footers: - XPipe - Plans and pricing includes: [] @@ -20,29 +23,47 @@ headingLevel: 2

XPipe API Documentation v10.0

-[TOC] - -> Scroll down for code samples, example requests and responses. Select a language for code samples from the tabs above or the mobile navigation menu. - The XPipe API provides programmatic access to XPipe’s features. +The XPipe application will start up an HTTP server that can be used to send requests. +You can change the port of it in the settings menu. +Note that this server is HTTP-only for now as it runs only on localhost. HTTPS requests are not accepted. + +The main use case for the API right now is programmatically managing remote systems. +To start off, you can query connections based on various filters. +With the matched connections, you can start remote shell sessions for each one and run arbitrary commands in them. +You get the command exit code and output as a response, allowing you to adapt your control flow based on command outputs. +Any kind of passwords another secret are automatically provided by XPipe when establishing a shell connection. +If required password is not stored and is set to be dynamically prompted, the running XPipe application will ask you to enter any required passwords. + +You can quickly get started by either using this page as an API reference or alternatively import the [OpenAPI definition file](/openapi.yaml) into your API client of choice. +See the authentication handshake below on how to authenticate prior to sending requests. + Base URLs: -* https://localhost:21721 +* http://localhost:21721 -* https://localhost:21722 +Table of contents: +[TOC] -undefined +# Authentication + +- HTTP Authentication, scheme: bearer The bearer token used is the session token that you receive from the handshake exchange.

Default

-## Create new session +## Establish a new API session `POST /handshake` -Creates a new API session, allowing you to send requests to the daemon once it is established. +Prior to sending requests to the API, you first have to establish a new API session via the handshake endpoint. +In the response you will receive a session token that you can use to authenticate during this session. + +This is done so that the daemon knows what kind of clients are connected and can manage individual capabilities for clients. + +Note that for development you can also turn off the required authentication in the XPipe settings menu, allowing you to send unauthenticated requests. > Body parameter @@ -59,31 +80,40 @@ Creates a new API session, allowing you to send requests to the daemon once it i } ``` -undefined +

Parameters

-undefined +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[HandshakeRequest](#schemahandshakerequest)|true|none| -undefined +> Example responses -undefined - -> Code samples - -```shell -# You can also use wget -curl -X POST https://localhost:21721/handshake \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' +> 200 Response +```json +{ + "sessionToken": "string" +} ``` -```http -POST https://localhost:21721/handshake HTTP/1.1 -Host: localhost:21721 -Content-Type: application/json -Accept: application/json +

Responses

-``` +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|The handshake was successful. The returned token can be used for authentication in this session. The token is valid as long as XPipe is running.|[HandshakeResponse](#schemahandshakeresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Please check error message and your parameters.|None| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Authorization failed. Please supply a `Bearer` token via the `Authorization` header.|None| +|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Authorization failed. Please supply a valid `Bearer` token via the `Authorization` header.|None| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|The requested resource could not be found.|None| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal error.|None| + + + +
+ +Code samples ```javascript const inputBody = '{ @@ -101,7 +131,7 @@ const headers = { 'Accept':'application/json' }; -fetch('https://localhost:21721/handshake', +fetch('http://localhost:21721/handshake', { method: 'POST', body: inputBody, @@ -115,23 +145,6 @@ fetch('https://localhost:21721/handshake', ``` -```ruby -require 'rest-client' -require 'json' - -headers = { - 'Content-Type' => 'application/json', - 'Accept' => 'application/json' -} - -result = RestClient.post 'https://localhost:21721/handshake', - params: { - }, headers: headers - -p JSON.parse(result) - -``` - ```python import requests headers = { @@ -139,46 +152,14 @@ headers = { 'Accept': 'application/json' } -r = requests.post('https://localhost:21721/handshake', headers = headers) +r = requests.post('http://localhost:21721/handshake', headers = headers) print(r.json()) ``` -```php - 'application/json', - 'Accept' => 'application/json', -); - -$client = new \GuzzleHttp\Client(); - -// Define array of request body. -$request_body = array(); - -try { - $response = $client->request('POST','https://localhost:21721/handshake', array( - 'headers' => $headers, - 'json' => $request_body, - ) - ); - print_r($response->getBody()->getContents()); - } - catch (\GuzzleHttp\Exception\BadResponseException $e) { - // handle exception or api errors. - print_r($e->getMessage()); - } - - // ... - -``` - ```java -URL obj = new URL("https://localhost:21721/handshake"); +URL obj = new URL("http://localhost:21721/handshake"); HttpURLConnection con = (HttpURLConnection) obj.openConnection(); con.setRequestMethod("POST"); int responseCode = con.getResponseCode(); @@ -210,7 +191,7 @@ func main() { } data := bytes.NewBuffer([]byte{jsonReq}) - req, err := http.NewRequest("POST", "https://localhost:21721/handshake", data) + req, err := http.NewRequest("POST", "http://localhost:21721/handshake", data) req.Header = headers client := &http.Client{} @@ -220,62 +201,110 @@ func main() { ``` +```shell +# You can also use wget +curl -X POST http://localhost:21721/handshake \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' + +``` + +
+ ## Query connections `POST /connection/query` -Queries all connections using various filters +Queries all connections using various filters. + +The filters support globs and can match the category names and connection names. +All matching is case insensitive. > Body parameter ```json { - "categoryFilter": "**", - "connectionFilter": "**", + "categoryFilter": "*", + "connectionFilter": "*", "typeFilter": "*" } ``` -undefined +

Parameters

-undefined +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[ConnectionQueryRequest](#schemaconnectionqueryrequest)|true|none| -undefined +> Example responses -undefined - -> Code samples - -```shell -# You can also use wget -curl -X POST https://localhost:21721/connection/query \ - -H 'Content-Type: application/json' \ - -H 'Accept: application/json' +> The query was successful. The body contains all matched connections. +```json +{ + "found": [ + { + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b", + "category": [ + "default" + ], + "connection": [ + "local machine" + ], + "type": "local" + }, + { + "uuid": "e1462ddc-9beb-484c-bd91-bb666027e300", + "category": [ + "default", + "category 1" + ], + "connection": [ + "ssh system", + "shell environments", + "bash" + ], + "type": "shellEnvironment" + } + ] +} ``` -```http -POST https://localhost:21721/connection/query HTTP/1.1 -Host: localhost:21721 -Content-Type: application/json -Accept: application/json +

Responses

-``` +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|The query was successful. The body contains all matched connections.|[ConnectionQueryResponse](#schemaconnectionqueryresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Please check error message and your parameters.|None| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Authorization failed. Please supply a `Bearer` token via the `Authorization` header.|None| +|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Authorization failed. Please supply a valid `Bearer` token via the `Authorization` header.|None| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|The requested resource could not be found.|None| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal error.|None| + + + +
+ +Code samples ```javascript const inputBody = '{ - "categoryFilter": "**", - "connectionFilter": "**", + "categoryFilter": "*", + "connectionFilter": "*", "typeFilter": "*" }'; const headers = { 'Content-Type':'application/json', - 'Accept':'application/json' + 'Accept':'application/json', + 'Authorization':'Bearer {access-token}' }; -fetch('https://localhost:21721/connection/query', +fetch('http://localhost:21721/connection/query', { method: 'POST', body: inputBody, @@ -289,70 +318,22 @@ fetch('https://localhost:21721/connection/query', ``` -```ruby -require 'rest-client' -require 'json' - -headers = { - 'Content-Type' => 'application/json', - 'Accept' => 'application/json' -} - -result = RestClient.post 'https://localhost:21721/connection/query', - params: { - }, headers: headers - -p JSON.parse(result) - -``` - ```python import requests headers = { 'Content-Type': 'application/json', - 'Accept': 'application/json' + 'Accept': 'application/json', + 'Authorization': 'Bearer {access-token}' } -r = requests.post('https://localhost:21721/connection/query', headers = headers) +r = requests.post('http://localhost:21721/connection/query', headers = headers) print(r.json()) ``` -```php - 'application/json', - 'Accept' => 'application/json', -); - -$client = new \GuzzleHttp\Client(); - -// Define array of request body. -$request_body = array(); - -try { - $response = $client->request('POST','https://localhost:21721/connection/query', array( - 'headers' => $headers, - 'json' => $request_body, - ) - ); - print_r($response->getBody()->getContents()); - } - catch (\GuzzleHttp\Exception\BadResponseException $e) { - // handle exception or api errors. - print_r($e->getMessage()); - } - - // ... - -``` - ```java -URL obj = new URL("https://localhost:21721/connection/query"); +URL obj = new URL("http://localhost:21721/connection/query"); HttpURLConnection con = (HttpURLConnection) obj.openConnection(); con.setRequestMethod("POST"); int responseCode = con.getResponseCode(); @@ -381,10 +362,11 @@ func main() { headers := map[string][]string{ "Content-Type": []string{"application/json"}, "Accept": []string{"application/json"}, + "Authorization": []string{"Bearer {access-token}"}, } data := bytes.NewBuffer([]byte{jsonReq}) - req, err := http.NewRequest("POST", "https://localhost:21721/connection/query", data) + req, err := http.NewRequest("POST", "http://localhost:21721/connection/query", data) req.Header = headers client := &http.Client{} @@ -394,59 +376,72 @@ func main() { ``` -## Open URLs +```shell +# You can also use wget +curl -X POST http://localhost:21721/connection/query \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer {access-token}' - +``` -`POST /daemon/open` +
-Opens main window or executes given actions. +## Start shell connection + + + +`POST /shell/start` + +Starts a new shell session for a connection. If an existing shell session is already running for that connection, this operation will do nothing. + +Note that there are a variety of possible errors that can occur here when establishing the shell connection. +These errors will be returned with the HTTP return code 500. > Body parameter ```json { - "arguments": [ - "file:///home/user/.ssh/" - ] + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" } ``` -undefined +

Parameters

-undefined +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[ShellStartRequest](#schemashellstartrequest)|true|none| -undefined +

Responses

-undefined +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|The operation was successful. The shell session was started.|None| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Please check error message and your parameters.|None| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Authorization failed. Please supply a `Bearer` token via the `Authorization` header.|None| +|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Authorization failed. Please supply a valid `Bearer` token via the `Authorization` header.|None| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|The requested resource could not be found.|None| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal error.|None| -> Code samples + -```shell -# You can also use wget -curl -X POST https://localhost:21721/daemon/open \ - -H 'Content-Type: application/json' +
-``` - -```http -POST https://localhost:21721/daemon/open HTTP/1.1 -Host: localhost:21721 -Content-Type: application/json - -``` +Code samples ```javascript const inputBody = '{ - "arguments": [ - "file:///home/user/.ssh/" - ] + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" }'; const headers = { - 'Content-Type':'application/json' + 'Content-Type':'application/json', + 'Authorization':'Bearer {access-token}' }; -fetch('https://localhost:21721/daemon/open', +fetch('http://localhost:21721/shell/start', { method: 'POST', body: inputBody, @@ -460,67 +455,21 @@ fetch('https://localhost:21721/daemon/open', ``` -```ruby -require 'rest-client' -require 'json' - -headers = { - 'Content-Type' => 'application/json' -} - -result = RestClient.post 'https://localhost:21721/daemon/open', - params: { - }, headers: headers - -p JSON.parse(result) - -``` - ```python import requests headers = { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {access-token}' } -r = requests.post('https://localhost:21721/daemon/open', headers = headers) +r = requests.post('http://localhost:21721/shell/start', headers = headers) print(r.json()) ``` -```php - 'application/json', -); - -$client = new \GuzzleHttp\Client(); - -// Define array of request body. -$request_body = array(); - -try { - $response = $client->request('POST','https://localhost:21721/daemon/open', array( - 'headers' => $headers, - 'json' => $request_body, - ) - ); - print_r($response->getBody()->getContents()); - } - catch (\GuzzleHttp\Exception\BadResponseException $e) { - // handle exception or api errors. - print_r($e->getMessage()); - } - - // ... - -``` - ```java -URL obj = new URL("https://localhost:21721/daemon/open"); +URL obj = new URL("http://localhost:21721/shell/start"); HttpURLConnection con = (HttpURLConnection) obj.openConnection(); con.setRequestMethod("POST"); int responseCode = con.getResponseCode(); @@ -548,10 +497,11 @@ func main() { headers := map[string][]string{ "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer {access-token}"}, } data := bytes.NewBuffer([]byte{jsonReq}) - req, err := http.NewRequest("POST", "https://localhost:21721/daemon/open", data) + req, err := http.NewRequest("POST", "http://localhost:21721/shell/start", data) req.Header = headers client := &http.Client{} @@ -561,8 +511,399 @@ func main() { ``` +```shell +# You can also use wget +curl -X POST http://localhost:21721/shell/start \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer {access-token}' + +``` + +
+ +## Stop shell connection + + + +`POST /shell/stop` + +Stops an existing shell session for a connection. + +This operation will return once the shell has exited. +If the shell is busy or stuck, you might have to work with timeouts to account for these cases. + +> Body parameter + +```json +{ + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" +} +``` + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[ShellStopRequest](#schemashellstoprequest)|true|none| + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|The operation was successful. The shell session was stopped.|None| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Please check error message and your parameters.|None| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Authorization failed. Please supply a `Bearer` token via the `Authorization` header.|None| +|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Authorization failed. Please supply a valid `Bearer` token via the `Authorization` header.|None| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|The requested resource could not be found.|None| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal error.|None| + + + +
+ +Code samples + +```javascript +const inputBody = '{ + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" +}'; +const headers = { + 'Content-Type':'application/json', + 'Authorization':'Bearer {access-token}' +}; + +fetch('http://localhost:21721/shell/stop', +{ + method: 'POST', + body: inputBody, + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +```python +import requests +headers = { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {access-token}' +} + +r = requests.post('http://localhost:21721/shell/stop', headers = headers) + +print(r.json()) + +``` + +```java +URL obj = new URL("http://localhost:21721/shell/stop"); +HttpURLConnection con = (HttpURLConnection) obj.openConnection(); +con.setRequestMethod("POST"); +int responseCode = con.getResponseCode(); +BufferedReader in = new BufferedReader( + new InputStreamReader(con.getInputStream())); +String inputLine; +StringBuffer response = new StringBuffer(); +while ((inputLine = in.readLine()) != null) { + response.append(inputLine); +} +in.close(); +System.out.println(response.toString()); + +``` + +```go +package main + +import ( + "bytes" + "net/http" +) + +func main() { + + headers := map[string][]string{ + "Content-Type": []string{"application/json"}, + "Authorization": []string{"Bearer {access-token}"}, + } + + data := bytes.NewBuffer([]byte{jsonReq}) + req, err := http.NewRequest("POST", "http://localhost:21721/shell/stop", data) + req.Header = headers + + client := &http.Client{} + resp, err := client.Do(req) + // ... +} + +``` + +```shell +# You can also use wget +curl -X POST http://localhost:21721/shell/stop \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer {access-token}' + +``` + +
+ +## Execute command in a shell session + + + +`POST /shell/exec` + +Runs a command in an active shell session and waits for it to finish. The exit code and output will be returned in the response. + +Note that a variety of different errors can occur when executing the command. +If the command finishes, even with an error code, a normal HTTP 200 response will be returned. +However, if any other error occurs like the shell not responding or exiting unexpectedly, an HTTP 500 response will be returned. + +> Body parameter + +```json +{ + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b", + "command": "echo $USER" +} +``` + +

Parameters

+ +|Name|In|Type|Required|Description| +|---|---|---|---|---| +|body|body|[ShellExecRequest](#schemashellexecrequest)|true|none| + +> Example responses + +> The operation was successful. The shell command finished. + +```json +{ + "exitCode": 0, + "stdout": "root", + "stderr": "" +} +``` + +```json +{ + "exitCode": 127, + "stdout": "", + "stderr": "invalid: command not found" +} +``` + +

Responses

+ +|Status|Meaning|Description|Schema| +|---|---|---|---| +|200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|The operation was successful. The shell command finished.|[ShellExecResponse](#schemashellexecresponse)| +|400|[Bad Request](https://tools.ietf.org/html/rfc7231#section-6.5.1)|Bad request. Please check error message and your parameters.|None| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Authorization failed. Please supply a `Bearer` token via the `Authorization` header.|None| +|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Authorization failed. Please supply a valid `Bearer` token via the `Authorization` header.|None| +|404|[Not Found](https://tools.ietf.org/html/rfc7231#section-6.5.4)|The requested resource could not be found.|None| +|500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal error.|None| + + + +
+ +Code samples + +```javascript +const inputBody = '{ + "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b", + "command": "echo $USER" +}'; +const headers = { + 'Content-Type':'application/json', + 'Accept':'application/json', + 'Authorization':'Bearer {access-token}' +}; + +fetch('http://localhost:21721/shell/exec', +{ + method: 'POST', + body: inputBody, + headers: headers +}) +.then(function(res) { + return res.json(); +}).then(function(body) { + console.log(body); +}); + +``` + +```python +import requests +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': 'Bearer {access-token}' +} + +r = requests.post('http://localhost:21721/shell/exec', headers = headers) + +print(r.json()) + +``` + +```java +URL obj = new URL("http://localhost:21721/shell/exec"); +HttpURLConnection con = (HttpURLConnection) obj.openConnection(); +con.setRequestMethod("POST"); +int responseCode = con.getResponseCode(); +BufferedReader in = new BufferedReader( + new InputStreamReader(con.getInputStream())); +String inputLine; +StringBuffer response = new StringBuffer(); +while ((inputLine = in.readLine()) != null) { + response.append(inputLine); +} +in.close(); +System.out.println(response.toString()); + +``` + +```go +package main + +import ( + "bytes" + "net/http" +) + +func main() { + + headers := map[string][]string{ + "Content-Type": []string{"application/json"}, + "Accept": []string{"application/json"}, + "Authorization": []string{"Bearer {access-token}"}, + } + + data := bytes.NewBuffer([]byte{jsonReq}) + req, err := http.NewRequest("POST", "http://localhost:21721/shell/exec", data) + req.Header = headers + + client := &http.Client{} + resp, err := client.Do(req) + // ... +} + +``` + +```shell +# You can also use wget +curl -X POST http://localhost:21721/shell/exec \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Authorization: Bearer {access-token}' + +``` + +
+ # Schemas +

ShellStartRequest

+ + + + + + +```json +{ + "connection": "string" +} + +``` + +

Properties

+ +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|connection|string|true|none|The connection uuid| + +

ShellStopRequest

+ + + + + + +```json +{ + "connection": "string" +} + +``` + +

Properties

+ +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|connection|string|true|none|The connection uuid| + +

ShellExecRequest

+ + + + + + +```json +{ + "connection": "string", + "command": "string" +} + +``` + +

Properties

+ +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|connection|string|true|none|The connection uuid| +|command|string|true|none|The command to execute| + +

ShellExecResponse

+ + + + + + +```json +{ + "exitCode": 0, + "stdout": "string", + "stderr": "string" +} + +``` + +

Properties

+ +|Name|Type|Required|Restrictions|Description| +|---|---|---|---|---| +|exitCode|integer|true|none|The exit code of the command| +|stdout|string|true|none|The stdout output of the command| +|stderr|string|true|none|The stderr output of the command| +

ConnectionQueryRequest

@@ -579,13 +920,13 @@ func main() { ``` -### Properties +

Properties

|Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|categoryFilter|string|true|none|The filter string to match categories. Categories are delimited by / if they are hierarchical. The filter supports globs with * and **.| -|connectionFilter|string|true|none|The filter string to match connection names. Connection names are delimited by / if they are hierarchical. The filter supports globs with * and **.| -|typeFilter|string|true|none|The filter string to connection types. Every unique type of connection like SSH or docker has its own type identifier that you can match. The filter supports globs with *.| +|categoryFilter|string|true|none|The filter string to match categories. Categories are delimited by / if they are hierarchical. The filter supports globs.| +|connectionFilter|string|true|none|The filter string to match connection names. Connection names are delimited by / if they are hierarchical. The filter supports globs.| +|typeFilter|string|true|none|The filter string to connection types. Every unique type of connection like SSH or docker has its own type identifier that you can match. The filter supports globs.|

ConnectionQueryResponse

@@ -599,8 +940,12 @@ func main() { "found": [ { "uuid": "string", - "category": "string", - "connection": "string", + "category": [ + "string" + ], + "connection": [ + "string" + ], "type": "string" } ] @@ -608,14 +953,14 @@ func main() { ``` -### Properties +

Properties

|Name|Type|Required|Restrictions|Description| |---|---|---|---|---| |found|[object]|true|none|The found connections| |» uuid|string|true|none|The unique id of the connection| -|» category|string|true|none|The full category path| -|» connection|string|true|none|The full connection name path| +|» category|[string]|true|none|The full category path as an array| +|» connection|[string]|true|none|The full connection name path as an array| |» type|string|true|none|The type identifier of the connection|

HandshakeRequest

@@ -638,7 +983,7 @@ func main() { ``` -### Properties +

Properties

|Name|Type|Required|Restrictions|Description| |---|---|---|---|---| @@ -654,16 +999,16 @@ func main() { ```json { - "token": "string" + "sessionToken": "string" } ``` -### Properties +

Properties

|Name|Type|Required|Restrictions|Description| |---|---|---|---|---| -|token|string|true|none|The generated bearer token that can be used for authentication in this session| +|sessionToken|string|true|none|The generated bearer token that can be used for authentication in this session|

AuthMethod

@@ -680,7 +1025,7 @@ func main() { ``` -### Properties +

Properties

|Name|Type|Required|Restrictions|Description| |---|---|---|---|---| @@ -715,7 +1060,7 @@ xor API key authentication -### Properties +

Properties

allOf - discriminator: AuthMethod.type @@ -748,7 +1093,7 @@ and Authentication method for local applications. Uses file system access as proof of authentication. -### Properties +

Properties

allOf - discriminator: AuthMethod.type @@ -777,7 +1122,7 @@ and ``` -### Properties +

Properties

|Name|Type|Required|Restrictions|Description| |---|---|---|---|---| @@ -800,7 +1145,7 @@ and Provides information about the client that connected to the XPipe API. -### Properties +

Properties

allOf - discriminator: ClientInformation.type @@ -815,5 +1160,3 @@ and |*anonymous*|object|false|none|none| |» name|string|true|none|The name of the client.| -undefined - diff --git a/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-dark.css b/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-dark.css index b818a8b7f..4fe441894 100644 --- a/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-dark.css +++ b/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-dark.css @@ -70,6 +70,12 @@ html { font-style: italic; } +.markdown-body summary { + font-weight: 600; + padding-bottom: .3em; + font-size: 1.4em; +} + .markdown-body h1 { margin: .67em 0; font-weight: 600; @@ -416,7 +422,7 @@ html { .markdown-body table, .markdown-body pre, .markdown-body details { - margin-top: 0; + margin-top: 16px; margin-bottom: 16px; } diff --git a/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-light.css b/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-light.css index 1c792edbe..c69cdde9c 100644 --- a/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-light.css +++ b/app/src/main/resources/io/xpipe/app/resources/misc/github-markdown-light.css @@ -542,10 +542,16 @@ html { .markdown-body table, .markdown-body pre, .markdown-body details { - margin-top: 0; + margin-top: 16px; margin-bottom: 16px; } +.markdown-body summary { + font-weight: 600; + padding-bottom: .3em; + font-size: 1.4em; +} + .markdown-body blockquote > :first-child { margin-top: 0; } diff --git a/beacon/src/main/java/io/xpipe/beacon/BeaconClient.java b/beacon/src/main/java/io/xpipe/beacon/BeaconClient.java index 834741672..80559fc12 100644 --- a/beacon/src/main/java/io/xpipe/beacon/BeaconClient.java +++ b/beacon/src/main/java/io/xpipe/beacon/BeaconClient.java @@ -26,7 +26,7 @@ public class BeaconClient { HandshakeExchange.Response response = client.performRequest(HandshakeExchange.Request.builder() .client(information) .auth(BeaconAuthMethod.Local.builder().authFileContent(auth).build()).build()); - client.token = response.getToken(); + client.token = response.getSessionToken(); return client; } diff --git a/beacon/src/main/java/io/xpipe/beacon/api/HandshakeExchange.java b/beacon/src/main/java/io/xpipe/beacon/api/HandshakeExchange.java index 2461fe44a..bab3caf52 100644 --- a/beacon/src/main/java/io/xpipe/beacon/api/HandshakeExchange.java +++ b/beacon/src/main/java/io/xpipe/beacon/api/HandshakeExchange.java @@ -35,6 +35,6 @@ public class HandshakeExchange extends BeaconInterface"}, "client": {"type": "Api", "name": "My client name"}} + value: { "auth": { "type": "ApiKey", "key": "" }, "client": { "type": "Api", "name": "My client name" } } local: summary: Local application handshake - value: {"auth": {"type": "Local", "authFileContent": ""}, "client": {"type": "Api", "name": "My client name"}} + value: { "auth": { "type": "Local", "authFileContent": "" }, "client": { "type": "Api", "name": "My client name" } } responses: 200: description: The handshake was successful. The returned token can be used for authentication in this session. The token is valid as long as XPipe is running. @@ -54,7 +74,11 @@ paths: /connection/query: post: summary: Query connections - description: Queries all connections using various filters + description: | + Queries all connections using various filters. + + The filters support globs and can match the category names and connection names. + All matching is case insensitive. operationId: connectionQuery requestBody: required: true @@ -65,13 +89,13 @@ paths: examples: all: summary: All - value: {"categoryFilter": "**", "connectionFilter": "**", "typeFilter": "*"} + value: { "categoryFilter": "*", "connectionFilter": "*", "typeFilter": "*" } simple: summary: Simple filter - value: {"categoryFilter": "default", "connectionFilter": "local machine", "typeFilter": "*"} + value: { "categoryFilter": "default", "connectionFilter": "local machine", "typeFilter": "*" } globs: summary: Globs - value: {"categoryFilter": "**", "connectionFilter": "**/podman/*", "typeFilter": "*"} + value: { "categoryFilter": "*", "connectionFilter": "*/podman/*", "typeFilter": "*" } responses: 200: description: The query was successful. The body contains all matched connections. @@ -79,6 +103,13 @@ paths: application/json: schema: $ref: '#/components/schemas/ConnectionQueryResponse' + examples: + standard: + summary: Matched connections + value: { "found": [ { "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b", "category": ["default"] , + "connection": ["local machine"], "type": "local" }, + { "uuid": "e1462ddc-9beb-484c-bd91-bb666027e300", "category": ["default", "category 1"], + "connection": ["ssh system", "shell environments", "bash"], "type": "shellEnvironment" } ] } 400: $ref: '#/components/responses/BadRequest' 401: @@ -89,29 +120,107 @@ paths: $ref: '#/components/responses/NotFound' 500: $ref: '#/components/responses/InternalServerError' - /daemon/open: + /shell/start: post: - summary: Open URLs - description: Opens main window or executes given actions. - operationId: daemonOpen + summary: Start shell connection + description: | + Starts a new shell session for a connection. If an existing shell session is already running for that connection, this operation will do nothing. + + Note that there are a variety of possible errors that can occur here when establishing the shell connection. + These errors will be returned with the HTTP return code 500. + operationId: shellStart requestBody: required: true content: application/json: schema: - type: object - properties: - arguments: - description: |- - Arguments to open. These can be URLs of various different types to perform certain actions. - type: array - minItems: 0 - items: - type: string - example: file:///home/user/.ssh/ + $ref: '#/components/schemas/ShellStartRequest' + examples: + local: + summary: Start local shell + value: { "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" } responses: 200: - $ref: '#/components/responses/Success' + description: The operation was successful. The shell session was started. + 400: + $ref: '#/components/responses/BadRequest' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 500: + $ref: '#/components/responses/InternalServerError' + /shell/stop: + post: + summary: Stop shell connection + description: | + Stops an existing shell session for a connection. + + This operation will return once the shell has exited. + If the shell is busy or stuck, you might have to work with timeouts to account for these cases. + operationId: shellStop + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShellStopRequest' + examples: + local: + summary: Stop local shell + value: { "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" } + responses: + 200: + description: The operation was successful. The shell session was stopped. + 400: + $ref: '#/components/responses/BadRequest' + 401: + $ref: '#/components/responses/Unauthorized' + 403: + $ref: '#/components/responses/Forbidden' + 404: + $ref: '#/components/responses/NotFound' + 500: + $ref: '#/components/responses/InternalServerError' + /shell/exec: + post: + summary: Execute command in a shell session + description: | + Runs a command in an active shell session and waits for it to finish. The exit code and output will be returned in the response. + + Note that a variety of different errors can occur when executing the command. + If the command finishes, even with an error code, a normal HTTP 200 response will be returned. + However, if any other error occurs like the shell not responding or exiting unexpectedly, an HTTP 500 response will be returned. + operationId: shellExec + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShellExecRequest' + examples: + user: + summary: echo $USER + value: { "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b", "command": "echo $USER" } + invalid: + summary: invalid + value: { "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b", "command": "invalid" } + responses: + 200: + description: The operation was successful. The shell command finished. + content: + application/json: + schema: + $ref: '#/components/schemas/ShellExecResponse' + examples: + user: + summary: echo $USER + value: { "exitCode": 0, "stdout": "root", "stderr": "" } + fail: + summary: invalid + value: { "exitCode": 127, "stdout": "", "stderr": "invalid: command not found" } 400: $ref: '#/components/responses/BadRequest' 401: @@ -124,22 +233,66 @@ paths: $ref: '#/components/responses/InternalServerError' components: schemas: + ShellStartRequest: + type: object + properties: + connection: + type: string + description: The connection uuid + required: + - connection + ShellStopRequest: + type: object + properties: + connection: + type: string + description: The connection uuid + required: + - connection + ShellExecRequest: + type: object + properties: + connection: + type: string + description: The connection uuid + command: + type: string + description: The command to execute + required: + - connection + - command + ShellExecResponse: + type: object + properties: + exitCode: + type: integer + description: The exit code of the command + stdout: + type: string + description: The stdout output of the command + stderr: + type: string + description: The stderr output of the command + required: + - exitCode + - stdout + - stderr ConnectionQueryRequest: type: object properties: categoryFilter: type: string - description: The filter string to match categories. Categories are delimited by / if they are hierarchical. The filter supports globs with * and **. + description: The filter string to match categories. Categories are delimited by / if they are hierarchical. The filter supports globs. connectionFilter: type: string - description: The filter string to match connection names. Connection names are delimited by / if they are hierarchical. The filter supports globs with * and **. + description: The filter string to match connection names. Connection names are delimited by / if they are hierarchical. The filter supports globs. typeFilter: type: string - description: The filter string to connection types. Every unique type of connection like SSH or docker has its own type identifier that you can match. The filter supports globs with *. + description: The filter string to connection types. Every unique type of connection like SSH or docker has its own type identifier that you can match. The filter supports globs. required: - - categoryFilter - - connectionFilter - - typeFilter + - categoryFilter + - connectionFilter + - typeFilter ConnectionQueryResponse: type: object properties: @@ -153,21 +306,27 @@ components: type: string description: The unique id of the connection category: - type: string - description: The full category path + type: array + description: The full category path as an array + items: + type: string + description: Individual category name connection: - type: string - description: The full connection name path + type: array + description: The full connection name path as an array + items: + type: string + description: Individual connection name type: type: string description: The type identifier of the connection required: - - uuid - - category - - connection - - type + - uuid + - category + - connection + - type required: - - found + - found HandshakeRequest: type: object properties: @@ -176,16 +335,16 @@ components: client: $ref: '#/components/schemas/ClientInformation' required: - - auth - - client + - auth + - client HandshakeResponse: type: object properties: - token: + sessionToken: type: string description: The generated bearer token that can be used for authentication in this session required: - - token + - sessionToken AuthMethod: type: object discriminator: @@ -194,32 +353,32 @@ components: type: type: string required: - - type + - type oneOf: - - $ref: '#/components/schemas/ApiKey' - - $ref: '#/components/schemas/Local' + - $ref: '#/components/schemas/ApiKey' + - $ref: '#/components/schemas/Local' ApiKey: description: API key authentication allOf: - - $ref: '#/components/schemas/AuthMethod' - - type: object - properties: - key: - type: string - description: The API key - required: - - key + - $ref: '#/components/schemas/AuthMethod' + - type: object + properties: + key: + type: string + description: The API key + required: + - key Local: description: Authentication method for local applications. Uses file system access as proof of authentication. allOf: - - $ref: '#/components/schemas/AuthMethod' - - type: object - properties: - authFileContent: - type: string - description: The contents of the local file $TEMP/xpipe_auth. This file is automatically generated when XPipe starts. - required: - - authFileContent + - $ref: '#/components/schemas/AuthMethod' + - type: object + properties: + authFileContent: + type: string + description: The contents of the local file $TEMP/xpipe_auth. This file is automatically generated when XPipe starts. + required: + - authFileContent ClientInformation: type: object discriminator: @@ -228,18 +387,18 @@ components: type: type: string required: - - type + - type ApiClientInformation: description: Provides information about the client that connected to the XPipe API. allOf: - - $ref: '#/components/schemas/ClientInformation' - - type: object - properties: - name: - type: string - description: The name of the client. - required: - - name + - $ref: '#/components/schemas/ClientInformation' + - type: object + properties: + name: + type: string + description: The name of the client. + required: + - name responses: Success: description: The action was successfully performed. @@ -256,9 +415,9 @@ components: InternalServerError: description: Internal error. securitySchemes: - auth_header: - type: apiKey - description: Authentication with `Authorization` header and `Bearer` - authentication scheme - name: Authorization - in: header \ No newline at end of file + bearerAuth: + type: http + scheme: bearer + description: The bearer token used is the session token that you receive from the handshake exchange. +security: + - bearerAuth: []