diff --git a/build.gradle.kts b/build.gradle.kts index 70fe804..fd3af68 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,6 +20,13 @@ dependencies { implementation(compose.desktop.currentOs) implementation(compose.material3) implementation(compose.runtime) + + // Ktor server dependencies + implementation("io.ktor:ktor-server-core:2.3.6") + implementation("io.ktor:ktor-server-netty:2.3.6") + implementation("io.ktor:ktor-server-content-negotiation:2.3.6") + implementation("io.ktor:ktor-serialization-jackson:2.3.6") + implementation("ch.qos.logback:logback-classic:1.4.11") // Testing testImplementation(kotlin("test")) diff --git a/src/main/kotlin/org/game2048/Main.kt b/src/main/kotlin/org/game2048/Main.kt index d267bf7..df1911a 100644 --- a/src/main/kotlin/org/game2048/Main.kt +++ b/src/main/kotlin/org/game2048/Main.kt @@ -39,6 +39,7 @@ import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.AnimatedVisibility +import org.game2048.api.CommandApi import org.game2048.engine.GameEngine import org.game2048.engine.GameEngineImpl import org.game2048.model.Board @@ -46,6 +47,16 @@ import org.game2048.model.GameState import org.game2048.model.Move fun main() = application { + // Initialize and start the command API server + val commandApi = remember { CommandApi(port = 8080) } + + DisposableEffect(Unit) { + commandApi.start() + onDispose { + commandApi.stop() + } + } + val windowState = remember { WindowState( size = DpSize(550.dp, 700.dp) @@ -53,7 +64,10 @@ fun main() = application { } Window( - onCloseRequest = ::exitApplication, + onCloseRequest = { + commandApi.stop() + exitApplication() + }, title = "2048 Game", state = windowState, resizable = true, diff --git a/src/main/kotlin/org/game2048/api/CommandApi.kt b/src/main/kotlin/org/game2048/api/CommandApi.kt new file mode 100644 index 0000000..c80cac3 --- /dev/null +++ b/src/main/kotlin/org/game2048/api/CommandApi.kt @@ -0,0 +1,199 @@ +package org.game2048.api + +import io.ktor.http.* +import io.ktor.serialization.jackson.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.TimeUnit + +/** + * Data class representing a command execution request. + */ +data class CommandRequest( + val command: String, + val timeoutSeconds: Int = 30 +) { + /** + * Validates the request parameters. + * @throws IllegalArgumentException if validation fails + */ + fun validate() { + require(command.isNotBlank()) { "Command cannot be empty" } + require(timeoutSeconds in 1..300) { "Timeout must be between 1 and 300 seconds" } + } +} + +/** + * Data class representing a command execution response. + */ +data class CommandResponse( + val output: String, + val exitCode: Int, + val error: String? = null +) + +/** + * Class responsible for setting up and managing the command execution API. + */ +class CommandApi(private val port: Int = 8080) { + private var server: ApplicationEngine? = null + private val logger = LoggerFactory.getLogger(CommandApi::class.java) + + // List of potentially dangerous commands that should be blocked + private val blockedCommands = listOf( + "rm -rf", "mkfs", "dd", ":(){ :|:& };:", "chmod -R 777 /", + "> /dev/sda", "/dev/null", "mv / /dev/null" + ) + + /** + * Starts the API server. + */ + fun start() { + server = embeddedServer(Netty, port = port) { + install(ContentNegotiation) { + jackson() + } + + routing { + route("/api") { + post("/execute") { + try { + val request = call.receive() + + // Validate request + try { + request.validate() + } catch (e: IllegalArgumentException) { + logger.warn("Invalid request: ${e.message}") + call.respond( + HttpStatusCode.BadRequest, + CommandResponse( + output = "", + exitCode = -1, + error = "Invalid request: ${e.message}" + ) + ) + return@post + } + + // Check for dangerous commands + if (isCommandBlocked(request.command)) { + logger.warn("Blocked potentially dangerous command: ${request.command}") + call.respond( + HttpStatusCode.Forbidden, + CommandResponse( + output = "", + exitCode = -1, + error = "Command execution blocked for security reasons" + ) + ) + return@post + } + + logger.info("Executing command: ${request.command}") + val result = executeCommand(request.command, request.timeoutSeconds) + call.respond(HttpStatusCode.OK, result) + } catch (e: Exception) { + logger.error("Error processing request", e) + call.respond( + HttpStatusCode.InternalServerError, + CommandResponse( + output = "", + exitCode = -1, + error = "Error executing command: ${e.message}" + ) + ) + } + } + } + } + }.start(wait = false) + + logger.info("Command API server started on port $port") + println("Command API server started on port $port") + } + + /** + * Stops the API server. + */ + fun stop() { + server?.stop(1, 5, TimeUnit.SECONDS) + logger.info("Command API server stopped") + println("Command API server stopped") + } + + /** + * Checks if a command contains blocked patterns. + * + * @param command The command to check + * @return true if the command is blocked, false otherwise + */ + private fun isCommandBlocked(command: String): Boolean { + val lowercaseCommand = command.lowercase() + return blockedCommands.any { blockedCmd -> + lowercaseCommand.contains(blockedCmd.lowercase()) + } + } + + /** + * Executes an OS-level command and returns the result. + * + * @param command The command to execute + * @param timeoutSeconds Timeout in seconds + * @return CommandResponse containing the output and exit code + */ + private suspend fun executeCommand(command: String, timeoutSeconds: Int): CommandResponse { + return withContext(Dispatchers.IO) { + try { + logger.debug("Starting command execution: $command") + val process = ProcessBuilder() + .command("sh", "-c", command) + .redirectErrorStream(true) + .start() + + val output = StringBuilder() + BufferedReader(InputStreamReader(process.inputStream)).use { reader -> + var line: String? + while (reader.readLine().also { line = it } != null) { + output.append(line).append("\n") + } + } + + val completed = process.waitFor(timeoutSeconds.toLong(), TimeUnit.SECONDS) + if (!completed) { + process.destroy() + logger.warn("Command execution timed out after $timeoutSeconds seconds: $command") + return@withContext CommandResponse( + output = output.toString(), + exitCode = -1, + error = "Command execution timed out after $timeoutSeconds seconds" + ) + } + + val exitCode = process.exitValue() + logger.debug("Command completed with exit code $exitCode: $command") + CommandResponse( + output = output.toString(), + exitCode = exitCode + ) + } catch (e: Exception) { + logger.error("Error executing command: $command", e) + CommandResponse( + output = "", + exitCode = -1, + error = "Error executing command: ${e.message}" + ) + } + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/org/game2048/api/CommandApiTest.kt b/src/test/kotlin/org/game2048/api/CommandApiTest.kt new file mode 100644 index 0000000..854dedd --- /dev/null +++ b/src/test/kotlin/org/game2048/api/CommandApiTest.kt @@ -0,0 +1,113 @@ +package org.game2048.api + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.Assertions.* +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.registerKotlinModule + +class CommandApiTest { + private lateinit var commandApi: CommandApi + private val testPort = 8081 + private val objectMapper = ObjectMapper().registerKotlinModule() + private val httpClient = HttpClient.newBuilder().build() + + @BeforeEach + fun setup() { + commandApi = CommandApi(port = testPort) + commandApi.start() + // Give the server a moment to start + Thread.sleep(500) + } + + @AfterEach + fun tearDown() { + commandApi.stop() + // Give the server a moment to stop + Thread.sleep(500) + } + + @Test + fun `test execute simple command`() = runBlocking { + // Create request payload + val requestBody = objectMapper.writeValueAsString(CommandRequest( + command = "echo 'Hello, World!'", + timeoutSeconds = 5 + )) + + // Send request to API + val request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:$testPort/api/execute")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // Verify response + assertEquals(200, response.statusCode()) + + val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java) + assertEquals(0, commandResponse.exitCode) + assertTrue(commandResponse.output.contains("Hello, World!")) + assertNull(commandResponse.error) + } + + @Test + fun `test execute invalid command`() = runBlocking { + // Create request payload with empty command + val requestBody = objectMapper.writeValueAsString(CommandRequest( + command = "", + timeoutSeconds = 5 + )) + + // Send request to API + val request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:$testPort/api/execute")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // Verify response + assertEquals(400, response.statusCode()) + + val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java) + assertEquals(-1, commandResponse.exitCode) + assertNotNull(commandResponse.error) + assertTrue(commandResponse.error!!.contains("Command cannot be empty")) + } + + @Test + fun `test blocked command`() = runBlocking { + // Create request payload with a blocked command + val requestBody = objectMapper.writeValueAsString(CommandRequest( + command = "rm -rf /some/path", + timeoutSeconds = 5 + )) + + // Send request to API + val request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:$testPort/api/execute")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build() + + val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) + + // Verify response + assertEquals(403, response.statusCode()) + + val commandResponse = objectMapper.readValue(response.body(), CommandResponse::class.java) + assertEquals(-1, commandResponse.exitCode) + assertNotNull(commandResponse.error) + assertTrue(commandResponse.error!!.contains("blocked for security reasons")) + } +} \ No newline at end of file