diff --git a/.github/workflows/release-dev.yml b/.github/workflows/release-dev.yml index 4c85976..95e0569 100644 --- a/.github/workflows/release-dev.yml +++ b/.github/workflows/release-dev.yml @@ -44,6 +44,13 @@ jobs: id: commit_hash run: echo "COMMIT_HASH=$(git rev-parse --short HEAD)" >> $GITHUB_ENV + - name: Publish to SimpleCloud Repository + run: ./gradlew publishMavenJavaPublicationToSimplecloudRepository + env: + COMMIT_HASH: ${{ env.COMMIT_HASH }} + SIMPLECLOUD_USERNAME: ${{ secrets.SIMPLECLOUD_USERNAME }} + SIMPLECLOUD_PASSWORD: ${{ secrets.SIMPLECLOUD_PASSWORD }} + - name: Create Release id: create_release uses: actions/create-release@v1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2cd34d3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [2025] [SimpleCloud] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/auth.md b/auth.md new file mode 100644 index 0000000..9462188 --- /dev/null +++ b/auth.md @@ -0,0 +1,479 @@ +# Auth Server API Documentation +This documentation provides docs for every authorization server endpoint. +Internally by the controller and every official droplet, auth tokens are used for authentication. No GRPC request +with invalid auth token is possible. + +# Scoping +Scoping is the OAuth way to deal with permissions. Scopes are represented in a string, seperated by a whitespace. +In our case, they can also be wildcarded (`*`). + +**These scopes:** + - `test.*` + - `other.test` + +**will be passed as:** `test.* other.test` on auth server rest endpoints. + +The scope `*` will grant every permission. + +# Getting scopes in a GRPC context +You can get the scopes that are provided to the current context (but ony if this context uses the v3 controllers `AuthSecretInterceptor`) like this: + +```kt +val scopes = MetadataKeys.SCOPES.get() // List +``` + +# Restricted endpoints +For some endpoints, there are listed authorization scope requirements. +To access these endpoints, you must pass a token in the Authorization header that meets these requirements. + +### Header example +```json + { + "Authorization": "Bearer " + } +``` + +You can gain access to an auth token by using the master token, retrieving a token through user login (`/login`) +and by creating a custom client with the `client_credentials` method and calling the (`/token`) endpoint to log in with this client. + +# API Documentation for Authorization Endpoints + +The `AuthorizationHandler` provides various endpoints for client registration, authorization, token requests, revocation, and introspection. Below is a detailed description of each endpoint. + +--- + +## Register Client + +### Endpoint +`POST /oauth/register_client` + +### Description +Registers a new OAuth client, associating it with a unique client secret, grant types, and scope. + +### Request Parameters +- **`master_token`** (String, required): The master token for authentication (in the `.secrets/auth.secret` file). +- **`client_id`** (String, required): The unique identifier for the client. +- **`redirect_uri`** (String, required): The URI to redirect to after authorization. +- **`grant_types`** (String, required): The grant types supported by the client. +- **`scope`** (String, optional): The scopes the client has access to. +- **`client_secret`** (String, optional): The client secret. If not provided, a random one will be generated. + +### Authorization Scope Required +- None + +### Responses +- **200 OK**: The client was successfully registered. + ```json + { + "client_id": "client_id", + "client_secret": "client_secret" + } + ``` +- **400 Bad Request**: Missing required parameters such as `client_id` or `grant_types`. +- **403 Forbidden**: Invalid master token. + +--- + +## Authorize Request + +### Endpoint +`POST /oauth/authorize` + +### Description +Handles the authorization request for an OAuth client with PKCE (Proof Key for Code Exchange) support. + +### Request Parameters +- **`client_id`** (String, required): The unique identifier for the client. +- **`redirect_uri`** (String, required): The URI to redirect to after authorization. +- **`code_challenge_method`** (String, required): The challenge method. Must be `S256`. +- **`code_challenge`** (String, required): The PKCE code challenge. +- **`scope`** (String, required): The requested scope. + +### Authorization Scope Required +- None + +### Responses +- **200 OK**: Authorization successful. + ```json + { + "redirectUri": "?code=" + } + ``` +- **400 Bad Request**: Missing required parameters such as `client_id`, `redirect_uri`, `scope`, or `code_challenge`. +- **404 Not Found**: Client not found. +- **400 Bad Request**: Invalid challenge or unsupported grant types. + +--- + +## Token Request + +### Endpoint +`POST /oauth/token` + +### Description +Handles the token request to exchange the authorization code for an access token or to generate a client credentials token. + +### Request Parameters +- **`client_id`** (String, required): The unique identifier for the client. +- **`client_secret`** (String, required): The client secret. +- **`code`** (String, required if using authorization code flow): The authorization code. +- **`code_verifier`** (String, required if using PKCE): The PKCE code verifier. + +### Authorization Scope Required +- None + +### Responses +- **200 OK**: Token successfully issued. + ```json + { + "access_token": "access_token", + "scope": "scope", + "exp": "expiration_time", + "user_id": "user_id", + "client_id": "client_id" + } + ``` +- **400 Bad Request**: Missing required parameters such as `client_id` or `client_secret`. +- **404 Not Found**: Client not found. +- **400 Bad Request**: Invalid client secret or unsupported grant type. + +--- + +## Revoke Token + +### Endpoint +`POST /oauth/revoke` + +### Description +Revokes an OAuth token, rendering it inactive. + +### Request Parameters +- **`access_token`** (String, required): The access token to revoke. + +### Authorization Scope Required +- None + +### Responses +- **200 OK**: The token was successfully revoked. +- **400 Bad Request**: Invalid access token. +- **500 Internal Server Error**: Could not delete token. + +--- + +## Introspect Token + +### Endpoint +`POST /oauth/introspect` + +### Description +Introspects a token to verify its validity and return token details. + +### Request Parameters +- **`token`** (String, required): The token to introspect. + +### Authorization Scope Required +- None + +### Responses +- **200 OK**: Token is valid and active. + ```json + { + "active": true, + "token_id": "token_id", + "client_id": "client_id", + "scope": "scope", + "exp": "expiration_time" + } + ``` +- **200 OK**: Token is invalid or expired. + ```json + { + "active": false + } + ``` +- **400 Bad Request**: Token is missing. + +# Authentication Endpoints + +The `AuthenticationHandler` provides various endpoints to manage OAuth groups, users, and tokens. Below is a detailed +description of each endpoint. + +--- + +## Save Group + +### Endpoint + +`PUT /group` + +### Description + +Creates or updates an OAuth group with the specified scopes. + +### Request Parameters + +- **`group_name`** (String, required): Name of the group. +- **`scopes`** (String, optional): Space-separated list of scopes for the group. + +### Authorization Scope Required + +- `simplecloud.auth.group.save.` + +### Responses + +- **200 OK**: Success message. +- **400 Bad Request**: You must specify a group name. +- **401 Unauthorized**: Unauthorized. + +--- + +## Get Group + +### Endpoint + +`GET /group` + +### Description + +Retrieves details of a specific OAuth group. + +### Request Parameters + +- **`group_name`** (String, required): Name of the group to retrieve. + +### Authorization Scope Required + +- `simplecloud.auth.group.get.` + +### Responses + +- **200 OK**: Group details. + ```json + { + "group_name": "example_group", + "scope": "read write" + } + ``` +- **400 Bad Request**: You must specify a group name. +- **404 Not Found**: Group not found. +- **401 Unauthorized**: Unauthorized. + +--- + +## Get All Groups + +### Endpoint + +`GET /groups` + +### Description + +Fetches a list of all OAuth groups. + +### Authorization Scope Required + +- `simplecloud.auth.group.get.*` + +### Responses + +- **200 OK**: List of all groups. + ```json + [ + { + "group_name": "group1", + "scope": "read write" + }, + { + "group_name": "group2", + "scope": "read" + } + ] + ``` +- **401 Unauthorized**: Unauthorized. + +--- + +## Delete Group + +### Endpoint + +`DELETE /group` + +### Description + +Deletes a specific OAuth group. + +### Request Parameters + +- **`group_name`** (String, required): Name of the group to delete. + +### Authorization Scope Required + +- `simplecloud.auth.group.delete.` + +### Responses + +- **200 OK**: Success message. +- **400 Bad Request**: You must specify a group name. +- **404 Not Found**: Group not found. +- **401 Unauthorized**: Unauthorized. + +--- + +## Save User + +### Endpoint + +`PUT /user` + +### Description + +Creates or updates a user with the specified groups and scopes. + +### Request Parameters + +- **`username`** (String, required): The username. +- **`password`** (String, required): The password. +- **`groups`** (String, optional): Space-separated list of groups the user belongs to. +- **`scope`** (String, optional): Space-separated list of scopes for the user. + +### Authorization Scope Required + +- `simplecloud.auth.user.save` + +### Responses + +- **200 OK**: Success message. +- **400 Bad Request**: You must specify a username or password. +- **401 Unauthorized**: Unauthorized. + +--- + +## Get User + +### Endpoint + +`GET /user` + +### Description + +Fetches details of a specific user. + +### Request Parameters + +- **`username`** (String, required): Name of the user to retrieve. + +### Authorization Scope Required + +- `simplecloud.auth.user.get.` + +### Responses + +- **200 OK**: User details. + ```json + { + "user_id": "1234", + "username": "example_user", + "scope": "read write", + "groups": "group1 group2" + } + ``` +- **400 Bad Request**: You must specify a username. +- **404 Not Found**: User not found. +- **401 Unauthorized**: Unauthorized. + +--- + +## Get All Users + +### Endpoint + +`GET /users` + +### Description + +Fetches a list of all users. + +### Authorization Scope Required + +- `simplecloud.auth.user.get.*` + +### Responses + +- **200 OK**: List of all users. + ```json + [ + { + "user_id": "1234", + "username": "user1", + "scope": "read write", + "groups": "group1 group2" + }, + { + "user_id": "5678", + "username": "user2", + "scope": "read", + "groups": "group3" + } + ] + ``` +- **401 Unauthorized**: Unauthorized. + +--- + +## Delete User + +### Endpoint + +`DELETE /user` + +### Description + +Deletes a specific user. + +### Request Parameters + +- **`user_id`** (String, required): The user ID to delete. + +### Authorization Scope Required + +- `simplecloud.auth.user.delete` + +### Responses + +- **200 OK**: Success message. +- **400 Bad Request**: You must specify a user ID. +- **404 Not Found**: User not found. +- **401 Unauthorized**: Unauthorized. + +--- + +## Login + +### Endpoint + +`POST /login` + +### Description + +Authenticates a user and returns an access token if the username and password are valid. + +### Request Parameters + +- **`username`** (String, required): The username. +- **`password`** (String, required): The password. + +### Responses + +- **200 OK**: Access token and user details. + ```json + { + "access_token": "jwt_token", + "scope": "read write", + "exp": 3600, + "user_id": "1234", + "client_id": "abcd" + } + ``` +- **400 Bad Request**: You must specify a username and password. +- **401 Unauthorized**: Invalid username or password. diff --git a/build.gradle.kts b/build.gradle.kts index 9eaec1f..486ecae 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,17 +3,22 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { alias(libs.plugins.kotlin) alias(libs.plugins.shadow) - alias(libs.plugins.sonatypeCentralPortalPublisher) + alias(libs.plugins.sonatype.central.portal.publisher) `maven-publish` } +val baseVersion = "0.0.30" +val commitHash = System.getenv("COMMIT_HASH") +val snapshotversion = "${baseVersion}-dev.$commitHash" + allprojects { group = "app.simplecloud.controller" - version = "0.0.30" + version = if (commitHash != null) snapshotversion else baseVersion repositories { mavenCentral() maven("https://buf.build/gen/maven") + maven("https://repo.simplecloud.app/snapshots") } } @@ -24,12 +29,31 @@ subprojects { apply(plugin = "maven-publish") dependencies { - testImplementation(rootProject.libs.kotlinTest) - implementation(rootProject.libs.kotlinJvm) + testImplementation(rootProject.libs.kotlin.test) + implementation(rootProject.libs.kotlin.jvm) } publishing { + repositories { + maven { + name = "simplecloud" + url = uri("https://repo.simplecloud.app/snapshots/") + credentials { + username = System.getenv("SIMPLECLOUD_USERNAME")?: (project.findProperty("simplecloudUsername") as? String) + password = System.getenv("SIMPLECLOUD_PASSWORD")?: (project.findProperty("simplecloudPassword") as? String) + } + authentication { + create("basic") + } + } + } + publications { + // Not publish controller-runtime + if (project.name == "controller-runtime") { + return@publications + } + create("mavenJava") { from(components["java"]) } @@ -91,6 +115,10 @@ subprojects { } signing { + if (commitHash != null) { + return@signing + } + sign(publishing.publications) useGpgCmd() } diff --git a/controller-api/build.gradle.kts b/controller-api/build.gradle.kts index de26167..ecc433b 100644 --- a/controller-api/build.gradle.kts +++ b/controller-api/build.gradle.kts @@ -1,3 +1,17 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + dependencies { api(project(":controller-shared")) +} + +tasks.named("shadowJar", ShadowJar::class) { + mergeServiceFiles() + relocate("com", "app.simplecloud.external.com") + relocate("google", "app.simplecloud.external.google") + relocate("io", "app.simplecloud.external.io") + relocate("org", "app.simplecloud.external.org") + relocate("javax", "app.simplecloud.external.javax") + relocate("android", "app.simplecloud.external.android") + relocate("build.buf.gen.simplecloud", "app.simplecloud.buf") + archiveFileName.set("${project.name}.jar") } \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt index 75675bc..0b31653 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/ServerApi.kt @@ -1,11 +1,11 @@ package app.simplecloud.controller.api import app.simplecloud.controller.shared.group.Group -import build.buf.gen.simplecloud.controller.v1.ServerType import app.simplecloud.controller.shared.server.Server import build.buf.gen.simplecloud.controller.v1.ServerStartCause import build.buf.gen.simplecloud.controller.v1.ServerState import build.buf.gen.simplecloud.controller.v1.ServerStopCause +import build.buf.gen.simplecloud.controller.v1.ServerType import java.util.concurrent.CompletableFuture interface ServerApi { @@ -17,6 +17,13 @@ interface ServerApi { */ fun getAllServers(): CompletableFuture> + /** + * @return a [CompletableFuture] with the [Server] from the SIMPLECLOUD_UNIQUE_ID environment + */ + fun getCurrentServer(): CompletableFuture { + return getServerById(System.getenv("SIMPLECLOUD_UNIQUE_ID")) + } + /** * @param id the id of the server. * @return a [CompletableFuture] with the [Server]. @@ -52,14 +59,21 @@ interface ServerApi { * @param groupName the group name of the group the new server should be of. * @return a [CompletableFuture] with a [Server] or null. */ - fun startServer(groupName: String, startCause: ServerStartCause = ServerStartCause.API_START): CompletableFuture + fun startServer( + groupName: String, + startCause: ServerStartCause = ServerStartCause.API_START + ): CompletableFuture /** * @param groupName the group name of the servers group. * @param numericalId the numerical id of the server. * @return a [CompletableFuture] with the stopped [Server]. */ - fun stopServer(groupName: String, numericalId: Long, stopCause: ServerStopCause = ServerStopCause.API_STOP): CompletableFuture + fun stopServer( + groupName: String, + numericalId: Long, + stopCause: ServerStopCause = ServerStopCause.API_STOP + ): CompletableFuture /** * @param id the id of the server. @@ -67,6 +81,32 @@ interface ServerApi { */ fun stopServer(id: String, stopCause: ServerStopCause = ServerStopCause.API_STOP): CompletableFuture + /** + * Stops all servers within a specified group. + * + * @param groupName The name of the server group to stop. + * @param stopCause The reason for stopping the servers. Defaults to [ServerStopCause.API_STOP]. + * @return A [CompletableFuture] containing a list of stopped [Server] instances. + */ + fun stopServers( + groupName: String, + stopCause: ServerStopCause = ServerStopCause.API_STOP + ): CompletableFuture> + + /** + * Stops all servers within a specified group and sets a timeout to prevent new server starts for the group. + * + * @param groupName The name of the server group to stop. + * @param timeoutSeconds The duration (in seconds) for which new server starts will be prevented. + * @param stopCause The reason for stopping the servers. Defaults to [ServerStopCause.API_STOP]. + * @return A [CompletableFuture] containing a list of stopped [Server] instances. + */ + fun stopServers( + groupName: String, + timeoutSeconds: Int, + stopCause: ServerStopCause = ServerStopCause.API_STOP + ): CompletableFuture> + /** * @param id the id of the server. * @param state the new state of the server. @@ -91,6 +131,13 @@ interface ServerApi { */ suspend fun getAllServers(): List + /** + * @return the [Server] from the SIMPLECLOUD_UNIQUE_ID environment + */ + suspend fun getCurrentServer(): Server { + return getServerById(System.getenv("SIMPLECLOUD_UNIQUE_ID")) + } + /** * @param id the id of the server. * @return the [Server]. @@ -145,6 +192,32 @@ interface ServerApi { */ suspend fun stopServer(id: String, stopCause: ServerStopCause = ServerStopCause.API_STOP): Server + /** + * Stops all servers within a specified group. + * + * @param groupName The name of the server group to stop. + * @param stopCause The reason for stopping the servers. Defaults to [ServerStopCause.API_STOP]. + * @return A list of stopped [Server] instances. + */ + suspend fun stopServers( + groupName: String, + stopCause: ServerStopCause = ServerStopCause.API_STOP + ): List + + /** + * Stops all servers within a specified group and sets a timeout to prevent new server starts for the group. + * + * @param groupName The name of the server group to stop. + * @param timeoutSeconds The duration (in seconds) for which new server starts will be prevented. + * @param stopCause The reason for stopping the servers. Defaults to [ServerStopCause.API_STOP]. + * @return A list of stopped [Server] instances. + */ + suspend fun stopServers( + groupName: String, + timeoutSeconds: Int, + stopCause: ServerStopCause = ServerStopCause.API_STOP + ): List + /** * @param id the id of the server. * @param state the new state of the server. diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/GroupBuilders.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/GroupBuilders.kt new file mode 100644 index 0000000..97758a6 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/GroupBuilders.kt @@ -0,0 +1,68 @@ +package app.simplecloud.controller.api.dsl.builders + +import app.simplecloud.controller.api.dsl.markers.GroupDsl +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.ServerType + +@GroupDsl +class GroupCreateBuilder { + var name: String = "" + var type: ServerType = ServerType.UNKNOWN_SERVER + var minMemory: Long = 0 + var maxMemory: Long = 0 + var startPort: Long = 0 + var minOnlineCount: Long = 0 + var maxOnlineCount: Long = 0 + var maxPlayers: Long = 0 + var newServerPlayerRatio: Long = -1 + private val properties = mutableMapOf() + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + internal fun build() = Group( + name = name, + type = type, + minMemory = minMemory, + maxMemory = maxMemory, + startPort = startPort, + minOnlineCount = minOnlineCount, + maxOnlineCount = maxOnlineCount, + maxPlayers = maxPlayers, + newServerPlayerRatio = newServerPlayerRatio, + properties = properties + ) +} + +@GroupDsl +class GroupUpdateBuilder { + private var group: Group? = null + private val properties = mutableMapOf() + + fun fromGroup(existingGroup: Group) { + group = existingGroup + properties.putAll(existingGroup.properties) + } + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + internal fun build(): Group { + requireNotNull(group) { "Group must be set using fromGroup()" } + return group!!.copy(properties = properties) + } +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/PropertyBuilder.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/PropertyBuilder.kt new file mode 100644 index 0000000..5bb56b0 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/PropertyBuilder.kt @@ -0,0 +1,28 @@ +package app.simplecloud.controller.api.dsl.builders + +import app.simplecloud.controller.api.dsl.markers.PropertyDsl + +@PropertyDsl +class PropertyBuilder { + private val properties = mutableMapOf() + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + fun property(key: String, value: String) { + properties[key] = value + } + + fun properties(vararg pairs: Pair) { + properties.putAll(pairs) + } + + internal fun build(): Map = properties.toMap() +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/ServerBuilders.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/ServerBuilders.kt new file mode 100644 index 0000000..031fd2c --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/builders/ServerBuilders.kt @@ -0,0 +1,51 @@ +package app.simplecloud.controller.api.dsl.builders + +import app.simplecloud.controller.api.dsl.markers.ServerDsl +import build.buf.gen.simplecloud.controller.v1.ServerStartCause +import build.buf.gen.simplecloud.controller.v1.ServerState +import build.buf.gen.simplecloud.controller.v1.ServerStopCause + +@ServerDsl +class ServerStartBuilder { + var startCause: ServerStartCause = ServerStartCause.API_START + private val properties = mutableMapOf() + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + fun getProperties(): Map = properties.toMap() +} + +@ServerDsl +class ServerStopBuilder { + var stopCause: ServerStopCause = ServerStopCause.API_STOP +} + +@ServerDsl +class ServerStateBuilder { + var state: ServerState = ServerState.UNKNOWN_STATE +} + +@ServerDsl +class ServerPropertyBuilder { + private val properties = mutableMapOf() + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + internal fun build(): Map = properties.toMap() +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ControllerExtensions.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ControllerExtensions.kt new file mode 100644 index 0000000..9b00a1c --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ControllerExtensions.kt @@ -0,0 +1,32 @@ +package app.simplecloud.controller.api.dsl.extensions + +import app.simplecloud.controller.api.ControllerApi +import app.simplecloud.controller.api.dsl.markers.ControllerDsl +import app.simplecloud.controller.api.dsl.scopes.GroupScope +import app.simplecloud.controller.api.dsl.scopes.ServerScope +import kotlinx.coroutines.coroutineScope + +@ControllerDsl +class ControllerApiScope(private val api: ControllerApi.Coroutine) { + suspend fun groups(block: suspend GroupScope.() -> Unit) { + GroupScope(api.getGroups()).block() + } + + suspend fun servers(block: suspend ServerScope.() -> Unit) { + ServerScope(api.getServers()).block() + } +} + +suspend fun controllerApiScope(block: suspend ControllerApiScope.() -> Unit) { + val api = ControllerApi.createCoroutineApi() + controllerApiScope(api, block) +} + +suspend fun controllerApiScope( + api: ControllerApi.Coroutine, + block: suspend ControllerApiScope.() -> Unit +) { + coroutineScope { + ControllerApiScope(api).block() + } +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/GroupApiExtensions.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/GroupApiExtensions.kt new file mode 100644 index 0000000..9dd6c27 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/GroupApiExtensions.kt @@ -0,0 +1,96 @@ +package app.simplecloud.controller.api.dsl.extensions + +import app.simplecloud.controller.api.GroupApi +import app.simplecloud.controller.api.dsl.markers.GroupDsl +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.ServerType + +@GroupDsl +class GroupCreateBuilder { + var name: String = "" + var type: ServerType = ServerType.UNKNOWN_SERVER + var minMemory: Long = 0 + var maxMemory: Long = 0 + var startPort: Long = 0 + var minOnlineCount: Long = 0 + var maxOnlineCount: Long = 0 + var maxPlayers: Long = 0 + var newServerPlayerRatio: Long = -1 + private val properties = mutableMapOf() + + fun properties(block: ServerPropertyBuilder.() -> Unit) { + val builder = ServerPropertyBuilder().apply(block) + properties.putAll(builder.build()) + } + + internal fun build() = Group( + name = name, + type = type, + minMemory = minMemory, + maxMemory = maxMemory, + startPort = startPort, + minOnlineCount = minOnlineCount, + maxOnlineCount = maxOnlineCount, + maxPlayers = maxPlayers, + newServerPlayerRatio = newServerPlayerRatio, + properties = properties + ) +} + +@GroupDsl +class GroupUpdateBuilder { + private var group: Group? = null + private val properties = mutableMapOf() + + fun fromGroup(existingGroup: Group) { + group = existingGroup + properties.putAll(existingGroup.properties) + } + + fun properties(block: ServerPropertyBuilder.() -> Unit) { + val builder = ServerPropertyBuilder().apply(block) + properties.putAll(builder.build()) + } + + internal fun build(): Group { + requireNotNull(group) { "Group must be set using fromGroup()" } + return group!!.copy(properties = properties) + } +} + +suspend fun GroupApi.Coroutine.create(block: GroupCreateBuilder.() -> Unit): Group { + val builder = GroupCreateBuilder().apply(block) + return createGroup(builder.build()) +} + +suspend fun GroupApi.Coroutine.update(block: GroupUpdateBuilder.() -> Unit): Group { + val builder = GroupUpdateBuilder().apply(block) + return updateGroup(builder.build()) +} + +suspend fun GroupApi.Coroutine.delete( + name: String, + block: (Group) -> Unit = {} +) { + block(deleteGroup(name)) +} + +suspend fun GroupApi.Coroutine.getGroup( + name: String, + block: (Group) -> Unit = {} +) { + block(getGroupByName(name)) +} + +suspend fun GroupApi.Coroutine.getAllGroups( + block: (List) -> Unit = {} +) { + block(getAllGroups()) +} + +suspend fun GroupApi.Coroutine.getGroupsByType( + type: ServerType, + block: (List) -> Unit = {} +) { + block(getGroupsByType(type)) +} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/GroupExtensions.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/GroupExtensions.kt new file mode 100644 index 0000000..a586636 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/GroupExtensions.kt @@ -0,0 +1,18 @@ +package app.simplecloud.controller.api.dsl.extensions + +import app.simplecloud.controller.api.GroupApi +import app.simplecloud.controller.shared.group.Group + +suspend fun GroupApi.Coroutine.createGroup( + block: GroupCreateBuilder.() -> Unit +): Group { + val builder = GroupCreateBuilder().apply(block) + return createGroup(builder.build()) +} + +suspend fun GroupApi.Coroutine.updateGroup( + block: GroupUpdateBuilder.() -> Unit +): Group { + val builder = GroupUpdateBuilder().apply(block) + return updateGroup(builder.build()) +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ServerApiExtensions.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ServerApiExtensions.kt new file mode 100644 index 0000000..4e186ef --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ServerApiExtensions.kt @@ -0,0 +1,140 @@ +package app.simplecloud.controller.api.dsl.extensions + +import app.simplecloud.controller.api.ServerApi +import app.simplecloud.controller.api.dsl.markers.ServerDsl +import app.simplecloud.controller.shared.group.Group +import app.simplecloud.controller.shared.server.Server +import build.buf.gen.simplecloud.controller.v1.ServerStartCause +import build.buf.gen.simplecloud.controller.v1.ServerState +import build.buf.gen.simplecloud.controller.v1.ServerStopCause +import build.buf.gen.simplecloud.controller.v1.ServerType + +@ServerDsl +class ServerStartBuilder { + var startCause: ServerStartCause = ServerStartCause.API_START + private val properties = mutableMapOf() + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + fun getProperties(): Map = properties +} + +@ServerDsl +class ServerStopBuilder { + var stopCause: ServerStopCause = ServerStopCause.API_STOP +} + +@ServerDsl +class ServerStateBuilder { + var state: ServerState = ServerState.UNKNOWN_STATE +} + +@ServerDsl +class ServerPropertyBuilder { + private val properties = mutableMapOf() + + operator fun String.unaryPlus() = this + + operator fun Pair.unaryPlus() { + properties[first] = second + } + + infix fun String.to(value: String) { + properties[this] = value + } + + internal fun build(): Map = properties.toMap() +} + +suspend fun ServerApi.Coroutine.start( + groupName: String, + block: ServerStartBuilder.() -> Unit +): Server { + val builder = ServerStartBuilder().apply(block) + return startServer(groupName, builder.startCause) +} + +suspend fun ServerApi.Coroutine.stop( + id: String, + block: ServerStopBuilder.() -> Unit +): Server { + val builder = ServerStopBuilder().apply(block) + return stopServer(id, builder.stopCause) +} + +suspend fun ServerApi.Coroutine.stopByGroup( + groupName: String, + numericalId: Long, + block: ServerStopBuilder.() -> Unit +): Server { + val builder = ServerStopBuilder().apply(block) + return stopServer(groupName, numericalId, builder.stopCause) +} + +suspend fun ServerApi.Coroutine.updateState( + id: String, + block: ServerStateBuilder.() -> Unit +): Server { + val builder = ServerStateBuilder().apply(block) + return updateServerState(id, builder.state) +} + +suspend fun ServerApi.Coroutine.updateProperty( + id: String, + block: ServerPropertyBuilder.() -> Unit +): Server { + val builder = ServerPropertyBuilder().apply(block) + var updatedServer: Server? = null + builder.build().forEach { (key, value) -> + updatedServer = updateServerProperty(id, key, value) + } + return updatedServer ?: throw IllegalStateException("Failed to update server properties") +} + +suspend fun ServerApi.Coroutine.getAllServers(block: (List) -> Unit = {}) { + block(getAllServers()) +} + +suspend fun ServerApi.Coroutine.getServer( + id: String, + block: (Server) -> Unit = {} +) { + block(getServerById(id)) +} + +suspend fun ServerApi.Coroutine.getServersByGroup( + groupName: String, + block: (List) -> Unit = {} +) { + block(getServersByGroup(groupName)) +} + +suspend fun ServerApi.Coroutine.getServersByGroup( + group: Group, + block: (List) -> Unit = {} +) { + block(getServersByGroup(group)) +} + +suspend fun ServerApi.Coroutine.getServerByNumerical( + groupName: String, + numericalId: Long, + block: (Server) -> Unit = {} +) { + block(getServerByNumerical(groupName, numericalId)) +} + +suspend fun ServerApi.Coroutine.getServersByType( + type: ServerType, + block: (List) -> Unit = {} +) { + block(getServersByType(type)) +} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ServerExtensions.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ServerExtensions.kt new file mode 100644 index 0000000..84c912a --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/extensions/ServerExtensions.kt @@ -0,0 +1,54 @@ +package app.simplecloud.controller.api.dsl.extensions + +import app.simplecloud.controller.api.ServerApi +import app.simplecloud.controller.shared.server.Server + +suspend fun ServerApi.Coroutine.startServer( + groupName: String, + block: ServerStartBuilder.() -> Unit = {} +): Server { + val builder = ServerStartBuilder().apply(block) + val server = startServer(groupName, builder.startCause) + + builder.getProperties().forEach { (key, value) -> + updateServerProperty(server.uniqueId, key, value) + } + + return getServerById(server.uniqueId) +} + +suspend fun ServerApi.Coroutine.stopServer( + id: String, + block: ServerStopBuilder.() -> Unit = {} +): Server { + val builder = ServerStopBuilder().apply(block) + return stopServer(id, builder.stopCause) +} + +suspend fun ServerApi.Coroutine.updateServerState( + id: String, + block: ServerStateBuilder.() -> Unit +): Server { + val builder = ServerStateBuilder().apply(block) + return updateServerState(id, builder.state) +} + +suspend fun ServerApi.Coroutine.updateProperty( + id: String, + key: String, + value: Any +): Server { + return updateServerProperty(id, key, value) +} + +suspend fun ServerApi.Coroutine.updateProperties( + id: String, + block: ServerPropertyBuilder.() -> Unit +): Server { + val builder = ServerPropertyBuilder().apply(block) + var updatedServer: Server? = null + builder.build().forEach { (key, value) -> + updatedServer = updateServerProperty(id, key, value) + } + return updatedServer ?: throw IllegalStateException("Failed to update server properties") +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/markers/DslMarkers.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/markers/DslMarkers.kt new file mode 100644 index 0000000..134332e --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/markers/DslMarkers.kt @@ -0,0 +1,13 @@ +package app.simplecloud.controller.api.dsl.markers + +@DslMarker +annotation class ControllerDsl + +@DslMarker +annotation class ServerDsl + +@DslMarker +annotation class GroupDsl + +@DslMarker +annotation class PropertyDsl diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/scopes/GroupScope.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/scopes/GroupScope.kt new file mode 100644 index 0000000..db24fa8 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/scopes/GroupScope.kt @@ -0,0 +1,97 @@ +package app.simplecloud.controller.api.dsl.scopes + +import app.simplecloud.controller.api.GroupApi +import app.simplecloud.controller.api.dsl.builders.PropertyBuilder +import app.simplecloud.controller.api.dsl.markers.GroupDsl +import app.simplecloud.controller.shared.group.Group +import build.buf.gen.simplecloud.controller.v1.ServerType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope + +@GroupDsl +class GroupScope(private val api: GroupApi.Coroutine) { + suspend fun create(block: GroupCreateBuilder.() -> Unit): Group { + val builder = GroupCreateBuilder().apply(block) + return api.createGroup(builder.build()) + } + + suspend fun update(block: GroupUpdateBuilder.() -> Unit): Group { + val builder = GroupUpdateBuilder().apply(block) + return api.updateGroup(builder.build()) + } + + suspend fun delete(name: String) = api.deleteGroup(name) + + suspend fun getByName(name: String) = api.getGroupByName(name) + + suspend fun getAll() = api.getAllGroups() + + suspend fun getByType(type: ServerType) = api.getGroupsByType(type) + + suspend fun parallel(block: suspend ParallelGroupScope.() -> Unit) { + coroutineScope { + ParallelGroupScope(this, api).block() + } + } +} + +class ParallelGroupScope( + private val scope: CoroutineScope, + private val api: GroupApi.Coroutine +) { + fun getByName(name: String) = scope.async { api.getGroupByName(name) } + fun getAll() = scope.async { api.getAllGroups() } + fun getByType(type: ServerType) = scope.async { api.getGroupsByType(type) } +} + +@GroupDsl +class GroupCreateBuilder { + var name: String = "" + var type: ServerType = ServerType.UNKNOWN_SERVER + var minMemory: Long = 0 + var maxMemory: Long = 0 + var startPort: Long = 0 + var minOnlineCount: Long = 0 + var maxOnlineCount: Long = 0 + var maxPlayers: Long = 0 + var newServerPlayerRatio: Long = -1 + private val propertyBuilder = PropertyBuilder() + + fun properties(block: PropertyBuilder.() -> Unit) { + propertyBuilder.apply(block) + } + + internal fun build() = Group( + name = name, + type = type, + minMemory = minMemory, + maxMemory = maxMemory, + startPort = startPort, + minOnlineCount = minOnlineCount, + maxOnlineCount = maxOnlineCount, + maxPlayers = maxPlayers, + newServerPlayerRatio = newServerPlayerRatio, + properties = propertyBuilder.build() + ) +} + +@GroupDsl +class GroupUpdateBuilder { + private var existingGroup: Group? = null + private var propertyBuilder = PropertyBuilder() + + fun fromGroup(group: Group) { + existingGroup = group + propertyBuilder.properties(*group.properties.toList().toTypedArray()) + } + + fun properties(block: PropertyBuilder.() -> Unit) { + propertyBuilder.apply(block) + } + + internal fun build(): Group { + requireNotNull(existingGroup) { "Existing group must be set using fromGroup()" } + return existingGroup!!.copy(properties = propertyBuilder.build()) + } +} diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/scopes/ServerScope.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/scopes/ServerScope.kt new file mode 100644 index 0000000..8a1a598 --- /dev/null +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/dsl/scopes/ServerScope.kt @@ -0,0 +1,26 @@ +package app.simplecloud.controller.api.dsl.scopes + +import app.simplecloud.controller.api.ServerApi +import app.simplecloud.controller.api.dsl.builders.ServerStartBuilder +import app.simplecloud.controller.api.dsl.builders.ServerStopBuilder +import app.simplecloud.controller.api.dsl.markers.ServerDsl +import build.buf.gen.simplecloud.controller.v1.ServerState + +@ServerDsl +class ServerScope(private val api: ServerApi.Coroutine) { + + suspend fun start(groupName: String, block: ServerStartBuilder.() -> Unit) { + val builder = ServerStartBuilder().apply(block) + api.startServer(groupName, builder.startCause) + } + + suspend fun stop(id: String, block: ServerStopBuilder.() -> Unit) { + val builder = ServerStopBuilder().apply(block) + api.stopServer(id, builder.stopCause) + } + + suspend fun updateState(id: String, state: ServerState) { + api.updateServerState(id, state) + } + +} \ No newline at end of file diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ControllerApiCoroutineImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ControllerApiCoroutineImpl.kt index ae4ac39..7c2ca0e 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ControllerApiCoroutineImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ControllerApiCoroutineImpl.kt @@ -3,7 +3,7 @@ package app.simplecloud.controller.api.impl.coroutines import app.simplecloud.controller.api.ControllerApi import app.simplecloud.controller.api.GroupApi import app.simplecloud.controller.api.ServerApi -import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.droplet.api.auth.AuthCallCredentials import app.simplecloud.pubsub.PubSubClient import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/GroupApiCoroutineImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/GroupApiCoroutineImpl.kt index 091b3aa..5a111c6 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/GroupApiCoroutineImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/GroupApiCoroutineImpl.kt @@ -1,8 +1,8 @@ package app.simplecloud.controller.api.impl.coroutines import app.simplecloud.controller.api.GroupApi -import app.simplecloud.controller.shared.auth.AuthCallCredentials import app.simplecloud.controller.shared.group.Group +import app.simplecloud.droplet.api.auth.AuthCallCredentials import build.buf.gen.simplecloud.controller.v1.* import io.grpc.ManagedChannel diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ServerApiCoroutineImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ServerApiCoroutineImpl.kt index c8e1f3f..ea08a3a 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ServerApiCoroutineImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/coroutines/ServerApiCoroutineImpl.kt @@ -1,10 +1,10 @@ package app.simplecloud.controller.api.impl.coroutines import app.simplecloud.controller.api.ServerApi -import app.simplecloud.controller.shared.auth.AuthCallCredentials import app.simplecloud.controller.shared.group.Group -import build.buf.gen.simplecloud.controller.v1.* import app.simplecloud.controller.shared.server.Server +import app.simplecloud.droplet.api.auth.AuthCallCredentials +import build.buf.gen.simplecloud.controller.v1.* import io.grpc.ManagedChannel class ServerApiCoroutineImpl( @@ -13,7 +13,8 @@ class ServerApiCoroutineImpl( ) : ServerApi.Coroutine { private val serverServiceStub: ControllerServerServiceGrpcKt.ControllerServerServiceCoroutineStub = - ControllerServerServiceGrpcKt.ControllerServerServiceCoroutineStub(managedChannel).withCallCredentials(authCallCredentials) + ControllerServerServiceGrpcKt.ControllerServerServiceCoroutineStub(managedChannel) + .withCallCredentials(authCallCredentials) override suspend fun getAllServers(): List { return serverServiceStub.getAllServers(getAllServersRequest {}).serversList.map { @@ -100,6 +101,25 @@ class ServerApiCoroutineImpl( ) } + override suspend fun stopServers(groupName: String, stopCause: ServerStopCause): List { + return serverServiceStub.stopServersByGroup(stopServersByGroupRequest { + this.groupName = groupName + this.stopCause = stopCause + }).serversList.map { + Server.fromDefinition(it) + } + } + + override suspend fun stopServers(groupName: String, timeoutSeconds: Int, stopCause: ServerStopCause): List { + return serverServiceStub.stopServersByGroupWithTimeout(stopServersByGroupWithTimeoutRequest { + this.groupName = groupName + this.stopCause = stopCause + this.timeoutSeconds = timeoutSeconds + }).serversList.map { + Server.fromDefinition(it) + } + } + override suspend fun updateServerState(id: String, state: ServerState): Server { return Server.fromDefinition( serverServiceStub.updateServerState( diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ControllerApiFutureImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ControllerApiFutureImpl.kt index c72b56a..2837267 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ControllerApiFutureImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ControllerApiFutureImpl.kt @@ -3,7 +3,7 @@ package app.simplecloud.controller.api.impl.future import app.simplecloud.controller.api.ControllerApi import app.simplecloud.controller.api.GroupApi import app.simplecloud.controller.api.ServerApi -import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.droplet.api.auth.AuthCallCredentials import app.simplecloud.pubsub.PubSubClient import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/GroupApiFutureImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/GroupApiFutureImpl.kt index 5119bc6..86f4670 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/GroupApiFutureImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/GroupApiFutureImpl.kt @@ -1,9 +1,9 @@ package app.simplecloud.controller.api.impl.future import app.simplecloud.controller.api.GroupApi -import app.simplecloud.controller.shared.auth.AuthCallCredentials -import app.simplecloud.controller.shared.future.toCompletable import app.simplecloud.controller.shared.group.Group +import app.simplecloud.droplet.api.auth.AuthCallCredentials +import app.simplecloud.droplet.api.future.toCompletable import build.buf.gen.simplecloud.controller.v1.ControllerGroupServiceGrpc import build.buf.gen.simplecloud.controller.v1.CreateGroupRequest import build.buf.gen.simplecloud.controller.v1.DeleteGroupByNameRequest diff --git a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ServerApiFutureImpl.kt b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ServerApiFutureImpl.kt index 65a6a9b..ee81b4b 100644 --- a/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ServerApiFutureImpl.kt +++ b/controller-api/src/main/kotlin/app/simplecloud/controller/api/impl/future/ServerApiFutureImpl.kt @@ -1,11 +1,11 @@ package app.simplecloud.controller.api.impl.future import app.simplecloud.controller.api.ServerApi -import app.simplecloud.controller.shared.auth.AuthCallCredentials -import app.simplecloud.controller.shared.future.toCompletable import app.simplecloud.controller.shared.group.Group -import build.buf.gen.simplecloud.controller.v1.* import app.simplecloud.controller.shared.server.Server +import app.simplecloud.droplet.api.auth.AuthCallCredentials +import app.simplecloud.droplet.api.future.toCompletable +import build.buf.gen.simplecloud.controller.v1.* import io.grpc.ManagedChannel import java.util.concurrent.CompletableFuture @@ -79,7 +79,11 @@ class ServerApiFutureImpl( } } - override fun stopServer(groupName: String, numericalId: Long, stopCause: ServerStopCause): CompletableFuture { + override fun stopServer( + groupName: String, + numericalId: Long, + stopCause: ServerStopCause + ): CompletableFuture { return serverServiceStub.stopServerByNumerical( StopServerByNumericalRequest.newBuilder() .setGroupName(groupName) @@ -102,6 +106,33 @@ class ServerApiFutureImpl( } } + override fun stopServers(groupName: String, stopCause: ServerStopCause): CompletableFuture> { + return serverServiceStub.stopServersByGroup( + StopServersByGroupRequest.newBuilder() + .setGroupName(groupName) + .setStopCause(stopCause) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it.serversList) + } + } + + override fun stopServers( + groupName: String, + timeoutSeconds: Int, + stopCause: ServerStopCause + ): CompletableFuture> { + return serverServiceStub.stopServersByGroupWithTimeout( + StopServersByGroupWithTimeoutRequest.newBuilder() + .setGroupName(groupName) + .setStopCause(stopCause) + .setTimeoutSeconds(timeoutSeconds) + .build() + ).toCompletable().thenApply { + Server.fromDefinition(it.serversList) + } + } + override fun updateServerState(id: String, state: ServerState): CompletableFuture { return serverServiceStub.updateServerState( UpdateServerStateRequest.newBuilder() diff --git a/controller-runtime/build.gradle.kts b/controller-runtime/build.gradle.kts index 1a6d03e..d44a354 100644 --- a/controller-runtime/build.gradle.kts +++ b/controller-runtime/build.gradle.kts @@ -1,16 +1,20 @@ plugins { application - alias(libs.plugins.jooqCodegen) + alias(libs.plugins.jooq.codegen) + java } dependencies { api(project(":controller-shared")) - api(rootProject.libs.bundles.jooq) - api(rootProject.libs.sqliteJdbc) - jooqCodegen(rootProject.libs.jooqMetaExtensions) - implementation(rootProject.libs.bundles.log4j) - implementation(rootProject.libs.clikt) - implementation(rootProject.libs.spotifyCompletableFutures) + api(libs.bundles.jooq) + api(libs.sqlite.jdbc) + jooqCodegen(libs.jooq.meta.extensions) + implementation(libs.simplecloud.metrics) + implementation(libs.bundles.log4j) + implementation(libs.clikt) + implementation(libs.spring.crypto) + implementation(libs.spotify.completablefutures) + implementation(libs.envoy.controlplane) } application { diff --git a/controller-runtime/src/main/db/schema.sql b/controller-runtime/src/main/db/schema.sql index d779577..1b2367e 100644 --- a/controller-runtime/src/main/db/schema.sql +++ b/controller-runtime/src/main/db/schema.sql @@ -3,26 +3,73 @@ Execute jooqCodegen to create java classes for these files. */ -CREATE TABLE IF NOT EXISTS cloud_servers( - unique_id varchar NOT NULL PRIMARY KEY, - group_name varchar NOT NULL, - host_id varchar NOT NULL, - numerical_id int NOT NULL, - ip varchar NOT NULL, - port int NOT NULL, - minimum_memory int NOT NULL, - maximum_memory int NOT NULL, - max_players int NOT NULL, - player_count int NOT NULL, - state varchar NOT NULL, - type varchar NOT NULL, - created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP +CREATE TABLE IF NOT EXISTS cloud_servers +( + unique_id varchar NOT NULL PRIMARY KEY, + group_name varchar NOT NULL, + host_id varchar NOT NULL, + numerical_id int NOT NULL, + ip varchar NOT NULL, + port int NOT NULL, + minimum_memory int NOT NULL, + maximum_memory int NOT NULL, + max_players int NOT NULL, + player_count int NOT NULL, + state varchar NOT NULL, + type varchar NOT NULL, + created_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ); -CREATE TABLE IF NOT EXISTS cloud_server_properties( +CREATE TABLE IF NOT EXISTS cloud_server_properties +( server_id varchar NOT NULL, - key varchar NOT NULL, - value varchar, + key varchar NOT NULL, + value varchar, CONSTRAINT compound_key PRIMARY KEY (server_id, key) -); \ No newline at end of file +); + +CREATE TABLE IF NOT EXISTS oauth2_client_details +( + client_id varchar PRIMARY KEY, + client_secret varchar, + redirect_uri varchar, + grant_types varchar, + scope varchar +); + +CREATE TABLE IF NOT EXISTS oauth2_users +( + user_id varchar PRIMARY KEY, + scopes varchar, + username varchar UNIQUE NOT NULL, + hashed_password varchar NOT NULL +); + + +CREATE TABLE IF NOT EXISTS oauth2_tokens +( + token_id varchar PRIMARY KEY, + client_id varchar, + access_token varchar, + scope varchar, + expires_in timestamp, + user_id varchar, + CONSTRAINT fk_user_token FOREIGN KEY (user_id) REFERENCES oauth2_users (user_id) ON DELETE CASCADE +); + + +CREATE TABLE IF NOT EXISTS oauth2_groups +( + group_name varchar PRIMARY KEY, + scopes varchar +); + +CREATE TABLE IF NOT EXISTS oauth2_user_groups +( + user_id VARCHAR, + group_name VARCHAR, + PRIMARY KEY (user_id, group_name), + CONSTRAINT fk_user_group_user FOREIGN KEY (user_id) REFERENCES oauth2_users (user_id) ON DELETE CASCADE, + CONSTRAINT fk_user_group_group FOREIGN KEY (group_name) REFERENCES oauth2_groups (group_name) ON DELETE CASCADE +); diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt index 36ad827..e64364d 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/ControllerRuntime.kt @@ -1,16 +1,21 @@ package app.simplecloud.controller.runtime import app.simplecloud.controller.runtime.database.DatabaseFactory +import app.simplecloud.controller.runtime.droplet.ControllerDropletService +import app.simplecloud.controller.runtime.droplet.DropletRepository +import app.simplecloud.controller.runtime.envoy.ControlPlaneServer import app.simplecloud.controller.runtime.group.GroupRepository import app.simplecloud.controller.runtime.group.GroupService import app.simplecloud.controller.runtime.host.ServerHostRepository import app.simplecloud.controller.runtime.launcher.ControllerStartCommand +import app.simplecloud.controller.runtime.oauth.OAuthServer import app.simplecloud.controller.runtime.reconciler.Reconciler +import app.simplecloud.controller.runtime.server.ServerHostAttacher import app.simplecloud.controller.runtime.server.ServerNumericalIdRepository import app.simplecloud.controller.runtime.server.ServerRepository import app.simplecloud.controller.runtime.server.ServerService -import app.simplecloud.controller.shared.auth.AuthCallCredentials -import app.simplecloud.controller.shared.auth.AuthSecretInterceptor +import app.simplecloud.droplet.api.auth.AuthCallCredentials +import app.simplecloud.droplet.api.auth.AuthSecretInterceptor import app.simplecloud.pubsub.PubSubClient import app.simplecloud.pubsub.PubSubService import io.grpc.ManagedChannel @@ -19,7 +24,6 @@ import io.grpc.Server import io.grpc.ServerBuilder import kotlinx.coroutines.* import org.apache.logging.log4j.LogManager -import kotlin.concurrent.thread class ControllerRuntime( private val controllerStartCommand: ControllerStartCommand @@ -29,11 +33,20 @@ class ControllerRuntime( private val database = DatabaseFactory.createDatabase(controllerStartCommand.databaseUrl) private val authCallCredentials = AuthCallCredentials(controllerStartCommand.authSecret) - private val groupRepository = GroupRepository(controllerStartCommand.groupPath) + private val pubSubClient = PubSubClient( + controllerStartCommand.grpcHost, + controllerStartCommand.pubSubGrpcPort, + authCallCredentials + ) + private val groupRepository = GroupRepository(controllerStartCommand.groupPath, pubSubClient) private val numericalIdRepository = ServerNumericalIdRepository() private val serverRepository = ServerRepository(database, numericalIdRepository) private val hostRepository = ServerHostRepository() + private val serverHostAttacher = ServerHostAttacher(hostRepository, serverRepository) + private val dropletRepository = DropletRepository(authCallCredentials, serverHostAttacher, controllerStartCommand.envoyStartPort, hostRepository) private val pubSubService = PubSubService() + private val controlPlaneServer = ControlPlaneServer(controllerStartCommand, dropletRepository) + private val authServer = OAuthServer(controllerStartCommand, database) private val reconciler = Reconciler( groupRepository, serverRepository, @@ -45,13 +58,44 @@ class ControllerRuntime( private val server = createGrpcServer() private val pubSubServer = createPubSubGrpcServer() - fun start() { + suspend fun start() { + logger.info("Starting controller") setupDatabase() + startAuthServer() + startControlPlaneServer() startPubSubGrpcServer() startGrpcServer() startReconciler() loadGroups() loadServers() + + suspendCancellableCoroutine { continuation -> + Runtime.getRuntime().addShutdownHook(Thread { + server.shutdown() + continuation.resume(Unit) { cause, _, _ -> + logger.info("Server shutdown due to: $cause") + } + }) + } + } + + private fun startAuthServer() { + logger.info("Starting auth server...") + CoroutineScope(Dispatchers.IO).launch { + try { + authServer.start() + logger.info("Auth server stopped.") + } catch (e: Exception) { + logger.error("Error in gRPC server", e) + throw e + } + } + + } + + private fun startControlPlaneServer() { + logger.info("Starting envoy control plane...") + controlPlaneServer.start() } private fun setupDatabase() { @@ -71,17 +115,27 @@ class ControllerRuntime( private fun startGrpcServer() { logger.info("Starting gRPC server...") - thread { - server.start() - server.awaitTermination() + CoroutineScope(Dispatchers.IO).launch { + try { + server.start() + server.awaitTermination() + } catch (e: Exception) { + logger.error("Error in gRPC server", e) + throw e + } } } private fun startPubSubGrpcServer() { logger.info("Starting pubsub gRPC server...") - thread { - pubSubServer.start() - pubSubServer.awaitTermination() + CoroutineScope(Dispatchers.IO).launch { + try { + pubSubServer.start() + pubSubServer.awaitTermination() + } catch (e: Exception) { + logger.error("Error in gRPC server", e) + throw e + } } } @@ -110,19 +164,20 @@ class ControllerRuntime( serverRepository, hostRepository, groupRepository, - controllerStartCommand.forwardingSecret, authCallCredentials, - PubSubClient(controllerStartCommand.grpcHost, controllerStartCommand.pubSubGrpcPort, authCallCredentials) + pubSubClient, + serverHostAttacher ) ) - .intercept(AuthSecretInterceptor(controllerStartCommand.authSecret)) + .addService(ControllerDropletService(dropletRepository)) + .intercept(AuthSecretInterceptor(controllerStartCommand.grpcHost, controllerStartCommand.authorizationPort)) .build() } private fun createPubSubGrpcServer(): Server { return ServerBuilder.forPort(controllerStartCommand.pubSubGrpcPort) .addService(pubSubService) - .intercept(AuthSecretInterceptor(controllerStartCommand.authSecret)) + .intercept(AuthSecretInterceptor(controllerStartCommand.grpcHost, controllerStartCommand.authorizationPort)) .build() } @@ -133,7 +188,7 @@ class ControllerRuntime( } private fun startReconcilerJob(): Job { - return CoroutineScope(Dispatchers.Default).launch { + return CoroutineScope(Dispatchers.IO).launch { while (isActive) { reconciler.reconcile() delay(2000L) diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/MetricsEventNames.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/MetricsEventNames.kt new file mode 100644 index 0000000..bae8fbe --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/MetricsEventNames.kt @@ -0,0 +1,8 @@ +package app.simplecloud.controller.runtime + +object MetricsEventNames { + + private const val PREFIX = "metrics-droplet:" + const val RECORD_METRIC = "${PREFIX}record-metric" + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt index 3197a0c..db2c0e9 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/YamlDirectoryRepository.kt @@ -2,18 +2,23 @@ package app.simplecloud.controller.runtime import kotlinx.coroutines.* import org.apache.logging.log4j.LogManager +import org.spongepowered.configurate.ConfigurationNode import org.spongepowered.configurate.ConfigurationOptions import org.spongepowered.configurate.kotlin.objectMapperFactory import org.spongepowered.configurate.loader.ParsingException +import org.spongepowered.configurate.serialize.SerializationException +import org.spongepowered.configurate.serialize.TypeSerializer import org.spongepowered.configurate.yaml.NodeStyle import org.spongepowered.configurate.yaml.YamlConfigurationLoader import java.io.File +import java.lang.reflect.Type import java.nio.file.* abstract class YamlDirectoryRepository( private val directory: Path, private val clazz: Class, + private val watcherEvents: WatcherEvents = WatcherEvents.empty() ) : LoadableRepository { private val logger = LogManager.getLogger(this::class.java) @@ -74,7 +79,9 @@ abstract class YamlDirectoryRepository( protected fun save(fileName: String, entity: E) { val file = directory.resolve(fileName).toFile() val loader = getOrCreateLoader(file) - val node = loader.createNode(ConfigurationOptions.defaults()) + val node = loader.createNode(ConfigurationOptions.defaults().serializers { + it.register(Enum::class.java, GenericEnumSerializer) + }) node.set(clazz, entity) loader.save(node) entities[file] = entity @@ -88,6 +95,7 @@ abstract class YamlDirectoryRepository( .defaultOptions { options -> options.serializers { builder -> builder.registerAnnotatedObjects(objectMapperFactory()) + builder.register(Enum::class.java, GenericEnumSerializer) } }.build() } @@ -101,7 +109,7 @@ abstract class YamlDirectoryRepository( StandardWatchEventKinds.ENTRY_MODIFY ) - return CoroutineScope(Dispatchers.Default).launch { + return CoroutineScope(Dispatchers.IO).launch { while (isActive) { val key = watchService.take() for (event in key.pollEvents()) { @@ -113,13 +121,25 @@ abstract class YamlDirectoryRepository( val kind = event.kind() logger.info("Detected change in $resolvedPath (${getChangeStatus(kind)})") when (kind) { - StandardWatchEventKinds.ENTRY_CREATE, - StandardWatchEventKinds.ENTRY_MODIFY - -> { - load(resolvedPath.toFile()) + StandardWatchEventKinds.ENTRY_CREATE -> { + val entity = load(resolvedPath.toFile()) + if (entity != null) { + watcherEvents.onCreate(entity) + } + } + + StandardWatchEventKinds.ENTRY_MODIFY -> { + val entity = load(resolvedPath.toFile()) + if (entity != null) { + watcherEvents.onModify(entity) + } } StandardWatchEventKinds.ENTRY_DELETE -> { + val entity = entities[resolvedPath.toFile()] + if (entity != null) { + watcherEvents.onDelete(entity) + } deleteFile(resolvedPath.toFile()) } } @@ -138,4 +158,39 @@ abstract class YamlDirectoryRepository( } } + interface WatcherEvents { + fun onCreate(entity: E) + fun onDelete(entity: E) + fun onModify(entity: E) + + companion object { + fun empty(): WatcherEvents = object : WatcherEvents { + override fun onCreate(entity: E) {} + override fun onDelete(entity: E) {} + override fun onModify(entity: E) {} + } + } + } + + private object GenericEnumSerializer : TypeSerializer> { + override fun deserialize(type: Type, node: ConfigurationNode): Enum<*> { + val value = node.string ?: throw SerializationException("No value present in node") + + if (type !is Class<*> || !type.isEnum) { + throw SerializationException("Type is not an enum class") + } + + @Suppress("UNCHECKED_CAST") + return try { + java.lang.Enum.valueOf(type as Class>, value) + } catch (e: IllegalArgumentException) { + throw SerializationException("Invalid enum constant") + } + } + + override fun serialize(type: Type, obj: Enum<*>?, node: ConfigurationNode) { + node.set(obj?.name) + } + } + } \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/droplet/ControllerDropletService.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/droplet/ControllerDropletService.kt new file mode 100644 index 0000000..4aead6f --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/droplet/ControllerDropletService.kt @@ -0,0 +1,49 @@ +package app.simplecloud.controller.runtime.droplet + +import app.simplecloud.droplet.api.droplet.Droplet +import build.buf.gen.simplecloud.controller.v1.* +import io.grpc.Status +import io.grpc.StatusException + +class ControllerDropletService(private val dropletRepository: DropletRepository) : + ControllerDropletServiceGrpcKt.ControllerDropletServiceCoroutineImplBase() { + override suspend fun getDroplet(request: GetDropletRequest): GetDropletResponse { + val droplet = dropletRepository.find(request.type, request.id) + ?: throw StatusException(Status.NOT_FOUND.withDescription("This Droplet does not exist")) + return getDropletResponse { this.definition = droplet.toDefinition() } + } + + override suspend fun getAllDroplets(request: GetAllDropletsRequest): GetAllDropletsResponse { + val allDroplets = dropletRepository.getAll() + return getAllDropletsResponse { + definition.addAll(allDroplets.map { it.toDefinition() }) + } + } + + override suspend fun getDropletsByType(request: GetDropletsByTypeRequest): GetDropletsByTypeResponse { + val type = request.type + val typedDroplets = dropletRepository.getAll().filter { it.type == type } + return getDropletsByTypeResponse { + definition.addAll(typedDroplets.map { it.toDefinition() }) + } + } + + override suspend fun registerDroplet(request: RegisterDropletRequest): RegisterDropletResponse { + val droplet = Droplet.fromDefinition(request.definition) + try { + dropletRepository.delete(droplet) + dropletRepository.save(droplet) + } catch (e: Exception) { + throw StatusException(Status.INTERNAL.withDescription("Error whilst registering Droplet").withCause(e)) + } + return registerDropletResponse { this.definition = droplet.toDefinition() } + } + + override suspend fun unregisterDroplet(request: UnregisterDropletRequest): UnregisterDropletResponse { + val droplet = dropletRepository.find(request.id) + ?: throw StatusException(Status.NOT_FOUND.withDescription("This Droplet does not exist")) + val deleted = dropletRepository.delete(droplet) + if (!deleted) throw StatusException(Status.NOT_FOUND.withDescription("Could not delete this Droplet")) + return unregisterDropletResponse { } + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/droplet/DropletRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/droplet/DropletRepository.kt new file mode 100644 index 0000000..3e4da19 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/droplet/DropletRepository.kt @@ -0,0 +1,88 @@ +package app.simplecloud.controller.runtime.droplet + +import app.simplecloud.controller.runtime.Repository +import app.simplecloud.controller.runtime.envoy.DropletCache +import app.simplecloud.controller.runtime.hack.PortProcessHandle +import app.simplecloud.controller.runtime.host.ServerHostRepository +import app.simplecloud.controller.runtime.server.ServerHostAttacher +import app.simplecloud.controller.shared.host.ServerHost +import app.simplecloud.droplet.api.auth.AuthCallCredentials +import app.simplecloud.droplet.api.droplet.Droplet +import build.buf.gen.simplecloud.controller.v1.ServerHostServiceGrpcKt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class DropletRepository( + private val authCallCredentials: AuthCallCredentials, + private val serverHostAttacher: ServerHostAttacher, + private val envoyStartPort: Int, + private val serverHostRepository: ServerHostRepository, +) : Repository { + + private val currentDroplets = mutableListOf() + private val dropletCache = DropletCache() + + override suspend fun getAll(): List { + return currentDroplets + } + + override suspend fun find(identifier: String): Droplet? { + return currentDroplets.firstOrNull { it.id == identifier } + } + + fun find(type: String, identifier: String): Droplet? { + return currentDroplets.firstOrNull { it.type == type && it.id == identifier } + } + + @Synchronized + override fun save(element: Droplet) { + val updated = managePortRange(element.copy(envoyPort = envoyStartPort)) + val droplet = find(element.type, element.id) + if (droplet != null) { + currentDroplets[currentDroplets.indexOf(droplet)] = updated + postUpdate(updated) + return + } + currentDroplets.add(updated) + postUpdate(updated) + } + + @Synchronized + private fun postUpdate(droplet: Droplet) { + dropletCache.update(currentDroplets) + if (droplet.type != "serverhost") return + CoroutineScope(Dispatchers.IO).launch { + serverHostAttacher.attach( + ServerHost( + droplet.id, droplet.host, droplet.port, ServerHostServiceGrpcKt.ServerHostServiceCoroutineStub( + ServerHost.createChannel( + droplet.host, + droplet.port + ) + ).withCallCredentials(authCallCredentials) + ) + ) + } + } + + private fun managePortRange(element: Droplet): Droplet { + if (!currentDroplets.any { it.envoyPort == element.envoyPort } && PortProcessHandle.of(element.envoyPort).isEmpty) return element + return managePortRange(element.copy(envoyPort = element.envoyPort + 1)) + } + + override suspend fun delete(element: Droplet): Boolean { + val found = find(element.type, element.id) ?: return false + if (!currentDroplets.remove(found)) return false + dropletCache.update(currentDroplets) + if (element.type == "serverhost") { + val host = serverHostRepository.findServerHostById(element.id) ?: return true + serverHostRepository.delete(host) + } + return true + } + + fun getAsDropletCache(): DropletCache { + return dropletCache + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/ControlPlaneServer.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/ControlPlaneServer.kt new file mode 100644 index 0000000..f95c183 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/ControlPlaneServer.kt @@ -0,0 +1,61 @@ +package app.simplecloud.controller.runtime.envoy + +import app.simplecloud.controller.runtime.droplet.DropletRepository +import app.simplecloud.controller.runtime.launcher.ControllerStartCommand +import app.simplecloud.droplet.api.droplet.Droplet +import io.envoyproxy.controlplane.server.V3DiscoveryServer +import io.grpc.ServerBuilder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.apache.logging.log4j.LogManager + +/** + * @see ADS Documentation + */ +class ControlPlaneServer(private val args: ControllerStartCommand, private val dropletRepository: DropletRepository) { + private val server = V3DiscoveryServer(dropletRepository.getAsDropletCache().getCache()) + private val logger = LogManager.getLogger(ControlPlaneServer::class.java) + + fun start() { + val serverBuilder = ServerBuilder.forPort(args.envoyDiscoveryPort) + register(serverBuilder) + val server = serverBuilder.build() + CoroutineScope(Dispatchers.IO).launch { + try { + server.start() + server.awaitTermination() + } catch (e: Exception) { + logger.warn("Error in envoy control server server", e) + throw e + } + } + registerSelf() + } + + private fun registerSelf() { + dropletRepository.save( + Droplet( + type = "controller", + id = "internal-controller", + host = args.grpcHost, + port = args.grpcPort, + envoyPort = 8080, + ) + ) + dropletRepository.save( + Droplet( + type = "auth", + id = "internal-auth", + host = args.grpcHost, + port = args.authorizationPort, + envoyPort = 8080 + ) + ) + } + + private fun register(builder: ServerBuilder<*>) { + logger.info("Registering envoy ADS...") + builder.addService(server.aggregatedDiscoveryServiceImpl) + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/DropletCache.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/DropletCache.kt new file mode 100644 index 0000000..a9a9061 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/DropletCache.kt @@ -0,0 +1,174 @@ +package app.simplecloud.controller.runtime.envoy + +import app.simplecloud.controller.runtime.droplet.DropletRepository +import app.simplecloud.droplet.api.droplet.Droplet +import com.google.protobuf.Any +import com.google.protobuf.Duration +import com.google.protobuf.UInt32Value +import io.envoyproxy.controlplane.cache.ConfigWatcher +import io.envoyproxy.controlplane.cache.v3.SimpleCache +import io.envoyproxy.controlplane.cache.v3.Snapshot +import io.envoyproxy.envoy.config.cluster.v3.Cluster +import io.envoyproxy.envoy.config.cluster.v3.Cluster.EdsClusterConfig +import io.envoyproxy.envoy.config.core.v3.* +import io.envoyproxy.envoy.config.endpoint.v3.ClusterLoadAssignment +import io.envoyproxy.envoy.config.endpoint.v3.Endpoint +import io.envoyproxy.envoy.config.endpoint.v3.LbEndpoint +import io.envoyproxy.envoy.config.endpoint.v3.LocalityLbEndpoints +import io.envoyproxy.envoy.config.listener.v3.Filter +import io.envoyproxy.envoy.config.listener.v3.FilterChain +import io.envoyproxy.envoy.config.listener.v3.Listener +import io.envoyproxy.envoy.config.route.v3.* +import io.envoyproxy.envoy.extensions.filters.http.connect_grpc_bridge.v3.FilterConfig +import io.envoyproxy.envoy.extensions.filters.http.grpc_web.v3.GrpcWeb +import io.envoyproxy.envoy.extensions.filters.http.router.v3.Router +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager +import io.envoyproxy.envoy.extensions.filters.network.http_connection_manager.v3.HttpFilter +import io.envoyproxy.envoy.extensions.upstreams.http.v3.HttpProtocolOptions +import org.apache.logging.log4j.LogManager +import java.util.* + +/** + * This class handles the remapping of the [DropletRepository] to a [SimpleCache] of [Snapshot]s, which are used by the envoy ADS service. + */ +class DropletCache { + private val cache = SimpleCache(SimpleCloudNodeGroup()) + private val logger = LogManager.getLogger(DropletCache::class.java) + + //Create a new Snapshot by the droplet repository's data + fun update(droplets: List) { + logger.info("Detected new droplets in DropletRepository, adding to ADS...") + val clusters = mutableListOf() + val listeners = mutableListOf() + val clas = mutableListOf() + droplets.forEach { + clusters.add(createCluster(it)) + listeners.add(createListener(it)) + clas.add(createCLA(it)) + } + cache.setSnapshot( + SimpleCloudNodeGroup.GROUP, + Snapshot.create( + clusters, + clas, + listeners, + listOf(), // We don't need routes + listOf(), // We don't need secrets + UUID.randomUUID() + .toString() //This can be anything, used internally for versioning. THIS HAS TO BE DIFFERENT FOR EVERY SNAPSHOT + ) + ) + } + + //Creates endpoints users can connect with later + private fun createListener(it: Droplet): Listener { + return Listener.newBuilder().setName("${it.type}-${it.id}").setAddress( + Address.newBuilder().setSocketAddress( + SocketAddress.newBuilder().setProtocol(SocketAddress.Protocol.TCP).setAddress("0.0.0.0") + .setPortValue(it.envoyPort) + ) + ).setDefaultFilterChain(createListenerFilterChain("${it.type}-${it.id}")).build() + + } + + //Creates load assignments for new droplets (I don't yet know if they need to be called every time?) + private fun createCLA(it: Droplet): ClusterLoadAssignment { + return ClusterLoadAssignment.newBuilder().setClusterName("${it.type}-${it.id}") + .addEndpoints( + LocalityLbEndpoints.newBuilder().addLbEndpoints( + LbEndpoint.newBuilder().setEndpoint( + Endpoint.newBuilder() + .setAddress( + Address.newBuilder().setSocketAddress( + SocketAddress.newBuilder().setPortValue(it.port).setAddress(it.host) + .setProtocol(SocketAddress.Protocol.TCP) + ) + ) + ) + ) + ) + .build() + } + + //Creates clusters listening to droplets + private fun createCluster(it: Droplet): Cluster { + return Cluster.newBuilder().setName("${it.type}-${it.id}") + .setConnectTimeout(Duration.newBuilder().setSeconds(5)) + .setType(Cluster.DiscoveryType.EDS) + .setEdsClusterConfig( + EdsClusterConfig.newBuilder() + .setEdsConfig(ConfigSource.newBuilder().setAds(AggregatedConfigSource.getDefaultInstance())) + ) + .setLbPolicy(Cluster.LbPolicy.ROUND_ROBIN) + .setLoadAssignment( + ClusterLoadAssignment.newBuilder().setClusterName("${it.type}-${it.id}") + .addEndpoints( + LocalityLbEndpoints.newBuilder() + .addLbEndpoints( + LbEndpoint.newBuilder().setEndpoint( + Endpoint.newBuilder() + .setAddress( + Address.newBuilder().setSocketAddress( + SocketAddress.newBuilder().setPortValue(it.port).setAddress(it.host) + ) + ) + ) + ) + ) + ).putTypedExtensionProtocolOptions( + "envoy.extensions.upstreams.http.v3.HttpProtocolOptions", Any.pack( + HttpProtocolOptions.newBuilder().setExplicitHttpConfig( + HttpProtocolOptions.ExplicitHttpConfig.newBuilder().setHttp2ProtocolOptions( + Http2ProtocolOptions.newBuilder().setMaxConcurrentStreams( + UInt32Value.of(100) + ) + ) + ).build() + ) + ) + .build() + + } + + //Creates a filter chain that remaps http to grpc + private fun createListenerFilterChain(cluster: String): FilterChain.Builder { + return FilterChain.newBuilder() + .addFilters( + Filter.newBuilder().setName("envoy.filters.network.http_connection_manager") + .setTypedConfig( + Any.pack( + HttpConnectionManager.newBuilder().setStatPrefix("ingress_http") + .setCodecType(HttpConnectionManager.CodecType.AUTO) + .setRouteConfig( + RouteConfiguration.newBuilder().setName("local_route") + .addVirtualHosts( + VirtualHost.newBuilder().setName("local_service").addDomains("*") + .addRoutes( + Route.newBuilder().setRoute( + RouteAction.newBuilder().setCluster(cluster) + .setTimeout(Duration.newBuilder().setSeconds(0).setNanos(0)) + ).setMatch(RouteMatch.newBuilder().setPrefix("/")) + ) + ) + ).addHttpFilters( + HttpFilter.newBuilder().setName("envoy.filters.http.connect_grpc_bridge") + .setTypedConfig(Any.pack(FilterConfig.getDefaultInstance())) + ).addHttpFilters( + HttpFilter.newBuilder().setName("envoy.filters.http.grpc_web") + .setTypedConfig(Any.pack(GrpcWeb.getDefaultInstance())) + ).addHttpFilters( + HttpFilter.newBuilder().setName("envoy.filters.http.router") + .setTypedConfig(Any.pack(Router.getDefaultInstance())) + ) + + .build() + ) + ) + ) + } + + fun getCache(): ConfigWatcher { + return cache + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/SimpleCloudNodeGroup.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/SimpleCloudNodeGroup.kt new file mode 100644 index 0000000..b2d02fc --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/envoy/SimpleCloudNodeGroup.kt @@ -0,0 +1,22 @@ +package app.simplecloud.controller.runtime.envoy + +import io.envoyproxy.controlplane.cache.NodeGroup +import io.envoyproxy.envoy.config.core.v3.Node + +/** + * SimpleCloud only uses one envoy node. That's why we can just + * have one node in the nodegroup. + */ +class SimpleCloudNodeGroup : NodeGroup { + + companion object { + const val GROUP = "simplecloud" + } + + override fun hash(node: Node?): String { + if (node == null) { + throw IllegalArgumentException("Null node") + } + return GROUP + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt index 176920a..ce49285 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupRepository.kt @@ -1,13 +1,19 @@ package app.simplecloud.controller.runtime.group +import app.simplecloud.controller.runtime.MetricsEventNames import app.simplecloud.controller.runtime.YamlDirectoryRepository import app.simplecloud.controller.shared.group.Group +import app.simplecloud.droplet.api.time.ProtobufTimestamp +import app.simplecloud.pubsub.PubSubClient +import build.buf.gen.simplecloud.metrics.v1.metric +import build.buf.gen.simplecloud.metrics.v1.metricMeta import java.nio.file.Path -import java.util.concurrent.CompletableFuture +import java.time.LocalDateTime class GroupRepository( - path: Path -) : YamlDirectoryRepository(path, Group::class.java) { + path: Path, + private val pubSubClient: PubSubClient +) : YamlDirectoryRepository(path, Group::class.java, WatcherEvents(pubSubClient)) { override fun getFileName(identifier: String): String { return "$identifier.yml" } @@ -23,4 +29,106 @@ class GroupRepository( override suspend fun getAll(): List { return entities.values.toList() } + + private class WatcherEvents( + private val pubsubClient: PubSubClient + ) : YamlDirectoryRepository.WatcherEvents { + + override fun onCreate(entity: Group) { + pubsubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = entity.name + }, + metricMeta { + dataName = "status" + dataValue = "CREATED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "GROUP" + }, + metricMeta { + dataName = "groupName" + dataValue = entity.name + }, + metricMeta { + dataName = "by" + dataValue = "FILE_WATCHER" + } + ) + ) + }) + } + + override fun onDelete(entity: Group) { + pubsubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = entity.name + }, + metricMeta { + dataName = "status" + dataValue = "DELETED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "GROUP" + }, + metricMeta { + dataName = "groupName" + dataValue = entity.name + }, + metricMeta { + dataName = "by" + dataValue = "FILE_WATCHER" + } + ) + ) + }) + } + + override fun onModify(entity: Group) { + pubsubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = entity.name + }, + metricMeta { + dataName = "status" + dataValue = "EDITED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "GROUP" + }, + metricMeta { + dataName = "groupName" + dataValue = entity.name + }, + metricMeta { + dataName = "by" + dataValue = "FILE_WATCHER" + } + ) + ) + }) + } + + } } \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt index a57f9ab..8c7ff6e 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/group/GroupService.kt @@ -12,7 +12,7 @@ class GroupService( override suspend fun getGroupByName(request: GetGroupByNameRequest): GetGroupByNameResponse { val group = groupRepository.find(request.groupName) ?: throw StatusException(Status.NOT_FOUND.withDescription("This group does not exist")) - return getGroupByNameResponse { group.toDefinition() } + return getGroupByNameResponse { this.group = group.toDefinition() } } override suspend fun getAllGroups(request: GetAllGroupsRequest): GetAllGroupsResponse { @@ -34,6 +34,11 @@ class GroupService( groupRepository.find(request.group.name) ?: throw StatusException(Status.NOT_FOUND.withDescription("This group does not exist")) val group = Group.fromDefinition(request.group) + + if (group.minMemory > group.maxMemory) { + throw StatusException(Status.INVALID_ARGUMENT.withDescription("Minimum memory must be smaller than maximum memory")) + } + try { groupRepository.save(group) } catch (e: Exception) { @@ -47,6 +52,11 @@ class GroupService( throw StatusException(Status.NOT_FOUND.withDescription("This group already exists")) } val group = Group.fromDefinition(request.group) + + if (group.minMemory > group.maxMemory) { + throw StatusException(Status.INVALID_ARGUMENT.withDescription("Minimum memory must be smaller than maximum memory")) + } + try { groupRepository.save(group) } catch (e: Exception) { diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/hack/OS.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/hack/OS.kt new file mode 100644 index 0000000..97aaec2 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/hack/OS.kt @@ -0,0 +1,19 @@ +package app.simplecloud.controller.runtime.hack + +enum class OS(val names: List) { + WINDOWS(listOf("windows")), + LINUX(listOf("linux")), + MAC(listOf("mac")); + + companion object { + fun get(): OS? { + val name = System.getProperty("os.name").lowercase() + entries.forEach { + if (it.names.any { osName -> name.contains(osName) }) { + return it + } + } + return null + } + } +} diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/hack/PortProcessHandle.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/hack/PortProcessHandle.kt new file mode 100644 index 0000000..86c1c05 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/hack/PortProcessHandle.kt @@ -0,0 +1,81 @@ +package app.simplecloud.controller.runtime.hack + +import app.simplecloud.controller.shared.server.Server +import build.buf.gen.simplecloud.controller.v1.ServerDefinition +import java.time.LocalDateTime +import java.util.* +import java.util.concurrent.ConcurrentHashMap +import java.util.regex.Pattern + +object PortProcessHandle { + + private const val WINDOWS_PID_INDEX = 3 + private val windowsPattern = Pattern.compile("\\s*TCP\\s+\\S+:(\\d+)\\s+\\S+:(\\d+)\\s+\\S+\\s+(\\d+)") + + private val preBindPorts = ConcurrentHashMap() + + fun of(port: Int): Optional { + val os = OS.get() ?: return Optional.empty() + val command = when (os) { + OS.LINUX, OS.MAC -> arrayOf("sh", "-c", "lsof -i :$port | awk '{print \$2}'") + OS.WINDOWS -> arrayOf("cmd", "/c", "netstat -ano | findstr $port") + } + + val process = Runtime.getRuntime().exec(command) + + val bufferedReader = process.inputReader() + val processId = bufferedReader.useLines { lines -> + lines.firstNotNullOfOrNull { parseProcessIdOrNull(os, it) } + } + + if (processId == null) { + return Optional.empty() + } + + return ProcessHandle.of(processId) + } + + private fun parseProcessIdOrNull(os: OS, line: String): Long? { + return when (os) { + OS.LINUX, OS.MAC -> line.toLongOrNull() + OS.WINDOWS -> { + val matcher = windowsPattern.matcher(line) + if (!matcher.matches()) { + return null + } + + return matcher.group(WINDOWS_PID_INDEX).toLongOrNull() + } + } + } + + @Synchronized + fun findNextFreePort(startPort: Int, serverDefinition: ServerDefinition): Int { + val server = Server.fromDefinition(serverDefinition) + var port = startPort + val time = LocalDateTime.now() + while (isPortBound(port)) { + port++ + } + addPreBind(port, time, server.properties.getOrDefault("max-startup-seconds", "120").toLong()) + return port + } + + private fun addPreBind(port: Int, time: LocalDateTime, duration: Long) { + preBindPorts[port] = time.plusSeconds(duration) + } + + fun isPortBound(port: Int): Boolean { + return !of(port).isEmpty || LocalDateTime.now().isBefore(preBindPorts.getOrDefault(port, LocalDateTime.MIN)) + } + + fun removePreBind(port: Int, forced: Boolean = false) { + // Check if max-startup-seconds is reached and return if not + if (!forced && LocalDateTime.now().isBefore(preBindPorts.getOrDefault(port, LocalDateTime.MAX))) { + return + } + + preBindPorts.remove(port) + } + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt index 468d17e..bb4d857 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/host/ServerHostRepository.kt @@ -28,7 +28,7 @@ class ServerHostRepository : Repository { suspend fun areServerHostsAvailable(): Boolean { return coroutineScope { return@coroutineScope hosts.any { - val channel = it.value.stub.channel as ManagedChannel + val channel = it.value.stub?.channel as ManagedChannel val state = channel.getState(true) state == ConnectivityState.IDLE || state == ConnectivityState.READY } @@ -36,8 +36,8 @@ class ServerHostRepository : Repository { } override suspend fun delete(element: ServerHost): Boolean { - val host = hosts.get(element.id) ?: return false - (host.stub.channel as ManagedChannel).shutdown().awaitTermination(5L, TimeUnit.SECONDS) + val host = hosts[element.id] ?: return false + (host.stub?.channel as ManagedChannel).shutdown().awaitTermination(5L, TimeUnit.SECONDS) return hosts.remove(element.id, element) } diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt index 505fde3..cf5c968 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/ControllerStartCommand.kt @@ -1,23 +1,28 @@ package app.simplecloud.controller.runtime.launcher import app.simplecloud.controller.runtime.ControllerRuntime -import app.simplecloud.controller.shared.secret.AuthFileSecretFactory -import com.github.ajalt.clikt.core.CliktCommand +import app.simplecloud.droplet.api.secret.AuthFileSecretFactory +import app.simplecloud.metrics.internal.api.MetricsCollector +import com.github.ajalt.clikt.command.SuspendingCliktCommand import com.github.ajalt.clikt.core.context import com.github.ajalt.clikt.parameters.options.default import com.github.ajalt.clikt.parameters.options.defaultLazy import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.types.boolean import com.github.ajalt.clikt.parameters.types.int import com.github.ajalt.clikt.parameters.types.path import com.github.ajalt.clikt.sources.PropertiesValueSource +import com.github.ajalt.clikt.sources.ValueSource import java.io.File import java.nio.file.Path -class ControllerStartCommand : CliktCommand() { +class ControllerStartCommand( + private val metricsCollector: MetricsCollector? +) : SuspendingCliktCommand() { init { context { - valueSource = PropertiesValueSource.from(File("controller.properties")) + valueSource = PropertiesValueSource.from(File("controller.properties"), false, ValueSource.envvarKey()) } } @@ -35,6 +40,19 @@ class ControllerStartCommand : CliktCommand() { val pubSubGrpcPort: Int by option(help = "PubSub Grpc port (default: 5817)", envvar = "PUBSUB_GRPC_PORT").int() .default(5817) + val authorizationPort: Int by option( + help = "Authorization port (default: 5818)", + envvar = "AUTHORIZATION_PORT" + ).int().default(5818) + + val envoyDiscoveryPort: Int by option( + help = "Envoy Discovery port (default: 5814)", + envvar = "ENVOY_DISCOVERY_PORT" + ).int().default(5814) + + val envoyStartPort: Int by option(help = "Envoy start port (default: 8080)", envvar = "ENVOY_START_PORT").int() + .default(8080) + private val authSecretPath: Path by option( help = "Path to auth secret file (default: .auth.secret)", envvar = "AUTH_SECRET_PATH" @@ -45,17 +63,15 @@ class ControllerStartCommand : CliktCommand() { val authSecret: String by option(help = "Auth secret", envvar = "AUTH_SECRET_KEY") .defaultLazy { AuthFileSecretFactory.loadOrCreate(authSecretPath) } - private val forwardingSecretPath: Path by option( - help = "Path to the forwarding secret (default: .forwarding.secret)", - envvar = "FORWARDING_SECRET_PATH" - ) - .path() - .default(Path.of(".secrets", "forwarding.secret")) + private val trackMetrics: Boolean by option(help = "Track metrics", envvar = "TRACK_METRICS") + .boolean() + .default(true) - val forwardingSecret: String by option(help = "Forwarding secrewt", envvar = "FORWARDING_SECRET") - .defaultLazy { AuthFileSecretFactory.loadOrCreate(forwardingSecretPath) } + override suspend fun run() { + if (trackMetrics) { + metricsCollector?.start() + } - override fun run() { val controllerRuntime = ControllerRuntime(this) controllerRuntime.start() } diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt index 10c2f99..8a3218a 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/launcher/Launcher.kt @@ -1,16 +1,30 @@ package app.simplecloud.controller.runtime.launcher +import app.simplecloud.metrics.internal.api.MetricsCollector +import com.github.ajalt.clikt.command.main import org.apache.logging.log4j.LogManager -fun main(args: Array) { - configureLog4j() - ControllerStartCommand().main(args) +suspend fun main(args: Array) { + val metricsCollector = try { + MetricsCollector.create("controller") + } catch (e: Exception) { + null + } + configureLog4j( + metricsCollector + ) + ControllerStartCommand( + metricsCollector + ).main(args) } -fun configureLog4j() { +fun configureLog4j( + metricsCollector: MetricsCollector? +) { val globalExceptionHandlerLogger = LogManager.getLogger("GlobalExceptionHandler") Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + metricsCollector?.recordError(throwable) globalExceptionHandlerLogger.error("Uncaught exception in thread ${thread.name}", throwable) } } diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthClientRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthClientRepository.kt new file mode 100644 index 0000000..359bdf1 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthClientRepository.kt @@ -0,0 +1,80 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.controller.runtime.Repository +import app.simplecloud.controller.runtime.database.Database +import app.simplecloud.controller.shared.db.tables.records.Oauth2ClientDetailsRecord +import app.simplecloud.controller.shared.db.tables.references.OAUTH2_CLIENT_DETAILS +import app.simplecloud.droplet.api.auth.OAuthClient +import app.simplecloud.droplet.api.auth.Scope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.withContext +import org.jooq.exception.DataAccessException + +class AuthClientRepository( + private val database: Database +) : Repository { + + override suspend fun getAll(): List { + return database.context.selectFrom(OAUTH2_CLIENT_DETAILS) + .asFlow() + .toCollection(mutableListOf()) + .map { mapRecordToClient(it) } + } + + override suspend fun find(identifier: String): OAuthClient? { + return database.context.selectFrom(OAUTH2_CLIENT_DETAILS) + .where(OAUTH2_CLIENT_DETAILS.CLIENT_ID.eq(identifier)) + .limit(1) + .awaitFirstOrNull()?.let { mapRecordToClient(it) } + } + + override fun save(element: OAuthClient) { + database.context.insertInto( + OAUTH2_CLIENT_DETAILS, + + OAUTH2_CLIENT_DETAILS.CLIENT_ID, + OAUTH2_CLIENT_DETAILS.CLIENT_SECRET, + OAUTH2_CLIENT_DETAILS.GRANT_TYPES, + OAUTH2_CLIENT_DETAILS.REDIRECT_URI, + OAUTH2_CLIENT_DETAILS.SCOPE, + ).values( + element.clientId, + element.clientSecret, + element.grantTypes, + element.redirectUri, + element.scope.joinToString(";"), + ).onDuplicateKeyUpdate() + .set(OAUTH2_CLIENT_DETAILS.CLIENT_ID, element.clientId) + .set(OAUTH2_CLIENT_DETAILS.CLIENT_SECRET, element.clientSecret) + .set(OAUTH2_CLIENT_DETAILS.GRANT_TYPES, element.grantTypes) + .set(OAUTH2_CLIENT_DETAILS.REDIRECT_URI, element.redirectUri) + .set(OAUTH2_CLIENT_DETAILS.SCOPE, element.scope.joinToString(";")) + .executeAsync() + } + + override suspend fun delete(element: OAuthClient): Boolean { + return withContext(Dispatchers.IO) { + try { + database.context.deleteFrom(OAUTH2_CLIENT_DETAILS) + .where(OAUTH2_CLIENT_DETAILS.CLIENT_ID.eq(element.clientId)) + .execute() + return@withContext true + } catch (e: DataAccessException) { + return@withContext false + } + } + } + + private fun mapRecordToClient(record: Oauth2ClientDetailsRecord): OAuthClient { + return OAuthClient( + clientId = record.clientId!!, + clientSecret = record.clientSecret!!, + grantTypes = record.grantTypes!!, + redirectUri = record.redirectUri, + scope = Scope.fromString(record.scope ?: "", ";"), + ) + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthGroupRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthGroupRepository.kt new file mode 100644 index 0000000..c4812c8 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthGroupRepository.kt @@ -0,0 +1,57 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.controller.runtime.Repository +import app.simplecloud.controller.runtime.database.Database +import app.simplecloud.controller.shared.db.tables.records.Oauth2GroupsRecord +import app.simplecloud.controller.shared.db.tables.references.OAUTH2_GROUPS +import app.simplecloud.droplet.api.auth.OAuthGroup +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.withContext +import org.jooq.exception.DataAccessException + +class AuthGroupRepository(private val database: Database) : Repository { + override suspend fun getAll(): List { + return database.context.selectFrom(OAUTH2_GROUPS).asFlow().toCollection(mutableListOf()) + .map { mapRecordToGroup(it) } + } + + override suspend fun find(identifier: String): OAuthGroup? { + return database.context.selectFrom(OAUTH2_GROUPS).where(OAUTH2_GROUPS.GROUP_NAME.eq(identifier)).limit(1) + .awaitFirstOrNull()?.let { mapRecordToGroup(it) } + } + + override fun save(element: OAuthGroup) { + database.context.insertInto( + OAUTH2_GROUPS, + + OAUTH2_GROUPS.GROUP_NAME, OAUTH2_GROUPS.SCOPES + ).values( + element.name, + element.scopes.joinToString(";"), + ).onDuplicateKeyUpdate().set(OAUTH2_GROUPS.GROUP_NAME, element.name) + .set(OAUTH2_GROUPS.SCOPES, element.scopes.joinToString(";")).executeAsync() + } + + override suspend fun delete(element: OAuthGroup): Boolean { + return withContext(Dispatchers.IO) { + try { + database.context.deleteFrom(OAUTH2_GROUPS).where(OAUTH2_GROUPS.GROUP_NAME.eq(element.name)).execute() + return@withContext true + } catch (e: DataAccessException) { + return@withContext false + } + } + } + + companion object { + fun mapRecordToGroup(record: Oauth2GroupsRecord): OAuthGroup { + return OAuthGroup( + scopes = record.scopes?.split(";") ?: emptyList(), + name = record.groupName!!, + ) + } + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthTokenRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthTokenRepository.kt new file mode 100644 index 0000000..661eab0 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthTokenRepository.kt @@ -0,0 +1,102 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.controller.runtime.Repository +import app.simplecloud.controller.runtime.database.Database +import app.simplecloud.controller.shared.db.tables.records.Oauth2TokensRecord +import app.simplecloud.controller.shared.db.tables.references.OAUTH2_TOKENS +import app.simplecloud.droplet.api.auth.OAuthToken +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.withContext +import org.jooq.exception.DataAccessException +import java.time.Duration +import java.time.LocalDateTime + +class AuthTokenRepository(private val database: Database) : Repository { + override suspend fun getAll(): List { + return database.context.selectFrom(OAUTH2_TOKENS) + .asFlow() + .toCollection(mutableListOf()) + .map { mapRecordToToken(it) } + } + + override suspend fun find(identifier: String): OAuthToken? { + return database.context.selectFrom(OAUTH2_TOKENS) + .where(OAUTH2_TOKENS.TOKEN_ID.eq(identifier)) + .limit(1) + .awaitFirstOrNull()?.let { mapRecordToToken(it) } + } + + suspend fun findByAccessToken(token: String): OAuthToken? { + return database.context.selectFrom(OAUTH2_TOKENS) + .where(OAUTH2_TOKENS.ACCESS_TOKEN.eq(token)) + .limit(1) + .awaitFirstOrNull()?.let { mapRecordToToken(it) } + } + + suspend fun findByUserId(userId: String): OAuthToken? { + return database.context.selectFrom(OAUTH2_TOKENS) + .where(OAUTH2_TOKENS.USER_ID.eq(userId)) + .limit(1) + .awaitFirstOrNull()?.let { mapRecordToToken(it) } + } + + override fun save(element: OAuthToken) { + database.context.insertInto( + OAUTH2_TOKENS, + + OAUTH2_TOKENS.TOKEN_ID, + OAUTH2_TOKENS.ACCESS_TOKEN, + OAUTH2_TOKENS.SCOPE, + OAUTH2_TOKENS.CLIENT_ID, + OAUTH2_TOKENS.EXPIRES_IN, + OAUTH2_TOKENS.USER_ID, + ).values( + element.id, + element.accessToken, + element.scope, + element.clientId, + if (element.expiresIn != null) LocalDateTime.now().plusSeconds(element.expiresIn!!.toLong()) else null, + element.userId, + ).onDuplicateKeyUpdate() + .set(OAUTH2_TOKENS.TOKEN_ID, element.id) + .set(OAUTH2_TOKENS.ACCESS_TOKEN, element.accessToken) + .set(OAUTH2_TOKENS.SCOPE, element.scope) + .set(OAUTH2_TOKENS.CLIENT_ID, element.clientId) + .set( + OAUTH2_TOKENS.EXPIRES_IN, + if (element.expiresIn != null) LocalDateTime.now().plusSeconds(element.expiresIn!!.toLong()) else null + ) + .set(OAUTH2_TOKENS.USER_ID, element.userId) + .executeAsync() + } + + override suspend fun delete(element: OAuthToken): Boolean { + return withContext(Dispatchers.IO) { + try { + database.context.deleteFrom(OAUTH2_TOKENS) + .where(OAUTH2_TOKENS.CLIENT_ID.eq(element.clientId)) + .execute() + return@withContext true + } catch (e: DataAccessException) { + return@withContext false + } + } + } + + companion object { + fun mapRecordToToken(record: Oauth2TokensRecord): OAuthToken { + return OAuthToken( + id = record.tokenId!!, + scope = record.scope ?: "", + expiresIn = if (record.expiresIn != null) Duration.between(LocalDateTime.now(), record.expiresIn!!) + .toSeconds().toInt() else null, + accessToken = record.accessToken!!, + clientId = record.clientId, + userId = record.userId, + ) + } + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthUserRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthUserRepository.kt new file mode 100644 index 0000000..6d9a75b --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthUserRepository.kt @@ -0,0 +1,128 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.controller.runtime.Repository +import app.simplecloud.controller.runtime.database.Database +import app.simplecloud.controller.shared.db.tables.records.Oauth2UsersRecord +import app.simplecloud.controller.shared.db.tables.references.OAUTH2_TOKENS +import app.simplecloud.controller.shared.db.tables.references.OAUTH2_USERS +import app.simplecloud.controller.shared.db.tables.references.OAUTH2_USER_GROUPS +import app.simplecloud.droplet.api.auth.OAuthGroup +import app.simplecloud.droplet.api.auth.OAuthToken +import app.simplecloud.droplet.api.auth.OAuthUser +import app.simplecloud.droplet.api.auth.Scope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.toCollection +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.reactive.awaitFirstOrNull +import kotlinx.coroutines.withContext +import org.jooq.exception.DataAccessException + +class AuthUserRepository( + private val database: Database +) : Repository { + + override suspend fun getAll(): List { + return database.context.selectFrom( + OAUTH2_USERS + ) + .asFlow().toCollection(mutableListOf()).map { mapRecordToUser(it) } + } + + override suspend fun find(identifier: String): OAuthUser? { + return database.context.selectFrom( + OAUTH2_USERS + ) + .where(OAUTH2_USERS.USER_ID.eq(identifier)) + .limit(1) + .awaitFirstOrNull()?.let { mapRecordToUser(it) } + } + + suspend fun findByName(identifier: String): OAuthUser? { + return database.context.selectFrom( + OAUTH2_USERS + ) + .where(OAUTH2_USERS.USERNAME.eq(identifier)) + .limit(1) + .awaitFirstOrNull()?.let { mapRecordToUser(it) } + } + + + override fun save(element: OAuthUser) { + database.context.insertInto( + OAUTH2_USERS, + + OAUTH2_USERS.USER_ID, + OAUTH2_USERS.SCOPES, + OAUTH2_USERS.USERNAME, + OAUTH2_USERS.HASHED_PASSWORD, + ).values( + element.userId, + element.scopes.joinToString(";"), + element.username, + element.hashedPassword, + ).onDuplicateKeyUpdate() + .set(OAUTH2_USERS.USER_ID, element.userId) + .set(OAUTH2_USERS.SCOPES, element.scopes.joinToString(";")) + .set(OAUTH2_USERS.USERNAME, element.username) + .set(OAUTH2_USERS.HASHED_PASSWORD, element.hashedPassword) + .executeAsync() + database.context.deleteFrom(OAUTH2_USER_GROUPS).where(OAUTH2_USER_GROUPS.USER_ID.eq(element.userId)) + .executeAsync() + element.groups.forEach { + database.context.insertInto( + OAUTH2_USER_GROUPS, + + OAUTH2_USER_GROUPS.USER_ID, + OAUTH2_USER_GROUPS.GROUP_NAME, + ).values( + element.userId, + it.name, + ).onConflictDoNothing().executeAsync() + } + } + + override suspend fun delete(element: OAuthUser): Boolean { + return withContext(Dispatchers.IO) { + try { + database.context.deleteFrom(OAUTH2_USERS) + .where(OAUTH2_USERS.USER_ID.eq(element.userId)) + .execute() + return@withContext true + } catch (e: DataAccessException) { + return@withContext false + } + } + } + + private suspend fun mapRecordToUser( + record: Oauth2UsersRecord, + ): OAuthUser { + val token = getToken(record.userId!!) + val groups = getGroups(record.userId!!) + return OAuthUser( + scopes = Scope.fromString(record.scopes ?: ";"), + userId = record.userId!!, + username = record.username!!, + hashedPassword = record.hashedPassword!!, + token = token, + groups = groups + ) + } + + private suspend fun getToken(userId: String): OAuthToken? { + return database.context.selectFrom(OAUTH2_TOKENS).where(OAUTH2_TOKENS.USER_ID.eq(userId)).limit(1) + .awaitFirstOrNull() + ?.let { AuthTokenRepository.mapRecordToToken(it) } + } + + private suspend fun getGroups(userId: String): List { + return database.context.select(OAUTH2_USER_GROUPS, OAUTH2_USER_GROUPS.oauth2Groups()).from(OAUTH2_USER_GROUPS) + .where(OAUTH2_USER_GROUPS.USER_ID.eq(userId)) + .asFlow().toCollection(mutableListOf()).map { + if (it != null) { + return@map AuthGroupRepository.mapRecordToGroup(it.component2()) + } + return@map null + }.filterNotNull() + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthenticationHandler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthenticationHandler.kt new file mode 100644 index 0000000..1a61195 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthenticationHandler.kt @@ -0,0 +1,231 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.droplet.api.auth.* +import com.nimbusds.jwt.JWTClaimsSet +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.util.* + +class AuthenticationHandler( + private val groupRepository: AuthGroupRepository, + private val userRepository: AuthUserRepository, + private val tokenRepository: AuthTokenRepository, + private val jwtHandler: JwtHandler, +) { + + private suspend fun checkScope(call: RoutingCall, scope: String): Boolean { + val claims = call.receive() + val providedScope = Scope.fromString(claims.claims["scope"].toString()) + val requiredScope = Scope.fromString(scope) + if (!Scope.validate(requiredScope, providedScope)) { + call.respond(HttpStatusCode.Unauthorized) + return false + } + return true + } + + suspend fun saveGroup(call: RoutingCall) { + val params = call.receiveParameters() + val groupName = params["group_name"] + if (groupName == null) { + call.respond(HttpStatusCode.BadRequest, "You must specify a group name") + return + } + if (!checkScope(call, "simplecloud.auth.group.save.$groupName")) { + return + } + val scopes = Scope.fromString(params["scopes"] ?: "") + if (!checkScope(call, scopes.joinToString(" "))) { + return + } + groupRepository.save(OAuthGroup(scopes, groupName)) + call.respond("Group successfully saved") + } + + suspend fun getGroup(call: RoutingCall) { + val group = loadGroup(call) ?: return + if (!checkScope(call, "simplecloud.auth.group.get.${group.name}")) { + return + } + call.respond(mapOf("group_name" to group.name, "scope" to group.scopes.joinToString(" "))) + } + + suspend fun getGroups(call: RoutingCall) { + if (!checkScope(call, "simplecloud.auth.group.get.*")) { + return + } + val groups = groupRepository.getAll() + call.respond(listOf(groups.map { + mapOf( + "group_name" to it.name, + "scope" to it.scopes.joinToString(" ") + ) + }).flatten()) + } + + suspend fun deleteGroup(call: RoutingCall) { + val group = loadGroup(call) ?: return + if (!checkScope(call, "simplecloud.auth.group.delete.${group.name}")) { + return + } + groupRepository.delete(group) + call.respond("Group successfully deleted") + } + + private suspend fun loadGroup(call: RoutingCall): OAuthGroup? { + val params = call.receiveParameters() + val groupName = params["group_name"] + if (groupName == null) { + call.respond(HttpStatusCode.BadRequest, "You must specify a group name") + return null + } + val group = groupRepository.find(groupName) + if (group == null) { + call.respond(HttpStatusCode.NotFound, "Group not found") + return null + } + return group + } + + suspend fun saveUser(call: RoutingCall) { + if (!checkScope(call, "simplecloud.auth.user.save")) { + return + } + val params = call.receiveParameters() + val username = params["username"] + val password = params["password"] + val groups = (params["groups"] ?: "").split(" ") + val scope = Scope.fromString(params["scope"] ?: "") + if (username == null || password == null) { + call.respond(HttpStatusCode.BadRequest, "You must specify a username or password") + return + } + val existing = userRepository.findByName(username) + val parsedGroups = groups.mapNotNull { group -> groupRepository.find(group) } + val updated = OAuthUser( + userId = existing?.userId ?: UUID.randomUUID().toString(), + groups = parsedGroups, + username = username, + scopes = scope, + hashedPassword = PasswordEncoder.hashPassword(password) + ) + userRepository.save(updated) + call.respond("User successfully saved") + } + + suspend fun getUser(call: RoutingCall) { + val params = call.receiveParameters() + val username = params["username"] + if (username == null) { + call.respond(HttpStatusCode.BadRequest, "You must specify a user id") + return + } + if (!checkScope(call, "simplecloud.auth.user.get.$username")) { + return + } + val user = userRepository.findByName(username) + if (user == null) { + call.respond(HttpStatusCode.NotFound, "User not found") + return + } + call.respond( + mapOf( + "user_id" to user.userId, + "username" to user.username, + "scope" to user.scopes.joinToString(" "), + "groups" to user.groups.joinToString(" ") { group -> group.name } + )) + } + + suspend fun getUsers(call: RoutingCall) { + if (!checkScope(call, "simplecloud.auth.user.get.*")) { + return + } + val users = userRepository.getAll() + call.respond(listOf(users.map { + mapOf( + "user_id" to it.userId, + "username" to it.username, + "scope" to it.scopes.joinToString(" "), + "groups" to it.groups.joinToString(" ") { group -> group.name } + ) + }).flatten()) + } + + suspend fun deleteUser(call: RoutingCall) { + if (!checkScope(call, "simplecloud.auth.user.delete")) { + return + } + val params = call.receiveParameters() + val userId = params["user_id"] + if (userId == null) { + call.respond(HttpStatusCode.BadRequest, "You must specify a user id") + return + } + val user = userRepository.find(userId) + if (user == null) { + call.respond(HttpStatusCode.NotFound, "User not found") + return + } + userRepository.delete(user) + call.respond("User successfully deleted") + } + + suspend fun login(call: RoutingCall) { + val params = call.receiveParameters() + val username = params["username"] + val password = params["password"] + if (username == null || password == null) { + call.respond(HttpStatusCode.BadRequest, "You must specify a username and password") + return + } + val user = userRepository.findByName(username) + if (user == null) { + call.respond(HttpStatusCode.Unauthorized, "Invalid username or password") + return + } + if (!PasswordEncoder.verifyPassword(password, user.hashedPassword)) { + call.respond(HttpStatusCode.Unauthorized, "Invalid username or password") + return + } + val token = tokenRepository.findByUserId(user.userId) + if (token?.expiresIn != null && token.expiresIn!! > 0) { + call.respond( + mapOf( + "access_token" to token.accessToken, + "scope" to token.scope, + "exp" to token.expiresIn, + ) + ) + return + } + val combinedScopes = mutableListOf() + combinedScopes.addAll(user.scopes) + user.groups.forEach { + combinedScopes.addAll(it.scopes) + } + val jwtToken = jwtHandler.generateJwtSigned( + user.userId, + 3600, + Scope.fromString(combinedScopes.joinToString(" ")).joinToString(" ") + ) + val newToken = OAuthToken( + id = UUID.randomUUID().toString(), + userId = user.userId, + accessToken = jwtToken, + expiresIn = 3600, + scope = combinedScopes.joinToString(" ") + ) + call.respond( + mapOf( + "access_token" to newToken.accessToken, + "scope" to newToken.scope, + "exp" to newToken.expiresIn, + "user_id" to newToken.userId, + "client_id" to newToken.clientId + ) + ) + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthorizationHandler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthorizationHandler.kt new file mode 100644 index 0000000..fb80374 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/AuthorizationHandler.kt @@ -0,0 +1,290 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.droplet.api.auth.JwtHandler +import app.simplecloud.droplet.api.auth.OAuthClient +import app.simplecloud.droplet.api.auth.OAuthToken +import app.simplecloud.droplet.api.auth.Scope +import io.ktor.http.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import java.util.* + +class AuthorizationHandler( + private val secret: String, + private val clientRepository: AuthClientRepository, + private val tokenRepository: AuthTokenRepository, + private val pkceHandler: PKCEHandler, + private val jwtHandler: JwtHandler, + private val flowData: MutableMap> +) { + suspend fun registerClient(call: RoutingCall) { + val params = call.receiveParameters() + val providedMasterToken = params["master_token"] + if (providedMasterToken != secret) { + call.respond(HttpStatusCode.Forbidden, "Invalid master token") + return + } + val clientId = params["client_id"] + if (clientId == null) { + call.respond(HttpStatusCode.BadRequest, "Client id is required") + return + } + val redirectUri = params["redirect_uri"] + val grantTypes = params["grant_types"] + if (grantTypes == null) { + call.respond(HttpStatusCode.BadRequest, "Invalid grant_types") + return + } + val scope = Scope.fromString(params["scope"] ?: "") + val providedSecret = params["client_secret"] + val clientSecret = providedSecret ?: "secret-${UUID.randomUUID().toString().replace("-", "")}" + val client = OAuthClient(clientId, clientSecret, redirectUri, grantTypes, scope) + clientRepository.save(client) + call.respond( + mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "scope" to client.scope.joinToString(" "), + "grant_types" to client.grantTypes, + "redirect_uri" to client.redirectUri, + ) + ) + } + + suspend fun getClient(call: RoutingCall) { + val params = call.receiveParameters() + val clientId = params["client_id"] + if (clientId == null) { + call.respond(HttpStatusCode.BadRequest, "You must provide a valid client_id") + return + } + val masterToken = params["master_token"] + val clientSecret = params["client_secret"] + if (masterToken == null && clientSecret == null) { + call.respond(HttpStatusCode.BadRequest, "You must provide either a valid master_token or client_secret") + return + } + if (masterToken != null && secret != masterToken) { + call.respond(HttpStatusCode.BadRequest, "You must provide either a valid master_token or client_secret") + return + } + val client = clientRepository.find(clientId) + if (client == null) { + call.respond(HttpStatusCode.BadRequest, "You must provide a valid client_id") + return + } + if (masterToken == null && client.clientSecret != clientSecret) { + call.respond(HttpStatusCode.BadRequest, "You must provide either a valid master_token or client_secret") + return + } + call.respond( + mapOf( + "client_id" to clientId, + "client_secret" to clientSecret, + "scope" to client.scope.joinToString(" "), + "grant_types" to client.grantTypes, + "redirect_uri" to client.redirectUri, + ) + ) + } + + suspend fun authorizeRequest(call: RoutingCall) { + val params = call.receiveParameters() + val clientId = params["client_id"] + val redirectUri = params["redirect_uri"] + val challengeMethod = params["code_challenge_method"] + val challenge = params["code_challenge"] + val scope = params["scope"] + if (clientId == null || redirectUri == null || scope == null || challenge == null) { + call.respond( + HttpStatusCode.BadRequest, + "You have to provide redirect_uri, client_id, scope and challenge" + ) + return + } + if (challengeMethod == null || challengeMethod != "S256") { + call.respond(HttpStatusCode.BadRequest, "Invalid challenge, S256 is supported.") + return + } + val client = clientRepository.find(clientId) + if (client == null) { + call.respond(HttpStatusCode.NotFound, "Client not found") + return + } + if (!client.grantTypes.contains("authorization_code")) { + call.respond(HttpStatusCode.BadRequest, "User authorization is not supported by the client") + return + } + + if (!client.grantTypes.contains("pkce")) { + call.respond( + HttpStatusCode.BadRequest, + "User authorization using PKCE is not supported by the client" + ) + return + } + + if (!client.scope.contains(scope)) { + call.respond(HttpStatusCode.BadRequest, "This scope is not supported by the client") + return + } + + val authorizationCode = UUID.randomUUID().toString().replace("-", "") + flowData[authorizationCode] = listOf(client.clientId, challenge, scope) + call.respond(mapOf("redirectUri" to "$redirectUri?code=$authorizationCode")) + } + + suspend fun tokenRequest(call: RoutingCall) { + val params = call.receiveParameters() + val clientId = params["client_id"] + val clientSecret = params["client_secret"] + val code = params["code"] + val codeVerifier = params["code_verifier"] + + if (clientId == null) { + call.respond(HttpStatusCode.BadRequest, "You have to provide a client id") + return + } + + if (clientSecret == null) { + call.respond(HttpStatusCode.BadRequest, "You have to provide a client secret") + return + } + + val client = clientRepository.find(clientId) + if (client == null) { + call.respond(HttpStatusCode.BadRequest, "You have to provide a valid client id") + return + } + + if (client.clientSecret != clientSecret) { + call.respond(HttpStatusCode.BadRequest, "Invalid client secret") + return + } + + if (client.grantTypes.contains("authorization_code") && client.grantTypes.contains("pkce")) { + if (codeVerifier == null || code == null) { + call.respond(HttpStatusCode.BadRequest, "You have to provide a code and a code verifier") + return + } + val reconstructedChallenge = pkceHandler.generateCodeChallenge(codeVerifier) + val originalChallenge = flowData[code]?.get(1) + //If we can reconstruct the challenge, the authorization context was made in a secure context + if (originalChallenge == reconstructedChallenge) { + val token = OAuthToken( + id = UUID.randomUUID().toString(), + clientId = clientId, + accessToken = jwtHandler.generateJwtSigned( + clientId, + expiresIn = 3600, + scope = flowData[code]?.get(2)!! + ), + expiresIn = 3600, + scope = flowData[code]?.get(2)!! + ) + tokenRepository.save(token) + call.respond( + mapOf( + "access_token" to token.accessToken, + "scope" to token.scope, + "exp" to (token.expiresIn ?: -1), + "user_id" to token.userId, + "client_id" to token.clientId + ) + ) + return + } + call.respond( + HttpStatusCode.BadRequest, + "The token request was not made in the same context as the authorization." + ) + return + } else if (client.grantTypes.contains("client_credentials")) { + val scope = client.scope.ifEmpty { listOf("*") } + val token = OAuthToken( + id = UUID.randomUUID().toString(), + clientId = clientId, + accessToken = jwtHandler.generateJwtSigned(clientId, scope = scope.joinToString(" ")), + scope = scope.joinToString(" ") + ) + tokenRepository.save(token) + call.respond( + mapOf( + "access_token" to token.accessToken, + "scope" to token.scope, + "exp" to (token.expiresIn ?: -1), + ) + ) + return + } else { + call.respond(HttpStatusCode.BadRequest, "Invalid client") + return + } + } + + suspend fun revokeRequest(call: RoutingCall) { + val params = call.receiveParameters() + val accessToken = params["access_token"] + if (accessToken == null) { + call.respond(HttpStatusCode.BadRequest, "Access token is invalid") + return + } + val token = tokenRepository.findByAccessToken(accessToken) + if (token == null) { + call.respond(HttpStatusCode.BadRequest, "Access token is invalid") + return + } + + if (tokenRepository.delete(token)) { + call.respond("Access token revoked") + return + } + + call.respond(HttpStatusCode.InternalServerError, "Could not delete token") + + } + + suspend fun introspectRequest(call: RoutingCall) { + val params = call.receiveParameters() + val token = params["token"] + if (token == null) { + call.respond(HttpStatusCode.BadRequest, "Token is missing") + return + } + if (token == secret) { + call.respond( + mapOf( + "active" to true, + "scope" to "*", + "exp" to -1, + ), + ) + return + } + val authToken = tokenRepository.findByAccessToken(token) + if (authToken == null) { + call.respond(HttpStatusCode.OK, mapOf("active" to false)) + return + } + + val active = ((authToken.expiresIn ?: 1) > 0) && jwtHandler.verifyJwt(token) + if (!active) { + tokenRepository.delete(authToken) + call.respond(mapOf("active" to false)) + } + + // If the token exists, respond with token details + call.respond( + mapOf( + "active" to true, + "token_id" to authToken.id, + "client_id" to authToken.clientId, + "scope" to authToken.scope, + "exp" to (authToken.expiresIn ?: -1), + ) + ) + } + + +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/OAuthServer.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/OAuthServer.kt new file mode 100644 index 0000000..6ff8a2b --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/OAuthServer.kt @@ -0,0 +1,126 @@ +package app.simplecloud.controller.runtime.oauth + +import app.simplecloud.controller.runtime.database.Database +import app.simplecloud.controller.runtime.launcher.ControllerStartCommand +import app.simplecloud.droplet.api.auth.JwtHandler +import app.simplecloud.droplet.api.auth.OAuthIntrospector +import com.fasterxml.jackson.databind.SerializationFeature +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.auth.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.routing.* + +class OAuthServer(private val args: ControllerStartCommand, database: Database) { + private val issuer = "http://${args.grpcHost}:${args.authorizationPort}" + private val secret = args.authSecret + private val jwtHandler = JwtHandler(secret, issuer) + private val pkceHandler = PKCEHandler() + private val clientRepository = AuthClientRepository(database) + private val groupRepository = AuthGroupRepository(database) + private val userRepository = AuthUserRepository(database) + private val tokenRepository = AuthTokenRepository(database) + + //code to client_id, code_challenge and scope (this is in memory because it is only in use temporary) + private val flowData = mutableMapOf>() + + private val authorizationHandler = + AuthorizationHandler(secret, clientRepository, tokenRepository, pkceHandler, jwtHandler, flowData) + + private val authenticationHandler = + AuthenticationHandler(groupRepository, userRepository, tokenRepository, jwtHandler) + + private val introspector = OAuthIntrospector(issuer) + + + fun start() { + embeddedServer(Netty, host = args.grpcHost, port = args.authorizationPort) { + install(ContentNegotiation) { + jackson { + enable(SerializationFeature.INDENT_OUTPUT) + } + } + + install(Authentication) { + bearer { + authenticate { credential -> introspector.introspect(credential.token) } + } + } + + routing { + + // AUTHORIZATION + + // Client registration endpoint + post("/oauth/register_client") { + authorizationHandler.registerClient(call) + } + + // Client retrieval endpoint + get("/oauth/client") { + authorizationHandler.getClient(call) + } + // Authorization endpoint (simulating authorization code flow) + post("/oauth/authorize") { + authorizationHandler.authorizeRequest(call) + } + // Token endpoint + post("/oauth/token") { + authorizationHandler.tokenRequest(call) + } + // Revocation endpoint + post("/oauth/revoke") { + authorizationHandler.revokeRequest(call) + } + // Introspection endpoint + post("/oauth/introspect") { + authorizationHandler.introspectRequest(call) + } + + // AUTHENTICATION + + authenticate { + // Save group endpoint + put("/group") { + authenticationHandler.saveGroup(call) + } + // Get group endpoint + get("/group") { + authenticationHandler.getGroup(call) + } + // Delete group endpoint + delete("/group") { + authenticationHandler.deleteGroup(call) + } + // Get all groups endpoint + get("/groups") { + authenticationHandler.getGroups(call) + } + + put("/user") { + authenticationHandler.saveUser(call) + } + + get("/user") { + authenticationHandler.getUser(call) + } + + get("/users") { + authenticationHandler.getUsers(call) + } + + delete("/user") { + authenticationHandler.deleteUser(call) + } + + //Login endpoint + post("/login") { + authenticationHandler.login(call) + } + } + } + }.start(wait = true) + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/PKCEHandler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/PKCEHandler.kt new file mode 100644 index 0000000..39efeac --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/PKCEHandler.kt @@ -0,0 +1,18 @@ +package app.simplecloud.controller.runtime.oauth + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.* + +class PKCEHandler { + fun generateCodeVerifier(): String { + // Code verifier is a random string (e.g., 43-128 characters) + return UUID.randomUUID().toString().replace("-", "") + } + + fun generateCodeChallenge(codeVerifier: String): String { + // SHA256 the code verifier and base64 encode it + val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray(StandardCharsets.UTF_8)) + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest) + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/PasswordEncoder.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/PasswordEncoder.kt new file mode 100644 index 0000000..4406e85 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/oauth/PasswordEncoder.kt @@ -0,0 +1,15 @@ +package app.simplecloud.controller.runtime.oauth + +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder + +object PasswordEncoder { + fun hashPassword(password: String): String { + val passwordEncoder = BCryptPasswordEncoder() + return passwordEncoder.encode(password) + } + + fun verifyPassword(password: String, hashedPassword: String): Boolean { + val passwordEncoder = BCryptPasswordEncoder() + return passwordEncoder.matches(password, hashedPassword) + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt index 039a255..58e97c5 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/GroupReconciler.kt @@ -3,9 +3,9 @@ package app.simplecloud.controller.runtime.reconciler import app.simplecloud.controller.runtime.host.ServerHostRepository import app.simplecloud.controller.runtime.server.ServerNumericalIdRepository import app.simplecloud.controller.runtime.server.ServerRepository -import app.simplecloud.controller.shared.future.toCompletable import app.simplecloud.controller.shared.group.Group import app.simplecloud.controller.shared.server.Server +import app.simplecloud.droplet.api.future.toCompletable import build.buf.gen.simplecloud.controller.v1.* import build.buf.gen.simplecloud.controller.v1.ControllerServerServiceGrpc.ControllerServerServiceFutureStub import kotlinx.coroutines.runBlocking @@ -99,8 +99,14 @@ class GroupReconciler( private suspend fun startServers() { val available = serverHostRepository.areServerHostsAvailable() - if(!available) return - if(isNewServerNeeded()) + if (!available) return + group.timeout?.let { + if (it.isCooldownActive()) { + return + } + } + + if (isNewServerNeeded()) startServer() } diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt index 27398b9..c114192 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/reconciler/Reconciler.kt @@ -4,7 +4,7 @@ import app.simplecloud.controller.runtime.group.GroupRepository import app.simplecloud.controller.runtime.host.ServerHostRepository import app.simplecloud.controller.runtime.server.ServerNumericalIdRepository import app.simplecloud.controller.runtime.server.ServerRepository -import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.droplet.api.auth.AuthCallCredentials import build.buf.gen.simplecloud.controller.v1.ControllerServerServiceGrpc import io.grpc.ManagedChannel diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerHostAttacher.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerHostAttacher.kt new file mode 100644 index 0000000..2c470e8 --- /dev/null +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerHostAttacher.kt @@ -0,0 +1,38 @@ +package app.simplecloud.controller.runtime.server + +import app.simplecloud.controller.runtime.host.ServerHostRepository +import app.simplecloud.controller.shared.host.ServerHost +import app.simplecloud.controller.shared.server.Server +import io.grpc.Status +import io.grpc.StatusException +import kotlinx.coroutines.coroutineScope +import org.apache.logging.log4j.LogManager + +class ServerHostAttacher( + private val hostRepository: ServerHostRepository, + private val serverRepository: ServerRepository +) { + + private val logger = LogManager.getLogger(ServerHostAttacher::class.java) + + suspend fun attach(serverHost: ServerHost) { + hostRepository.delete(serverHost) + hostRepository.save(serverHost) + logger.info("Successfully registered ServerHost ${serverHost.id}.") + + coroutineScope { + serverRepository.findServersByHostId(serverHost.id).forEach { server -> + logger.info("Reattaching Server ${server.uniqueId} of group ${server.group}...") + try { + val result = serverHost.stub?.reattachServer(server.toDefinition()) + ?: throw StatusException(Status.INTERNAL.withDescription("Could not reattach server, is the host misconfigured?")) + serverRepository.save(Server.fromDefinition(result)) + logger.info("Success!") + } catch (e: Exception) { + logger.error("Server was found to be offline, unregistering...") + serverRepository.delete(server) + } + } + } + } +} \ No newline at end of file diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt index 81c5ef2..55fcd05 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerRepository.kt @@ -18,7 +18,7 @@ import java.time.LocalDateTime class ServerRepository( private val database: Database, - private val numericalIdRepository: ServerNumericalIdRepository + private val numericalIdRepository: ServerNumericalIdRepository, ) : LoadableRepository { override suspend fun find(identifier: String): Server? { diff --git a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt index 4b44ef1..a69a2cd 100644 --- a/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt +++ b/controller-runtime/src/main/kotlin/app/simplecloud/controller/runtime/server/ServerService.kt @@ -1,17 +1,20 @@ package app.simplecloud.controller.runtime.server +import app.simplecloud.controller.runtime.MetricsEventNames import app.simplecloud.controller.runtime.group.GroupRepository import app.simplecloud.controller.runtime.host.ServerHostRepository -import app.simplecloud.controller.shared.auth.AuthCallCredentials import app.simplecloud.controller.shared.group.Group +import app.simplecloud.controller.shared.group.GroupTimeout import app.simplecloud.controller.shared.host.ServerHost import app.simplecloud.controller.shared.server.Server -import app.simplecloud.controller.shared.time.ProtoBufTimestamp +import app.simplecloud.droplet.api.auth.AuthCallCredentials +import app.simplecloud.droplet.api.time.ProtobufTimestamp import app.simplecloud.pubsub.PubSubClient import build.buf.gen.simplecloud.controller.v1.* +import build.buf.gen.simplecloud.metrics.v1.metric +import build.buf.gen.simplecloud.metrics.v1.metricMeta import io.grpc.Status import io.grpc.StatusException -import kotlinx.coroutines.coroutineScope import org.apache.logging.log4j.LogManager import java.time.LocalDateTime import java.util.* @@ -21,35 +24,20 @@ class ServerService( private val serverRepository: ServerRepository, private val hostRepository: ServerHostRepository, private val groupRepository: GroupRepository, - private val forwardingSecret: String, private val authCallCredentials: AuthCallCredentials, private val pubSubClient: PubSubClient, + private val serverHostAttacher: ServerHostAttacher, ) : ControllerServerServiceGrpcKt.ControllerServerServiceCoroutineImplBase() { private val logger = LogManager.getLogger(ServerService::class.java) + @Deprecated("This method will be removed soon. Please use DropletService#registerDroplet") override suspend fun attachServerHost(request: AttachServerHostRequest): ServerHostDefinition { val serverHost = ServerHost.fromDefinition(request.serverHost, authCallCredentials) try { - hostRepository.delete(serverHost) - hostRepository.save(serverHost) + serverHostAttacher.attach(serverHost) } catch (e: Exception) { - throw StatusException(Status.INTERNAL.withDescription("Could not save serverhost").withCause(e)) - } - logger.info("Successfully registered ServerHost ${serverHost.id}.") - - coroutineScope { - serverRepository.findServersByHostId(serverHost.id).forEach { server -> - logger.info("Reattaching Server ${server.uniqueId} of group ${server.group}...") - try { - val result = serverHost.stub.reattachServer(server.toDefinition()) - serverRepository.save(Server.fromDefinition(result)) - logger.info("Success!") - } catch (e: Exception) { - logger.error("Server was found to be offline, unregistering...") - serverRepository.delete(server) - } - } + throw StatusException(Status.INTERNAL.withDescription("Could not attach serverhost").withCause(e)) } return serverHost.toDefinition() } @@ -84,12 +72,51 @@ class ServerService( try { val before = serverRepository.find(server.uniqueId) ?: throw StatusException(Status.NOT_FOUND.withDescription("Server not found")) - pubSubClient.publish( - "event", - ServerUpdateEvent.newBuilder() - .setUpdatedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) - .setServerBefore(before.toDefinition()).setServerAfter(request.server).build() - ) + val wasUpdated = before != server + + if (wasUpdated) { + pubSubClient.publish( + "event", + ServerUpdateEvent.newBuilder() + .setUpdatedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setServerBefore(before.toDefinition()).setServerAfter(request.server).build() + ) + + pubSubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = "${server.group} #${server.numericalId}" + }, + metricMeta { + dataName = "status" + dataValue = "EDITED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "SERVER" + }, + metricMeta { + dataName = "groupName" + dataValue = server.group + }, + metricMeta { + dataName = "numericalId" + dataValue = server.numericalId.toString() + }, + metricMeta { + dataName = "by" + dataValue = "API" + } + ) + ) + }) + } + serverRepository.save(server) return server.toDefinition() } catch (e: Exception) { @@ -112,11 +139,46 @@ class ServerService( pubSubClient.publish( "event", ServerStopEvent.newBuilder() .setServer(request.server) - .setStoppedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setStoppedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) .setStopCause(ServerStopCause.NATURAL_STOP) .setTerminationMode(ServerTerminationMode.UNKNOWN_MODE) //TODO: Add proto fields to make changing this possible .build() ) + + pubSubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = "${server.group} #${server.numericalId}" + }, + metricMeta { + dataName = "status" + dataValue = "STOPPED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "SERVER" + }, + metricMeta { + dataName = "groupName" + dataValue = server.group + }, + metricMeta { + dataName = "numericalId" + dataValue = server.numericalId.toString() + }, + metricMeta { + dataName = "by" + dataValue = ServerStopCause.NATURAL_STOP.toString() + } + ) + ) + }) + return server.toDefinition() } } @@ -139,6 +201,29 @@ class ServerService( return getServersByTypeResponse { servers.addAll(typeServers.map { it.toDefinition() }) } } + override suspend fun startMultipleServers(request: ControllerStartMultipleServersRequest): StartMultipleServerResponse { + val host = hostRepository.find(serverRepository) + ?: throw StatusException(Status.NOT_FOUND.withDescription("No server host found, could not start servers")) + val group = groupRepository.find(request.groupName) + ?: throw StatusException(Status.NOT_FOUND.withDescription("No group was found matching this name")) + + val startedServers = mutableListOf() + + try { + for (i in 1..request.amount) { + val server = startServer(host, group) + publishServerStartEvents(server, request.startCause) + startedServers.add(server) + } + } catch (e: Exception) { + throw StatusException(Status.INTERNAL.withDescription("Error whilst starting multiple servers").withCause(e)) + } + + return StartMultipleServerResponse.newBuilder() + .addAllServers(startedServers) + .build() + } + override suspend fun startServer(request: ControllerStartServerRequest): ServerDefinition { val host = hostRepository.find(serverRepository) ?: throw StatusException(Status.NOT_FOUND.withDescription("No server host found, could not start server")) @@ -146,13 +231,9 @@ class ServerService( ?: throw StatusException(Status.NOT_FOUND.withDescription("No group was found matching this name")) try { val server = startServer(host, group) - pubSubClient.publish( - "event", ServerStartEvent.newBuilder() - .setServer(server) - .setStartedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) - .setStartCause(request.startCause) - .build() - ) + + publishServerStartEvents(server, request.startCause) + return server } catch (e: Exception) { throw StatusException(Status.INTERNAL.withDescription("Error whilst starting server").withCause(e)) @@ -161,9 +242,9 @@ class ServerService( private suspend fun startServer(host: ServerHost, group: Group): ServerDefinition { val numericalId = numericalIdRepository.findNextNumericalId(group.name) - val server = buildServer(group, numericalId, forwardingSecret) + val server = buildServer(group, numericalId) serverRepository.save(server) - val stub = host.stub + val stub = host.stub ?: throw StatusException(Status.INTERNAL.withDescription("Server host has no stub")) serverRepository.save(server) try { val result = stub.startServer( @@ -182,7 +263,51 @@ class ServerService( } } - private fun buildServer(group: Group, numericalId: Int, forwardingSecret: String): Server { + private suspend fun publishServerStartEvents(server: ServerDefinition, startCause: ServerStartCause) { + pubSubClient.publish( + "event", ServerStartEvent.newBuilder() + .setServer(server) + .setStartedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setStartCause(startCause) + .build() + ) + + pubSubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = "${server.groupName} #${server.numericalId}" + }, + metricMeta { + dataName = "status" + dataValue = "STARTED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "SERVER" + }, + metricMeta { + dataName = "groupName" + dataValue = server.groupName + }, + metricMeta { + dataName = "numericalId" + dataValue = server.numericalId.toString() + }, + metricMeta { + dataName = "by" + dataValue = startCause.toString() + } + ) + ) + }) + } + + private fun buildServer(group: Group, numericalId: Int): Server { return Server.fromDefinition( ServerDefinition.newBuilder() .setNumericalId(numericalId) @@ -192,13 +317,12 @@ class ServerService( .setMaximumMemory(group.maxMemory) .setServerState(ServerState.PREPARING) .setMaxPlayers(group.maxPlayers) - .setCreatedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) - .setUpdatedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setCreatedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setUpdatedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) .setPlayerCount(0) .setUniqueId(UUID.randomUUID().toString().replace("-", "")).putAllCloudProperties( mapOf( - *group.properties.entries.map { it.key to it.value }.toTypedArray(), - "forwarding-secret" to forwardingSecret + *group.properties.entries.map { it.key to it.value }.toTypedArray() ) ).build() ) @@ -207,6 +331,14 @@ class ServerService( override suspend fun stopServer(request: StopServerRequest): ServerDefinition { val server = serverRepository.find(request.serverId) ?: throw StatusException(Status.NOT_FOUND.withDescription("No server was found matching this id.")) + + request.since?.let { sinceTimestamp -> + val sinceLocalDateTime = ProtobufTimestamp.toLocalDateTime(sinceTimestamp) + if (server.createdAt.isBefore(sinceLocalDateTime)) { + return server.toDefinition() + } + } + try { val stopped = stopServer(server.toDefinition(), request.stopCause) return stopped @@ -215,22 +347,106 @@ class ServerService( } } - private suspend fun stopServer(server: ServerDefinition, cause: ServerStopCause = ServerStopCause.NATURAL_STOP): ServerDefinition { + override suspend fun stopServersByGroupWithTimeout(request: StopServersByGroupWithTimeoutRequest): StopServersByGroupResponse { + val sinceLocalDateTime = request.since?.let { + ProtobufTimestamp.toLocalDateTime(it) + } + return stopServersByGroup(request.groupName, request.timeoutSeconds, request.stopCause, sinceLocalDateTime) + } + + override suspend fun stopServersByGroup(request: StopServersByGroupRequest): StopServersByGroupResponse { + val sinceLocalDateTime = request.since?.let { + ProtobufTimestamp.toLocalDateTime(it) + } + return stopServersByGroup(request.groupName, null, request.stopCause, sinceLocalDateTime) + } + + private suspend fun stopServersByGroup( + groupName: String, + timeout: Int?, + cause: ServerStopCause = ServerStopCause.NATURAL_STOP, + since: LocalDateTime? = null + ): StopServersByGroupResponse { + val group = groupRepository.find(groupName) + ?: throw StatusException(Status.NOT_FOUND.withDescription("No group was found matching this name. $groupName")) + val groupServers = serverRepository.findServersByGroup(group.name) + .filter { since == null || it.createdAt.isAfter(since) } + + if (groupServers.isEmpty()) { + throw StatusException(Status.NOT_FOUND.withDescription("No server was found matching this group name. ${group.name}")) + } + + val serverDefinitionList = mutableListOf() + + try { + timeout?.let { + group.timeout = GroupTimeout(it); + } + + groupServers.forEach { server -> + serverDefinitionList.add(stopServer(server.toDefinition(), cause)) + } + + return stopServersByGroupResponse { servers.addAll(serverDefinitionList) } + } catch (e: Exception) { + throw StatusException(Status.INTERNAL.withDescription("Error whilst stopping server by group").withCause(e)) + } + } + + private suspend fun stopServer( + server: ServerDefinition, + cause: ServerStopCause = ServerStopCause.NATURAL_STOP + ): ServerDefinition { val host = hostRepository.findServerHostById(server.hostId) ?: throw Status.NOT_FOUND .withDescription("No server host was found matching this server.") .asRuntimeException() - val stub = host.stub + val stub = host.stub ?: throw StatusException(Status.INTERNAL.withDescription("Server host has no stub")) try { val stopped = stub.stopServer(server) pubSubClient.publish( "event", ServerStopEvent.newBuilder() .setServer(stopped) - .setStoppedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setStoppedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) .setStopCause(cause) .setTerminationMode(ServerTerminationMode.UNKNOWN_MODE) //TODO: Add proto fields to make changing this possible .build() ) + + pubSubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = "${server.groupName} #${server.numericalId}" + }, + metricMeta { + dataName = "status" + dataValue = "STOPPED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "SERVER" + }, + metricMeta { + dataName = "groupName" + dataValue = server.groupName + }, + metricMeta { + dataName = "numericalId" + dataValue = server.numericalId.toString() + }, + metricMeta { + dataName = "by" + dataValue = cause.toString() + } + ) + ) + }) + serverRepository.delete(Server.fromDefinition(stopped)) return stopped } catch (e: Exception) { @@ -242,14 +458,55 @@ class ServerService( override suspend fun updateServerProperty(request: UpdateServerPropertyRequest): ServerDefinition { val server = serverRepository.find(request.serverId) ?: throw StatusException(Status.NOT_FOUND.withDescription("Server with id ${request.serverId} does not exist.")) - val serverBefore = server.copy() + val serverBefore = Server.fromDefinition(server.toDefinition()) server.properties[request.propertyKey] = request.propertyValue serverRepository.save(server) - pubSubClient.publish( - "event", - ServerUpdateEvent.newBuilder().setUpdatedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) - .setServerBefore(serverBefore.toDefinition()).setServerAfter(server.toDefinition()).build() - ) + + if (serverBefore.properties[request.propertyKey] != server.properties[request.propertyKey]) { + pubSubClient.publish( + "event", + ServerUpdateEvent.newBuilder() + .setUpdatedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) + .setServerBefore(serverBefore.toDefinition()) + .setServerAfter(server.toDefinition()) + .build() + ) + + pubSubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = "${server.group} #${server.numericalId}" + }, + metricMeta { + dataName = "status" + dataValue = "EDITED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "SERVER" + }, + metricMeta { + dataName = "groupName" + dataValue = server.group + }, + metricMeta { + dataName = "numericalId" + dataValue = server.numericalId.toString() + }, + metricMeta { + dataName = "by" + dataValue = "API" + } + ) + ) + }) + } + return server.toDefinition() } @@ -261,9 +518,44 @@ class ServerService( serverRepository.save(server) pubSubClient.publish( "event", - ServerUpdateEvent.newBuilder().setUpdatedAt(ProtoBufTimestamp.fromLocalDateTime(LocalDateTime.now())) + ServerUpdateEvent.newBuilder().setUpdatedAt(ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now())) .setServerBefore(serverBefore.toDefinition()).setServerAfter(server.toDefinition()).build() ) + + pubSubClient.publish(MetricsEventNames.RECORD_METRIC, metric { + metricType = "ACTIVITY_LOG" + metricValue = 1L + time = ProtobufTimestamp.fromLocalDateTime(LocalDateTime.now()) + meta.addAll( + listOf( + metricMeta { + dataName = "displayName" + dataValue = "${server.group} #${server.numericalId}" + }, + metricMeta { + dataName = "status" + dataValue = "EDITED" + }, + metricMeta { + dataName = "resourceType" + dataValue = "SERVER" + }, + metricMeta { + dataName = "groupName" + dataValue = server.group + }, + metricMeta { + dataName = "numericalId" + dataValue = server.numericalId.toString() + }, + metricMeta { + dataName = "by" + dataValue = "API" + } + ) + ) + }) + return server.toDefinition() } diff --git a/controller-shared/build.gradle.kts b/controller-shared/build.gradle.kts index 9a3361e..4998624 100644 --- a/controller-shared/build.gradle.kts +++ b/controller-shared/build.gradle.kts @@ -1,7 +1,7 @@ dependencies { - api(rootProject.libs.bundles.proto) - api(rootProject.libs.simpleCloudPubSub) + api(rootProject.libs.simplecloud.pubsub) api(rootProject.libs.bundles.configurate) api(rootProject.libs.clikt) - api(rootProject.libs.kotlinCoroutines) + api(rootProject.libs.kotlin.coroutines) + api(rootProject.libs.simplecloud.droplet.api) } diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/MetadataKeys.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/MetadataKeys.kt deleted file mode 100644 index bf9c277..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/MetadataKeys.kt +++ /dev/null @@ -1,9 +0,0 @@ -package app.simplecloud.controller.shared - -import io.grpc.Metadata - -object MetadataKeys { - - val AUTH_SECRET_KEY = Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER) - -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthCallCredentials.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthCallCredentials.kt deleted file mode 100644 index 8ffa976..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthCallCredentials.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.simplecloud.controller.shared.auth - -import app.simplecloud.controller.shared.MetadataKeys -import io.grpc.CallCredentials -import io.grpc.Metadata -import java.util.concurrent.Executor - -class AuthCallCredentials( - private val secretKey: String -): CallCredentials() { - - override fun applyRequestMetadata( - requestInfo: RequestInfo, - appExecutor: Executor, - applier: MetadataApplier - ) { - appExecutor.execute { - val headers = Metadata() - headers.put(MetadataKeys.AUTH_SECRET_KEY, secretKey) - applier.apply(headers) - } - } - -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthSecretInterceptor.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthSecretInterceptor.kt deleted file mode 100644 index 290c710..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/auth/AuthSecretInterceptor.kt +++ /dev/null @@ -1,24 +0,0 @@ -package app.simplecloud.controller.shared.auth - -import app.simplecloud.controller.shared.MetadataKeys -import io.grpc.* - -class AuthSecretInterceptor( - private val secretKey: String -) : ServerInterceptor { - - override fun interceptCall( - call: ServerCall, - headers: Metadata, - next: ServerCallHandler - ): ServerCall.Listener { - val secretKey = headers.get(MetadataKeys.AUTH_SECRET_KEY) - if (this.secretKey != secretKey) { - call.close(Status.UNAUTHENTICATED, headers) - return object : ServerCall.Listener() {} - } - - return Contexts.interceptCall(Context.current(), call, headers, next) - } - -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt deleted file mode 100644 index 013f43d..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureAdapter.kt +++ /dev/null @@ -1,41 +0,0 @@ -package app.simplecloud.controller.shared.future - -import com.google.common.util.concurrent.FutureCallback -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture -import java.util.concurrent.CompletableFuture -import java.util.concurrent.ForkJoinPool - - -class ListenableFutureAdapter( - val listenableFuture: ListenableFuture -) { - - val completableFuture: CompletableFuture = object : CompletableFuture() { - override fun cancel(mayInterruptIfRunning: Boolean): Boolean { - val cancelled = listenableFuture.cancel(mayInterruptIfRunning) - super.cancel(cancelled) - return cancelled - } - } - - init { - Futures.addCallback(listenableFuture, object : FutureCallback { - override fun onSuccess(result: T) { - completableFuture.complete(result) - } - - override fun onFailure(ex: Throwable) { - completableFuture.completeExceptionally(ex) - } - }, ForkJoinPool.commonPool()) - } - - companion object { - fun toCompletable(listenableFuture: ListenableFuture): CompletableFuture { - val listenableFutureAdapter: ListenableFutureAdapter = ListenableFutureAdapter(listenableFuture) - return listenableFutureAdapter.completableFuture - } - } - -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureExtension.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureExtension.kt deleted file mode 100644 index f12685d..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/future/ListenableFutureExtension.kt +++ /dev/null @@ -1,8 +0,0 @@ -package app.simplecloud.controller.shared.future - -import com.google.common.util.concurrent.ListenableFuture -import java.util.concurrent.CompletableFuture - -fun ListenableFuture.toCompletable(): CompletableFuture { - return ListenableFutureAdapter.toCompletable(this) -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt index 034784a..0cff3cc 100644 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/Group.kt @@ -15,9 +15,12 @@ data class Group( val maxOnlineCount: Long = 0, val maxPlayers: Long = 0, val newServerPlayerRatio: Long = -1, - val properties: Map = mapOf() + val properties: Map = mutableMapOf() ) { + @Transient + var timeout: GroupTimeout? = null + fun toDefinition(): GroupDefinition { return GroupDefinition.newBuilder() .setName(name) diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/GroupTimeout.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/GroupTimeout.kt new file mode 100644 index 0000000..5e216b4 --- /dev/null +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/group/GroupTimeout.kt @@ -0,0 +1,16 @@ +package app.simplecloud.controller.shared.group + +data class GroupTimeout(val timeoutDuration: Int, val timeoutBegin: Long = System.currentTimeMillis()) { + + fun isCooldownActive(): Boolean { + val cooldownEndTime = timeoutBegin + timeoutDuration * 1000L + return System.currentTimeMillis() < cooldownEndTime + } + + fun remainingTimeInSeconds(): Long { + val cooldownEndTime = timeoutBegin + timeoutDuration * 1000L + val remainingTime = cooldownEndTime - System.currentTimeMillis() + return if (remainingTime > 0) remainingTime / 1000 else 0 + } + +} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt index d5aaefd..d7db03c 100644 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/host/ServerHost.kt @@ -1,18 +1,16 @@ package app.simplecloud.controller.shared.host -import app.simplecloud.controller.shared.auth.AuthCallCredentials +import app.simplecloud.droplet.api.auth.AuthCallCredentials import build.buf.gen.simplecloud.controller.v1.ServerHostDefinition import build.buf.gen.simplecloud.controller.v1.ServerHostServiceGrpcKt import io.grpc.ManagedChannel import io.grpc.ManagedChannelBuilder -import org.spongepowered.configurate.objectmapping.ConfigSerializable -@ConfigSerializable data class ServerHost( val id: String, val host: String, val port: Int, - val stub: ServerHostServiceGrpcKt.ServerHostServiceCoroutineStub, + val stub: ServerHostServiceGrpcKt.ServerHostServiceCoroutineStub? = null, ) { fun toDefinition(): ServerHostDefinition { diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/AuthFileSecretFactory.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/AuthFileSecretFactory.kt deleted file mode 100644 index bd0d540..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/AuthFileSecretFactory.kt +++ /dev/null @@ -1,28 +0,0 @@ -package app.simplecloud.controller.shared.secret - -import java.nio.file.Files -import java.nio.file.Path - -object AuthFileSecretFactory { - - fun loadOrCreate(path: Path): String { - if (!Files.exists(path)) { - return create(path) - } - - return Files.readString(path) - } - - - private fun create(path: Path): String { - val secret = SecretGenerator.generate() - - if (!Files.exists(path)) { - path.parent?.let { Files.createDirectories(it) } - Files.writeString(path, secret) - } - - return secret - } - -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/SecretGenerator.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/SecretGenerator.kt deleted file mode 100644 index 8c18bd3..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/secret/SecretGenerator.kt +++ /dev/null @@ -1,15 +0,0 @@ -package app.simplecloud.controller.shared.secret - -import java.security.SecureRandom -import java.util.* - -object SecretGenerator { - - fun generate(size: Int = 64): String { - val random = SecureRandom() - val bytes = ByteArray(size) - random.nextBytes(bytes) - return Base64.getEncoder().encodeToString(bytes) - } - -} \ No newline at end of file diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt index 58f70cf..cce4dcd 100644 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt +++ b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/server/Server.kt @@ -1,12 +1,10 @@ package app.simplecloud.controller.shared.server -import app.simplecloud.controller.shared.time.ProtoBufTimestamp +import app.simplecloud.droplet.api.time.ProtobufTimestamp import build.buf.gen.simplecloud.controller.v1.ServerDefinition import build.buf.gen.simplecloud.controller.v1.ServerState import build.buf.gen.simplecloud.controller.v1.ServerType -import java.time.Instant import java.time.LocalDateTime -import java.time.ZoneId data class Server( val uniqueId: String, @@ -40,8 +38,8 @@ data class Server( .setMaxPlayers(maxPlayers) .putAllCloudProperties(properties) .setNumericalId(numericalId) - .setCreatedAt(ProtoBufTimestamp.fromLocalDateTime(createdAt)) - .setUpdatedAt(ProtoBufTimestamp.fromLocalDateTime(updatedAt)) + .setCreatedAt(ProtobufTimestamp.fromLocalDateTime(createdAt)) + .setUpdatedAt(ProtobufTimestamp.fromLocalDateTime(updatedAt)) .build() } @@ -83,8 +81,8 @@ data class Server( serverDefinition.playerCount, serverDefinition.cloudPropertiesMap, serverDefinition.serverState, - ProtoBufTimestamp.toLocalDateTime(serverDefinition.createdAt), - ProtoBufTimestamp.toLocalDateTime(serverDefinition.updatedAt), + ProtobufTimestamp.toLocalDateTime(serverDefinition.createdAt), + ProtobufTimestamp.toLocalDateTime(serverDefinition.updatedAt), ) } diff --git a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/time/ProtoBufTimestamp.kt b/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/time/ProtoBufTimestamp.kt deleted file mode 100644 index f2c5d39..0000000 --- a/controller-shared/src/main/kotlin/app/simplecloud/controller/shared/time/ProtoBufTimestamp.kt +++ /dev/null @@ -1,21 +0,0 @@ -package app.simplecloud.controller.shared.time - -import com.google.protobuf.Timestamp -import java.time.Instant -import java.time.LocalDateTime -import java.time.ZoneId - -object ProtoBufTimestamp { - fun toLocalDateTime(timestamp: Timestamp): LocalDateTime { - return LocalDateTime.ofInstant(Instant.ofEpochSecond(timestamp.seconds, timestamp.nanos.toLong()), ZoneId.systemDefault()) - } - - fun fromLocalDateTime(localDateTime: LocalDateTime): Timestamp { - val instant = localDateTime.atZone(ZoneId.systemDefault()).toInstant() - - return Timestamp.newBuilder() - .setSeconds(instant.epochSecond) - .setNanos(instant.nano) - .build() - } -} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 600fb9c..e550a9e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,79 +1,69 @@ [versions] kotlin = "2.0.20" -kotlinCoroutines = "1.9.0" +kotlin-coroutines = "1.9.0" shadow = "8.3.3" log4j = "2.20.0" -protobuf = "3.25.2" -grpc = "1.61.0" -grpcKotlin = "1.4.1" -simpleCloudProtoSpecs = "1.4.1.1.20241001163139.58018cb317ed" -simpleCloudPubSub = "1.0.5" +droplet-api = "0.0.1-dev.16b322c" +simplecloud-pubsub = "1.0.5" +simplecloud-metrics = "1.0.0" jooq = "3.19.3" configurate = "4.1.2" -sqliteJdbc = "3.44.1.0" -clikt = "4.3.0" -sonatypeCentralPortalPublisher = "1.2.3" -spotifyCompletableFutures = "0.3.6" +sqlite-jdbc = "3.44.1.0" +clikt = "5.0.1" +sonatype-central-portal-publisher = "1.2.3" +spotify-completablefutures = "0.3.6" +spring-crypto = "6.3.4" +envoy = "1.0.46" [libraries] -kotlinJvm = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } -kotlinTest = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } -kotlinCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinCoroutines" } +kotlin-jvm = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } +kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } -log4jCore = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } -log4jApi = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } -log4jSlf4j = { module = "org.apache.logging.log4j:log4j-slf4j-impl", version.ref = "log4j" } +log4j-core = { module = "org.apache.logging.log4j:log4j-core", version.ref = "log4j" } +log4j-api = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j" } +log4j-slf4j = { module = "org.apache.logging.log4j:log4j-slf4j-impl", version.ref = "log4j" } -protobufKotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } +simplecloud-droplet-api = { module = "app.simplecloud.droplet.api:droplet-api", version.ref = "droplet-api" } +simplecloud-pubsub = { module = "app.simplecloud:simplecloud-pubsub", version.ref = "simplecloud-pubsub" } +simplecloud-metrics = { module = "app.simplecloud:internal-metrics-api", version.ref = "simplecloud-metrics" } -grpcStub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } -grpcKotlinStub = { module = "io.grpc:grpc-kotlin-stub", version.ref = "grpcKotlin" } -grpcProtobuf = { module = "io.grpc:grpc-protobuf", version.ref = "grpc" } -grpcNettyShaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc" } +jooq = { module = "org.jooq:jooq-kotlin", version.ref = "jooq" } +jooq-meta = { module = "org.jooq:jooq-meta", version.ref = "jooq" } +jooq-meta-extensions = { module = "org.jooq:jooq-meta-extensions", version.ref = "jooq" } +jooq-kotlin-coroutines = { module = "org.jooq:jooq-kotlin-coroutines", version.ref = "jooq" } -simpleCloudProtoSpecs = { module = "build.buf.gen:simplecloud_proto-specs_grpc_kotlin", version.ref = "simpleCloudProtoSpecs" } -simpleCloudPubSub = { module = "app.simplecloud:simplecloud-pubsub", version.ref = "simpleCloudPubSub" } +configurate-yaml = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate" } +configurate-extra-kotlin = { module = "org.spongepowered:configurate-extra-kotlin", version.ref = "configurate" } -qooq = { module = "org.jooq:jooq-kotlin", version.ref = "jooq" } -qooqMeta = { module = "org.jooq:jooq-meta", version.ref = "jooq" } -jooqMetaExtensions = { module = "org.jooq:jooq-meta-extensions", version.ref = "jooq" } -jooqKotlinCoroutines = { module = "org.jooq:jooq-kotlin-coroutines", version.ref = "jooq" } +sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite-jdbc" } -configurateYaml = { module = "org.spongepowered:configurate-yaml", version.ref = "configurate" } -configurateExtraKotlin = { module = "org.spongepowered:configurate-extra-kotlin", version.ref = "configurate" } +clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } -sqliteJdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqliteJdbc" } +spotify-completablefutures = { module = "com.spotify:completable-futures", version.ref = "spotify-completablefutures" } -clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } +spring-crypto = { module = "org.springframework.security:spring-security-crypto", version.ref = "spring-crypto" } +envoy-controlplane = { module = "io.envoyproxy.controlplane:server", version.ref = "envoy" } -spotifyCompletableFutures = { module = "com.spotify:completable-futures", version.ref = "spotifyCompletableFutures" } [bundles] log4j = [ - "log4jCore", - "log4jApi", - "log4jSlf4j" -] -proto = [ - "protobufKotlin", - "grpcStub", - "grpcKotlinStub", - "grpcProtobuf", - "grpcNettyShaded", - "simpleCloudProtoSpecs", + "log4j-core", + "log4j-api", + "log4j-slf4j" ] jooq = [ - "qooq", - "qooqMeta", - "jooqKotlinCoroutines" + "jooq", + "jooq-meta", + "jooq-kotlin-coroutines" ] configurate = [ - "configurateYaml", - "configurateExtraKotlin" + "configurate-yaml", + "configurate-extra-kotlin" ] [plugins] kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } -jooqCodegen = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" } -sonatypeCentralPortalPublisher = { id = "net.thebugmc.gradle.sonatype-central-portal-publisher", version.ref = "sonatypeCentralPortalPublisher" } \ No newline at end of file +jooq-codegen = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" } +sonatype-central-portal-publisher = { id = "net.thebugmc.gradle.sonatype-central-portal-publisher", version.ref = "sonatype-central-portal-publisher" } \ No newline at end of file diff --git a/readme.md b/readme.md index e7394f5..94efe8a 100644 --- a/readme.md +++ b/readme.md @@ -1,27 +1,101 @@ -# SimpleCloud v3 Controller +# Controller -Process that (automatically) manages minecraft server deployments (across multiple root-servers). -At least one [ServerHost](#serverhosts) is needed to actually start servers. -> Please visit [our documentation](https://docs.simplecloud.app/controller) to learn how exactly it works +![Banner][banner] +
+ +[![Modrinth][badge-modrinth]][modrinth] +[![Dev][badge-dev]][dev] +[![License][badge-license]][license] +
+ +[![Discord][badge-discord]][social-discord] +[![Follow @simplecloudapp][badge-x]][social-x] +[![Follow @simplecloudapp][badge-bluesky]][social-bluesky] +[![Follow @simplecloudapp][badge-youtube]][social-youtube] +
+ +[Report a Bug][issue-bug-report] +ยท +[Request a Feature][issue-feature-request] +
+ +๐ŸŒŸ Give us a star โ€” your support means the world to us! +
+
+ +> All information about this project can be found in our detailed [documentation][docs-thisproject]. + +The controller is a small program that keeps track of server groups and their online servers and manages them. It's the heart of SimpleCloud v3. ## Features - [x] Reconciler (auto-deploying for servers) -- [x] [API](#api-usage) using [gRPC](https://grpc.io/) +- [x] API using [gRPC](https://grpc.io/) - [x] Server cache [SQL](https://en.wikipedia.org/wiki/SQL)-Database (any dialect) +- [ ] Multi controller support +## Dependency + +> For always up-to-date artifacts visit [dev artifacts][dev-artifacts] or [artifacts][artifacts]. + +> Note: If you want to use the dev version, you have to use the [snapshot repository][snapshots]. + +### Gradle Kotlin +```kt +implementation("app.simplecloud.controller:controller-api:VERSION") +``` +### Gradle Groovy +```groovy +implementation 'app.simplecloud.controller:controller-api:VERSION' +``` + +### Maven +```xml + + app.simplecloud.controller + controller-api + VERSION + +``` + +## Contributing +Contributions to SimpleCloud are welcome and highly appreciated. However, before you jump right into it, we would like you to read our [Contribution Guide][docs-contribute]. + +## License +This repository is licensed under [Apache 2.0][license]. + + + + + +[banner]: https://raw.githubusercontent.com/simplecloudapp/branding/refs/heads/main/readme/banner/controller.png +[issue-bug-report]: https://github.com/theSimpleCloud/simplecloud-controller/issues/new?labels=bug&projects=template=01_BUG-REPORT.yml&title=%5BBUG%5D+%3Ctitle%3E +[issue-feature-request]: https://github.com/theSimpleCloud/simplecloud-controller/discussions/new?category=ideas +[docs-thisproject]: https://docs.simplecloud.app/controller +[docs-contribute]: https://docs.simplecloud.app/contribute -## ServerHosts +[modrinth]: https://modrinth.com/organization/simplecloud +[maven-central]: https://central.sonatype.com/artifact/app.simplecloud.controller/controller-api +[dev]: https://repo.simplecloud.app/#/snapshots/app/simplecloud/controller/controller-api -ServerHosts are processes, that directly handle minecraft server deployments. Each root-server should have exactly one -ServerHost online. We provide a [default implementation](), -however, you can write your [own implementation](). You can have as many ServerHost instances as you like. -## API usage +[artifacts]: https://repo.simplecloud.app/#/snapshots/app/simplecloud/controller/controller-api +[dev-artifacts]: https://repo.simplecloud.app/#/snapshots/app/simplecloud/controller/controller-api -> If you are searching for documentation, please visit our [official documentation](https://docs.simplecloud.app/api) +[badge-maven-central]: https://img.shields.io/maven-central/v/app.simplecloud.controller/controller-api?labelColor=18181b&style=flat-square&color=65a30d&label=Release +[badge-dev]: https://repo.simplecloud.app/api/badge/latest/snapshots/app/simplecloud/controller/controller-api?name=Dev&style=flat-square&color=0ea5e9 -The SimpleCloud v3 Controller provides API for both server groups and actual servers. -The group API is used for [CRUD-Operations](https://en.wikipedia.org/wiki/Create,_read,_update_and_delete) of server -groups, whereas the server API is used to manage running servers or starting new ones. + +[license]: https://opensource.org/licenses/Apache-2.0 +[snapshots]: https://repo.simplecloud.app/#/snapshots +[social-x]: https://x.com/simplecloudapp +[social-bluesky]: https://bsky.app/profile/simplecloud.app +[social-youtube]: https://www.youtube.com/@thesimplecloud9075 +[social-discord]: https://discord.simplecloud.app +[badge-modrinth]: https://img.shields.io/badge/modrinth-18181b.svg?style=flat-square&logo=modrinth +[badge-license]: https://img.shields.io/badge/apache%202.0-blue.svg?style=flat-square&label=license&labelColor=18181b&style=flat-square&color=e11d48 +[badge-discord]: https://img.shields.io/badge/Community_Discord-d95652.svg?style=flat-square&logo=discord&color=27272a +[badge-x]: https://img.shields.io/badge/Follow_@simplecloudapp-d95652.svg?style=flat-square&logo=x&color=27272a +[badge-bluesky]: https://img.shields.io/badge/Follow_@simplecloud.app-d95652.svg?style=flat-square&logo=bluesky&color=27272a +[badge-youtube]: https://img.shields.io/badge/youtube-d95652.svg?style=flat-square&logo=youtube&color=27272a \ No newline at end of file diff --git a/test-envoy/docker-compose.yml b/test-envoy/docker-compose.yml new file mode 100644 index 0000000..585ac11 --- /dev/null +++ b/test-envoy/docker-compose.yml @@ -0,0 +1,6 @@ +services: + envoy: + network_mode: "host" + image: envoyproxy/envoy:v1.31.4 + volumes: + - ./envoy-bootstrap.yaml:/etc/envoy/envoy.yaml \ No newline at end of file diff --git a/test-envoy/envoy-bootstrap.yaml b/test-envoy/envoy-bootstrap.yaml new file mode 100644 index 0000000..3bd962a --- /dev/null +++ b/test-envoy/envoy-bootstrap.yaml @@ -0,0 +1,45 @@ +node: + cluster: simplecloud + id: simplecloud + +dynamic_resources: + ads_config: + api_type: GRPC + transport_api_version: V3 + grpc_services: + - envoy_grpc: + cluster_name: ads_cluster + cds_config: + ads: {} + lds_config: + ads: {} + +static_resources: + clusters: + - name: ads_cluster + lb_policy: ROUND_ROBIN + type: STRICT_DNS + load_assignment: + cluster_name: ads_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: 127.0.0.1 + port_value: 5814 + # It is recommended to configure either HTTP/2 or TCP keepalives in order to detect + # connection issues, and allow Envoy to reconnect. TCP keepalive is less expensive, but + # may be inadequate if there is a TCP proxy between Envoy and the management server. + # HTTP/2 keepalive is slightly more expensive, but may detect issues through more types + # of intermediate proxies. + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: + connection_keepalive: + interval: 30s + timeout: 5s + upstream_connection_options: + tcp_keepalive: {} \ No newline at end of file