initial commit

This commit is contained in:
Andrey Terentev 2026-01-03 20:39:19 +07:00
commit 435e7d58dc
57 changed files with 2178 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
.kotlin
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
.vscode/
.DS_Store

3
api/build.gradle Normal file
View File

@ -0,0 +1,3 @@
dependencies {
}

View File

@ -0,0 +1,3 @@
package ru.dragonestia.kubik;
public record ServerEntry(String id, String address, int port) {}

View File

@ -0,0 +1,8 @@
package ru.dragonestia.kubik.dto;
import java.util.HashSet;
public record KubikEventUpdate(HashSet<String> unregistered, HashSet<Entry> registered, boolean initial) {
public record Entry(String id, String address, int port) {}
}

View File

@ -0,0 +1,5 @@
package ru.dragonestia.kubik.dto;
public record ServerData(
String id
) {}

View File

@ -0,0 +1,5 @@
package ru.dragonestia.kubik.dto;
public record ServerRegistrationData(
String id
) {}

41
build.gradle Normal file
View File

@ -0,0 +1,41 @@
subprojects {
apply plugin: 'java'
apply plugin: 'java-library'
apply plugin: 'maven-publish'
group = 'ru.dragonestia.kubik'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks {
compileJava {
options.encoding = "UTF-8"
}
compileTestJava {
options.encoding = "UTF-8"
}
}
repositories {
mavenCentral()
maven { url "https://jitpack.io" }
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.42'
annotationProcessor 'org.projectlombok:lombok:1.18.42'
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.mockito:mockito-core:5.14.2'
}
test {
useJUnitPlatform()
}
}

28
client/build.gradle Normal file
View File

@ -0,0 +1,28 @@
plugins {
id("com.gradleup.shadow") version "9.3.0"
}
tasks {
shadowJar {
manifest {
attributes (
"Multi-Release": true,
"Add-Opens": "java.base/java.lang java.base/java.lang.reflect"
)
}
mergeServiceFiles()
}
assemble {
dependsOn shadowJar, processResources
}
}
dependencies {
api project(":api")
api 'com.squareup.okhttp3:okhttp:5.3.2'
api 'com.squareup.okhttp3:okhttp-sse:5.3.2'
api 'com.google.code.gson:gson:2.13.2'
}

View File

@ -0,0 +1,81 @@
package ru.dragonestia.kubik.client;
import com.google.gson.Gson;
import lombok.Getter;
import okhttp3.HttpUrl;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import ru.dragonestia.kubik.client.proxy.ProxyControl;
import ru.dragonestia.kubik.client.proxy.ProxyServerRegistry;
import ru.dragonestia.kubik.client.server.ServerControl;
import ru.dragonestia.kubik.dto.ServerData;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
public class Kubik {
private final static Gson gson = new Gson();
@Getter private final String httpUrl;
@Getter private final String wsUrl;
@Getter private final KubikConfig config;
@Getter private final OkHttpClient okHttpClient;
private final ScheduledExecutorService executor;
public Kubik(String httpUrl, String wsUrl) {
this(httpUrl, wsUrl, KubikConfig.builder().build());
}
public Kubik(String httpUrl, String wsUrl, KubikConfig config) {
this.httpUrl = httpUrl;
this.wsUrl = wsUrl;
this.config = config;
this.executor = Executors.newScheduledThreadPool(config.getThreadPoolSize());
this.okHttpClient = new OkHttpClient.Builder().build();
}
public ServerControl initServer(String groupId, int port) {
return new ServerControl(this, executor, groupId, port);
}
public ProxyControl initProxy(ProxyServerRegistry registry) {
return new ProxyControl(this, executor, registry);
}
public String pickServer(String groupId) {
var request = new Request.Builder()
.url(HttpUrl.get(getHttpUrl() + "/api/v1/server").newBuilder()
.addQueryParameter("groupId", groupId)
.build())
.get()
.build();
try (var response = getOkHttpClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new RuntimeException("Taken bad response: %s, %s".formatted(response.code(), response.message()));
}
var data = gson.fromJson(response.body().string(), ServerData.class);
return data.id();
} catch (Exception ex) {
throw new RuntimeException(ex);
}
}
static void main() throws InterruptedException {
var kubik = new Kubik("http://localhost:8080", "ws://localhost:8080");
var serverControl = kubik.initServer("TestServer", 19136);
var proxyControl = kubik.initProxy(new ProxyServerRegistry() {
@Override
public void register(String id, String address, int port) {
System.out.println("Registering " + id + " to " + address + ":" + port);
}
@Override
public void unregister(String id) {
System.out.println("Unregistering " + id);
}
});
}
}

View File

@ -0,0 +1,15 @@
package ru.dragonestia.kubik.client;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
@AllArgsConstructor
public class KubikConfig {
@Builder.Default private int threadPoolSize = 1;
@Builder.Default private int reconnectDelay = 10;
@Builder.Default private int heartbeatDuration = 20;
}

View File

