Merge pull request #1 from JAJAR94/main

feat: websocket auto detect server info change
This commit is contained in:
2025-07-03 21:56:06 +07:00
committed by GitHub
13 changed files with 248 additions and 36 deletions

View File

@@ -36,6 +36,28 @@ const bootstrap = async () => {
console.log("Default user created"); console.log("Default user created");
}; };
const connectedClients = new Set<any>();
const broadcastServerChange = (action: string, serverType: string, serverId: string) => {
const message = {
type: "SERVER_CHANGE",
action,
serverType,
serverId,
timestamp: new Date().toISOString(),
};
connectedClients.forEach(client => {
try {
client.send(JSON.stringify(message));
} catch (error) {
console.error("Error sending WebSocket message:", error);
connectedClients.delete(client);
}
});
console.log(`Notified Velocity proxy: ${action} ${serverType} ${serverId}`);
};
const app = new Elysia() const app = new Elysia()
.use(swagger({ .use(swagger({
path: '/swagger', path: '/swagger',
@@ -46,6 +68,26 @@ const app = new Elysia()
} }
} }
})) }))
.ws("/ws", {
open(ws) {
const apiKey = ws.data.query.apiKey;
if (!apiKey) {
console.log("apiKey required");
ws.close();
return;
}
connectedClients.add(ws);
console.log("Velocity proxy connected via WebSocket");
},
close(ws) {
connectedClients.delete(ws);
console.log("Velocity proxy disconnected from WebSocket");
},
message(ws, message) {
console.log("Received message from Velocity proxy:", message);
},
})
.group('/api', app => app .group('/api', app => app
.derive(async ({ headers, cookie: { session_token }, path }) => { .derive(async ({ headers, cookie: { session_token }, path }) => {
// Skip token validation for login route // Skip token validation for login route
@@ -150,19 +192,6 @@ const app = new Elysia()
}), }),
} }
) )
.ws("/ws", {
body: t.Object({
message: t.String(),
}),
message(ws, { message }) {
const { id } = ws.data.query;
ws.send({
id,
message,
time: Date.now(),
});
},
})
.post("/logout", async ({ session, cookie: { session_token } }) => { .post("/logout", async ({ session, cookie: { session_token } }) => {
if (!session) return { success: true }; if (!session) return { success: true };
@@ -175,6 +204,24 @@ const app = new Elysia()
}; };
}) })
.get("/servers", async ({ session }) => { .get("/servers", async ({ session }) => {
// Broadcast to all connected WebSocket clients
const message = {
type: "test",
endpoint: "/servers",
timestamp: new Date().toISOString(),
};
connectedClients.forEach(client => {
try {
client.send(JSON.stringify(message));
} catch (error) {
console.error("Error sending WebSocket message:", error);
connectedClients.delete(client);
}
});
console.log(`/servers API called, notified ${connectedClients.size} WebSocket clients`);
return await ServerService.getAllServers(!session); return await ServerService.getAllServers(!session);
}) })
.get("/servers/:id", async ({ session, params: { id } }) => { .get("/servers/:id", async ({ session, params: { id } }) => {
@@ -205,6 +252,8 @@ const app = new Elysia()
memory: body.memory, memory: body.memory,
}); });
broadcastServerChange("CREATE", "SERVER", server.id,);
return { return {
success: true, success: true,
data: { data: {
@@ -252,6 +301,8 @@ const app = new Elysia()
const newServer = await ServerService.getServerById(id, !session); const newServer = await ServerService.getServerById(id, !session);
broadcastServerChange("UPDATE", "SERVER", server.id);
return { return {
success: true, success: true,
data: { data: {
@@ -280,6 +331,8 @@ const app = new Elysia()
where: { id }, where: { id },
}); });
broadcastServerChange("DELETE", "SERVER", server.id);
return { return {
success: true, success: true,
}; };
@@ -314,6 +367,8 @@ const app = new Elysia()
memory: body.memory, memory: body.memory,
}); });
broadcastServerChange("CREATE", "REVERSE_PROXY_SERVER", server.id);
return { return {
success: true, success: true,
data: { data: {
@@ -371,6 +426,8 @@ const app = new Elysia()
!session !session
); );
broadcastServerChange("UPDATE", "REVERSE_PROXY_SERVER", server.id);
return { return {
success: true, success: true,
data: { data: {
@@ -404,6 +461,8 @@ const app = new Elysia()
where: { id }, where: { id },
}); });
broadcastServerChange("DELETE", "REVERSE_PROXY_SERVER", server.id);
return { return {
success: true, success: true,
}; };

BIN
bun.lockb

Binary file not shown.

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
version: '3.8'
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
volumes:
postgres_data:

View File

@@ -41,8 +41,8 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
private val servers: MutableMap<String, RegisteredServer> = HashMap() private val servers: MutableMap<String, RegisteredServer> = HashMap()
private val client = OkHttpClient() private val client = OkHttpClient()
private val apiKey: String = System.getenv("MINIKURA_API_KEY") ?: "" private val apiKey: String = System.getenv("MINIKURA_API_KEY") ?: ""
private val apiUrl: String = System.getenv("MINIKURA_API_URL") ?: "http://localhost:3000" private val apiUrl: String = System.getenv("MINIKURA_API_URL") ?: "http://localhost:3000/api"
private val websocketUrl: String = System.getenv("MINIKURA_WEBSOCKET_URL") ?: "ws://localhost:3000/ws" private val websocketUrl: String = System.getenv("MINIKURA_WEBSOCKET_URL") ?: "ws://localhost:3000/ws?apiKey=$apiKey"
private var acceptingTransfers = AtomicBoolean(false) private var acceptingTransfers = AtomicBoolean(false)
private val redisBungeeApi = RedisBungeeAPI.getRedisBungeeApi() private val redisBungeeApi = RedisBungeeAPI.getRedisBungeeApi()
@@ -87,6 +87,10 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
.plugin(this) .plugin(this)
.build() .build()
val getallServerCommandMeta: CommandMeta = commandManager.metaBuilder("getallserver")
.plugin(this)
.build()
// TODO: Rework this command and support <origin> and <destination> arguments // TODO: Rework this command and support <origin> and <destination> arguments
val migrateCommand = SimpleCommand { p -> val migrateCommand = SimpleCommand { p ->
val source = p.source() val source = p.source()
@@ -114,10 +118,15 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
source.sendMessage(Component.text("Migrating players to server '$targetServerName'...")) source.sendMessage(Component.text("Migrating players to server '$targetServerName'..."))
} }
val getallServerCommand = SimpleCommand { p ->
getallServer()
}
commandManager.register(serversCommandMeta, ServerCommand.createServerCommand(server)) commandManager.register(serversCommandMeta, ServerCommand.createServerCommand(server))
commandManager.register(endCommandMeta, EndCommand.createEndCommand(server)) commandManager.register(endCommandMeta, EndCommand.createEndCommand(server))
commandManager.register(refreshCommandMeta, refreshCommand) commandManager.register(refreshCommandMeta, refreshCommand)
commandManager.register(migrateCommandMeta, migrateCommand) commandManager.register(migrateCommandMeta, migrateCommand)
commandManager.register(getallServerCommandMeta, getallServerCommand)
val connectionHandler = ProxyTransferHandler(servers, logger, acceptingTransfers) val connectionHandler = ProxyTransferHandler(servers, logger, acceptingTransfers)
server.eventManager.register(this, connectionHandler) server.eventManager.register(this, connectionHandler)
@@ -132,6 +141,22 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
logger.info("Minikura-Velocity has been initialized.") logger.info("Minikura-Velocity has been initialized.")
} }
fun refreshServers() {
fetchServers()
fetchReverseProxyServers()
}
fun getallServer() {
synchronized(ServerDataStore) {
ServerDataStore.getServers().forEach { serverData ->
logger.info("Server ID: ${serverData.id}, Type: ${serverData.type}, Description: ${serverData.description}")
}
ServerDataStore.getReverseProxyServers().forEach { reverseProxyServerData ->
logger.info("Reverse Proxy Server ID: ${reverseProxyServerData.id}, External Address: ${reverseProxyServerData.external_address}, External Port: ${reverseProxyServerData.external_port}")
}
}
}
private fun fetchReverseProxyServers() { private fun fetchReverseProxyServers() {
val request = Request.Builder() val request = Request.Builder()
.url("$apiUrl/reverse_proxy_servers") .url("$apiUrl/reverse_proxy_servers")
@@ -207,9 +232,9 @@ class Main @Inject constructor(private val logger: Logger, private val server: P
servers.clear() servers.clear()
for (data in serversData) { for (data in serversData) {
val serverInfo = ServerInfo(data.name, InetSocketAddress(data.address, data.port)) val serverInfo = ServerInfo(data.id, InetSocketAddress("localhost", data.listen_port))
val registeredServer = server.createRawRegisteredServer(serverInfo) val registeredServer = server.createRawRegisteredServer(serverInfo)
servers[data.name] = registeredServer servers[data.id] = registeredServer
this.server.registerServer(registeredServer.serverInfo) this.server.registerServer(registeredServer.serverInfo)
} }
} }

View File

@@ -6,22 +6,105 @@ import org.java_websocket.handshake.ServerHandshake
import org.slf4j.Logger import org.slf4j.Logger
import java.net.URI import java.net.URI
import java.time.Duration import java.time.Duration
import com.google.gson.JsonParser
class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logger, private val server: ProxyServer, serverUri: URI?) : WebSocketClient(serverUri) { class MinikuraWebSocketClient(private val plugin: Main, private val logger: Logger, private val server: ProxyServer, serverUri: URI?) : WebSocketClient(serverUri) {
override fun onOpen(handshakedata: ServerHandshake) { override fun onOpen(handshakedata: ServerHandshake) {
logger.info("Connected to websocket") logger.info("Connected to WebSocket server at: ${uri}")
} }
override fun onMessage(message: String) { override fun onMessage(message: String) {
logger.debug("Received: $message") logger.debug("Received: $message")
try {
val jsonElement = JsonParser.parseString(message)
if (jsonElement.isJsonObject) {
val jsonObject = jsonElement.asJsonObject
val type = jsonObject.get("type")?.asString
val endpoint = jsonObject.get("endpoint")?.asString
val timestamp = jsonObject.get("timestamp")?.asString
when (type) {
"test" -> {
logger.info("API Call detected: endpoint=$endpoint, timestamp=$timestamp")
when (endpoint) {
"/servers" -> {
logger.info("dawdawdawdawd")
}
else -> {
logger.info("API endpoint $endpoint was accessed")
}
}
}
"SERVER_CHANGE" -> {
val action = jsonObject.get("action")?.asString
val serverType = jsonObject.get("serverType")?.asString
val serverId = jsonObject.get("serverId")?.asString
logger.info("Server change detected: action=$action, serverType=$serverType, serverId=$serverId")
when (action) {
"CREATE" -> {
logger.info("Server '$serverId' was created")
executeRefreshCommand()
}
"UPDATE" -> {
logger.info("Server '$serverId' was updated")
executeRefreshCommand()
}
"DELETE" -> {
logger.info("Server '$serverId' was deleted")
executeRefreshCommand()
}
else -> {
logger.info("$action")
}
}
}
else -> {
logger.info("Received WebSocket message of type: $type")
}
}
} else {
logger.info("Received non-JSON WebSocket message: $message")
}
} catch (e: Exception) {
logger.warn("Failed to parse WebSocket message: $message", e)
}
} }
override fun onError(ex: Exception) { override fun onError(ex: Exception) {
ex.printStackTrace() when (ex) {
is java.net.ConnectException -> {
logger.warn("Failed to connect to WebSocket server at: ${uri}")
}
else -> {
logger.error("WebSocket error occurred", ex)
}
}
} }
override fun onClose(code: Int, reason: String, remote: Boolean) { override fun onClose(code: Int, reason: String, remote: Boolean) {
logger.info("Connection closed, attempting to reconnect...") logger.info("WebSocket connection closed (code: $code, reason: $reason)")
server.scheduler.buildTask(plugin, Runnable { reconnect() }).delay(Duration.ofMillis(5000)).schedule() }
private fun executeRefreshCommand() {
try {
server.scheduler.buildTask(plugin, Runnable {
try {
logger.info("Executing automatic server refresh...")
plugin.refreshServers()
plugin.getallServer()
logger.info("Server refresh completed successfully")
} catch (e: Exception) {
logger.error("Failed to refresh servers", e)
}
}).schedule()
} catch (e: Exception) {
logger.error("Failed to schedule refresh command", e)
}
} }
} }

