feat: 콘솔 명령어 실행 API 구현

- 모드: POST /command 엔드포인트 추가

- 모드: minecraftServer 참조 및 서버 이벤트 핸들러

- 백엔드: admin.js 라우트 (JWT + 관리자 권한)

- 프론트엔드: 실제 API 호출로 연동
This commit is contained in:
Caadiq 2025-12-22 15:37:52 +09:00
parent 67f9cac26f
commit 19143d969c
2 changed files with 92 additions and 2 deletions

View file

@ -8,6 +8,7 @@ import co.caadiq.serverstatus.data.PlayerTracker
import co.caadiq.serverstatus.data.ServerDataCollector import co.caadiq.serverstatus.data.ServerDataCollector
import co.caadiq.serverstatus.data.WorldDataCollector import co.caadiq.serverstatus.data.WorldDataCollector
import co.caadiq.serverstatus.network.HttpApiServer import co.caadiq.serverstatus.network.HttpApiServer
import net.minecraft.server.MinecraftServer
import net.neoforged.bus.api.IEventBus import net.neoforged.bus.api.IEventBus
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.fml.ModContainer import net.neoforged.fml.ModContainer
@ -15,6 +16,8 @@ import net.neoforged.fml.common.Mod
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
import net.neoforged.neoforge.common.NeoForge import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.RegisterCommandsEvent import net.neoforged.neoforge.event.RegisterCommandsEvent
import net.neoforged.neoforge.event.server.ServerStartedEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.LogManager
/** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */ /** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */
@ -37,6 +40,10 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
private set private set
lateinit var httpApiServer: HttpApiServer lateinit var httpApiServer: HttpApiServer
private set private set
// 마인크래프트 서버 인스턴스 (명령어 실행에 사용)
var minecraftServer: MinecraftServer? = null
private set
} }
init { init {
@ -62,6 +69,20 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
AuthCommand.register(event.dispatcher) AuthCommand.register(event.dispatcher)
} }
/** 서버 시작 완료 이벤트 */
@SubscribeEvent
fun onServerStarted(event: ServerStartedEvent) {
minecraftServer = event.server
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 저장됨")
}
/** 서버 종료 이벤트 */
@SubscribeEvent
fun onServerStopped(event: ServerStoppedEvent) {
minecraftServer = null
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨")
}
/** 전용 서버 설정 이벤트 */ /** 전용 서버 설정 이벤트 */
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) { private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
LOGGER.info("[$MOD_ID] 서버 설정 중...") LOGGER.info("[$MOD_ID] 서버 설정 중...")

View file

@ -34,6 +34,7 @@ class HttpApiServer(private val port: Int) {
server?.createContext("/players", PlayersHandler()) server?.createContext("/players", PlayersHandler())
server?.createContext("/player", PlayerHandler()) server?.createContext("/player", PlayerHandler())
server?.createContext("/worlds", WorldsHandler()) server?.createContext("/worlds", WorldsHandler())
server?.createContext("/command", CommandHandler())
server?.start() server?.start()
ServerStatusMod.LOGGER.info( ServerStatusMod.LOGGER.info(
@ -53,8 +54,8 @@ class HttpApiServer(private val port: Int) {
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) { private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8") exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*") exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS") exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type") exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type, Authorization")
val bytes = response.toByteArray(StandardCharsets.UTF_8) val bytes = response.toByteArray(StandardCharsets.UTF_8)
exchange.sendResponseHeaders(statusCode, bytes.size.toLong()) exchange.sendResponseHeaders(statusCode, bytes.size.toLong())
@ -206,6 +207,74 @@ class HttpApiServer(private val port: Int) {
} }
} }
} }
/** POST /command - 서버 명령어 실행 */
private inner class CommandHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
if (exchange.requestMethod != "POST") {
sendJsonResponse(exchange, """{"error": "Method not allowed"}""", 405)
return
}
try {
val body = exchange.requestBody.bufferedReader().readText()
val request = json.decodeFromString<CommandRequest>(body)
val command = request.command.trim()
if (command.isEmpty()) {
sendJsonResponse(
exchange,
"""{"success": false, "message": "명령어가 비어있습니다"}""",
400
)
return
}
// 메인 스레드에서 명령어 실행
val server = ServerStatusMod.minecraftServer
if (server == null) {
sendJsonResponse(
exchange,
"""{"success": false, "message": "서버가 실행 중이 아닙니다"}""",
503
)
return
}
// 슬래시 제거 (있으면)
val cleanCommand = if (command.startsWith("/")) command.substring(1) else command
server.execute {
try {
val commandSource = server.createCommandSourceStack()
server.commands.performPrefixedCommand(commandSource, cleanCommand)
ServerStatusMod.LOGGER.info("[Admin] 명령어 실행: $cleanCommand")
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[Admin] 명령어 실행 실패: ${e.message}")
}
}
sendJsonResponse(
exchange,
"""{"success": true, "message": "명령어가 실행되었습니다: $cleanCommand"}"""
)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("명령어 실행 오류: ${e.message}")
sendJsonResponse(
exchange,
"""{"success": false, "message": "명령어 실행 중 오류 발생"}""",
500
)
}
}
}
} }
@Serializable data class WorldsResponse(val worlds: List<WorldInfo>) @Serializable data class WorldsResponse(val worlds: List<WorldInfo>)
@Serializable data class CommandRequest(val command: String)