@ -0,0 +1,125 @@
package ru.dragonestia.kubik.client.proxy;
import com.google.gson.Gson;
import okhttp3.*;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import ru.dragonestia.kubik.ServerEntry;
import ru.dragonestia.kubik.client.Kubik;
import ru.dragonestia.kubik.dto.KubikEventUpdate;
import java.util.HashSet;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.logging.Logger;
public class ProxyControl {
private static final Gson gson = new Gson();
private final Kubik kubik;
private final ScheduledExecutorService executor;
private final ProxyServerRegistry registry;
private final Object lock = new Object();
private final Map<String, ServerEntry> knownServers = new ConcurrentHashMap<>();
private final Logger logger = Logger.getLogger(ProxyControl.class.getName());
private WebSocket ws;
private ScheduledFuture<?> future;
public ProxyControl(Kubik kubik, ScheduledExecutorService executor, ProxyServerRegistry registry) {
this.kubik = kubik;
this.executor = executor;
this.registry = registry;
createConnection();
}
private synchronized void handle(KubikEventUpdate update) {
for (var toDelete: update.unregistered()) {
var server = knownServers.remove(toDelete);
if (server != null) registry.unregister(toDelete);
}
if (update.initial()) {
var notAdded = new HashSet<>(knownServers.keySet());
for (var toAdd: update.registered()) {
var newServer = new ServerEntry(toAdd.id(), toAdd.address(), toAdd.port());
var prevServer = knownServers.put(toAdd.id(), newServer);
if (!newServer.equals(prevServer)) registry.register(toAdd.id(), toAdd.address(), toAdd.port());
notAdded.remove(toAdd.id());
}
for (var toDelete: notAdded) {
knownServers.remove(toDelete);
registry.unregister(toDelete);
}
return;
}
for (var toAdd: update.registered()) {
var newServer = new ServerEntry(toAdd.id(), toAdd.address(), toAdd.port());
var prevServer = knownServers.put(toAdd.id(), newServer);
if (!newServer.equals(prevServer)) registry.register(toAdd.id(), toAdd.address(), toAdd.port());
}
}
public void dispose() {
synchronized (lock) {
if (future != null) {
future.cancel(false);
future = null;
}
if (ws != null) {
ws.cancel();
ws = null;
}
}
}
private void createConnection() {
synchronized (lock) {
if (future != null) future = null;
var request = new Request.Builder()
.url(kubik.getWsUrl() + "/api/v1/server/updates/ws")
.build();
ws = kubik.getOkHttpClient().newWebSocket(request, new WebSocketListener() {
@Override
public void onOpen(@NotNull WebSocket webSocket, @NotNull Response response) {
//System.out.println("Connected to discovery updates: " + kubik.getWsUrl());
}
@Override
public void onMessage(@NotNull WebSocket webSocket, @NotNull String text) {
handle(gson.fromJson(text, KubikEventUpdate.class));
}
@Override
public void onFailure(@NotNull WebSocket webSocket, @NotNull Throwable t, @Nullable Response response) {
synchronized (lock) {
ws.cancel();
ws = null;
future = executor.schedule(() -> createConnection(), kubik.getConfig().getReconnectDelay(), TimeUnit.SECONDS);
}
}
@Override
public void onClosing(@NotNull WebSocket webSocket, int code, @NotNull String reason) {
synchronized (lock) {
ws.cancel();
ws = null;
future = executor.schedule(() -> createConnection(), kubik.getConfig().getReconnectDelay(), TimeUnit.SECONDS);
}
}
});
}
}
}

View File

@ -0,0 +1,8 @@
package ru.dragonestia.kubik.client.proxy;
public interface ProxyServerRegistry {
void register(String id, String address, int port);
void unregister(String id);
}

View File

@ -0,0 +1,99 @@
package ru.dragonestia.kubik.client.server;
import com.google.gson.Gson;
import lombok.Getter;
import okhttp3.HttpUrl;
import okhttp3.Request;
import okhttp3.RequestBody;
import ru.dragonestia.kubik.client.Kubik;
import ru.dragonestia.kubik.dto.ServerRegistrationData;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
public class ServerControl {
private static final Gson gson = new Gson();
private final Kubik kubik;
private final ScheduledExecutorService executor;
@Getter private final String groupId;
@Getter private final int port;
@Getter private String id;
private ScheduledFuture<?> future;
private final Object lock = new Object();
public ServerControl(Kubik kubik, ScheduledExecutorService executor, String groupId, int port) {
this.kubik = kubik;
this.executor = executor;
this.groupId = groupId;
this.port = port;
executor.execute(this::doRegisterServer);
}
private void doRegisterServer() {
synchronized (lock) {
try {
registerServer();
future = executor.schedule(this::doRegisterServer, kubik.getConfig().getHeartbeatDuration(), TimeUnit.SECONDS);
} catch (Exception ex) {
future = executor.schedule(this::doRegisterServer, kubik.getConfig().getReconnectDelay(), TimeUnit.SECONDS);
}
}
}
private void registerServer() {
var request = new Request.Builder()
.url(HttpUrl.get(kubik.getHttpUrl() + "/api/v1/server").newBuilder()
.addQueryParameter("groupId", groupId)
.addQueryParameter("port", Integer.toString(port))
.build())
.post(RequestBody.EMPTY)
.build();
try (var response = kubik.getOkHttpClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new RuntimeException("Taken bad response: %s, %s".formatted(response.code(), response.message()));
}
var responseObject = gson.fromJson(response.body().string(), ServerRegistrationData.class);
id = responseObject.id();
} catch (Exception ex) {
id = null;
throw new RuntimeException(ex);
}
}
public void dispose() {
synchronized (lock) {
if (id == null) return;
if (future != null) {
future.cancel(false);
future = null;
}
unregisterServer();
}
}
private void unregisterServer() {
var request = new Request.Builder()
.url(HttpUrl.get(kubik.getHttpUrl() + "/api/v1/server").newBuilder()
.addQueryParameter("id", id)
.build())
.delete()
.build();
try (var response = kubik.getOkHttpClient().newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new RuntimeException("Taken bad response: %s, %s".formatted(response.code(), response.message()));
}
} catch (Exception ex) {
throw new RuntimeException(ex);
} finally {
id = null;
}
}
}

View File

@ -0,0 +1,13 @@
package ru.dragonestia.kubik.client.util;
import lombok.experimental.UtilityClass;
import java.util.Optional;
@UtilityClass
public class EnvUtil {
public String getString(String key, String defaultValue) {
return Optional.ofNullable(System.getenv(key)).orElse(defaultValue);
}
}

3
discovery/.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
/gradlew text eol=lf
*.bat text eol=crlf
*.jar binary

41
discovery/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
node_modules
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Vaadin ###
src/main/frontend/generated

9
discovery/Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM amazoncorretto:25.0.1-alpine
RUN mkdir /app
COPY build/libs/discovery.jar /app
WORKDIR /app
EXPOSE 8080
ENTRYPOINT exec java -jar discovery.jar

29
discovery/build.gradle Normal file
View File

