api rework

This commit is contained in:
crschnick 2024-06-04 14:48:42 +00:00
parent 86a205289c
commit 3e91e8df01
12 changed files with 968 additions and 353 deletions

View file

@ -150,6 +150,13 @@ processResources {
into resourcesDir into resourcesDir
} }
} }
doLast {
copy {
from file("$rootDir/openapi.yaml")
into file("${sourceSets.main.output.resourcesDir}/io/xpipe/app/resources/misc");
}
}
} }
distTar { distTar {

View file

@ -119,6 +119,7 @@ public class AppBeaconServer {
})); }));
var resourceMap = Map.of( var resourceMap = Map.of(
"openapi.yaml", "misc/openapi.yaml",
"markdown.css", "misc/github-markdown-dark.css", "markdown.css", "misc/github-markdown-dark.css",
"highlight.min.js", "misc/highlight.min.js", "highlight.min.js", "misc/highlight.min.js",
"github-dark.min.css", "misc/github-dark.min.css" "github-dark.min.css", "misc/github-dark.min.css"

View file

@ -6,6 +6,7 @@ import io.xpipe.app.storage.DataStoreEntry;
import io.xpipe.beacon.BeaconClientException; import io.xpipe.beacon.BeaconClientException;
import io.xpipe.beacon.BeaconServerException; import io.xpipe.beacon.BeaconServerException;
import io.xpipe.beacon.api.ConnectionQueryExchange; import io.xpipe.beacon.api.ConnectionQueryExchange;
import io.xpipe.core.store.StorePath;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
@ -16,7 +17,7 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange {
@Override @Override
public Object handle(HttpExchange exchange, Request msg) throws IOException, BeaconClientException, BeaconServerException { 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())); var conMatcher = Pattern.compile(toRegex(msg.getConnectionFilter()));
List<DataStoreEntry> found = new ArrayList<>(); List<DataStoreEntry> found = new ArrayList<>();
@ -45,7 +46,8 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange {
var mapped = new ArrayList<QueryResponse>(); var mapped = new ArrayList<QueryResponse>();
for (DataStoreEntry e : found) { 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() var obj = ConnectionQueryExchange.QueryResponse.builder()
.uuid(e.getUuid()).category(cat).connection(DataStorage.get() .uuid(e.getUuid()).category(cat).connection(DataStorage.get()
.getStorePath(e)).type(e.getProvider().getId()).build(); .getStorePath(e)).type(e.getProvider().getId()).build();
@ -55,6 +57,92 @@ public class ConnectionQueryExchangeImpl extends ConnectionQueryExchange {
} }
private String toRegex(String pattern) { 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();
} }
} }

View file

