feat(plugins): add Velocity proxy and Fabric/Forge/NeoForge/Paper integration mods

Server-side integration plugins: the Velocity proxy plugin plus Fabric, Forge, NeoForge, and Paper mods with a shared module. Gradle build output is not tracked.
This commit is contained in:
flyemoji committed 2026-06-26 23:32:40 +09:00
1 parent eee00c2772
commit 93f143f5b6
53 files changed
+4832

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
plugins {
id 'java'
}
group = 'best.lolicon.felis'
version = '0.1.0'
// JDK 17 is the ceiling the whole plugin suite targets (Velocity 3.3.0 is a
// Java-17 line); we run Gradle on JDK 17 and compile to 17 bytecode rather than
// provisioning a separate toolchain.
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
maven {
name = 'papermc'
url = 'https://repo.papermc.io/repository/maven-public/'
}
}
dependencies {
// velocity-api is compile-only (the proxy provides it at runtime); the
// annotation processor turns @Plugin into the generated velocity-plugin.json.
compileOnly 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
annotationProcessor 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
}
// The platform-agnostic link core lives in ../shared and is compiled straight
// into this jar. The core has zero third-party dependencies, so source-sharing
// keeps every loader self-contained with nothing to shade.
sourceSets {
main {
java {
srcDir '../shared/src/main/java'
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'felis-velocity'
@@ -0,0 +1,207 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.MenuStatus;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.messages.ChannelIdentifier;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import org.slf4j.Logger;
import java.util.UUID;
/**
* ControlChannel is the proxy end of the {@code felis:control} plugin-message channel
* (spec §12): the bridge between the felis-paper lobby's {@code /menu} GUI and the
* routing core. The lobby is a pure UI face — it holds no felis-api token and keeps
* no queue — so every menu action arrives here as a {@link ControlFrame}, this class
* drives the felis-api internal endpoints and the shared waiting queue, and answers
* downstream with another frame. It is the §27 scenario-10 path: {@code /menu →
* plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
*
* <p><b>Anti-spoof (spec §14).</b> The acting identity is taken from the
* {@link ServerConnection} the message arrived on — {@code source.getPlayer()} — and
* never from the frame's {@code player} field, so a compromised backend cannot drive
* an action as another player. A frame whose source is not a backend server (e.g. a
* client) is consumed and dropped. The frame's {@code server} field is data, not
* identity: it names which backend the player asked for, and the autostartPolicy /
* ownership gates server-side decide whether this UUID may act on it.
*
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
* thread, but every felis-api call below blocks on HTTP. So each handler does the
* non-blocking work synchronously — decode, derive identity, and
* {@code setResult(handled())} to stop the frame being forwarded — then hands the
* blocking call to {@link FelisVelocityPlugin#async}, sending any downstream frame
* from inside that callback. {@code setResult} must run before the handler returns;
* it cannot be set from the async hop.
*
* <p>The three upstream frames map onto the menu's buttons (spec §12): a
* {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
* (ownerless server) runs the two-rule split — claim asserts ownership/quota, then
* the wake applies the autostartPolicy gate — and parks on success. Refusals come
* back as {@code Error}; readiness as {@code TransferReady} just before the proxy
* Connects the player (via {@link WaitingRouter.MenuTransferListener}).
*/
public final class ControlChannel implements WaitingRouter.MenuTransferListener {
/** The namespaced channel both ends register; shared with the codec's name. */
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.from(Control.CHANNEL);
private final ProxyServer proxy;
private final Logger log;
private final FelisApiClient api;
private final WaitingRouter router;
private final FelisVelocityPlugin plugin;
ControlChannel(ProxyServer proxy, Logger log, FelisApiClient api,
WaitingRouter router, FelisVelocityPlugin plugin) {
this.proxy = proxy;
this.log = log;
this.api = api;
this.router = router;
this.plugin = plugin;
}
/**
* register opens the channel and wires this instance as the waiting queue's
* menu-transfer listener. The caller still registers it as an event subscriber.
*/
void register() {
proxy.getChannelRegistrar().register(CHANNEL);
router.setMenuTransferListener(this);
}
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
if (!CHANNEL.getId().equals(event.getIdentifier().getId())) {
return; // not ours → leave Velocity's default handling alone
}
// We own this channel end to end: a felis:control frame is never relayed to
// the other side, whatever its source. Consume it before doing anything else.
event.setResult(PluginMessageEvent.ForwardResult.handled());
// Identity comes from the connection, never the frame (spec §14). A frame from
// anything but a backend server (e.g. a client) is not a legitimate lobby
// action — drop it.
if (!(event.getSource() instanceof ServerConnection)) {
return;
}
ServerConnection source = (ServerConnection) event.getSource();
Player player = source.getPlayer();
ControlFrame frame;
try {
frame = Control.decode(event.getData());
} catch (IllegalArgumentException e) {
log.debug("Felis: dropping malformed felis:control frame from {}: {}",
source.getServerInfo().getName(), e.getMessage());
return;
}
switch (frame.type()) {
case ControlFrame.STATUS_QUERY:
handleStatusQuery(source, frame.server());
break;
case ControlFrame.WAKE_REQUEST:
handleWake(source, player, frame.server());
break;
case ControlFrame.CLAIM_REQUEST:
handleClaim(source, player, frame.server());
break;
default:
// Downstream-only types (StatusUpdate/TransferReady/Error) are not
// actionable arriving upstream; a well-behaved lobby never sends them.
log.debug("Felis: ignoring non-actionable felis:control frame '{}' from {}",
frame.type(), player.getUsername());
}
}
// A StatusQuery refreshes one tile: read the menu projection and answer with a
// StatusUpdate, or an Error if felis-api refuses (e.g. 404 unknown server).
private void handleStatusQuery(ServerConnection source, String server) {
if (isBlank(server)) {
return; // nothing to look up
}
plugin.async(() -> {
try {
MenuStatus s = api.menuStatus(server);
send(source, ControlFrame.statusUpdate(
s.name(), s.phase(), s.ready(), s.playersOnline(), s.playersMax(), s.claimable()));
} catch (LinkException e) {
send(source, errorFrame(e, server));
}
});
}
// A WakeRequest is the menu's Join/Wake button on a server the player owns: wake
// it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread
// and reports its own refusals to the player; nothing to await here.
private void handleWake(ServerConnection source, Player player, String server) {
if (isBlank(server)) {
send(source, ControlFrame.error("bad_request", "wake without a server", null));
return;
}
router.enqueueFromMenu(player, server);
}
// A ClaimRequest is the menu's Claim & Start on an ownerless server: the two-rule
// split. Claim first (ownership + quota); only on success wake-and-park (the
// autostartPolicy gate). A claim refusal answers with Error and never wakes.
private void handleClaim(ServerConnection source, Player player, String server) {
if (isBlank(server)) {
send(source, ControlFrame.error("bad_request", "claim without a server", null));
return;
}
UUID id = player.getUniqueId();
plugin.async(() -> {
try {
api.claim(server, id);
} catch (LinkException e) {
send(source, errorFrame(e, server));
return; // claim refused → do not wake a server the player doesn't own
}
// Owned now → run the second rule. enqueueFromMenu spawns its own async
// hop for the wake, which is fine from here.
router.enqueueFromMenu(player, server);
});
}
/**
* onReady fires when a menu-parked player's backend goes ready, just before the
* proxy Connects them. The lobby uses {@code TransferReady} to react (close the
* menu / show "joining"); the actual move is the proxy's Connect, not this frame.
*/
@Override
public void onReady(Player player, String serverName) {
player.getCurrentServer().ifPresent(sc ->
send(sc, ControlFrame.transferReady(player.getUsername(), serverName)));
}
private void send(ServerConnection connection, ControlFrame frame) {
connection.sendPluginMessage(CHANNEL, Control.encode(frame));
}
// errorFrame turns a LinkException into a downstream Error. felis-api's structured
// errors carry user-safe text (the same {code,message} the external API returns),
// but a transport failure (statusCode 0) carries internal IO detail — host names,
// refused ports — that must not reach a player's screen, so it is generalized.
private static ControlFrame errorFrame(LinkException e, String server) {
String code = e.errorCode() != null ? e.errorCode() : "error";
String message = e.statusCode() == 0
? "felis is temporarily unavailable — please try again."
: e.getMessage();
return ControlFrame.error(code, message, server);
}
private static boolean isBlank(String s) {
return s == null || s.isEmpty();
}
}
@@ -0,0 +1,91 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.LinkConfigLoader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Properties;
/**
* FelisVelocityConfig extends the shared link config with the two inputs only the
* full proxy needs: the {@code root-domain} the deployment serves (the zone
* subdomains are carved from) and the {@code lobby-server} waiters are parked in
* while their backend wakes. It reuses {@link LinkConfigLoader} for the API base
* URL + service token (and its first-run template), so {@code /link} keeps working
* exactly as before; these extra keys are read from the same properties file (or
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOBBY_SERVER}).
*
* <p>Both extras are optional at load time and the routing layer degrades rather
* than crashing: a missing {@code root-domain} disables routing (with a clear log
* line) while {@code /link} still runs, and a missing {@code lobby-server} means
* the proxy has nowhere to hold waiters, so it refuses the join with a "reconnect
* shortly" message instead of dropping the player onto a not-yet-ready backend.
* The root domain is the only place the deployment zone enters the proxy — it is
* never compiled in (CI red line).
*/
final class FelisVelocityConfig {
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
private static final String KEY_ROOT_DOMAIN = "root-domain";
private static final String KEY_LOBBY = "lobby-server";
private final LinkConfig linkConfig;
private final String rootDomain; // null → routing disabled
private final String lobbyServer; // null → no lobby to park waiters in
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, String lobbyServer) {
this.linkConfig = linkConfig;
this.rootDomain = rootDomain;
this.lobbyServer = lobbyServer;
}
static FelisVelocityConfig load(Path file) throws IOException {
LinkConfig link = LinkConfigLoader.load(file); // url + token (required) + template + validate
Properties props = new Properties();
if (Files.exists(file)) {
try (InputStream in = Files.newInputStream(file)) {
props.load(in);
}
}
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
return new FelisVelocityConfig(link, root == null ? null : root.toLowerCase(Locale.ROOT), lobby);
}
LinkConfig linkConfig() {
return linkConfig;
}
/** rootDomain is the deployment zone, or null when routing should stay disabled. */
String rootDomain() {
return rootDomain;
}
boolean routingEnabled() {
return rootDomain != null;
}
/** lobbyServer is the velocity.toml server name waiters are parked in, or null. */
String lobbyServer() {
return lobbyServer;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.trim().isEmpty()) {
return a;
}
return b;
}
private static String trimToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
}
@@ -0,0 +1,256 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.ServerView;
import com.google.inject.Inject;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.tree.LiteralCommandNode;
import com.velocitypowered.api.command.BrigadierCommand;
import com.velocitypowered.api.command.CommandManager;
import com.velocitypowered.api.command.CommandMeta;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
/**
* FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11
* domain-autostart routing). Velocity sits on the player-facing edge, off-cluster,
* and is where two responsibilities naturally live:
*
* <ul>
* <li><b>{@code /link}</b> — mints a one-time account-link code from the player's
* online-mode-verified UUID (the first leg of §10), unchanged.</li>
* <li><b>Domain-autostart routing</b> — recognizes each server's subdomain,
* registers backends dynamically, routes joins, wakes a sleeping target and
* holds the player in a lobby until it is ready, and reports real joins so
* the reaper and allowlist see them (§11, driving §9).</li>
* </ul>
*
* <p>Routing has two hard preconditions, each fails safe: the proxy must run in
* online mode (verified UUIDs are the whole basis of the autostartPolicy and
* allowlist gates — under offline mode routing is refused while {@code /link}
* keeps working), and a {@code root-domain} must be configured (the only place the
* deployment zone enters the proxy; never compiled in). With routing active the
* {@code felis:control} plugin-message channel (spec §12) is also opened: the
* felis-paper lobby's {@code /menu} drives the same waiting queue through
* {@link ControlChannel}, deriving identity from the backend connection rather than
* the frame so a compromised lobby cannot act as another player (spec §14).
*/
@Plugin(
id = "felis-link",
name = "Felis Velocity",
version = "0.2.0",
description = "In-game /link plus domain-autostart routing: recognizes server subdomains, "
+ "registers backends, wakes sleeping servers and holds players until ready.",
authors = {"Felis"}
)
public final class FelisVelocityPlugin {
private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15);
private static final Duration WAIT_POLL = Duration.ofSeconds(2);
private final ProxyServer proxy;
private final Logger logger;
private final Path dataDirectory;
private FelisVelocityConfig config;
private LinkClient linkClient;
private FelisApiClient apiClient;
private ServerRegistry registry;
private boolean onlineMode;
private boolean routingActive;
@Inject
public FelisVelocityPlugin(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) {
this.proxy = proxy;
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onProxyInitialize(ProxyInitializeEvent event) {
try {
this.config = FelisVelocityConfig.load(dataDirectory.resolve("felis-link.properties"));
} catch (Exception e) {
logger.error("Felis disabled: {}", e.getMessage());
return;
}
this.linkClient = new LinkClient(config.linkConfig());
registerLinkCommand();
registerFelisCommand();
this.onlineMode = proxy.getConfiguration().isOnlineMode();
if (!onlineMode) {
logger.error("Felis routing DISABLED: the proxy is in offline mode (online-mode=false). "
+ "Domain autostart and the allowlist trust Mojang-verified UUIDs; refusing to route on "
+ "spoofable identities. /link remains available. Set online-mode=true to enable routing.");
return;
}
if (!config.routingEnabled()) {
logger.warn("Felis routing DISABLED: no root-domain configured. Add 'root-domain=' to "
+ "felis-link.properties (or set " + FelisVelocityConfig.ENV_ROOT_DOMAIN + ") to enable "
+ "host-based routing. /link remains available.");
return;
}
this.apiClient = new FelisApiClient(config.linkConfig());
this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
WaitingRouter router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
MotdResponder motd = new MotdResponder(registry);
proxy.getEventManager().register(this, router);
proxy.getEventManager().register(this, motd);
// The felis:control face (spec §12): the felis-paper lobby's /menu drives the
// same waiting queue through this channel. Opened only with routing active —
// it depends on the same online-mode + root-domain guards, and its claim/wake
// identity is the verified UUID off the backend connection (spec §14).
ControlChannel control = new ControlChannel(proxy, logger, apiClient, router, this);
control.register();
proxy.getEventManager().register(this, control);
// Prime registrations immediately, then keep them fresh; drain the queue often.
refreshRegistrations();
repeating(REGISTRATION_REFRESH, this::refreshRegistrations);
repeating(WAIT_POLL, router::tick);
this.routingActive = true;
if (config.lobbyServer() == null) {
logger.warn("Felis routing active without a lobby-server: a player whose target is asleep will be "
+ "asked to reconnect rather than parked. Set 'lobby-server=' to enable the waiting queue.");
}
logger.info("Felis routing ready: rootDomain={}, lobby={}. /link and /felis registered.",
config.rootDomain(), config.lobbyServer() == null ? "<none>" : config.lobbyServer());
}
/** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */
void async(Runnable task) {
proxy.getScheduler().buildTask(this, task).schedule();
}
private void repeating(Duration interval, Runnable task) {
proxy.getScheduler().buildTask(this, task).delay(interval).repeat(interval).schedule();
}
private void refreshRegistrations() {
try {
List<ServerView> servers = apiClient.listServers();
registry.refresh(servers);
} catch (LinkException e) {
// Keep existing registrations on a control-plane blip (spec §11): a
// transient failure must never deregister live backends.
logger.warn("Felis: server list refresh failed (status={}): {}; keeping current registrations.",
e.statusCode(), e.getMessage());
}
}
// ---- /link (spec §10 first leg) ----
private void registerLinkCommand() {
CommandManager commands = proxy.getCommandManager();
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("link")
.executes(ctx -> {
CommandSource source = ctx.getSource();
if (!(source instanceof Player)) {
source.sendMessage(Component.text("/link can only be run by a player.", NamedTextColor.RED));
return Command.SINGLE_SUCCESS;
}
requestAndReply((Player) source);
return Command.SINGLE_SUCCESS;
})
.build();
CommandMeta meta = commands.metaBuilder("link").plugin(this).build();
commands.register(meta, new BrigadierCommand(node));
}
private void requestAndReply(Player player) {
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
async(() -> {
try {
LinkCode code = linkClient.requestCode(player.getUniqueId());
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
player.sendMessage(Component.text(
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
NamedTextColor.GRAY));
} catch (LinkException e) {
logger.warn("link code request failed for {} (status={}, code={}): {}",
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't get a link code right now. Please try again in a moment.",
NamedTextColor.RED));
}
});
}
// ---- /felis (operator status) ----
private void registerFelisCommand() {
CommandManager commands = proxy.getCommandManager();
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("felis")
.executes(ctx -> {
sendSummary(ctx.getSource());
return Command.SINGLE_SUCCESS;
})
.then(BrigadierCommand.literalArgumentBuilder("list")
.executes(ctx -> {
sendList(ctx.getSource());
return Command.SINGLE_SUCCESS;
}))
.build();
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
commands.register(meta, new BrigadierCommand(node));
}
private void sendSummary(CommandSource source) {
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
if (!routingActive) {
source.sendMessage(Component.text(
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
NamedTextColor.YELLOW));
return;
}
source.sendMessage(field("root-domain", config.rootDomain()));
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
}
private void sendList(CommandSource source) {
if (!routingActive) {
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
return;
}
Collection<ServerView> servers = registry.all();
if (servers.isEmpty()) {
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
return;
}
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
for (ServerView v : servers) {
String phase = v.phase() == null ? "?" : v.phase();
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
}
}
private static Component field(String key, String value) {
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
.append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE));
}
}
@@ -0,0 +1,76 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyPingEvent;
import com.velocitypowered.api.proxy.server.ServerPing;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import java.net.InetSocketAddress;
import java.util.Optional;
/**
* MotdResponder answers the server-list ping for a felis subdomain from cache, so
* a player sees the right server in their list — and whether it is awake — without
* the ping itself waking anything (spec §11 "ping MOTD:只读缓存,后台刷新"). The
* cache it reads is the {@link ServerRegistry}'s lifecycle view, refreshed in the
* background by the plugin's registration poll; the ping handler never touches the
* backend or the wake lever.
*
* <p>This is the read-only, phase-aware subset of the responsibility: the MOTD is
* synthesized from the server's lifecycle (online / starting / sleeping) and its
* cached player counts. Mirroring each backend's <em>own</em> MOTD string (by
* pinging ready servers in the background and caching the result) is a richer
* variant deferred to a later slice; nothing here ever pings a sleeping backend.
*/
public final class MotdResponder {
private final ServerRegistry registry;
MotdResponder(ServerRegistry registry) {
this.registry = registry;
}
@Subscribe
public void onProxyPing(ProxyPingEvent event) {
Optional<InetSocketAddress> vh = event.getConnection().getVirtualHost();
if (vh.isEmpty()) {
return; // no SRV host → leave the proxy's own MOTD
}
Optional<ServerView> viewOpt = registry.resolveByHost(vh.get().getHostString());
if (viewOpt.isEmpty()) {
return; // not a felis subdomain → leave the proxy's own MOTD
}
ServerView v = viewOpt.get();
ServerPing.Builder b = event.getPing().asBuilder();
b.description(Component.text("« " + v.name() + " » ", NamedTextColor.AQUA)
.append(Component.text(statusLine(v), statusColor(v))));
if (v.ready()) {
b.onlinePlayers(v.playersOnline());
b.maximumPlayers(Math.max(v.playersMax(), v.playersOnline()));
}
event.setPing(b.build());
}
private static String statusLine(ServerView v) {
if (v.ready()) {
return "online";
}
if ("Running".equals(v.desiredState())) {
return "starting…";
}
return "sleeping — join to wake";
}
private static NamedTextColor statusColor(ServerView v) {
if (v.ready()) {
return NamedTextColor.GREEN;
}
if ("Running".equals(v.desiredState())) {
return NamedTextColor.YELLOW;
}
return NamedTextColor.GRAY;
}
}
@@ -0,0 +1,150 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.proxy.server.ServerInfo;
import org.slf4j.Logger;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* ServerRegistry mirrors the felis server set into Velocity's dynamic server
* registry and indexes it for host-based routing (spec §11). It owns two things:
* the felis lifecycle views keyed by name + subdomain, and the registration of
* each server's backend address as a Velocity {@link RegisteredServer}.
*
* <p>{@link #refresh(Collection)} is called only on a <em>successful</em> fetch
* from felis-api, so a name's absence is a genuine removal. On an API failure the
* caller skips the refresh entirely and the previous registrations survive
* untouched (spec §11 keep-old-on-failure) — a transient control-plane blip must
* never deregister live backends out from under connected players.
*
* <p>Only felis-managed servers live in this registry; servers defined statically
* in {@code velocity.toml} (notably the lobby) are never added here and so are
* never deregistered by a refresh. Static server names must therefore not collide
* with felis server names.
*/
final class ServerRegistry {
private static final int DEFAULT_PORT = 25565;
private final ProxyServer proxy;
private final Logger log;
private final String rootDomain;
private final Map<String, ServerView> byName = new ConcurrentHashMap<>();
private final Map<String, String> subdomainToName = new ConcurrentHashMap<>();
ServerRegistry(ProxyServer proxy, Logger log, String rootDomain) {
this.proxy = proxy;
this.log = log;
this.rootDomain = rootDomain.toLowerCase(Locale.ROOT);
}
/** refresh reconciles registrations against a freshly fetched server list. */
void refresh(Collection<ServerView> servers) {
Set<String> seen = new HashSet<>();
for (ServerView v : servers) {
String name = v.name();
if (name == null || name.isEmpty()) {
continue;
}
seen.add(name);
byName.put(name, v);
String sub = v.subdomain();
if (sub != null && !sub.isEmpty()) {
subdomainToName.put(sub.toLowerCase(Locale.ROOT), name);
}
ensureRegistered(v);
}
// Drop servers that vanished from a successful fetch (iterate a snapshot so
// deregister can mutate byName underneath us).
for (String name : new ArrayList<>(byName.keySet())) {
if (!seen.contains(name)) {
deregister(name);
}
}
subdomainToName.values().removeIf(n -> !seen.contains(n));
}
private void ensureRegistered(ServerView v) {
String addr = v.endpointAddress();
if (addr == null || addr.isEmpty()) {
return; // no backend address yet (server never started) → nothing to register
}
InetSocketAddress target = parseAddress(addr);
Optional<RegisteredServer> existing = proxy.getServer(v.name());
if (existing.isPresent()) {
if (existing.get().getServerInfo().getAddress().equals(target)) {
return; // already registered at this address
}
proxy.unregisterServer(existing.get().getServerInfo()); // address changed → re-register
}
proxy.registerServer(new ServerInfo(v.name(), target));
log.info("Felis: registered backend {} -> {}", v.name(), addr);
}
private void deregister(String name) {
byName.remove(name);
proxy.getServer(name).ifPresent(rs -> {
proxy.unregisterServer(rs.getServerInfo());
log.info("Felis: deregistered backend {}", name);
});
}
/** resolveByHost maps {@code subdomain.<root_domain>} to its current view. */
Optional<ServerView> resolveByHost(String host) {
if (host == null) {
return Optional.empty();
}
String h = host.toLowerCase(Locale.ROOT);
String suffix = "." + rootDomain;
if (!h.endsWith(suffix)) {
return Optional.empty();
}
String sub = h.substring(0, h.length() - suffix.length());
String name = subdomainToName.get(sub);
return name == null ? Optional.empty() : Optional.ofNullable(byName.get(name));
}
ServerView view(String name) {
return name == null ? null : byName.get(name);
}
boolean isManaged(String name) {
return name != null && byName.containsKey(name);
}
Optional<RegisteredServer> registered(String name) {
return proxy.getServer(name);
}
Collection<ServerView> all() {
return new ArrayList<>(byName.values());
}
/** parseAddress splits {@code host[:port]} into an unresolved socket address. */
static InetSocketAddress parseAddress(String addr) {
int idx = addr.lastIndexOf(':');
if (idx > 0 && idx < addr.length() - 1) {
try {
int port = Integer.parseInt(addr.substring(idx + 1));
// Unresolved: the backend's DNS (a K8s Service) may not resolve yet
// while the server is asleep; Velocity resolves at connect time.
return InetSocketAddress.createUnresolved(addr.substring(0, idx), port);
} catch (NumberFormatException ignored) {
// not host:port → fall through to the default Minecraft port
}
}
return InetSocketAddress.createUnresolved(addr, DEFAULT_PORT);
}
}
@@ -0,0 +1,281 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WaitingRouter implements the §11 domain-autostart routing loop and its waiting
* queue. It resolves the virtual host a player connected with to a felis server
* and decides what happens next:
*
* <pre>
* host has no felis subdomain → leave Velocity's default routing alone
* server ready + registered → set it as the initial server (straight in)
* server not ready, lobby configured → park in lobby, wake it, enqueue a transfer
* server not ready, no lobby → refuse cleanly ("reconnect shortly"), wake
* </pre>
*
* <p>The queue is drained by {@link #tick()}, scheduled by the plugin on the async
* pool. Each tick polls felis-api once per distinct waited-on server and, when one
* reports ready, transfers everyone waiting on it. A waiter drops out when it times
* out, when the player leaves the proxy, or on a successful transfer.
*
* <p>The wake is gated server-side by autostartPolicy keyed on the player's
* online-mode UUID: a 403 means this player may not start the server (we tell them
* and stop), a 429 means a wake is already in flight (we keep waiting). Real joins
* to a felis backend are reported back so the reaper sees activity and the player
* is auto-added to the allowlist.
*/
public final class WaitingRouter {
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
private final ProxyServer proxy;
private final Logger log;
private final FelisApiClient api;
private final ServerRegistry registry;
private final FelisVelocityPlugin plugin;
private final String lobbyServer; // may be null → no lobby
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
// Notified just before a menu-originated waiter is transferred, so the lobby's
// felis:control face can tell the player's GUI the backend is ready. Null until
// the ControlChannel is wired in at proxy init; set once, read on the tick pool.
private volatile MenuTransferListener menuListener;
WaitingRouter(ProxyServer proxy, Logger log, FelisApiClient api, ServerRegistry registry,
FelisVelocityPlugin plugin, String lobbyServer) {
this.proxy = proxy;
this.log = log;
this.api = api;
this.registry = registry;
this.plugin = plugin;
this.lobbyServer = lobbyServer;
}
/**
* setMenuTransferListener wires the felis:control face so a menu-driven wait can
* notify the lobby when its backend is ready. Called once at proxy init.
*/
void setMenuTransferListener(MenuTransferListener listener) {
this.menuListener = listener;
}
/**
* enqueueFromMenu parks a player who is already on the proxy (sitting in the
* lobby) on a server they asked for through the felis-paper {@code /menu}, then
* wakes it and lets {@link #tick()} transfer them when ready — the same shared
* waiting queue used by host-based autostart routing (spec §12, §27 scenario 10).
* It differs from initial-server routing only in origin: the player drove it from
* a GUI button rather than a connecting virtual host, so the waiter is flagged to
* fire {@link MenuTransferListener} on transfer.
*/
void enqueueFromMenu(Player player, String serverName) {
wakeAndWait(player, serverName, true);
}
@Subscribe
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
Player player = event.getPlayer();
Optional<String> host = virtualHost(player);
if (host.isEmpty()) {
return; // direct connect / no SRV host → leave default routing
}
Optional<ServerView> targetOpt = registry.resolveByHost(host.get());
if (targetOpt.isEmpty()) {
return; // host is not a felis subdomain → leave default routing
}
ServerView target = targetOpt.get();
Optional<RegisteredServer> backend = registry.registered(target.name());
if (target.ready() && backend.isPresent()) {
event.setInitialServer(backend.get()); // ready → straight in
return;
}
Optional<RegisteredServer> lobby = lobby();
if (lobby.isEmpty()) {
// Nowhere to hold the player while the backend wakes: refuse cleanly so
// they reconnect onto a ready server, rather than dropping them onto a
// backend that is still starting. Still fire the wake so the reconnect
// lands faster.
player.disconnect(Component.text(
"« " + target.name() + " » is starting up — please reconnect in a moment.",
NamedTextColor.YELLOW));
fireWake(player.getUniqueId(), target.name());
return;
}
event.setInitialServer(lobby.get()); // park in lobby
wakeAndWait(player, target.name(), false);
}
@Subscribe
public void onServerConnected(ServerConnectedEvent event) {
String name = event.getServer().getServerInfo().getName();
if (!registry.isManaged(name)) {
return; // lobby / static server → not a felis backend, nothing to report
}
UUID id = event.getPlayer().getUniqueId();
plugin.async(() -> {
try {
api.reportJoin(name, id);
} catch (LinkException e) {
log.debug("Felis: join-event {} failed (status={}): {}", name, e.statusCode(), e.getMessage());
}
});
}
/** tick drains the waiting queue; the plugin schedules it on the async pool. */
void tick() {
if (waiting.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
Map<String, Boolean> readyCache = new HashMap<>(); // one status poll per distinct server
for (Map.Entry<UUID, Waiter> e : new ArrayList<>(waiting.entrySet())) {
UUID id = e.getKey();
Waiter w = e.getValue();
Optional<Player> po = proxy.getPlayer(id);
if (po.isEmpty()) {
waiting.remove(id); // player left the proxy
continue;
}
Player player = po.get();
if (now > w.deadlineMillis) {
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is taking longer than expected to start. "
+ "You can keep waiting in the lobby or try again later.", NamedTextColor.YELLOW));
continue;
}
Boolean ready = readyCache.get(w.serverName);
if (ready == null) {
try {
ready = api.serverStatus(w.serverName).ready();
} catch (LinkException ex) {
ready = Boolean.FALSE; // transient → keep waiting until the deadline
}
readyCache.put(w.serverName, ready);
}
if (!ready) {
continue;
}
Optional<RegisteredServer> backend = registry.registered(w.serverName);
if (backend.isEmpty()) {
continue; // ready but not yet registered → next tick
}
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
// Tell a menu-driven lobby its tile is live before we pull the player off
// it; the proxy still performs the actual Connect just below.
MenuTransferListener listener = menuListener;
if (w.fromMenu && listener != null) {
listener.onReady(player, w.serverName);
}
transfer(player, w.serverName, backend.get());
}
}
private void wakeAndWait(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
plugin.async(() -> {
try {
api.wake(serverName, id);
} catch (LinkException e) {
switch (e.statusCode()) {
case 403:
player.sendMessage(Component.text(
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
return; // policy gate refused → do not enqueue
case 429:
break; // a wake is already in flight → fall through to waiting
default:
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't start « " + serverName + " » right now. Try again shortly.",
NamedTextColor.RED));
return;
}
}
player.sendMessage(Component.text(
"Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
waiting.put(id, new Waiter(serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
});
}
private void fireWake(UUID id, String serverName) {
plugin.async(() -> {
try {
api.wake(serverName, id);
} catch (LinkException e) {
if (e.statusCode() != 429 && e.statusCode() != 403) {
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
}
}
});
}
private void transfer(Player player, String serverName, RegisteredServer backend) {
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
if (err != null || (result != null && !result.isSuccessful())) {
player.sendMessage(Component.text(
"Couldn't connect you to « " + serverName + " ». Please try again.",
NamedTextColor.RED));
}
});
}
private Optional<RegisteredServer> lobby() {
return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer);
}
private static Optional<String> virtualHost(Player player) {
return player.getVirtualHost()
.map(InetSocketAddress::getHostString)
.map(s -> s.toLowerCase(Locale.ROOT));
}
private static final class Waiter {
final String serverName;
final long deadlineMillis;
final boolean fromMenu; // true → notify the felis:control face on transfer
Waiter(String serverName, long deadlineMillis, boolean fromMenu) {
this.serverName = serverName;
this.deadlineMillis = deadlineMillis;
this.fromMenu = fromMenu;
}
}
/**
* MenuTransferListener bridges the shared waiting queue to the felis:control face
* without {@link WaitingRouter} depending on the wire codec: it is told a
* menu-originated player's backend is ready, and the implementation owns encoding
* and sending the {@code TransferReady} frame. The proxy still performs the
* Connect itself ({@link #transfer}); this is only the lobby-UI notification.
*/
interface MenuTransferListener {
void onReady(Player player, String serverName);
}
}