@ -0,0 +1,29 @@
plugins {
id 'org.springframework.boot' version '4.0.1'
id 'io.spring.dependency-management' version '1.1.7'
id 'com.vaadin' version '25.0.2'
}
ext {
set('vaadinVersion', "25.0.2")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'com.vaadin:vaadin-spring-boot-starter'
implementation 'commons-codec:commons-codec:1.20.0'
implementation project(":api")
developmentOnly 'com.vaadin:vaadin-dev'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
dependencyManagement {
imports {
mavenBom "com.vaadin:vaadin-bom:${vaadinVersion}"
}
}

View File

@ -0,0 +1,41 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: kubik-discovery
labels:
app: kubik-discovery
spec:
replicas: 1
selector:
matchLabels:
app: kubik-discovery
template:
metadata:
labels:
app: kubik-discovery
spec:
containers:
- name: kubik-discovery
image: kubik-discovery:latest
imagePullPolicy: Never
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: kubik-discovery
spec:
type: LoadBalancer
selector:
app: kubik-discovery
ports:
- port: 8080
targetPort: 8080
protocol: TCP

View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<!--
This file is auto-generated by Vaadin.
-->
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<style>
html, body, #outlet {
height: 100%;
width: 100%;
margin: 0;
}
</style>
<!-- index.ts is included here automatically (either by the dev server or during the build) -->
</head>
<body>
<!-- This outlet div is where the views are rendered -->
<div id="outlet"></div>
</body>
</html>

View File

@ -0,0 +1,14 @@
package ru.dragonestia.kubik.discovery;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@EnableScheduling
@SpringBootApplication
public class DiscoveryApplication {
static void main(String[] args) {
SpringApplication.run(DiscoveryApplication.class, args);
}
}

View File

@ -0,0 +1,29 @@
package ru.dragonestia.kubik.discovery.component;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Component;
import ru.dragonestia.kubik.discovery.model.ServerIdentifierStrategy;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@Log4j2
@Component
public class ServerIdentifierGenerator implements ServerIdentifierStrategy {
private final Map<Key, String> rememberedServers = new ConcurrentHashMap<>();
private final Map<String, AtomicInteger> groupFreeIds = new ConcurrentHashMap<>();
@Override
public synchronized String of(String groupId, String address, int port) {
var key = new Key(groupId, address, port);
return rememberedServers.computeIfAbsent(key, _ -> {
var id = groupFreeIds.computeIfAbsent(groupId, _ -> new AtomicInteger(0));
return "%s_%s".formatted(groupId, id.getAndIncrement());
});
}
private record Key(String groupId, String address, int port) {}
}

View File

@ -0,0 +1,94 @@
package ru.dragonestia.kubik.discovery.component;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import ru.dragonestia.kubik.discovery.event.ServerRegisterEvent;
import ru.dragonestia.kubik.discovery.event.ServerUnregisterEvent;
import ru.dragonestia.kubik.discovery.service.ServerService;
import ru.dragonestia.kubik.dto.KubikEventUpdate;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Log4j2
@RequiredArgsConstructor
@Component
public class UpdateStateNotifier {
private final ServerService serverService;
private final Map<UUID, Subscriber> subscribers = new ConcurrentHashMap<>();
@EventListener
void onRegisterServer(ServerRegisterEvent event) {
var changes = new KubikEventUpdate(
new HashSet<>(),
new HashSet<>(List.of(new KubikEventUpdate.Entry(event.id(), event.address(), event.port()))),
false
);
subscribers.values().forEach(subscriber -> subscriber.update(changes));
}
@EventListener
void onUnregisterServer(ServerUnregisterEvent event) {
var changes = new KubikEventUpdate(
new HashSet<>(List.of(event.id())),
new HashSet<>(),
false
);
subscribers.values().forEach(subscriber -> subscriber.update(changes));
}
public Subscriber subscribe(Consumer<Object> valueConsumer) {
synchronized (UpdateStateNotifier.class) {
var subscriber = new Subscriber(this, valueConsumer);
subscriber.update(initValues());
subscribers.put(subscriber.uuid, subscriber);
return subscriber;
}
}
public Object initValues() {
return new KubikEventUpdate(
new HashSet<>(),
new HashSet<>(serverService.getAllServers().stream()
.map(server -> new KubikEventUpdate.Entry(server.getId(), server.getAddress(), server.getPort()))
.toList()),
true);
}
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public static class Subscriber {
private final UUID uuid = UUID.randomUUID();
private final UpdateStateNotifier manager;
private final Consumer<Object> consumer;
private boolean disposed = false;
public void update(Object response) {
try {
consumer.accept(response);
} catch (Exception ex) {
log.error(ex.getMessage(), ex);
dispose();
}
}
public void dispose() {
synchronized (UpdateStateNotifier.class) {
if (disposed) return;
disposed = true;
manager.subscribers.remove(uuid);
}
}
}
}

View File

@ -0,0 +1,25 @@
package ru.dragonestia.kubik.discovery.config;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
import ru.dragonestia.kubik.discovery.component.UpdateStateNotifier;
import ru.dragonestia.kubik.discovery.ws.WebSocketHandler;
import tools.jackson.databind.ObjectMapper;
@EnableWebSocket
@RequiredArgsConstructor
@Configuration
public class WebSocketConfig implements WebSocketConfigurer {
private final UpdateStateNotifier updateStateNotifier;
private final ObjectMapper objectMapper;
@Override
public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) {
registry.addHandler(new WebSocketHandler(updateStateNotifier, objectMapper), "/api/v1/server/updates/ws");
}
}

View File

