loadBalancerDiscovery =
@@ -59,6 +63,10 @@ public void registerLoadBalancer(LoadBalancer loadBalancer) {
initialServerNames.add(serverName);
}
+ if (loadBalancer.getForcedHost() != null) {
+ addForcedHost(loadBalancer.getForcedHost(), serverName);
+ }
+
plugin
.getLogger()
.info(
@@ -101,6 +109,7 @@ public void unregisterLoadBalancer(LoadBalancer loadBalancer) {
replicaSetUidAndServerNameMap.unregister(loadBalancer.getReplicaSetUid());
initialServerNames.remove(serverName);
+ removeForcedHost(serverName);
plugin
.getLogger()
@@ -150,6 +159,44 @@ private void updateLoadBalancerEndpoints(LoadBalancer loadBalancer) {
loadBalancer.setEndpoints(endpoints);
}
+ /**
+ * Rebuild every registered load balancer's endpoint membership from the current live pod list.
+ *
+ * Used by the periodic reconcile so that a backend whose pod has rolled away is dropped from
+ * the load-balancer rotation even if an incremental update was missed, preventing players from
+ * being routed to a dead endpoint.
+ */
+ public void reconcileLoadBalancerEndpoints() {
+ for (LoadBalancer loadBalancer : loadBalancerServerMap.values()) {
+ updateLoadBalancerEndpoints(loadBalancer);
+ }
+ }
+
+ /**
+ * Look up a live (Running) pod by its UID within the configured namespace and label selectors.
+ *
+ * @param podUid The pod UID to look up.
+ * @return The matching Running pod, or null if no such live pod exists.
+ */
+ @Nullable
+ private Pod getLivePodByUid(String podUid) {
+ FilterWatchListDeletable request =
+ client.pods().inNamespace(namespace);
+
+ for (Entry e : plugin.getKuvelConfig().getLabelSelectors().entrySet()) {
+ request = request.withLabel(e.getKey(), e.getValue());
+ }
+
+ return request
+ .withField("status.phase", "Running")
+ .list()
+ .getItems()
+ .stream()
+ .filter(p -> p.getMetadata().getUid().equals(podUid))
+ .findFirst()
+ .orElse(null);
+ }
+
/**
* Replace new server discovery instance and unregister old one. Specify null for shutdown current
* discovery instance.
@@ -244,13 +291,26 @@ public boolean registerPod(Pod pod, String serverName) {
}
}
+ String labelKeyPrefix = plugin.getKuvelConfig().getLabelKeyPrefix();
String initialServerStr =
pod.getMetadata().getLabels().getOrDefault(
- LabelKeys.INITIAL_SERVER.getKey(plugin.getKuvelConfig().getLabelKeyPrefix()), "false");
+ LabelKeys.INITIAL_SERVER.getKey(labelKeyPrefix), "false");
if (Boolean.parseBoolean(initialServerStr)) {
initialServerNames.add(serverName);
}
+ String forcedHost =
+ pod.getMetadata().getLabels().getOrDefault(
+ LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
+ if (forcedHost == null) {
+ forcedHost =
+ pod.getMetadata().getAnnotations().getOrDefault(
+ LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
+ }
+ if (forcedHost != null) {
+ addForcedHost(forcedHost, serverName);
+ }
+
plugin
.getLogger()
.info("Registered server: " + serverName + " (" + pod.getMetadata().getUid() + ")");
@@ -292,16 +352,40 @@ public void unregisterPod(String podUid) {
}
String serverName = podUidAndServerNameMap.unregister(podUid);
- if (podUidAndServerNameMap.getUidFromServerName(serverName) != null) {
+
+ String survivingUid = podUidAndServerNameMap.getUidFromServerName(serverName);
+ if (survivingUid != null) {
+ // The server name is still claimed by another pod UID. Keep the registration
+ // only when that UID belongs to a genuinely live (Running) pod, and in that
+ // case rebind the Velocity ServerInfo / load balancer endpoint to the live
+ // pod's IP. Otherwise fall through to teardown so the name is never left
+ // pointing at a departed pod (the "ghost backend").
+ Pod survivingPod = getLivePodByUid(survivingUid);
+ if (survivingPod != null && registerPod(survivingPod, serverName)) {
+ plugin
+ .getLogger()
+ .info(
+ "Unregistered pod: "
+ + podUid
+ + " (server name "
+ + serverName
+ + " rebound to live pod "
+ + survivingUid
+ + ")");
+ return;
+ }
+
+ // Surviving UID is not a live pod (or could not be registered); drop the stale
+ // mapping before tearing down so the name is not left bound to a departed UID.
+ podUidAndServerNameMap.unregister(survivingUid);
plugin
.getLogger()
- .info(
- "Unregistered pod: "
- + podUid
- + " (server name "
+ .warn(
+ "Server name "
+ serverName
- + " is still in use)");
- return;
+ + " was still mapped to non-live pod "
+ + survivingUid
+ + "; tearing down stale registration.");
}
plugin
.getProxy()
@@ -315,6 +399,7 @@ public void unregisterPod(String podUid) {
}
initialServerNames.remove(serverName);
+ removeForcedHost(serverName);
plugin.getLogger().info("Unregistered server: " + serverName + " (" + podUid + ")");
}
@@ -337,4 +422,17 @@ public void unregisterPod(Pod pod) {
public boolean isPodRegistered(String podId) {
return podUidAndServerNameMap.getServerNameFromUid(podId) != null;
}
+
+ private void addForcedHost(String hostname, String serverName) {
+ forcedHosts.computeIfAbsent(hostname, k -> new CopyOnWriteArrayList<>()).add(serverName);
+ }
+
+ private void removeForcedHost(String serverName) {
+ forcedHosts.values().forEach(list -> list.remove(serverName));
+ forcedHosts.entrySet().removeIf(entry -> entry.getValue().isEmpty());
+ }
+
+ public Map> getForcedHosts() {
+ return forcedHosts;
+ }
}
diff --git a/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisLoadBalancerDiscovery.java b/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisLoadBalancerDiscovery.java
index 4bfd931f..11966f21 100644
--- a/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisLoadBalancerDiscovery.java
+++ b/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisLoadBalancerDiscovery.java
@@ -136,6 +136,16 @@ private void registerOrIgnore(ReplicaSet replicaSet, boolean isFetchedFromRedis)
.getLabels()
.getOrDefault(LabelKeys.INITIAL_SERVER.getKey(labelKeyPrefix), "false")
.equalsIgnoreCase("true");
+ String forcedHost =
+ metadata
+ .getLabels()
+ .getOrDefault(LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
+ if (forcedHost == null) {
+ forcedHost =
+ metadata
+ .getAnnotations()
+ .getOrDefault(LabelKeys.FORCED_HOST.getKey(labelKeyPrefix), null);
+ }
if (isDisableLoadBalancer(metadata)) {
return;
}
@@ -169,7 +179,7 @@ private void registerOrIgnore(ReplicaSet replicaSet, boolean isFetchedFromRedis)
kuvelServiceHandler.getReplicaSetUidAndServerNameMap().register(uid, serverName);
jedis.hset(RedisKeys.LOAD_BALANCERS_PREFIX.getKey() + groupName, uid, serverName);
- redisConnectionLeader.publishNewLoadBalancer(uid, serverName, initialServer);
+ redisConnectionLeader.publishNewLoadBalancer(uid, serverName, initialServer, forcedHost);
RegisteredServer server =
plugin
@@ -182,7 +192,8 @@ private void registerOrIgnore(ReplicaSet replicaSet, boolean isFetchedFromRedis)
server,
new RoundRobinLoadBalancingStrategy(),
uid,
- initialServer));
+ initialServer,
+ forcedHost));
}
}
diff --git a/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisServerDiscovery.java b/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisServerDiscovery.java
index cee0c7f8..55039116 100644
--- a/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisServerDiscovery.java
+++ b/src/main/java/net/azisaba/kuvel/discovery/impl/redis/RedisServerDiscovery.java
@@ -9,10 +9,12 @@
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
@@ -71,8 +73,19 @@ public void start() {
}
}
- List uidList = podDiffChecker.getDeletedPodUidList(client);
- uidList.forEach(this::unregisterPodOrIgnore);
+ // Delete detection and the level-triggered reconcile run under the same lock
+ // that guards registration (processUpdatedPod). Previously the delete path ran
+ // outside the lock, racing register/unregister (TOCTOU). Holding the lock makes
+ // eviction and the load-balancer rebuild atomic with respect to registration.
+ lock.lock();
+ try {
+ List uidList = podDiffChecker.getDeletedPodUidList(client);
+ uidList.forEach(this::unregisterPodOrIgnore);
+
+ reconcile(podList);
+ } finally {
+ lock.unlock();
+ }
};
taskReference.getAndUpdate(
@@ -264,6 +277,38 @@ private void processUpdatedPod(Pod pod) {
}
}
+ /**
+ * Level-triggered reconcile run once per poll. Evicts any registered pod UID that is no longer a
+ * live (Running) labelled pod, then rebuilds every load balancer's membership from the live pod
+ * set.
+ *
+ * This mirrors the pruning that {@link #getServersForStartup()} already performs on boot (which
+ * is what a manual proxy restart does), but applies it continuously so a rolled backend can never
+ * linger as a "ghost" server registration or load-balancer endpoint pointing at a dead pod IP.
+ *
+ *
Must be called while holding {@link #lock}.
+ *
+ * @param labelledPods The labelled pods fetched during this poll tick.
+ */
+ private void reconcile(List labelledPods) {
+ Set liveRunningUids = new HashSet<>();
+ for (Pod pod : labelledPods) {
+ if (pod.getStatus() != null && "Running".equalsIgnoreCase(pod.getStatus().getPhase())) {
+ liveRunningUids.add(pod.getMetadata().getUid());
+ }
+ }
+
+ // getAllMap() returns a copy, so iterating it while unregisterPodOrIgnore mutates the
+ // underlying map is safe.
+ for (String uid : kuvelServiceHandler.getPodUidAndServerNameMap().getAllMap().keySet()) {
+ if (!liveRunningUids.contains(uid)) {
+ unregisterPodOrIgnore(uid);
+ }
+ }
+
+ kuvelServiceHandler.reconcileLoadBalancerEndpoints();
+ }
+
private void registerPodOrIgnore(Pod pod) {
String uid = pod.getMetadata().getUid();
if (kuvelServiceHandler.getPodUidAndServerNameMap().getServerNameFromUid(uid) != null) {
diff --git a/src/main/java/net/azisaba/kuvel/listener/ChooseInitialServerListener.java b/src/main/java/net/azisaba/kuvel/listener/ChooseInitialServerListener.java
index 492378c8..1c021a2d 100644
--- a/src/main/java/net/azisaba/kuvel/listener/ChooseInitialServerListener.java
+++ b/src/main/java/net/azisaba/kuvel/listener/ChooseInitialServerListener.java
@@ -4,6 +4,7 @@
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
+import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -19,6 +20,21 @@ public class ChooseInitialServerListener {
@Subscribe
public void onInitialServerChoose(PlayerChooseInitialServerEvent event) {
+ Optional virtualHost = event.getPlayer().getVirtualHost();
+ if (virtualHost.isPresent()) {
+ String hostname = virtualHost.get().getHostString();
+ List forcedServerNames = handler.getForcedHosts().get(hostname);
+ if (forcedServerNames != null && !forcedServerNames.isEmpty()) {
+ for (String serverName : forcedServerNames) {
+ Optional optionalServer = proxy.getServer(serverName);
+ if (optionalServer.isPresent()) {
+ event.setInitialServer(optionalServer.get());
+ return;
+ }
+ }
+ }
+ }
+
if (handler.getInitialServerNames().isEmpty()) {
return;
}
diff --git a/src/main/java/net/azisaba/kuvel/loadbalancer/LoadBalancer.java b/src/main/java/net/azisaba/kuvel/loadbalancer/LoadBalancer.java
index 16ac3f2a..0e7ae7aa 100644
--- a/src/main/java/net/azisaba/kuvel/loadbalancer/LoadBalancer.java
+++ b/src/main/java/net/azisaba/kuvel/loadbalancer/LoadBalancer.java
@@ -19,6 +19,7 @@ public class LoadBalancer {
private final String replicaSetUid;
private final boolean isInitialServer;
+ private final String forcedHost;
private final List endpointServers = new ArrayList<>();
public void addEndpoint(String serverName) {
diff --git a/src/main/java/net/azisaba/kuvel/redis/RedisConnectionLeader.java b/src/main/java/net/azisaba/kuvel/redis/RedisConnectionLeader.java
index 187784d2..9dcbdd9e 100644
--- a/src/main/java/net/azisaba/kuvel/redis/RedisConnectionLeader.java
+++ b/src/main/java/net/azisaba/kuvel/redis/RedisConnectionLeader.java
@@ -80,11 +80,11 @@ public void leaveLeader() {
}
public void publishNewLoadBalancer(
- String replicaSetUid, String serverName, boolean initialServer) {
+ String replicaSetUid, String serverName, boolean initialServer, String forcedHost) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.publish(
RedisKeys.LOAD_BALANCER_ADDED_NOTIFY_PREFIX.getKey() + groupName,
- replicaSetUid + ":" + serverName + ":" + initialServer);
+ replicaSetUid + ":" + serverName + ":" + initialServer + ":" + (forcedHost != null ? forcedHost : ""));
}
}
diff --git a/src/main/java/net/azisaba/kuvel/redis/RedisSubscriber.java b/src/main/java/net/azisaba/kuvel/redis/RedisSubscriber.java
index 9503c8b3..3438c6e9 100644
--- a/src/main/java/net/azisaba/kuvel/redis/RedisSubscriber.java
+++ b/src/main/java/net/azisaba/kuvel/redis/RedisSubscriber.java
@@ -45,6 +45,10 @@ public void onPMessage(String pattern, String channel, String message) {
String replicaSetUid = message.split(":")[0];
String serverName = message.split(":")[1];
boolean initialServer = Boolean.parseBoolean(message.split(":")[2]);
+ String forcedHost = message.split(":").length > 3 ? message.split(":")[3] : null;
+ if (forcedHost != null && forcedHost.isEmpty()) {
+ forcedHost = null;
+ }
RegisteredServer server =
plugin
@@ -56,7 +60,8 @@ public void onPMessage(String pattern, String channel, String message) {
server,
new RoundRobinLoadBalancingStrategy(),
replicaSetUid,
- initialServer);
+ initialServer,
+ forcedHost);
kuvelServiceHandler.registerLoadBalancer(loadBalancer);
} else if (channel.startsWith(RedisKeys.POD_DELETED_NOTIFY_PREFIX.getKey())) {
kuvelServiceHandler.unregisterPod(message);
diff --git a/src/main/java/net/azisaba/kuvel/util/LabelKeys.java b/src/main/java/net/azisaba/kuvel/util/LabelKeys.java
index aa5744c6..77969573 100644
--- a/src/main/java/net/azisaba/kuvel/util/LabelKeys.java
+++ b/src/main/java/net/azisaba/kuvel/util/LabelKeys.java
@@ -9,6 +9,7 @@ public enum LabelKeys {
INITIAL_SERVER("initial-server"),
DISABLE_NAME_SUFFIX("disable-name-suffix"),
DISABLE_LOAD_BALANCER("disable-load-balancer"),
+ FORCED_HOST("forced-host"),
;
private final String key;
diff --git a/src/test/java/net/azisaba/kuvel/ForcedHostConcurrencyTest.java b/src/test/java/net/azisaba/kuvel/ForcedHostConcurrencyTest.java
new file mode 100644
index 00000000..2859dc3a
--- /dev/null
+++ b/src/test/java/net/azisaba/kuvel/ForcedHostConcurrencyTest.java
@@ -0,0 +1,155 @@
+package net.azisaba.kuvel;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for the forced-host ConcurrentHashMap + CopyOnWriteArrayList fix
+ * in KuvelServiceHandler.
+ *
+ * The original code used ArrayList inside ConcurrentHashMap.computeIfAbsent,
+ * which is not thread-safe for concurrent reads during iteration (e.g. in
+ * ChooseInitialServerListener). The fix switches to CopyOnWriteArrayList.
+ */
+public class ForcedHostConcurrencyTest {
+
+ /**
+ * Simulates the fixed addForcedHost / removeForcedHost / iteration pattern.
+ * Multiple threads add and remove entries while another iterates,
+ * verifying no ConcurrentModificationException is thrown.
+ */
+ @Test
+ void concurrentAddRemoveAndIterate_noConcurrentModificationException() throws Exception {
+ Map> forcedHosts = new ConcurrentHashMap<>();
+ int threads = 8;
+ int opsPerThread = 500;
+ ExecutorService executor = Executors.newFixedThreadPool(threads);
+ CountDownLatch latch = new CountDownLatch(threads);
+ AtomicInteger errors = new AtomicInteger(0);
+
+ // Writers: add and remove forced hosts
+ for (int t = 0; t < threads / 2; t++) {
+ final int threadId = t;
+ executor.submit(() -> {
+ try {
+ for (int i = 0; i < opsPerThread; i++) {
+ String hostname = "host-" + (i % 5) + ".example.com";
+ String serverName = "server-" + threadId + "-" + i;
+ // addForcedHost
+ forcedHosts.computeIfAbsent(hostname, k -> new CopyOnWriteArrayList<>())
+ .add(serverName);
+ // removeForcedHost (some of the time)
+ if (i % 3 == 0) {
+ forcedHosts.values().forEach(list -> list.remove(serverName));
+ forcedHosts.entrySet().removeIf(entry -> entry.getValue().isEmpty());
+ }
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ // Readers: iterate forced hosts (like ChooseInitialServerListener does)
+ for (int t = threads / 2; t < threads; t++) {
+ executor.submit(() -> {
+ try {
+ for (int i = 0; i < opsPerThread; i++) {
+ for (Map.Entry> entry : forcedHosts.entrySet()) {
+ List servers = entry.getValue();
+ if (servers != null && !servers.isEmpty()) {
+ // Iterate over the list - this would throw
+ // ConcurrentModificationException with ArrayList
+ for (String serverName : servers) {
+ assertNotNull(serverName);
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ errors.incrementAndGet();
+ } finally {
+ latch.countDown();
+ }
+ });
+ }
+
+ assertTrue(latch.await(30, TimeUnit.SECONDS), "Threads did not finish in time");
+ executor.shutdown();
+ assertEquals(0, errors.get(), "Concurrent operations caused errors");
+ }
+
+ /**
+ * Verifies that CopyOnWriteArrayList allows safe iteration while being modified,
+ * which is the core property we need for ChooseInitialServerListener.
+ */
+ @Test
+ void copyOnWriteArrayList_safeIterationDuringModification() {
+ CopyOnWriteArrayList list = new CopyOnWriteArrayList<>();
+ list.add("lobby-1");
+ list.add("lobby-2");
+ list.add("lobby-3");
+
+ // Iterate and modify simultaneously - should NOT throw
+ int count = 0;
+ for (String s : list) {
+ assertNotNull(s);
+ count++;
+ if (count == 1) {
+ // Modification during iteration is safe with CopyOnWriteArrayList
+ list.add("lobby-4");
+ list.remove("lobby-2");
+ }
+ }
+
+ // Iterator saw the snapshot (3 elements), not the modified list
+ assertEquals(3, count);
+ // But the list itself was modified
+ assertTrue(list.contains("lobby-4"));
+ assertFalse(list.contains("lobby-2"));
+ }
+
+ /**
+ * Verifies the addForcedHost / removeForcedHost logic works correctly.
+ */
+ @Test
+ void addAndRemoveForcedHost_correctBehavior() {
+ Map> forcedHosts = new ConcurrentHashMap<>();
+
+ // Add
+ forcedHosts.computeIfAbsent("lobby.example.com", k -> new CopyOnWriteArrayList<>()).add("lobby-1");
+ forcedHosts.computeIfAbsent("lobby.example.com", k -> new CopyOnWriteArrayList<>()).add("lobby-2");
+ forcedHosts.computeIfAbsent("survival.example.com", k -> new CopyOnWriteArrayList<>()).add("survival-1");
+
+ assertEquals(2, forcedHosts.get("lobby.example.com").size());
+ assertEquals(1, forcedHosts.get("survival.example.com").size());
+
+ // Remove a server from all hosts (like removeForcedHost does)
+ String toRemove = "lobby-1";
+ forcedHosts.values().forEach(list -> list.remove(toRemove));
+ forcedHosts.entrySet().removeIf(entry -> entry.getValue().isEmpty());
+
+ assertEquals(1, forcedHosts.get("lobby.example.com").size());
+ assertEquals("lobby-2", forcedHosts.get("lobby.example.com").get(0));
+
+ // Remove last entry from survival
+ forcedHosts.values().forEach(list -> list.remove("survival-1"));
+ forcedHosts.entrySet().removeIf(entry -> entry.getValue().isEmpty());
+
+ assertNull(forcedHosts.get("survival.example.com"), "Empty host should be removed");
+ assertEquals(1, forcedHosts.size());
+ }
+}
diff --git a/src/test/java/net/azisaba/kuvel/listener/ChooseInitialServerListenerTest.java b/src/test/java/net/azisaba/kuvel/listener/ChooseInitialServerListenerTest.java
new file mode 100644
index 00000000..5e975af2
--- /dev/null
+++ b/src/test/java/net/azisaba/kuvel/listener/ChooseInitialServerListenerTest.java
@@ -0,0 +1,152 @@
+package net.azisaba.kuvel.listener;
+
+import static org.mockito.Mockito.*;
+
+import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
+import com.velocitypowered.api.proxy.Player;
+import com.velocitypowered.api.proxy.ProxyServer;
+import com.velocitypowered.api.proxy.server.RegisteredServer;
+import java.net.InetSocketAddress;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import net.azisaba.kuvel.KuvelServiceHandler;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for ChooseInitialServerListener, specifically the forced-host
+ * lookup refactored from lambda-based ifPresent() to explicit if/get.
+ */
+public class ChooseInitialServerListenerTest {
+
+ private ProxyServer proxy;
+ private KuvelServiceHandler handler;
+ private ChooseInitialServerListener listener;
+
+ @BeforeEach
+ void setUp() {
+ proxy = mock(ProxyServer.class);
+ handler = mock(KuvelServiceHandler.class);
+ listener = new ChooseInitialServerListener(proxy, handler);
+ }
+
+ @Test
+ void forcedHost_routesToCorrectServer() {
+ // Setup forced hosts map
+ Map> forcedHosts = new ConcurrentHashMap<>();
+ forcedHosts.put("lobby.example.com", new CopyOnWriteArrayList<>(Arrays.asList("lobby")));
+ when(handler.getForcedHosts()).thenReturn(forcedHosts);
+ when(handler.getInitialServerNames()).thenReturn(new ArrayList<>());
+
+ // Setup player with virtual host
+ Player player = mock(Player.class);
+ when(player.getVirtualHost()).thenReturn(Optional.of(new InetSocketAddress("lobby.example.com", 25565)));
+
+ // Setup registered server
+ RegisteredServer lobbyServer = mock(RegisteredServer.class);
+ when(proxy.getServer("lobby")).thenReturn(Optional.of(lobbyServer));
+
+ // Create event
+ PlayerChooseInitialServerEvent event = mock(PlayerChooseInitialServerEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+
+ // Execute
+ listener.onInitialServerChoose(event);
+
+ // Verify the forced host was used
+ verify(event).setInitialServer(lobbyServer);
+ }
+
+ @Test
+ void forcedHost_noVirtualHost_fallsThrough() {
+ // Player without virtual host
+ Player player = mock(Player.class);
+ when(player.getVirtualHost()).thenReturn(Optional.empty());
+ when(handler.getInitialServerNames()).thenReturn(new ArrayList<>());
+ when(handler.getForcedHosts()).thenReturn(new ConcurrentHashMap<>());
+
+ PlayerChooseInitialServerEvent event = mock(PlayerChooseInitialServerEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+
+ listener.onInitialServerChoose(event);
+
+ // Should not set any server (no virtual host, no initial servers)
+ verify(event, never()).setInitialServer(any());
+ }
+
+ @Test
+ void forcedHost_unknownHostname_fallsToInitialServer() {
+ // Setup forced hosts with a different hostname
+ Map> forcedHosts = new ConcurrentHashMap<>();
+ forcedHosts.put("lobby.example.com", new CopyOnWriteArrayList<>(Arrays.asList("lobby")));
+ when(handler.getForcedHosts()).thenReturn(forcedHosts);
+
+ // Player connects with different hostname
+ Player player = mock(Player.class);
+ when(player.getVirtualHost()).thenReturn(Optional.of(new InetSocketAddress("unknown.example.com", 25565)));
+
+ // Setup initial server fallback
+ List initialServers = new ArrayList<>(Arrays.asList("default-server"));
+ when(handler.getInitialServerNames()).thenReturn(initialServers);
+ RegisteredServer defaultServer = mock(RegisteredServer.class);
+ when(proxy.getServer("default-server")).thenReturn(Optional.of(defaultServer));
+
+ PlayerChooseInitialServerEvent event = mock(PlayerChooseInitialServerEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+
+ listener.onInitialServerChoose(event);
+
+ // Should fall through to initial server
+ verify(event).setInitialServer(defaultServer);
+ }
+
+ @Test
+ void forcedHost_serverNotRegistered_skipsToNext() {
+ // Two forced servers, first not registered
+ Map> forcedHosts = new ConcurrentHashMap<>();
+ forcedHosts.put("lobby.example.com", new CopyOnWriteArrayList<>(Arrays.asList("lobby-down", "lobby-up")));
+ when(handler.getForcedHosts()).thenReturn(forcedHosts);
+ when(handler.getInitialServerNames()).thenReturn(new ArrayList<>());
+
+ Player player = mock(Player.class);
+ when(player.getVirtualHost()).thenReturn(Optional.of(new InetSocketAddress("lobby.example.com", 25565)));
+
+ // First server not found, second one exists
+ when(proxy.getServer("lobby-down")).thenReturn(Optional.empty());
+ RegisteredServer lobbyUp = mock(RegisteredServer.class);
+ when(proxy.getServer("lobby-up")).thenReturn(Optional.of(lobbyUp));
+
+ PlayerChooseInitialServerEvent event = mock(PlayerChooseInitialServerEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+
+ listener.onInitialServerChoose(event);
+
+ // Should use the second server
+ verify(event).setInitialServer(lobbyUp);
+ }
+
+ @Test
+ void forcedHost_emptyServerList_fallsThrough() {
+ Map> forcedHosts = new ConcurrentHashMap<>();
+ forcedHosts.put("lobby.example.com", new CopyOnWriteArrayList<>());
+ when(handler.getForcedHosts()).thenReturn(forcedHosts);
+ when(handler.getInitialServerNames()).thenReturn(new ArrayList<>());
+
+ Player player = mock(Player.class);
+ when(player.getVirtualHost()).thenReturn(Optional.of(new InetSocketAddress("lobby.example.com", 25565)));
+
+ PlayerChooseInitialServerEvent event = mock(PlayerChooseInitialServerEvent.class);
+ when(event.getPlayer()).thenReturn(player);
+
+ listener.onInitialServerChoose(event);
+
+ // Empty forced host list, no initial servers → no server set
+ verify(event, never()).setInitialServer(any());
+ }
+}
diff --git a/test-k8s/Dockerfile.kuvel-plugin b/test-k8s/Dockerfile.kuvel-plugin
new file mode 100644
index 00000000..154a8ba5
--- /dev/null
+++ b/test-k8s/Dockerfile.kuvel-plugin
@@ -0,0 +1,2 @@
+FROM busybox:latest
+COPY target/Kuvel.jar /plugins/Kuvel.jar
diff --git a/test-k8s/backends.yaml b/test-k8s/backends.yaml
new file mode 100644
index 00000000..7756b199
--- /dev/null
+++ b/test-k8s/backends.yaml
@@ -0,0 +1,41 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: lobby
+ namespace: minecraft
+ labels:
+ kuvel.azisaba.net/enable-server-discovery: "true"
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: lobby
+ template:
+ metadata:
+ labels:
+ app: lobby
+ kuvel.azisaba.net/enable-server-discovery: "true"
+ kuvel.azisaba.net/preferred-server-name: "lobby"
+ kuvel.azisaba.net/initial-server: "true"
+ kuvel.azisaba.net/forced-host: "lobby.localhost"
+ spec:
+ containers:
+ - name: paper
+ image: itzg/minecraft-server:latest
+ env:
+ - name: EULA
+ value: "TRUE"
+ - name: TYPE
+ value: "PAPER"
+ - name: MEMORY
+ value: "1G"
+ - name: ONLINE_MODE
+ value: "FALSE"
+ ports:
+ - containerPort: 25565
+ resources:
+ requests:
+ memory: "512Mi"
+ cpu: "250m"
+ limits:
+ memory: "1Gi"
diff --git a/test-k8s/dragonfly.yaml b/test-k8s/dragonfly.yaml
new file mode 100644
index 00000000..e7a4dc38
--- /dev/null
+++ b/test-k8s/dragonfly.yaml
@@ -0,0 +1,39 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: dragonfly
+ namespace: minecraft
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: dragonfly
+ template:
+ metadata:
+ labels:
+ app: dragonfly
+ spec:
+ containers:
+ - name: redis
+ image: redis:7-alpine
+ args: ["redis-server", "--requirepass", "password"]
+ ports:
+ - containerPort: 6379
+ resources:
+ requests:
+ memory: "64Mi"
+ cpu: "50m"
+ limits:
+ memory: "128Mi"
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: dragonfly
+ namespace: minecraft
+spec:
+ selector:
+ app: dragonfly
+ ports:
+ - port: 6379
+ targetPort: 6379
diff --git a/test-k8s/test_forced_host.py b/test-k8s/test_forced_host.py
new file mode 100644
index 00000000..ae92d327
--- /dev/null
+++ b/test-k8s/test_forced_host.py
@@ -0,0 +1,216 @@
+#!/usr/bin/env python3
+"""Test the forced-host routing in the Kuvel Velocity plugin.
+
+Sends Minecraft protocol handshake + login start packets to the proxy
+with different virtual hosts to verify forced-host routing works.
+"""
+
+import socket
+import struct
+import sys
+import time
+
+
+def write_varint(value):
+ """Encode an integer as a Minecraft VarInt."""
+ result = b""
+ while True:
+ byte = value & 0x7F
+ value >>= 7
+ if value != 0:
+ byte |= 0x80
+ result += struct.pack("B", byte)
+ if value == 0:
+ break
+ return result
+
+
+def read_varint(sock):
+ """Read a VarInt from a socket."""
+ result = 0
+ num_read = 0
+ while True:
+ data = sock.recv(1)
+ if len(data) == 0:
+ raise ConnectionError("Connection closed")
+ byte = data[0]
+ result |= (byte & 0x7F) << (7 * num_read)
+ num_read += 1
+ if num_read > 5:
+ raise ValueError("VarInt too big")
+ if (byte & 0x80) == 0:
+ break
+ return result
+
+
+def make_handshake_packet(hostname, port, next_state=2):
+ """Create a Minecraft handshake packet."""
+ # Protocol version (767 = 1.21.1, but any recent version works)
+ protocol_version = write_varint(767)
+ # Server address (this is the virtual host the proxy checks)
+ hostname_bytes = hostname.encode("utf-8")
+ server_address = write_varint(len(hostname_bytes)) + hostname_bytes
+ # Server port
+ server_port = struct.pack(">H", port)
+ # Next state (2 = login)
+ next_state_bytes = write_varint(next_state)
+
+ # Packet ID 0x00 = Handshake
+ packet_data = write_varint(0x00) + protocol_version + server_address + server_port + next_state_bytes
+ # Length-prefixed packet
+ return write_varint(len(packet_data)) + packet_data
+
+
+def make_login_start_packet(username):
+ """Create a Minecraft login start packet."""
+ # Packet ID 0x00 = Login Start
+ username_bytes = username.encode("utf-8")
+ name_field = write_varint(len(username_bytes)) + username_bytes
+ # UUID (empty/zero for offline mode)
+ uuid_bytes = b"\x00" * 16
+
+ packet_data = write_varint(0x00) + name_field + uuid_bytes
+ return write_varint(len(packet_data)) + packet_data
+
+
+def read_packet(sock, timeout=5):
+ """Read a full Minecraft packet and return (packet_id, data)."""
+ sock.settimeout(timeout)
+ try:
+ length = read_varint(sock)
+ data = b""
+ while len(data) < length:
+ chunk = sock.recv(length - len(data))
+ if not chunk:
+ raise ConnectionError("Connection closed while reading packet")
+ data += chunk
+
+ # Parse packet ID
+ idx = 0
+ packet_id = 0
+ shift = 0
+ while True:
+ byte = data[idx]
+ idx += 1
+ packet_id |= (byte & 0x7F) << shift
+ shift += 7
+ if (byte & 0x80) == 0:
+ break
+
+ return packet_id, data[idx:]
+ except socket.timeout:
+ return None, None
+
+
+def read_string(data, offset=0):
+ """Read a length-prefixed string from data."""
+ idx = offset
+ str_len = 0
+ shift = 0
+ while True:
+ byte = data[idx]
+ idx += 1
+ str_len |= (byte & 0x7F) << shift
+ shift += 7
+ if (byte & 0x80) == 0:
+ break
+ return data[idx:idx + str_len].decode("utf-8", errors="replace"), idx + str_len
+
+
+def test_connection(host, port, virtual_host, username="TestPlayer"):
+ """Test connecting to proxy with a given virtual host.
+
+ Returns a tuple (connected, message).
+ """
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(10)
+ try:
+ sock.connect((host, port))
+
+ # Send handshake with the virtual host
+ handshake = make_handshake_packet(virtual_host, port, next_state=2)
+ sock.sendall(handshake)
+
+ # Send login start
+ login = make_login_start_packet(username)
+ sock.sendall(login)
+
+ # Read response
+ packet_id, data = read_packet(sock, timeout=10)
+
+ if packet_id is None:
+ return False, "No response (timeout)"
+
+ if packet_id == 0x00:
+ # Disconnect packet - read the reason
+ reason, _ = read_string(data)
+ return False, f"Disconnected: {reason}"
+ elif packet_id == 0x02:
+ # Login success
+ return True, "Login success"
+ elif packet_id == 0x03:
+ # Set compression
+ return True, "Compression set (login proceeding)"
+ else:
+ return True, f"Got packet 0x{packet_id:02x} (connection proceeding)"
+
+ except ConnectionRefusedError:
+ return False, "Connection refused"
+ except ConnectionError as e:
+ return False, f"Connection error: {e}"
+ except socket.timeout:
+ return False, "Connection timeout"
+ except Exception as e:
+ return False, f"Error: {e}"
+ finally:
+ sock.close()
+
+
+def main():
+ proxy_host = "127.0.0.1"
+ proxy_port = 30577
+
+ print("=" * 60)
+ print("Kuvel Forced-Host Routing Test")
+ print("=" * 60)
+ print()
+
+ # Test 1: Connect with forced-host "lobby.localhost"
+ print("[Test 1] Virtual host: lobby.localhost")
+ print(" Expected: Routes to 'lobby' server (forced-host match)")
+ success, msg = test_connection(proxy_host, proxy_port, "lobby.localhost", "TestPlayer1")
+ print(f" Result: {msg}")
+ test1_pass = "Disconnected" not in msg or "lobby" in msg.lower() or "could not connect" not in msg.lower()
+ print(f" Status: {'PASS' if test1_pass else 'FAIL'} - Connection was attempted to a server")
+ print()
+
+ # Test 2: Connect with unknown host
+ print("[Test 2] Virtual host: unknown.example.com")
+ print(" Expected: Falls back to initial server (no forced-host match)")
+ success, msg = test_connection(proxy_host, proxy_port, "unknown.example.com", "TestPlayer2")
+ print(f" Result: {msg}")
+ print(f" Status: Connection handled (fell through to initial server logic)")
+ print()
+
+ # Test 3: Connect with no virtual host (just IP)
+ print("[Test 3] Virtual host: 127.0.0.1")
+ print(" Expected: Falls back to initial server (no forced-host match)")
+ success, msg = test_connection(proxy_host, proxy_port, "127.0.0.1", "TestPlayer3")
+ print(f" Result: {msg}")
+ print(f" Status: Connection handled (fell through to initial server logic)")
+ print()
+
+ print("=" * 60)
+ print("Test Summary")
+ print("=" * 60)
+ print()
+ print("The forced-host implementation is working if:")
+ print(" - Test 1 attempts connection to a backend server")
+ print(" - Tests 2 & 3 fall through to initial server selection")
+ print()
+ print("Check proxy logs for detailed routing decisions:")
+ print(" kubectl logs -n minecraft -l app=velocity-proxy -c velocity")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/test-k8s/velocity-config.yaml b/test-k8s/velocity-config.yaml
new file mode 100644
index 00000000..c0f0c269
--- /dev/null
+++ b/test-k8s/velocity-config.yaml
@@ -0,0 +1,59 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: velocity-config
+ namespace: minecraft
+data:
+ velocity.toml: |
+ # Velocity configuration
+ config-version = "2.7"
+ bind = "0.0.0.0:25577"
+ motd = "Kuvel Test Proxy"
+ show-max-players = 100
+ online-mode = false
+ force-key-authentication = false
+ prevent-client-proxy-connections = false
+ player-info-forwarding-mode = "modern"
+ forwarding-secret-file = "forwarding.secret"
+ announce-forge = false
+ kick-existing-players = false
+ ping-passthrough = "DISABLED"
+
+ [servers]
+ try = []
+
+ [forced-hosts]
+
+ [advanced]
+ compression-threshold = 256
+ compression-level = -1
+ login-ratelimit = 3000
+ connection-timeout = 5000
+ read-timeout = 30000
+ haproxy-protocol = false
+ tcp-fast-open = false
+ bungee-plugin-message-channel = true
+ show-ping-requests = false
+ failover-on-unexpected-server-disconnect = true
+ announce-proxy-commands = true
+ log-command-executions = false
+ log-player-connections = true
+ accept-transfers = false
+
+ [query]
+ enabled = false
+ port = 25577
+ map = "Velocity"
+ show-plugins = false
+ kuvel-config.yml: |
+ namespace: "minecraft"
+ label-key-prefix: "kuvel.azisaba.net"
+ redis:
+ group-name: "test"
+ connection:
+ hostname: "dragonfly.minecraft.svc.cluster.local"
+ port: 6379
+ username: "default"
+ password: "password"
+ label-selectors:
+ - "kuvel.azisaba.net/enable-server-discovery=true"
diff --git a/test-k8s/velocity-proxy.yaml b/test-k8s/velocity-proxy.yaml
new file mode 100644
index 00000000..bb116e4f
--- /dev/null
+++ b/test-k8s/velocity-proxy.yaml
@@ -0,0 +1,110 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: velocity-proxy
+ namespace: minecraft
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: velocity-proxy
+ template:
+ metadata:
+ labels:
+ app: velocity-proxy
+ spec:
+ serviceAccountName: kuvel-sa
+ initContainers:
+ - name: copy-kuvel-plugin
+ image: kuvel-plugin:latest
+ imagePullPolicy: Never
+ command: ['sh', '-c', 'mkdir -p /server-data/plugins && cp /plugins/Kuvel.jar /server-data/plugins/Kuvel.jar']
+ volumeMounts:
+ - name: proxy-data
+ mountPath: /server-data
+ containers:
+ - name: velocity
+ image: itzg/mc-proxy:latest
+ env:
+ - name: TYPE
+ value: "VELOCITY"
+ - name: MEMORY
+ value: "512m"
+ - name: KUVEL_NAMESPACE
+ value: "minecraft"
+ - name: KUVEL_REDIS_CONNECTION_HOSTNAME
+ value: "dragonfly.minecraft.svc.cluster.local"
+ - name: KUVEL_REDIS_CONNECTION_PORT
+ value: "6379"
+ - name: KUVEL_REDIS_CONNECTION_USERNAME
+ value: "default"
+ - name: KUVEL_REDIS_CONNECTION_PASSWORD
+ value: "password"
+ - name: KUVEL_REDIS_GROUPNAME
+ value: "test"
+ ports:
+ - containerPort: 25577
+ name: minecraft
+ volumeMounts:
+ - name: proxy-data
+ mountPath: /server
+ - name: velocity-config
+ mountPath: /config/velocity.toml
+ subPath: velocity.toml
+ resources:
+ requests:
+ memory: "256Mi"
+ cpu: "100m"
+ limits:
+ memory: "512Mi"
+ volumes:
+ - name: proxy-data
+ emptyDir: {}
+ - name: velocity-config
+ configMap:
+ name: velocity-config
+---
+apiVersion: v1
+kind: Service
+metadata:
+ name: velocity-proxy
+ namespace: minecraft
+spec:
+ type: NodePort
+ selector:
+ app: velocity-proxy
+ ports:
+ - port: 25577
+ targetPort: 25577
+ nodePort: 30577
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: kuvel-sa
+ namespace: minecraft
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: kuvel-role
+rules:
+ - apiGroups: [""]
+ resources: ["pods"]
+ verbs: ["get", "list", "watch"]
+ - apiGroups: ["apps"]
+ resources: ["replicasets"]
+ verbs: ["get", "list", "watch"]
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: kuvel-rolebinding
+subjects:
+ - kind: ServiceAccount
+ name: kuvel-sa
+ namespace: minecraft
+roleRef:
+ kind: ClusterRole
+ name: kuvel-role
+ apiGroup: rbac.authorization.k8s.io