@ -23,7 +23,7 @@ public class HandshakeExchangeImpl extends HandshakeExchange {
var session = new BeaconSession(body.getClient(), UUID.randomUUID().toString()); var session = new BeaconSession(body.getClient(), UUID.randomUUID().toString());
AppBeaconServer.get().addSession(session); AppBeaconServer.get().addSession(session);
return Response.builder().token(session.getToken()).build(); return Response.builder().sessionToken(session.getToken()).build();
} }
private boolean checkAuth(BeaconAuthMethod authMethod) { private boolean checkAuth(BeaconAuthMethod authMethod) {

View file

@ -5,6 +5,7 @@ import io.xpipe.app.core.mode.OperationMode;
import io.xpipe.app.issue.ErrorEvent; import io.xpipe.app.issue.ErrorEvent;
import io.xpipe.app.launcher.LauncherInput; import io.xpipe.app.launcher.LauncherInput;
import io.xpipe.app.prefs.AppPrefs; import io.xpipe.app.prefs.AppPrefs;
import io.xpipe.app.util.PlatformState;
import io.xpipe.app.util.ThreadHelper; import io.xpipe.app.util.ThreadHelper;
import io.xpipe.core.process.OsType; import io.xpipe.core.process.OsType;
@ -16,6 +17,10 @@ import java.util.List;
public class AppDesktopIntegration { public class AppDesktopIntegration {
public static void setupDesktopIntegrations() { public static void setupDesktopIntegrations() {
if (PlatformState.getCurrent() != PlatformState.RUNNING) {
return;
}
try { try {
if (Desktop.isDesktopSupported()) { if (Desktop.isDesktopSupported()) {
Desktop.getDesktop().addAppEventListener(new SystemSleepListener() { Desktop.getDesktop().addAppEventListener(new SystemSleepListener() {

View file

@ -763,13 +763,13 @@ public abstract class DataStorage {
public StorePath getStorePath(DataStoreEntry entry) { public StorePath getStorePath(DataStoreEntry entry) {
return StorePath.create(getStoreParentHierarchy(entry).stream() return StorePath.create(getStoreParentHierarchy(entry).stream()
.filter(e -> !(e.getStore() instanceof LocalStore)) .filter(e -> !(e.getStore() instanceof LocalStore))
.map(e -> e.getName().replaceAll("/", "_")) .map(e -> e.getName().toLowerCase().replaceAll("/", "_"))
.toArray(String[]::new)); .toArray(String[]::new));
} }
public StorePath getStorePath(DataStoreCategory entry) { public StorePath getStorePath(DataStoreCategory entry) {
return StorePath.create(getCategoryParentHierarchy(entry).stream() return StorePath.create(getCategoryParentHierarchy(entry).stream()
.map(e -> e.getName().replaceAll("/", "_")) .map(e -> e.getName().toLowerCase().replaceAll("/", "_"))
.toArray(String[]::new)); .toArray(String[]::new));
} }

File diff suppressed because it is too large Load diff

View file

@ -70,6 +70,12 @@ html {
font-style: italic; font-style: italic;
} }
.markdown-body summary {
font-weight: 600;
padding-bottom: .3em;
font-size: 1.4em;
}
.markdown-body h1 { .markdown-body h1 {
margin: .67em 0; margin: .67em 0;
font-weight: 600; font-weight: 600;
@ -416,7 +422,7 @@ html {
.markdown-body table, .markdown-body table,
.markdown-body pre, .markdown-body pre,
.markdown-body details { .markdown-body details {
margin-top: 0; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }

View file

@ -542,10 +542,16 @@ html {
.markdown-body table, .markdown-body table,
.markdown-body pre, .markdown-body pre,
.markdown-body details { .markdown-body details {
margin-top: 0; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }
.markdown-body summary {
font-weight: 600;
padding-bottom: .3em;
font-size: 1.4em;
}
.markdown-body blockquote > :first-child { .markdown-body blockquote > :first-child {
margin-top: 0; margin-top: 0;
} }

View file

@ -26,7 +26,7 @@ public class BeaconClient {
HandshakeExchange.Response response = client.performRequest(HandshakeExchange.Request.builder() HandshakeExchange.Response response = client.performRequest(HandshakeExchange.Request.builder()
.client(information) .client(information)
.auth(BeaconAuthMethod.Local.builder().authFileContent(auth).build()).build()); .auth(BeaconAuthMethod.Local.builder().authFileContent(auth).build()).build());
client.token = response.getToken(); client.token = response.getSessionToken();
return client; return client;
} }

View file

@ -35,6 +35,6 @@ public class HandshakeExchange extends BeaconInterface<HandshakeExchange.Request
@Value @Value
public static class Response { public static class Response {
@NonNull @NonNull
String token; String sessionToken;
} }
} }

View file

@ -1,7 +1,22 @@
openapi: 3.0.1 openapi: 3.0.1
info: info:
title: XPipe API Documentation title: XPipe API Documentation
description: The XPipe API provides programmatic access to XPipes features. description: |
The XPipe API provides programmatic access to XPipes 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.
termsOfService: https://docs.xpipe.io/terms-of-service termsOfService: https://docs.xpipe.io/terms-of-service
contact: contact:
name: XPipe - Contact us name: XPipe - Contact us
@ -11,16 +26,21 @@ externalDocs:
description: XPipe - Plans and pricing description: XPipe - Plans and pricing
url: https://xpipe.io/pricing url: https://xpipe.io/pricing
servers: servers:
- url: https://localhost:21721 - url: http://localhost:21721
description: XPipe Daemon API description: XPipe Daemon API
- url: https://localhost:21722
description: XPipe PTB Daemon API
paths: paths:
/handshake: /handshake:
post: post:
summary: Create new session summary: Establish a new API session
description: Creates a new API session, allowing you to send requests to the daemon once it is established. description: |
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.
operationId: handshake operationId: handshake
security: []
requestBody: requestBody:
required: true required: true
content: content:
@ -54,7 +74,11 @@ paths:
/connection/query: /connection/query:
post: post:
summary: Query connections 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 operationId: connectionQuery
requestBody: requestBody:
required: true required: true
@ -65,13 +89,13 @@ paths:
examples: examples:
all: all:
summary: All summary: All
value: {"categoryFilter": "**", "connectionFilter": "**", "typeFilter": "*"} value: { "categoryFilter": "*", "connectionFilter": "*", "typeFilter": "*" }
simple: simple:
summary: Simple filter summary: Simple filter
value: { "categoryFilter": "default", "connectionFilter": "local machine", "typeFilter": "*" } value: { "categoryFilter": "default", "connectionFilter": "local machine", "typeFilter": "*" }
globs: globs:
summary: Globs summary: Globs
value: {"categoryFilter": "**", "connectionFilter": "**/podman/*", "typeFilter": "*"} value: { "categoryFilter": "*", "connectionFilter": "*/podman/*", "typeFilter": "*" }
responses: responses:
200: 200:
description: The query was successful. The body contains all matched connections. description: The query was successful. The body contains all matched connections.
@ -79,6 +103,13 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ConnectionQueryResponse' $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: 400:
$ref: '#/components/responses/BadRequest' $ref: '#/components/responses/BadRequest'
401: 401:
@ -89,29 +120,107 @@ paths:
$ref: '#/components/responses/NotFound' $ref: '#/components/responses/NotFound'
500: 500:
$ref: '#/components/responses/InternalServerError' $ref: '#/components/responses/InternalServerError'
/daemon/open: /shell/start:
post: post:
summary: Open URLs summary: Start shell connection
description: Opens main window or executes given actions. description: |
operationId: daemonOpen 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: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: schema:
type: object $ref: '#/components/schemas/ShellStartRequest'
properties: examples:
arguments: local:
description: |- summary: Start local shell
Arguments to open. These can be URLs of various different types to perform certain actions. value: { "uuid": "f0ec68aa-63f5-405c-b178-9a4454556d6b" }
type: array
minItems: 0
items:
type: string
example: file:///home/user/.ssh/
responses: responses:
200: 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: 400:
$ref: '#/components/responses/BadRequest' $ref: '#/components/responses/BadRequest'
401: 401:
@ -124,18 +233,62 @@ paths:
$ref: '#/components/responses/InternalServerError' $ref: '#/components/responses/InternalServerError'
components: components:
schemas: 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: ConnectionQueryRequest:
type: object type: object
properties: properties:
categoryFilter: categoryFilter:
type: string 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: connectionFilter:
type: string 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: typeFilter:
type: string 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: required:
- categoryFilter - categoryFilter
- connectionFilter - connectionFilter
@ -153,11 +306,17 @@ components:
type: string type: string
description: The unique id of the connection description: The unique id of the connection
category: category:
type: array
description: The full category path as an array
items:
type: string type: string
description: The full category path description: Individual category name
connection: connection:
type: array
description: The full connection name path as an array
items:
type: string type: string
description: The full connection name path description: Individual connection name
type: type:
type: string type: string
description: The type identifier of the connection description: The type identifier of the connection
@ -181,11 +340,11 @@ components:
HandshakeResponse: HandshakeResponse:
type: object type: object
properties: properties:
token: sessionToken:
type: string type: string
description: The generated bearer token that can be used for authentication in this session description: The generated bearer token that can be used for authentication in this session
required: required:
- token - sessionToken
AuthMethod: AuthMethod:
type: object type: object
discriminator: discriminator:
@ -256,9 +415,9 @@ components:
InternalServerError: InternalServerError:
description: Internal error. description: Internal error.
securitySchemes: securitySchemes:
auth_header: bearerAuth:
type: apiKey type: http
description: Authentication with `Authorization` header and `Bearer` scheme: bearer
authentication scheme description: The bearer token used is the session token that you receive from the handshake exchange.
name: Authorization security:
in: header - bearerAuth: []