@ -0,0 +1,88 @@
package ru.dragonestia.kubik.discovery.controller;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import ru.dragonestia.kubik.discovery.component.UpdateStateNotifier;
import ru.dragonestia.kubik.discovery.service.ServerService;
import ru.dragonestia.kubik.dto.ServerData;
import ru.dragonestia.kubik.dto.ServerRegistrationData;
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/v1/server")
@RestController
public class ServerController {
private final ServerService serverService;
private final UpdateStateNotifier updateStateNotifier;
@PostMapping
ServerRegistrationData registerServer(
@RequestParam String groupId,
@RequestParam int port,
@RequestParam(required = false, defaultValue = "true") boolean canPicked,
HttpServletRequest request
) {
return registerServer(groupId, request.getRemoteAddr(), port, canPicked);
}
@PostMapping("/direct")
ServerRegistrationData registerServer(
@RequestParam String groupId,
@RequestParam String address,
@RequestParam int port,
@RequestParam(required = false, defaultValue = "true") boolean canPicked
) {
var server = serverService.register(groupId, address, port);
server.setCanPicked(canPicked);
return new ServerRegistrationData(server.getId());
}
@DeleteMapping
void unregisterServer(@RequestParam String id) {
serverService.unregister(id);
}
@GetMapping
ServerData pickServer(@RequestParam String groupId) {
var server = serverService.pick(groupId);
if (server == null) return new ServerData(null);
return new ServerData(server.getId());
}
@PutMapping("/can-picked")
void updateCanPicked(
@RequestParam String id,
@RequestParam boolean value
) {
serverService.find(id).ifPresent(server -> server.setCanPicked(value));
}
@GetMapping("/updates")
SseEmitter updateStateStream(HttpServletRequest request) {
var emitter = new SseEmitter(-1L);
var subscriber = updateStateNotifier.subscribe(response -> {
try {
emitter.send(response);
} catch (Exception ex) {
throw new RuntimeException(ex);
}
});
emitter.onTimeout(() -> {
log.warn("Timeout SSE connection with {}:{}", request.getRemoteAddr(), request.getRemotePort());
subscriber.dispose();
});
emitter.onError(throwable -> {
log.error("Failed send update state for {}:{}. Reason: {}, {}",
request.getRemoteAddr(),
request.getRemotePort(),
throwable.getClass().getSimpleName(),
throwable.getMessage());
subscriber.dispose();
});
return emitter;
}
}

View File

@ -0,0 +1,3 @@
package ru.dragonestia.kubik.discovery.event;
public record ServerRegisterEvent(String id, String address, int port) {}

View File

@ -0,0 +1,3 @@
package ru.dragonestia.kubik.discovery.event;
public record ServerUnregisterEvent(String id) {}

View File

@ -0,0 +1,45 @@
package ru.dragonestia.kubik.discovery.model;
import lombok.Getter;
import lombok.Setter;
import org.apache.commons.codec.binary.Hex;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Objects;
@Getter
public class Server {
private final String id;
private final String groupId;
private final String address;
private final int port;
@Setter private boolean canPicked = true;
@Setter private Instant lastActivity = Instant.now();
public Server(ServerIdentifierStrategy identifierStrategy, String groupId, String address, int port) {
this.id = identifierStrategy.of(groupId, address, port);
this.groupId = groupId;
this.address = address;
this.port = port;
}
@Override
public String toString() {
return "Server(id=%s, groupId=%s, address=%s, port=%s, canPicked=%s)".formatted(id, groupId, address, port, canPicked);
}
@Override
public int hashCode() {
return id.hashCode();
}
@Override
public boolean equals(Object other) {
if (other == null || getClass() != other.getClass()) return false;
var server = (Server) other;
return Objects.equals(id, server.id);
}
}

View File

@ -0,0 +1,6 @@
package ru.dragonestia.kubik.discovery.model;
public interface ServerIdentifierStrategy {
String of(String groupId, String address, int port);
}

View File

@ -0,0 +1,118 @@
package ru.dragonestia.kubik.discovery.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import ru.dragonestia.kubik.discovery.event.ServerRegisterEvent;
import ru.dragonestia.kubik.discovery.event.ServerUnregisterEvent;
import ru.dragonestia.kubik.discovery.model.Server;
import ru.dragonestia.kubik.discovery.model.ServerIdentifierStrategy;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
@Log4j2
@RequiredArgsConstructor
@Service
public class ServerService {
private final ApplicationEventPublisher eventPublisher;
private final ServerIdentifierStrategy serverIdentifierStrategy;
private final Map<String, Server> servers = new ConcurrentHashMap<>();
private final Map<String, Set<Server>> groupedServers = new ConcurrentHashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock();
@Value("${kubik.activity-timeout}")
private int activityTimeout;
@Scheduled(timeUnit = TimeUnit.SECONDS, fixedDelay = 1)
void gc() {
lock.writeLock().lock();
try {
var now = Instant.now();
var timeout = new ArrayList<Server>();
for (var server: servers.values()) {
if (server.getLastActivity().plusSeconds(activityTimeout).isAfter(now)) continue;
timeout.add(server);
log.warn("Detected inactive server: {}", server);
}
timeout.forEach(server -> unregister(server.getId()));
} finally {
lock.writeLock().unlock();
}
}
public Server register(String groupId, String address, int port) {
lock.writeLock().lock();
try {
var server = new Server(serverIdentifierStrategy, groupId, address, port);
var lastServer = servers.put(server.getId(), server);
if (server.equals(lastServer)) return server;
log.info("Registering server with groupId={} address={} port={}", groupId, address, port);
groupedServers.computeIfAbsent(groupId, _ -> new HashSet<>()).add(server);
eventPublisher.publishEvent(new ServerRegisterEvent(server.getId(), address, port));
return server;
} finally {
lock.writeLock().unlock();
}
}
public void unregister(String id) {
lock.writeLock().lock();
try {
log.info("Unregistering server {}", id);
var server = servers.remove(id);
if (server != null) {
var group = groupedServers.get(server.getGroupId());
if (group != null) group.remove(server);
}
eventPublisher.publishEvent(new ServerUnregisterEvent(id));
} finally {
lock.writeLock().unlock();
}
}
public Server pick(String groupId) {
lock.readLock().lock();
try {
for (var server: groupedServers.get(groupId)) {
if (!server.isCanPicked()) continue;
return server;
}
return null;
} finally {
lock.readLock().unlock();
}
}
public Optional<Server> find(String id) {
lock.readLock().lock();
try {
return Optional.ofNullable(servers.get(id));
} finally {
lock.readLock().unlock();
}
}
public Collection<Server> getAllServers() {
lock.readLock().lock();
try {
return servers.values();
} finally {
lock.readLock().unlock();
}
}
}

