From c17652b0920ad40a068aebd14b7f7344c10ff069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chip=20Wolf=20=E2=80=AE?= Date: Thu, 23 Apr 2026 16:47:37 +0000 Subject: [PATCH] feat: add forced-host label for Velocity forced hosts support Add a new 'forced-host' label/annotation that can be set on Pods and ReplicaSets to configure Velocity's forced hosts functionality. Players connecting via a specific hostname will be routed to the server with the matching forced-host label. See: https://docs.papermc.io/velocity/configuration/\#forced-hosts-section --- .gitignore | 4 +- README-JP.md | 17 ++++++--- README.md | 5 +++ .../azisaba/kuvel/KuvelServiceHandler.java | 38 ++++++++++++++++++- .../redis/RedisLoadBalancerDiscovery.java | 15 +++++++- .../listener/ChooseInitialServerListener.java | 16 ++++++++ .../kuvel/loadbalancer/LoadBalancer.java | 1 + .../kuvel/redis/RedisConnectionLeader.java | 4 +- .../azisaba/kuvel/redis/RedisSubscriber.java | 7 +++- .../net/azisaba/kuvel/util/LabelKeys.java | 1 + 10 files changed, 95 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 39c68bf9..98cd7dad 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,6 @@ buildNumber.properties # JDT-specific (Eclipse Java Development Tools) .classpath -# End of https://www.toptal.com/developers/gitignore/api/maven,intellij+all \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/maven,intellij+all + +.vscode/ diff --git a/README-JP.md b/README-JP.md index 3bb82a62..36acf79c 100644 --- a/README-JP.md +++ b/README-JP.md @@ -85,16 +85,18 @@ labelはconfigで指定します。デフォルトでは、`kuvel.azisaba.net/en また、一部の機能を使用するために以下のlabelを使用します。 -| Label名 | 値 | -|:---------------------------------------:|:-------------------:| -| kuvel.azisaba.net/preferred-server-name | Velocityに登録したいサーバー名 | -| kuvel.azisaba.net/initial-server | true / false | -| kuvel.azisaba.net/disable-name-suffix | true / false | -| kuvel.azisaba.net/disable-load-balancer | true / false | +| Label名 | 値 | +|:---------------------------------------:|:----------------------------:| +| kuvel.azisaba.net/preferred-server-name | Velocityに登録したいサーバー名 | +| kuvel.azisaba.net/initial-server | true / false | +| kuvel.azisaba.net/disable-name-suffix | true / false | +| kuvel.azisaba.net/disable-load-balancer | true / false | +| kuvel.azisaba.net/forced-host | このサーバーにルーティングするホスト名 (forced hosts) | サーバー名が63文字を超える場合は、ラベルではなく`kuvel.azisaba.net/preferred-server-name`のアノテーションを使用してください。 Podに`kuvel.azisaba.net/disable-name-suffix=true`を設定すると、`preferred-server-name`をそのまま登録します (`-1`などのsuffixを付与しません)。複数レプリカが検出された場合はエラーを出して追加Podを登録しません。単一レプリカのPod向けの設定です。 ReplicaSetまたはDeploymentのmetadataに`kuvel.azisaba.net/disable-load-balancer=true`を設定すると、そのReplicaSet用の仮想LoadBalancerサーバーは作成されません。配下のPod自体は通常どおり登録されます。 +Pod、Deployment、またはReplicaSetに`kuvel.azisaba.net/forced-host`を設定すると、そのホスト名で接続したプレイヤーは対応するサーバーにルーティングされます。これはVelocityの[forced hosts](https://docs.papermc.io/velocity/configuration/#forced-hosts-section)機能をKubernetesのラベル/アノテーションで動的に設定するものです。 ### Podの場合 @@ -108,6 +110,7 @@ metadata: kuvel.azisaba.net/preferred-server-name: "test-server" # Kuvelがサーバーの命名をするために必要 # kuvel.azisaba.net/initial-server: "true" # 初期サーバーにする場合はコメントアウトを外す # kuvel.azisaba.net/disable-name-suffix: "true" # preferred-server-nameをそのまま登録する (単一レプリカのみ) + # kuvel.azisaba.net/forced-host: "pvp.example.com" # pvp.example.comで接続したプレイヤーをこのサーバーに送る spec: containers: - name: test-server @@ -134,6 +137,7 @@ spec: kuvel.azisaba.net/enable-server-discovery: "true" # KuvelがMinecraftサーバーを見つけるために必要 (Configに依存) kuvel.azisaba.net/preferred-server-name: "test-server" # Kuvelがサーバーの命名をするために必要 # kuvel.azisaba.net/initial-server: "true" # 初期サーバーにする場合はコメントアウトを外す + # kuvel.azisaba.net/forced-host: "pvp.example.com" # pvp.example.comで接続したプレイヤーをこのサーバーに送る spec: containers: - name: test-server @@ -159,6 +163,7 @@ metadata: kuvel.azisaba.net/preferred-server-name: "lobby" # kuvel.azisaba.net/initial-server: "true" # このロードバランサーを初期サーバーにする場合はコメントアウトを外す # kuvel.azisaba.net/disable-load-balancer: "true" # LoadBalancerエントリだけ無効にする場合はコメントアウトを外す + # kuvel.azisaba.net/forced-host: "lobby.example.com" # lobby.example.comで接続したプレイヤーをこのロードバランサーに送る spec: replicas: 3 selector: diff --git a/README.md b/README.md index 5936558b..d8522a87 100644 --- a/README.md +++ b/README.md @@ -89,10 +89,12 @@ The following labels are also used for some other features. | kuvel.azisaba.net/initial-server | true / false | | kuvel.azisaba.net/disable-name-suffix | true / false | | kuvel.azisaba.net/disable-load-balancer | true / false | +| kuvel.azisaba.net/forced-host | Hostname to route to this server (forced hosts) | If server names longer than 63 characters are desired, the `kuvel.azisaba.net/preferred-server-name` annotation can be used instead of the label. If `kuvel.azisaba.net/disable-name-suffix=true` is set on a Pod, Kuvel will register the server name exactly as `preferred-server-name` (no `-1` suffix). If multiple replicas are detected, Kuvel logs an error and skips the extra pods. This label is intended for single-replica Pods only. If `kuvel.azisaba.net/disable-load-balancer=true` is set on a ReplicaSet or Deployment metadata, Kuvel will skip creating the virtual load balancer server for that ReplicaSet. The backing Pods are still registered normally. +If `kuvel.azisaba.net/forced-host` is set on a Pod, Deployment, or ReplicaSet, players connecting via that hostname will be routed to the corresponding server. This mirrors Velocity's [forced hosts](https://docs.papermc.io/velocity/configuration/#forced-hosts-section) feature but is configured dynamically through Kubernetes labels/annotations. ### Pod @@ -106,6 +108,7 @@ metadata: kuvel.azisaba.net/preferred-server-name: "test-server" # Required for Kuvel to name the server # kuvel.azisaba.net/initial-server: "true" # Uncomment this line if you want to make this server the initial server. # kuvel.azisaba.net/disable-name-suffix: "true" # Use the exact preferred name (no -1 suffix); single replica only. + # kuvel.azisaba.net/forced-host: "pvp.example.com" # Players connecting via pvp.example.com will be sent to this server. spec: containers: - name: test-server @@ -132,6 +135,7 @@ spec: kuvel.azisaba.net/enable-server-discovery: "true" # Required for Kuvel to detect Minecraft servers. Depends on your config. kuvel.azisaba.net/preferred-server-name: "test-server" # Required for Kuvel to name the server # kuvel.azisaba.net/initial-server: "true" # Uncomment this line if you want to make this server the initial server. + # kuvel.azisaba.net/forced-host: "pvp.example.com" # Players connecting via pvp.example.com will be sent to this server. spec: containers: - name: test-server @@ -158,6 +162,7 @@ metadata: kuvel.azisaba.net/preferred-server-name: "lobby" # kuvel.azisaba.net/initial-server: "true" # Uncomment this line if you want to make this load balancer server the initial server. # kuvel.azisaba.net/disable-load-balancer: "true" # Uncomment this line to disable only the load balancer entry. + # kuvel.azisaba.net/forced-host: "lobby.example.com" # Players connecting via lobby.example.com will be sent to this load balancer. spec: replicas: 3 selector: diff --git a/src/main/java/net/azisaba/kuvel/KuvelServiceHandler.java b/src/main/java/net/azisaba/kuvel/KuvelServiceHandler.java index e039f6b2..2586bba0 100644 --- a/src/main/java/net/azisaba/kuvel/KuvelServiceHandler.java +++ b/src/main/java/net/azisaba/kuvel/KuvelServiceHandler.java @@ -10,8 +10,11 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Map.Entry; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicReference; import javax.annotation.Nullable; @@ -38,6 +41,7 @@ public class KuvelServiceHandler { private final UidAndServerNameMap replicaSetUidAndServerNameMap = new UidAndServerNameMap(); private final List initialServerNames = new ArrayList<>(); + private final Map> forcedHosts = new ConcurrentHashMap<>(); private final AtomicReference serverDiscovery = new AtomicReference<>(); private final AtomicReference 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() @@ -244,13 +253,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() + ")"); @@ -315,6 +337,7 @@ public void unregisterPod(String podUid) { } initialServerNames.remove(serverName); + removeForcedHost(serverName); plugin.getLogger().info("Unregistered server: " + serverName + " (" + podUid + ")"); } @@ -337,4 +360,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/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;