View File

@@ -29,11 +29,11 @@ object ServerDataStore {
} }
fun getServer(name: String): ServerData? { fun getServer(name: String): ServerData? {
return servers.find { it.name == name } return servers.find { it.id == name }
} }
fun getReverseProxyServer(name: String): ReverseProxyServerData? { fun getReverseProxyServer(name: String): ReverseProxyServerData? {
return reverseProxyServers.find { it.name == name } return reverseProxyServers.find { it.id == name }
} }
fun getServers(): List<ServerData> { fun getServers(): List<ServerData> {

View File

@@ -124,9 +124,7 @@ class ProxyTransferHandler(
} }
val sortedServers = ServerDataStore.getServers() val sortedServers = ServerDataStore.getServers()
.filter { it.join_priority != null } .mapNotNull { servers[it.id] }
.sortedBy { it.join_priority }
.mapNotNull { servers[it.name] }
for (server in sortedServers) { for (server in sortedServers) {
if (server != currentServer) { if (server != currentServer) {

View File

@@ -0,0 +1,15 @@
package cafe.kirameki.minikuraVelocity.models
import java.time.LocalDateTime
data class CustomEnvironmentVariableData(
val id: String,
val key: String,
val value: String,
val created_at: String,
val updated_at: String,
val server_id: String? = null,
val server: ServerData? = null,
val reverse_proxy_id: String? = null,
val reverse_proxy_server: ReverseProxyServerData? = null
)

View File

@@ -1,12 +1,18 @@
package cafe.kirameki.minikuraVelocity.models package cafe.kirameki.minikuraVelocity.models
import cafe.kirameki.minikuraVelocity.models.components.ReverseProxyServerType
import java.time.LocalDateTime
data class ReverseProxyServerData( data class ReverseProxyServerData(
val id: String, val id: String,
val name: String, val type: ReverseProxyServerType,
val description: String?, val description: String?,
val address: String, val external_address: String,
val port: Int, val external_port: Int,
val listen_port: Int = 25565,
val memory: String = "512M",
val api_key: String, val api_key: String,
val env_variables: List<CustomEnvironmentVariableData> = emptyList(),
val created_at: String, val created_at: String,
val updated_at: String val updated_at: String
) )

View File

@@ -1,13 +1,15 @@
package cafe.kirameki.minikuraVelocity.models package cafe.kirameki.minikuraVelocity.models
import cafe.kirameki.minikuraVelocity.models.components.ServerType
import java.time.LocalDateTime
data class ServerData( data class ServerData(
val id: String, val id: String,
val name: String, val type: ServerType,
val description: String?, val description: String?,
val address: String, val listen_port: Int = 25565,
val port: Int, val memory: String = "1G",
val type: String, val env_variables: List<CustomEnvironmentVariableData> = emptyList(),
val join_priority: Int?,
val api_key: String, val api_key: String,
val created_at: String, val created_at: String,
val updated_at: String val updated_at: String

View File

@@ -0,0 +1,5 @@
package cafe.kirameki.minikuraVelocity.models.components
enum class ReverseProxyServerType {
VELOCITY, BUNGEECORD
}

View File

@@ -0,0 +1,5 @@
package cafe.kirameki.minikuraVelocity.models.components
enum class ServerType {
STATEFUL, STATELESS,
}

View File

@@ -28,7 +28,7 @@ object ProxyTransferUtils {
lateinit var acceptingTransfers: AtomicBoolean lateinit var acceptingTransfers: AtomicBoolean
fun migratePlayersToServer(targetServer: ReverseProxyServerData, disconnectFailed : Boolean = false): ScheduledTask { fun migratePlayersToServer(targetServer: ReverseProxyServerData, disconnectFailed : Boolean = false): ScheduledTask {
val targetAddress = InetSocketAddress(targetServer.address, targetServer.port) val targetAddress = InetSocketAddress(targetServer.external_address, targetServer.external_port)
val currentProxyName = redisBungeeApi.proxyId val currentProxyName = redisBungeeApi.proxyId
val playerOnThisProxy = redisBungeeApi.getPlayersOnProxy(currentProxyName) val playerOnThisProxy = redisBungeeApi.getPlayersOnProxy(currentProxyName)
@@ -77,7 +77,7 @@ object ProxyTransferUtils {
player.disconnect(Component.text("Failed to migrate you to the target server. Please reconnect.")) player.disconnect(Component.text("Failed to migrate you to the target server. Please reconnect."))
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.error("Error migrating player ${player.username} to server ${targetServer.name}: ${e.message}", e) logger.error("Error migrating player ${player.username} to server ${targetServer.id}: ${e.message}", e)
if (disconnectFailed) { if (disconnectFailed) {
player.disconnect(Component.text("Failed to migrate you to the target server. Please reconnect.")) player.disconnect(Component.text("Failed to migrate you to the target server. Please reconnect."))
} }