View File

@ -0,0 +1,8 @@
package ru.dragonestia.kubik.discovery.vaadin;
import com.vaadin.flow.component.page.AppShellConfigurator;
import com.vaadin.flow.component.page.Push;
import com.vaadin.flow.shared.communication.PushMode;
@Push(PushMode.MANUAL)
public class VaadinConfig implements AppShellConfigurator {}

View File

@ -0,0 +1,83 @@
package ru.dragonestia.kubik.discovery.vaadin.route;
import com.vaadin.flow.component.Unit;
import com.vaadin.flow.component.grid.Grid;
import com.vaadin.flow.component.html.H1;
import com.vaadin.flow.component.orderedlayout.VerticalLayout;
import com.vaadin.flow.data.provider.DataProvider;
import com.vaadin.flow.data.provider.ListDataProvider;
import com.vaadin.flow.router.PageTitle;
import com.vaadin.flow.router.Route;
import ru.dragonestia.kubik.discovery.model.Server;
import ru.dragonestia.kubik.discovery.service.ServerService;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@PageTitle("Kubik")
@Route("")
public class HomeRoute extends VerticalLayout {
private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final ServerService serverService;
private final List<Server> providedServers;
private final DataProvider<Server, ?> serverProvider;
public HomeRoute(ServerService serverService) {
this.serverService = serverService;
providedServers = new ArrayList<>();
serverProvider = new ListDataProvider<>(providedServers);
add(new H1("Active servers"));
add(createGrid());
update();
addAttachListener(event -> {
var future = scheduler.scheduleWithFixedDelay(
() -> {
var ui = event.getUI();
ui.access(() -> {
update();
ui.push();
});
},
1,
1,
TimeUnit.SECONDS
);
addDetachListener(_ -> future.cancel(false));
});
}
private Grid<Server> createGrid() {
var grid = new Grid<>(Server.class, false);
grid.addColumn(Server::getId).setHeader("Id");
grid.addColumn(Server::getGroupId).setHeader("Group id");
grid.addColumn(Server::getAddress).setHeader("Address");
grid.addColumn(Server::getPort).setHeader("Port");
grid.addColumn(Server::isCanPicked).setHeader("Can picked");
grid.addColumn(server -> {
var now = Instant.now();
var delta = now.getEpochSecond() - server.getLastActivity().getEpochSecond();
return "%ss ago".formatted(delta);
}).setHeader("Last activity");
grid.setHeight(80, Unit.VH);
grid.setDataProvider(serverProvider);
return grid;
}
private void update() {
providedServers.clear();
providedServers.addAll(serverService.getAllServers());
serverProvider.refreshAll();
}
}

View File

@ -0,0 +1,56 @@
package ru.dragonestia.kubik.discovery.ws;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import ru.dragonestia.kubik.discovery.component.UpdateStateNotifier;
import tools.jackson.databind.ObjectMapper;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Log4j2
@RequiredArgsConstructor
public class WebSocketHandler extends TextWebSocketHandler {
private final UpdateStateNotifier updateStateNotifier;
private final ObjectMapper objectMapper;
private final Map<String, UpdateStateNotifier.Subscriber> sessions = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(@NonNull WebSocketSession session) {
log.info("Opened new ws connection form {}", session.getRemoteAddress());
var subscriber = updateStateNotifier.subscribe(response -> {
try {
send(session, objectMapper.writeValueAsString(response));
} catch (Exception ex) {
log.error(ex.getMessage(), ex);
}
});
sessions.put(session.getId(), subscriber);
}
@Override
public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) {
log.info("Closed ws connection form {}", session.getRemoteAddress());
var subscriber = sessions.remove(session.getId());
if (subscriber != null) subscriber.dispose();
}
private void send(WebSocketSession session, String message) {
log.debug("Sending WS message to {}: {}", session.getRemoteAddress(), message);
try {
session.sendMessage(new TextMessage(message));
} catch (Exception ex) {
log.error(ex.getMessage(), ex);
}
}
}

View File

@ -0,0 +1,12 @@
spring:
application:
name: discovery
server:
port: 8080
vaadin:
allowed-packages:
- 'ru/dragonestia/kubik/discovery/vaadin'
kubik:
activity-timeout: 30

View File

@ -0,0 +1,13 @@
package ru.dragonestia.kubik.discovery;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DiscoveryApplicationTests {
@Test
void contextLoads() {
}
}

0
gradle.properties Normal file
View File

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,6 @@
#Sat Jan 03 00:43:51 KRAT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
gradlew vendored Normal file
View File

@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# 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
#
# https://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.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,9 @@
FROM amazoncorretto:25.0.1-alpine
RUN mkdir /app
COPY build/libs/msb-example-server-all.jar /app
WORKDIR /app
EXPOSE 8080
ENTRYPOINT exec java -jar msb-example-server-all.jar

View File

@ -0,0 +1,26 @@
plugins {
id("com.gradleup.shadow") version "9.3.0"
}
tasks {
shadowJar {
manifest {
attributes (
"Main-Class": "ru.dragonestia.kubik.msb.Main",
"Multi-Release": true,
"Add-Opens": "java.base/java.lang java.base/java.lang.reflect"
)
}
mergeServiceFiles()
}
assemble {
dependsOn shadowJar, processResources
}
}
dependencies {
implementation project(":client")
implementation files("./lib/msb.jar")
}

Binary file not shown.

View File

@ -0,0 +1,80 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: msb-example-server
labels:
app: msb-example-server
spec:
replicas: 5
selector:
matchLabels:
app: msb-example-server
template:
metadata:
labels:
app: msb-example-server
spec:
containers:
- name: msb-example-server
image: msb-example-server:latest
imagePullPolicy: Never
ports:
- containerPort: 25565
env:
- name: KUBIK_DISCOVERY_HTTP_URL
value: http://kubik-discovery:8080
- name: KUBIK_DISCOVERY_WS_URL
value: ws://kubik-discovery:8080
- name: KUBIK_GROUP_ID
value: test
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: lobby
labels:
app: lobby
spec:
replicas: 1
selector:
matchLabels:
app: lobby
template:
metadata:
labels:
app: lobby
spec:
containers:
- name: lobby
image: msb-example-server:latest
imagePullPolicy: Never
ports:
- containerPort: 25565
env:
- name: KUBIK_DISCOVERY_HTTP_URL
value: http://kubik-discovery:8080
- name: KUBIK_DISCOVERY_WS_URL
value: ws://kubik-discovery:8080
- name: KUBIK_GROUP_ID
value: lobby
---
apiVersion: v1
kind: Service
metadata:
name: lobby
spec:
type: NodePort
selector:
app: lobby
ports:
- port: 25565
targetPort: 25565
protocol: TCP

View File

@ -0,0 +1,54 @@
package ru.dragonestia.kubik.msb;
import lombok.extern.log4j.Log4j2;
import net.minestom.server.Auth;
import net.minestom.server.MinecraftServer;
import net.minestom.server.entity.GameMode;
import ru.dragonestia.kubik.client.Kubik;
import ru.dragonestia.kubik.client.server.ServerControl;
import ru.dragonestia.kubik.client.util.EnvUtil;
import ru.dragonestia.kubik.msb.command.TransferCommand;
import ru.dragonestia.msb.boot.ServerBootstrap;
import ru.dragonestia.msb.boot.ServerInitializer;
import ru.dragonestia.msb.module.DebugCommands;
import ru.dragonestia.msb.module.DefaultFlatWorld;
@Log4j2
public class Main extends ServerInitializer {
private Kubik kubik;
private ServerControl kubikServerControl;
static void main() {
ServerBootstrap.start("0.0.0.0", 25565, new Main(), new Auth.Velocity("PRk6nlzCuJaE"));
}
@Override
public void onLoad() {
var kubikHttpUrl = EnvUtil.getString("KUBIK_DISCOVERY_HTTP_URL", "http://localhost:8080");
var kubikWsUrl = EnvUtil.getString("KUBIK_DISCOVERY_WS_URL", "ws://localhost:8080");
log.info("Using kubik urls: {}, {}", kubikHttpUrl, kubikWsUrl);
kubik = new Kubik(
kubikHttpUrl,
kubikWsUrl
);
}
@Override
public void onDefaultModulesLoaded() {
DefaultFlatWorld.init(GameMode.CREATIVE);
DebugCommands.init();
}
@Override
public void onServerStarted() {
var kubikGroupId = EnvUtil.getString("KUBIK_GROUP_ID", "none");
if (!kubikGroupId.equals("none")) {
kubikServerControl = kubik.initServer(kubikGroupId, 25565);
}
MinecraftServer.getCommandManager().register(new TransferCommand(kubik, kubikServerControl));
}
}

View File

@ -0,0 +1,55 @@
package ru.dragonestia.kubik.msb.command;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.CommandContext;
import net.minestom.server.command.builder.arguments.ArgumentString;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.entity.Player;
import ru.dragonestia.kubik.client.Kubik;
import ru.dragonestia.kubik.client.server.ServerControl;
public class TransferCommand extends Command {
private final Kubik kubik;
private final ServerControl serverControl;
private final ArgumentString argServerId = ArgumentType.String("server_Id");
private final ArgumentString argGroupId = ArgumentType.String("group_Id");
public TransferCommand(Kubik kubik, ServerControl serverControl) {
super("transfer");
this.kubik = kubik;
this.serverControl = serverControl;
addSyntax(this::executeTransferCommandByServerId, ArgumentType.Literal("serverId"), argServerId);
addSyntax(this::executeTransferCommandByGroupId, ArgumentType.Literal("groupId"), argGroupId);
}
private void executeTransferCommandByServerId(CommandSender sender, CommandContext ctx) {
var player = (Player) sender;
var serverId = ctx.get(argServerId);
transfer(player, serverId);
}
private void executeTransferCommandByGroupId(CommandSender sender, CommandContext ctx) {
var player = (Player) sender;
var groupId = ctx.get(argGroupId);
var serverId = kubik.pickServer(groupId);
if (serverId == null) {
player.sendMessage(Component.text("Cannot find server with groupId=%s".formatted(groupId), NamedTextColor.RED));
return;
}
transfer(player, serverId);
}
private void transfer(Player player, String serverId) {
player.sendMessage(Component.text("Trying transfer to %s".formatted(serverId), NamedTextColor.YELLOW));
player.sendPluginMessage("kubik:transfer", serverId);
}
}

6
settings.gradle Normal file
View File

@ -0,0 +1,6 @@
rootProject.name = 'kubik'
include 'api', 'velocity-plugin', 'discovery'
include 'client'
include 'msb-example-server'

View File

@ -0,0 +1,47 @@
plugins {
id("xyz.jpenilla.run-velocity") version "2.3.1"
id("com.gradleup.shadow") version "9.3.0"
}
group = 'ru.dragonestia.kubik'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
maven {
name = "papermc-repo"
url = "https://repo.papermc.io/repository/maven-public/"
}
}
dependencies {
compileOnly "com.velocitypowered:velocity-api:3.4.0-SNAPSHOT"
implementation project(":client")
annotationProcessor "com.velocitypowered:velocity-api:3.4.0-SNAPSHOT"
}
tasks {
shadowJar {
manifest {
attributes (
"Multi-Release": true,
"Add-Opens": "java.base/java.lang java.base/java.lang.reflect"
)
}
mergeServiceFiles()
}
assemble {
dependsOn shadowJar, processResources
}
runVelocity {
// Configure the Velocity version for our task.
// This is the only required configuration besides applying the plugin.
// Your plugin's jar (or shadowJar if present) will be used automatically.
velocityVersion("3.4.0-SNAPSHOT")
}
}

View File

@ -0,0 +1,121 @@
package ru.dragonestia.kubik.plugin;
import com.google.gson.Gson;
import com.google.inject.Inject;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyShutdownEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.proxy.server.ServerInfo;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import ru.dragonestia.kubik.client.Kubik;
import ru.dragonestia.kubik.client.proxy.ProxyControl;
import ru.dragonestia.kubik.client.proxy.ProxyServerRegistry;
import ru.dragonestia.kubik.client.util.EnvUtil;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
@Plugin(
id = "kubik-velocity-plugin",
name = "KubikConnector",
version = "1.0.0",
description = "Kubik connector plugin",
authors = { "ScarletRedMan" }
)
public class KubikPlugin implements ProxyServerRegistry {
private final static MinecraftChannelIdentifier KUBIK_TRANSFER_CHANNEL_ID = MinecraftChannelIdentifier.from("kubik:transfer");
private final ProxyServer proxyServer;
private final Logger logger;
private final ProxyControl proxyControl;
private final Gson gson = new Gson();
private final Map<String, Consumer<PluginMessageEvent>> pluginChannelHandlers = new ConcurrentHashMap<>();
private final Map<String, RegisteredServer> registeredServers = new ConcurrentHashMap<>();
@Inject
public KubikPlugin(ProxyServer proxyServer, Logger logger) {
this.proxyServer = proxyServer;
this.logger = logger;
var kubikHttpUrl = EnvUtil.getString("KUBIK_DISCOVERY_HTTP_URL", "http://localhost:8080");
var kubikWsUrl = EnvUtil.getString("KUBIK_DISCOVERY_WS_URL", "ws://localhost:8080");
logger.info("Using kubik urls: {}, {}", kubikHttpUrl, kubikWsUrl);
var kubik = new Kubik(kubikHttpUrl, kubikWsUrl);
proxyControl = kubik.initProxy(this);
}
@Subscribe
public void onProxyInitialization(ProxyInitializeEvent event) {
registerPluginChannel(KUBIK_TRANSFER_CHANNEL_ID, e -> {
e.setResult(PluginMessageEvent.ForwardResult.handled());
if (!(e.getSource() instanceof ServerConnection backend)) return;
var serverId = new String(e.getData(), StandardCharsets.UTF_8);
var player = (Player) e.getTarget();
logger.info("Taken transfer request for player {} to {}", player.getUsername(), serverId);
var server = registeredServers.get(serverId);
if (server == null) {
logger.error("Could not find server with id '{}'", serverId);
player.disconnect(Component.text("Сервер '{}' недоступен", NamedTextColor.RED));
return;
}
player.createConnectionRequest(server).fireAndForget();
});
}
@Subscribe
public void onPreShutdown(ProxyShutdownEvent event) {
proxyControl.dispose();
}
@Subscribe
public void onTakenPluginMessage(PluginMessageEvent event) {
var handler = pluginChannelHandlers.get(event.getIdentifier().getId());
if (handler != null) handler.accept(event);
}
private void registerPluginChannel(MinecraftChannelIdentifier channelId, Consumer<PluginMessageEvent> handler) {
pluginChannelHandlers.put(channelId.getId(), handler);
proxyServer.getChannelRegistrar().register(KUBIK_TRANSFER_CHANNEL_ID);
logger.info("Registered plugin channel '{}'", channelId.getId());
}
@Override
public void register(String id, String address, int port) {
var server = registeredServers.computeIfAbsent(id, _ -> proxyServer.createRawRegisteredServer(new ServerInfo(
id,
InetSocketAddress.createUnresolved(address, port)
)));
logger.info("Registered server '{}'(address={} port={})", id, address, port);
}
@Override
public void unregister(String id) {
var server = registeredServers.remove(id);
logger.info("Unregistered server '{}'", id);
if (server == null) return;
//TODO
}
}

5
velocity/.dockerignore Normal file
View File

@ -0,0 +1,5 @@
Dockerfile
logs
lang
.gitignore
kubik-proxy.yml

3
velocity/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
lang
logs
plugins/*.jar

9
velocity/Dockerfile Normal file
View File

@ -0,0 +1,9 @@
FROM amazoncorretto:25.0.1-alpine
RUN mkdir /app
COPY . /app
WORKDIR /app
EXPOSE 25565
ENTRYPOINT exec java -jar proxy.jar

View File

@ -0,0 +1 @@
PRk6nlzCuJaE

46
velocity/kubik-proxy.yml Normal file
View File

@ -0,0 +1,46 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: kubik-proxy
labels:
app: kubik-proxy
spec:
replicas: 1
selector:
matchLabels:
app: kubik-proxy
template:
metadata:
labels:
app: kubik-proxy
spec:
containers:
- name: kubik-proxy
image: kubik-proxy:latest
imagePullPolicy: Never
ports:
- containerPort: 25565
env:
- name: KUBIK_DISCOVERY_HTTP_URL
value: http://kubik-discovery:8080
- name: KUBIK_DISCOVERY_WS_URL
value: ws://kubik-discovery:8080
---
apiVersion: v1
kind: Service
metadata:
name: kubik-proxy
spec:
type: LoadBalancer
selector:
app: kubik-proxy
ports:
- port: 25565
targetPort: 25565
protocol: TCP

View File

@ -0,0 +1,10 @@
# bStats (https://bStats.org) collects some basic information for plugin authors, like
# how many people use their plugin and their total player count. It's recommended to keep
# bStats enabled, but if you're not comfortable with this, you can turn this setting off.
# There is no performance penalty associated with having metrics enabled, and data sent to
# bStats is fully anonymous.
enabled=false
server-uuid=83212111-4623-4135-ad0e-d444d41e7a82
log-errors=false
log-sent-data=false
log-response-status-text=false

BIN
velocity/proxy.jar Normal file

Binary file not shown.

185
velocity/velocity.toml Normal file
View File

@ -0,0 +1,185 @@
# Config version. Do not change this
config-version = "2.7"
# What port should the proxy be bound to? By default, we'll bind to all addresses on port 25565.
bind = "0.0.0.0:25565"
# What should be the MOTD? This gets displayed when the player adds your server to
# their server list. Only MiniMessage format is accepted.
motd = "<#09add3>A Velocity Server"
# What should we display for the maximum number of players? (Velocity does not support a cap
# on the number of players online.)
show-max-players = 500
# Should we authenticate players with Mojang? By default, this is on.
online-mode = true
# Should the proxy enforce the new public key security standard? By default, this is on.
force-key-authentication = true
# If client's ISP/AS sent from this proxy is different from the one from Mojang's
# authentication server, the player is kicked. This disallows some VPN and proxy
# connections but is a weak form of protection.
prevent-client-proxy-connections = false
# Should we forward IP addresses and other data to backend servers?
# Available options:
# - "none": No forwarding will be done. All players will appear to be connecting
# from the proxy and will have offline-mode UUIDs.
# - "legacy": Forward player IPs and UUIDs in a BungeeCord-compatible format. Use this
# if you run servers using Minecraft 1.12 or lower.
# - "bungeeguard": Forward player IPs and UUIDs in a format supported by the BungeeGuard
# plugin. Use this if you run servers using Minecraft 1.12 or lower, and are
# unable to implement network level firewalling (on a shared host).
# - "modern": Forward player IPs and UUIDs as part of the login process using
# Velocity's native forwarding. Only applicable for Minecraft 1.13 or higher.
player-info-forwarding-mode = "modern"
# If you are using modern or BungeeGuard IP forwarding, configure a file that contains a unique secret here.
# The file is expected to be UTF-8 encoded and not empty.
forwarding-secret-file = "forwarding.secret"
# Announce whether or not your server supports Forge. If you run a modded server, we
# suggest turning this on.
#
# If your network runs one modpack consistently, consider using ping-passthrough = "mods"
# instead for a nicer display in the server list.
announce-forge = false
# If enabled (default is false) and the proxy is in online mode, Velocity will kick
# any existing player who is online if a duplicate connection attempt is made.
kick-existing-players = false
# Should Velocity pass server list ping requests to a backend server?
# Available options:
# - "disabled": No pass-through will be done. The velocity.toml and server-icon.png
# will determine the initial server list ping response.
# - "mods": Passes only the mod list from your backend server into the response.
# The first server in your try list (or forced host) with a mod list will be
# used. If no backend servers can be contacted, Velocity won't display any
# mod information.
# - "description": Uses the description and mod list from the backend server. The first
# server in the try (or forced host) list that responds is used for the
# description and mod list.
# - "all": Uses the backend server's response as the proxy response. The Velocity
# configuration is used if no servers could be contacted.
ping-passthrough = "DISABLED"
# If enabled (default is false), then a sample of the online players on the proxy will be visible
# when hovering over the player count in the server list.
# This doesn't have any effect when ping passthrough is set to either "description" or "all".
sample-players-in-ping = false
# If not enabled (default is true) player IP addresses will be replaced by <ip address withheld> in logs
enable-player-address-logging = true
[servers]
# Configure your servers here. Each key represents the server's name, and the value
# represents the IP address of the server to connect to.
lobby = "lobby:25565"
# In what order we should try servers when a player logs in or is kicked from a server.
try = [
"lobby"
]
[forced-hosts]
# Configure your forced hosts here.
"lobby.example.com" = [
"lobby"
]
[advanced]
# How large a Minecraft packet has to be before we compress it. Setting this to zero will
# compress all packets, and setting it to -1 will disable compression entirely.
compression-threshold = 256
# How much compression should be done (from 0-9). The default is -1, which uses the
# default level of 6.
compression-level = -1
# How fast (in milliseconds) are clients allowed to connect after the last connection? By
# default, this is three seconds. Disable this by setting this to 0.
login-ratelimit = 3000
# Specify a custom timeout for connection timeouts here. The default is five seconds.
connection-timeout = 5000
# Specify a read timeout for connections here. The default is 30 seconds.
read-timeout = 30000
# Enables compatibility with HAProxy's PROXY protocol. If you don't know what this is for, then
# don't enable it.
haproxy-protocol = false
# Enables TCP fast open support on the proxy. Requires the proxy to run on Linux.
tcp-fast-open = false
# Enables BungeeCord plugin messaging channel support on Velocity.
bungee-plugin-message-channel = true
# Shows ping requests to the proxy from clients.
show-ping-requests = false
# By default, Velocity will attempt to gracefully handle situations where the user unexpectedly
# loses connection to the server without an explicit disconnect message by attempting to fall the
# user back, except in the case of read timeouts. BungeeCord will disconnect the user instead. You
# can disable this setting to use the BungeeCord behavior.
failover-on-unexpected-server-disconnect = true
# Declares the proxy commands to 1.13+ clients.
announce-proxy-commands = true
# Enables the logging of commands
log-command-executions = false
# Enables logging of player connections when connecting to the proxy, switching servers
# and disconnecting from the proxy.
log-player-connections = true
# Allows players transferred from other hosts via the
# Transfer packet (Minecraft 1.20.5) to be received.
accepts-transfers = false
# Enables support for SO_REUSEPORT. This may help the proxy scale better on multicore systems
# with a lot of incoming connections, and provide better CPU utilization than the existing
# strategy of having a single thread accepting connections and distributing them to worker
# threads. Disabled by default. Requires Linux or macOS.
enable-reuse-port = false
# How fast (in milliseconds) are clients allowed to send commands after the last command
# By default this is 50ms (20 commands per second)
command-rate-limit = 50
# Should we forward commands to the backend upon being rate limited?
# This will forward the command to the server instead of processing it on the proxy.
# Since most server implementations have a rate limit, this will prevent the player
# from being able to send excessive commands to the server.
forward-commands-if-rate-limited = true
# How many commands are allowed to be sent after the rate limit is hit before the player is kicked?
# Setting this to 0 or lower will disable this feature.
kick-after-rate-limited-commands = 0
# How fast (in milliseconds) are clients allowed to send tab completions after the last tab completion
tab-complete-rate-limit = 10
# How many tab completions are allowed to be sent after the rate limit is hit before the player is kicked?
# Setting this to 0 or lower will disable this feature.
kick-after-rate-limited-tab-completes = 0
[query]
# Whether to enable responding to GameSpy 4 query responses or not.
enabled = false
# If query is enabled, on what port should the query protocol listen on?
port = 25565
# This is the map name that is reported to the query services.
map = "Velocity"
# Whether plugins should be shown in query response by default or not
show-plugins = false