feat(velocity): legacy forwarding 列表跟随 CR 的 forwarding=legacy 标签实时更新 (#15)

This commit is contained in:
Lemon-miaow committed 2026-09-26 08:09:49 +08:00
1 parent 7c2fa08e85
commit 5099b2501f
15 files changed
+522 -14

No files matched your search

+13 -10
View File
@@ -38,10 +38,12 @@
# FELIS_NANO_PROXY_CIDR the proxy allowed to reach a non-loopback nano bind, as an address # FELIS_NANO_PROXY_CIDR the proxy allowed to reach a non-loopback nano bind, as an address
# with a prefix length (for example 10.0.0.7/32). firewalld opens the # with a prefix length (for example 10.0.0.7/32). firewalld opens the
# port to that source only; unset, it opens nothing # port to that source only; unset, it opens nothing
# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that receive their identity # FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that always receive their
# through the handshake address instead of modern forwarding # identity through the handshake address instead of modern forwarding
# (default: legacy18). Read once at Velocity start, so changing it # (default: legacy18). A floor: any server whose MinecraftServer CR is
# means re-running this script and restarting the proxy. # labelled felis.lolicon.best/forwarding=legacy joins it while the
# proxy runs (docs/operations.md), so a new 1.8 backend needs a label,
# not a re-run. Changing the floor itself means re-running this script.
# FELIS_VELOCITY_XMX maximum heap of the Velocity proxy, as <n>M or <n>G (default: 1G; # FELIS_VELOCITY_XMX maximum heap of the Velocity proxy, as <n>M or <n>G (default: 1G;
# at least 256M). docs/operations.md sizes it by player count. # at least 256M). docs/operations.md sizes it by player count.
# FELIS_VELOCITY_FORK_JAR path to a Felis-Legacy Velocity fork build to install as the # FELIS_VELOCITY_FORK_JAR path to a Felis-Legacy Velocity fork build to install as the
@@ -2741,12 +2743,13 @@ install_velocity_service() {
# fork reads this list from -Dfelis.legacy-forwarding.servers and forwards those servers legacy; # fork reads this list from -Dfelis.legacy-forwarding.servers and forwards those servers legacy;
# every other backend keeps modern+secret untouched. # every other backend keeps modern+secret untouched.
# #
# The list is a JVM system property, so it is fixed for the life of the proxy process and a # This value is the floor of the list. The felis-velocity plugin adds every server whose
# change needs a Velocity restart. FELIS_LEGACY_FORWARDING_SERVERS makes that reachable # MinecraftServer CR is labelled felis.lolicon.best/forwarding=legacy by rewriting the same
# without editing this script, which is as far as a startup property can go. Having it follow # property on each server-list refresh (LegacyForwarding.java), and drops it again when the
# the MinecraftServer CRs instead is a larger change: the forwarding decision lives in the # label goes; the floor always stays in. A fork carrying patch 0004 re-reads the property on
# fork's patch to Velocity core, not in the Felis plugin, so core would need to read state the # every backend connection, so a label applies from the next connection. A fork with 0003
# plugin owns and refreshes. # alone reads it once, after the plugin's first refresh, so a label applies at the next proxy
# restart. Stock Velocity ignores it, and the plugin logs a warning for a labelled server.
# #
# The -D below is double-quoted in ExecStart on purpose. The fork trims each element, so it # The -D below is double-quoted in ExecStart on purpose. The fork trims each element, so it
# accepts "legacy18, legacy112", but systemd splits ExecStart on whitespace before java ever # accepts "legacy18, legacy112", but systemd splits ExecStart on whitespace before java ever
+6
View File
@@ -482,6 +482,12 @@ components:
playerCountUnknown: playerCountUnknown:
type: boolean type: boolean
description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.
legacyForwarding:
type: boolean
description: >-
Present and true when the CR carries the label felis.lolicon.best/forwarding=legacy.
The proxy then forwards this server's players BungeeCord-style in the handshake
address instead of modern forwarding (Felis-Legacy Velocity fork only).
FleetServer: FleetServer:
description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView).
+32
View File
@@ -383,6 +383,38 @@ version, in this order, each step one release:
3. A later release stops serving `v1alpha1`. Felis itself reads through one Go type at a 3. A later release stops serving `v1alpha1`. Felis itself reads through one Go type at a
time, so the operator and felis-api switch in the release that moves storage. time, so the operator and felis-api switch in the release that moves storage.
### Legacy-forwarded backends [VM-VERIFIED]
A 1.8-era backend sits behind ViaVersion, which drops modern forwarding's login plugin
message on the way down to protocol 47, so the proxy has to hand that server the
player's identity BungeeCord-style, in the handshake address. Only the Felis-Legacy
Velocity fork can do that per server. Mark the server's CR and the proxy picks it up at
its next server-list refresh (every 15 s):
```sh
kubectl -n minecraft label minecraftserver <name> felis.lolicon.best/forwarding=legacy
kubectl -n minecraft label minecraftserver <name> felis.lolicon.best/forwarding- # back to modern
journalctl -u felis-velocity | grep 'legacy forwarding list'
```
The installer's `FELIS_LEGACY_FORWARDING_SERVERS` (default `legacy18`) stays in the list
whatever the labels say. What a label does depends on the proxy the host runs:
| Proxy | A label applies |
|---|---|
| Fork with patch 0004 (`build-velocity.sh` default arm) | from the next connection to that server |
| Fork with 0003 alone (`--deployed`) | at the next `systemctl restart felis-velocity` |
| Stock Velocity | never; the log line is a warning naming the server |
On the test VM (fork with 0004) labelling a server logged `legacy forwarding list is now
[legacy18,resolvecheck]` 12 s later, and removing the label logged the list back to
`[legacy18]`. The fork's own test (`FelisLegacyForwardingTest`) covers the next
connection following the rewritten list.
Legacy forwarding carries no secret. A marked server believes any identity that reaches
its game port, which `felis-allow-game-from-velocity` limits to the proxy and the node
itself; anything else running on the node can reach it too.
## 5. Disaster recovery ## 5. Disaster recovery
The procedures are in §16: what a database bundle holds, restoring one on the same host, The procedures are in §16: what a database bundle holds, restoring one on the same host,
+3
View File
@@ -34,6 +34,9 @@ type ServerInfo struct {
// PlayerCountUnknown is true while the operator cannot read the player // PlayerCountUnknown is true while the operator cannot read the player
// count over RCON; idle auto-stop waits until it can. // count over RCON; idle auto-stop waits until it can.
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"` PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
// LegacyForwarding mirrors the CR's forwarding=legacy label: the proxy
// forwards this server's players in the handshake address (#15).
LegacyForwarding bool `json:"legacyForwarding,omitempty"`
} }
// CreateServerInput is the validated, structured create-server form (spec §15). // CreateServerInput is the validated, structured create-server form (spec §15).
+1
View File
@@ -446,6 +446,7 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
IdleStopSeconds: idleStopSeconds(ms), IdleStopSeconds: idleStopSeconds(ms),
PlayerCountUnknown: ms.Status.Phase == v1alpha1.PhaseRunning && PlayerCountUnknown: ms.Status.Phase == v1alpha1.PhaseRunning &&
meta.IsStatusConditionFalse(ms.Status.Conditions, v1alpha1.ConditionPlayersCounted), meta.IsStatusConditionFalse(ms.Status.Conditions, v1alpha1.ConditionPlayersCounted),
LegacyForwarding: ms.Labels[v1alpha1.LabelForwarding] == v1alpha1.ForwardingLegacy,
} }
} }
+37
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"sort" "sort"
"strings" "strings"
@@ -253,3 +254,39 @@ func TestSubdomainOf(t *testing.T) {
t.Fatalf("SubdomainOf(no subdomain) = %v, want nil", got) t.Fatalf("SubdomainOf(no subdomain) = %v, want nil", got)
} }
} }
// The forwarding=legacy label reaches the proxy as legacyForwarding on the server
// list (#15); any other value, and no label, leaves the server on modern
// forwarding and keeps the key out of the JSON.
func TestServerListCarriesLegacyForwarding(t *testing.T) {
scheme := runtime.NewScheme()
if err := v1alpha1.AddToScheme(scheme); err != nil {
t.Fatalf("scheme: %v", err)
}
labeled := func(name, value string) *v1alpha1.MinecraftServer {
ms := testServer(name, name)
ms.Labels = map[string]string{v1alpha1.LabelForwarding: value}
return ms
}
k := NewK8sCluster(fake.NewClientBuilder().WithScheme(scheme).WithObjects(
labeled("legacy18", "legacy"), labeled("shouty", "LEGACY"), labeled("modern", "modern"),
testServer("plain", "plain"),
).Build(), "minecraft")
infos, err := k.ListServers(context.Background())
if err != nil {
t.Fatalf("ListServers: %v", err)
}
for _, i := range infos {
b, err := json.Marshal(i)
if err != nil {
t.Fatal(err)
}
want := i.Name == "legacy18"
if i.LegacyForwarding != want || strings.Contains(string(b), `"legacyForwarding":true`) != want {
t.Errorf("%s: legacyForwarding = %v, JSON %s; want %v", i.Name, i.LegacyForwarding, b, want)
}
if !want && strings.Contains(string(b), "legacyForwarding") {
t.Errorf("%s: JSON carries legacyForwarding although it is off: %s", i.Name, b)
}
}
}
@@ -19,8 +19,22 @@ const (
// LabelSystemRole identifies setup-owned system servers. Its value is the // LabelSystemRole identifies setup-owned system servers. Its value is the
// reserved role name (for example, "login" or "lobby"). // reserved role name (for example, "login" or "lobby").
LabelSystemRole = GroupName + "/system-role" LabelSystemRole = GroupName + "/system-role"
// LabelForwarding set to ForwardingLegacy makes the proxy hand this server the
// player's identity BungeeCord-style, in the handshake address, instead of the
// proxy-wide modern forwarding (#15). It is for a 1.8-era backend behind
// ViaVersion, which drops modern forwarding's login plugin message; such a
// backend trusts whatever reaches its game port, so nothing but the proxy may.
// Only the Felis-Legacy Velocity fork acts on it. A label rather than a spec
// field: the CRD needs no change, so an existing install takes it as is; the
// operator copies no CR label onto the pod, so setting it never restarts the
// server; and felis-api never writes it, so marking a server takes kubectl on
// the cluster, which fits a switch that drops the forwarding secret.
LabelForwarding = GroupName + "/forwarding"
) )
// ForwardingLegacy is the LabelForwarding value that selects legacy forwarding.
const ForwardingLegacy = "legacy"
// DesiredState is the operator-facing intent toggle (spec §4 spec.desiredState). // DesiredState is the operator-facing intent toggle (spec §4 spec.desiredState).
type DesiredState string type DesiredState string
+2
View File
@@ -2346,6 +2346,8 @@ export interface components {
idleStopSeconds: number; idleStopSeconds: number;
/** @description Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. */ /** @description Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. */
playerCountUnknown?: boolean; playerCountUnknown?: boolean;
/** @description Present and true when the CR carries the label felis.lolicon.best/forwarding=legacy. The proxy then forwards this server's players BungeeCord-style in the handshake address instead of modern forwarding (Felis-Legacy Velocity fork only). */
legacyForwarding?: boolean;
}; };
/** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */ /** @description One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). */
FleetServer: components["schemas"]["ServerInfo"] & { FleetServer: components["schemas"]["ServerInfo"] & {
+3
View File
@@ -62,6 +62,9 @@ export interface ServerStatus {
idleStopSeconds: number; idleStopSeconds: number;
/** True while the operator cannot read the player count; idle stop waits. */ /** True while the operator cannot read the player count; idle stop waits. */
playerCountUnknown?: boolean; playerCountUnknown?: boolean;
/** True when the CR is labelled forwarding=legacy: the proxy forwards this
* server's players BungeeCord-style (a 1.8 backend behind ViaVersion). */
legacyForwarding?: boolean;
} }
/** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access). /** WhitelistResult projects GET /servers/{name}/access/whitelist (spec §7 access).
@@ -34,10 +34,19 @@ public final class ServerView {
private final String endpointAddress; private final String endpointAddress;
private final int playersOnline; private final int playersOnline;
private final int playersMax; private final int playersMax;
private final boolean legacyForwarding;
public ServerView(String name, String subdomain, String phase, boolean ready, public ServerView(String name, String subdomain, String phase, boolean ready,
String autostartPolicy, String desiredState, String endpointMode, String autostartPolicy, String desiredState, String endpointMode,
String endpointAddress, int playersOnline, int playersMax) { String endpointAddress, int playersOnline, int playersMax) {
this(name, subdomain, phase, ready, autostartPolicy, desiredState, endpointMode,
endpointAddress, playersOnline, playersMax, false);
}
public ServerView(String name, String subdomain, String phase, boolean ready,
String autostartPolicy, String desiredState, String endpointMode,
String endpointAddress, int playersOnline, int playersMax,
boolean legacyForwarding) {
this.name = name; this.name = name;
this.subdomain = subdomain; this.subdomain = subdomain;
this.phase = phase; this.phase = phase;
@@ -48,6 +57,7 @@ public final class ServerView {
this.endpointAddress = endpointAddress; this.endpointAddress = endpointAddress;
this.playersOnline = playersOnline; this.playersOnline = playersOnline;
this.playersMax = playersMax; this.playersMax = playersMax;
this.legacyForwarding = legacyForwarding;
} }
/** fromJson builds a view from a parsed felis-api object, tolerating absent fields. */ /** fromJson builds a view from a parsed felis-api object, tolerating absent fields. */
@@ -62,7 +72,8 @@ public final class ServerView {
str(o, "endpointMode"), str(o, "endpointMode"),
str(o, "endpointAddress"), str(o, "endpointAddress"),
intval(o, "playersOnline"), intval(o, "playersOnline"),
intval(o, "playersMax")); intval(o, "playersMax"),
bool(o, "legacyForwarding"));
} }
/** /**
@@ -112,6 +123,7 @@ public final class ServerView {
field(b, "endpointAddress", v.endpointAddress, false); field(b, "endpointAddress", v.endpointAddress, false);
b.append(",\"playersOnline\":").append(v.playersOnline); b.append(",\"playersOnline\":").append(v.playersOnline);
b.append(",\"playersMax\":").append(v.playersMax); b.append(",\"playersMax\":").append(v.playersMax);
b.append(",\"legacyForwarding\":").append(v.legacyForwarding);
b.append('}'); b.append('}');
} }
return b.append("]}").toString(); return b.append("]}").toString();
@@ -167,6 +179,15 @@ public final class ServerView {
return playersMax; return playersMax;
} }
/**
* legacyForwarding is true when the server's CR is labelled
* {@code felis.lolicon.best/forwarding=legacy}: the proxy forwards its players
* BungeeCord-style instead of modern forwarding ({@code LegacyForwarding}).
*/
public boolean legacyForwarding() {
return legacyForwarding;
}
private static String str(Map<?, ?> o, String key) { private static String str(Map<?, ?> o, String key) {
Object v = o.get(key); Object v = o.get(key);
return v instanceof String ? (String) v : null; return v instanceof String ? (String) v : null;
+10 -1
View File
@@ -20,7 +20,8 @@
# lasts and when it ends, the link-status outage # lasts and when it ends, the link-status outage
# fallback fails closed outside its window, a proxy restarted during an API # fallback fails closed outside its window, a proxy restarted during an API
# outage routes on the last saved server list (and only until a fetch # outage routes on the last saved server list (and only until a fetch
# succeeds), /invite prompts cannot double-fire # succeeds), a CR marked forwarding=legacy joins the fork's legacy-forwarding
# list above the installer's floor, /invite prompts cannot double-fire
# or outlive their TTL, the invite card really is a green/red clickable # or outlive their TTL, the invite card really is a green/red clickable
# prompt, and the op-login approval card names the account and leaves its # prompt, and the op-login approval card names the account and leaves its
# name for the admin to type. InviteCardTest and OpApprovalCardTest need the # name for the admin to type. InviteCardTest and OpApprovalCardTest need the
@@ -127,6 +128,14 @@ javac -d "$work/list-classes" \
plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java
java -cp "$work/list-classes" best.lolicon.felis.velocity.ServerListSourceTest java -cp "$work/list-classes" best.lolicon.felis.velocity.ServerListSourceTest
echo "==> LegacyForwardingTest (CR-marked servers reach the fork's legacy-forwarding list, velocity)"
mkdir -p "$work/legacy-classes"
javac -d "$work/legacy-classes" \
plugins/shared/src/main/java/best/lolicon/felis/link/*.java \
plugins/velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java \
plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java
java -cp "$work/legacy-classes" best.lolicon.felis.velocity.LegacyForwardingTest
echo "==> ApiPoolTest (bounded felis-api pool, non-overlapping repeats, velocity)" echo "==> ApiPoolTest (bounded felis-api pool, non-overlapping repeats, velocity)"
mkdir -p "$work/pool-classes" mkdir -p "$work/pool-classes"
javac -d "$work/pool-classes" \ javac -d "$work/pool-classes" \
@@ -113,6 +113,7 @@ public final class FelisVelocityPlugin {
private FelisApiClient apiClient; private FelisApiClient apiClient;
private ServerRegistry registry; private ServerRegistry registry;
private ServerListSource serverList; private ServerListSource serverList;
private LegacyForwarding legacyForwarding;
private WaitingRouter router; private WaitingRouter router;
private boolean onlineMode; private boolean onlineMode;
private boolean routingActive; private boolean routingActive;
@@ -158,6 +159,8 @@ public final class FelisVelocityPlugin {
this.apiClient = new FelisApiClient(config.linkConfig()); this.apiClient = new FelisApiClient(config.linkConfig());
this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
this.serverList = new ServerListSource(apiClient::listServers, dataDirectory.resolve(SERVER_LIST_FILE)); this.serverList = new ServerListSource(apiClient::listServers, dataDirectory.resolve(SERVER_LIST_FILE));
this.legacyForwarding = new LegacyForwarding(System.getProperty(LegacyForwarding.PROPERTY),
LegacyForwarding.detect(proxy.getClass().getClassLoader()));
this.router = new WaitingRouter(proxy, logger, apiClient, registry, this, this.router = new WaitingRouter(proxy, logger, apiClient, registry, this,
config.loginServer(), config.lobbyServer()); config.loginServer(), config.lobbyServer());
MotdResponder motd = new MotdResponder(registry); MotdResponder motd = new MotdResponder(registry);
@@ -178,6 +181,9 @@ public final class FelisVelocityPlugin {
repeating(REGISTRATION_REFRESH, this::refreshRegistrations); repeating(REGISTRATION_REFRESH, this::refreshRegistrations);
repeating(WAIT_POLL, router::tick); repeating(WAIT_POLL, router::tick);
repeating(STATS_INTERVAL, this::logStats); repeating(STATS_INTERVAL, this::logStats);
// Velocity accepts no connection until this handler returns, so the first
// refresh above reached even a fork that reads the legacy list only once.
legacyForwarding.accepting();
this.routingActive = true; this.routingActive = true;
logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link, /felis and /invite registered.", logger.info("Felis routing ready: rootDomain={}, login={}, lobby={}. /link, /felis and /invite registered.",
@@ -285,6 +291,14 @@ public final class FelisVelocityPlugin {
} }
ServerListSource.Result r = serverList.next(); ServerListSource.Result r = serverList.next();
if (r.servers != null) { if (r.servers != null) {
// The forwarding mode goes first, so a newly registered legacy backend is
// never reachable under modern forwarding.
LegacyForwarding.Update lf = legacyForwarding.apply(r.servers);
if (lf != null && lf.warning) {
logger.warn(lf.message);
} else if (lf != null) {
logger.info(lf.message);
}
registry.refresh(r.servers); registry.refresh(r.servers);
} }
if (r.failure == null) { if (r.failure == null) {
@@ -0,0 +1,171 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.ServerView;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
/**
* LegacyForwarding keeps the Felis-Legacy fork's per-server legacy-forwarding list, the
* system property {@value #PROPERTY}, in step with felis-api's server list (#15). A
* server whose CR carries {@code felis.lolicon.best/forwarding=legacy} joins the list,
* and leaves it when the label goes. The value the proxy was started with (bootstrap's
* {@code -D}, {@code legacy18} by default) is a floor that always stays in, so an
* install upgraded without labelling its CRs forwards exactly as before.
*
* <p>The fork reads the property itself; this class only rewrites it. What a rewrite
* achieves depends on the proxy build, which {@link #detect(ClassLoader)} tells apart:
* <ul>
* <li>stock Velocity has one proxy-wide mode and never reads the property, so a
* marked server keeps modern forwarding;</li>
* <li>a fork carrying patch 0003 alone parses the property once, when the first
* player reaches a backend. The first rewrite runs inside ProxyInitializeEvent,
* which Velocity waits for before it accepts a connection, so that one lands;
* a later change needs a proxy restart;</li>
* <li>a fork carrying 0004 as well re-reads the property on every backend
* connection, so a change applies from the next one.</li>
* </ul>
*/
final class LegacyForwarding {
static final String PROPERTY = "felis.legacy-forwarding.servers";
static final String CONNECTION_CLASS = "com.velocitypowered.proxy.connection.backend.VelocityServerConnection";
/** Proxy is what the running Velocity build does with {@link #PROPERTY}. */
enum Proxy {
STOCK,
FORK_READS_ONCE,
FORK_LIVE
}
/** Update is what one {@link #apply} changed, worded for the proxy log. */
static final class Update {
final String value;
final List<String> added;
final List<String> removed;
/** True when the running proxy forwards by the new list from the next connection. */
final boolean effective;
/** True when the change leaves a server forwarded other than its CR asks. */
final boolean warning;
final String message;
private Update(String value, List<String> added, List<String> removed, Proxy proxy, boolean effective) {
this.value = value;
this.added = added;
this.removed = removed;
this.effective = effective;
String change = "legacy forwarding list is now [" + value + "] (added " + added + ", removed " + removed + ")";
if (effective) {
this.warning = false;
this.message = "Felis: " + change + ".";
} else if (proxy == Proxy.FORK_READS_ONCE) {
this.warning = true;
this.message = "Felis: " + change + ", but this Felis-Legacy build read the list when the first player "
+ "reached a backend and keeps it until it restarts. Restart felis-velocity to apply it.";
} else {
this.warning = !added.isEmpty();
this.message = "Felis: " + change + ". This proxy is stock Velocity, which forwards every backend the "
+ "same way and ignores the list"
+ (added.isEmpty() ? "." : ": " + added + " keep modern forwarding until felis-velocity runs "
+ "the Felis-Legacy fork.");
}
}
}
private final Set<String> floor;
private final Proxy proxy;
private Set<String> current;
private boolean accepting;
LegacyForwarding(String startupValue, Proxy proxy) {
this.floor = parse(startupValue);
this.current = floor;
this.proxy = proxy;
}
/**
* accepting marks the end of ProxyInitializeEvent: from here on a fork that reads
* the list once may already have read it.
*/
synchronized void accepting() {
accepting = true;
}
/**
* apply rewrites the property for a server list felis-api answered with (or the
* saved one restored at startup), and returns what changed, or null when nothing did.
*/
synchronized Update apply(Collection<ServerView> servers) {
Set<String> next = new TreeSet<>(floor);
for (ServerView v : servers) {
if (v.legacyForwarding() && v.name() != null && !v.name().isEmpty()) {
next.add(v.name());
}
}
if (next.equals(current)) {
return null;
}
List<String> added = new ArrayList<>();
for (String n : next) {
if (!current.contains(n)) {
added.add(n);
}
}
List<String> removed = new ArrayList<>();
for (String n : current) {
if (!next.contains(n)) {
removed.add(n);
}
}
String value = String.join(",", next);
System.setProperty(PROPERTY, value);
current = next;
boolean effective = proxy == Proxy.FORK_LIVE || (proxy == Proxy.FORK_READS_ONCE && !accepting);
return new Update(value, added, removed, proxy, effective);
}
/** parse reads the property's comma-separated form the way the fork does. */
static Set<String> parse(String value) {
Set<String> out = new TreeSet<>();
if (value == null) {
return out;
}
for (String s : value.split(",")) {
String t = s.trim();
if (!t.isEmpty()) {
out.add(t);
}
}
return out;
}
static Proxy detect(ClassLoader loader) {
return detect(loader, CONNECTION_CLASS);
}
/**
* detect looks for the methods the fork patches add to the backend connection class.
* The class is loaded without being initialized: initializing it would run a 0003-only
* fork's static parse of the property before {@link #apply} had rewritten it.
*/
static Proxy detect(ClassLoader loader, String className) {
boolean perServer = false;
boolean live = false;
try {
Class<?> c = Class.forName(className, false, loader);
for (Method m : c.getDeclaredMethods()) {
perServer |= m.getName().equals("effectiveForwardingMode");
live |= m.getName().equals("felisLegacyForwardingServers");
}
} catch (ClassNotFoundException | LinkageError e) {
return Proxy.STOCK;
}
if (live && perServer) {
return Proxy.FORK_LIVE;
}
return perServer ? Proxy.FORK_READS_ONCE : Proxy.STOCK;
}
}
@@ -0,0 +1,174 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.ServerView;
import java.util.List;
/**
* LegacyForwardingTest covers how a CR's forwarding=legacy label reaches the fork's
* {@code felis.legacy-forwarding.servers} property (#15): marked servers join and leave
* it, the startup value stays in as a floor, an unchanged list rewrites nothing, and the
* report says whether the running proxy acts on the change (stock, a fork that reads
* the list once, a fork that re-reads it). Detection must not initialize the proxy's
* connection class. Framework free: a failed assertion throws and the process exits
* non-zero.
*
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
* velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java
* velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java && java -cp <out>
* best.lolicon.felis.velocity.LegacyForwardingTest}.
*/
public final class LegacyForwardingTest {
private static int checks;
public static void main(String[] args) {
markedServersJoinAndLeaveAboveTheFloor();
anUnchangedListRewritesNothing();
aForkThatReadsOnceTakesOnlyTheStartupList();
stockVelocityReportsMarkedServersAsIgnored();
detectionTellsTheBuildsApartWithoutInitializing();
System.out.println("LegacyForwardingTest OK (" + checks + " checks)");
}
private static void markedServersJoinAndLeaveAboveTheFloor() {
System.setProperty(LegacyForwarding.PROPERTY, " legacy18 ,");
LegacyForwarding lf = new LegacyForwarding(System.getProperty(LegacyForwarding.PROPERTY),
LegacyForwarding.Proxy.FORK_LIVE);
LegacyForwarding.Update u = lf.apply(List.of(view("beta", true), view("modern", false), view("alpha", true)));
assertEq("marked servers join the floor, sorted", "alpha,beta,legacy18", System.getProperty(LegacyForwarding.PROPERTY));
assertEq("update carries the value", "alpha,beta,legacy18", u.value);
assertEq("added", List.of("alpha", "beta"), u.added);
assertEq("nothing removed", List.of(), u.removed);
assertEq("a live fork acts on it", true, u.effective);
assertEq("no warning", false, u.warning);
// beta loses its label; the floor's own server is listed unmarked and stays in.
u = lf.apply(List.of(view("alpha", true), view("beta", false), view("legacy18", false)));
assertEq("an unlabelled server leaves, the floor stays", "alpha,legacy18",
System.getProperty(LegacyForwarding.PROPERTY));
assertEq("removed", List.of("beta"), u.removed);
assertEq("nothing added", List.of(), u.added);
u = lf.apply(List.of());
assertEq("an empty list falls back to the floor", "legacy18", System.getProperty(LegacyForwarding.PROPERTY));
assertEq("removed alpha", List.of("alpha"), u.removed);
}
private static void anUnchangedListRewritesNothing() {
System.setProperty(LegacyForwarding.PROPERTY, "legacy18");
LegacyForwarding lf = new LegacyForwarding("legacy18", LegacyForwarding.Proxy.FORK_LIVE);
assertEq("floor alone is no change", null, lf.apply(List.of(view("modern", false))));
lf.apply(List.of(view("old", true)));
System.setProperty(LegacyForwarding.PROPERTY, "sentinel");
assertEq("same marks again is no change", null, lf.apply(List.of(view("old", true), view("modern", false))));
assertEq("the property is left alone", "sentinel", System.getProperty(LegacyForwarding.PROPERTY));
System.clearProperty(LegacyForwarding.PROPERTY);
LegacyForwarding none = new LegacyForwarding(null, LegacyForwarding.Proxy.FORK_LIVE);
assertEq("no floor and no marks is no change", null, none.apply(List.of(view("modern", false))));
assertEq("the property stays unset", null, System.getProperty(LegacyForwarding.PROPERTY));
assertEq("a nameless marked entry is skipped", null, none.apply(List.of(view(null, true), view("", true))));
}
private static void aForkThatReadsOnceTakesOnlyTheStartupList() {
LegacyForwarding lf = new LegacyForwarding("legacy18", LegacyForwarding.Proxy.FORK_READS_ONCE);
LegacyForwarding.Update u = lf.apply(List.of(view("old", true)));
assertEq("before accepting, the rewrite lands", true, u.effective);
assertEq("no warning before accepting", false, u.warning);
lf.accepting();
u = lf.apply(List.of(view("old", true), view("older", true)));
assertEq("the property still follows the CRs", "legacy18,old,older", System.getProperty(LegacyForwarding.PROPERTY));
assertEq("after accepting, it waits for a restart", false, u.effective);
assertEq("which is a warning", true, u.warning);
assertEq("naming the restart", true, u.message.contains("Restart felis-velocity"));
}
private static void stockVelocityReportsMarkedServersAsIgnored() {
LegacyForwarding lf = new LegacyForwarding("", LegacyForwarding.Proxy.STOCK);
LegacyForwarding.Update u = lf.apply(List.of(view("old", true)));
assertEq("stock never acts on it", false, u.effective);
assertEq("a marked server on stock is a warning", true, u.warning);
assertEq("naming the server and the fork", true,
u.message.contains("[old] keep modern forwarding") && u.message.contains("Felis-Legacy fork"));
u = lf.apply(List.of(view("old", false)));
assertEq("dropping a mark on stock is no warning", false, u.warning);
}
private static void detectionTellsTheBuildsApartWithoutInitializing() {
ClassLoader cl = LegacyForwardingTest.class.getClassLoader();
String p = LegacyForwardingTest.class.getName() + "$";
assertEq("no connection class", LegacyForwarding.Proxy.STOCK, LegacyForwarding.detect(cl, p + "Absent"));
assertEq("the real name is absent here too", LegacyForwarding.Proxy.STOCK, LegacyForwarding.detect(cl));
assertEq("stock connection", LegacyForwarding.Proxy.STOCK, LegacyForwarding.detect(cl, p + "StockConnection"));
assertEq("0003 alone", LegacyForwarding.Proxy.FORK_READS_ONCE, LegacyForwarding.detect(cl, p + "ReadsOnceConnection"));
assertEq("0003 + 0004", LegacyForwarding.Proxy.FORK_LIVE, LegacyForwarding.detect(cl, p + "LiveConnection"));
assertEq("the reload hook alone is not the fork", LegacyForwarding.Proxy.STOCK,
LegacyForwarding.detect(cl, p + "HookOnlyConnection"));
assertEq("no connection class was initialized", 0, initialized);
}
// ---- fixtures ----
// Each fixture class counts its own initialization; detect must leave it at zero.
static int initialized;
static final class StockConnection {
static {
initialized++;
}
Object startHandshake() {
return null;
}
}
static final class ReadsOnceConnection {
static {
initialized++;
}
Object effectiveForwardingMode() {
return null;
}
}
static final class LiveConnection {
static {
initialized++;
}
Object effectiveForwardingMode() {
return null;
}
static java.util.Set<String> felisLegacyForwardingServers() {
return java.util.Set.of();
}
}
static final class HookOnlyConnection {
static {
initialized++;
}
static java.util.Set<String> felisLegacyForwardingServers() {
return java.util.Set.of();
}
}
private static ServerView view(String name, boolean legacy) {
return new ServerView(name, name, "Running", true, null, "Running", "ClusterIP", "10.43.0.9:25565", 0, 20, legacy);
}
private static void assertEq(String what, Object want, Object got) {
if (want == null ? got != null : !want.equals(got)) {
throw new AssertionError(what + ": want " + want + ", got " + got);
}
checks++;
}
}
@@ -43,6 +43,7 @@ public final class ServerListSourceTest {
theSavedListIsHandedOutOnce(dir.resolve("c")); theSavedListIsHandedOutOnce(dir.resolve("c"));
noSavedListMeansNothingToRestore(dir.resolve("d")); noSavedListMeansNothingToRestore(dir.resolve("d"));
aMalformedSavedListIsReportedAndIgnored(dir.resolve("e")); aMalformedSavedListIsReportedAndIgnored(dir.resolve("e"));
theLegacyForwardingMarkIsSaved(dir.resolve("f"));
theApiEnvelopeParses(); theApiEnvelopeParses();
} finally { } finally {
try (Stream<Path> walk = Files.walk(dir)) { try (Stream<Path> walk = Files.walk(dir)) {
@@ -148,15 +149,32 @@ public final class ServerListSourceTest {
assertEq("rewritten", true, Files.readString(file, StandardCharsets.UTF_8).contains("\"10.43.0.17:25565\"")); assertEq("rewritten", true, Files.readString(file, StandardCharsets.UTF_8).contains("\"10.43.0.17:25565\""));
} }
// A proxy restarted during an outage must keep forwarding a marked 1.8 backend the
// legacy way (#15), so the mark rides the saved list like every other field.
private static void theLegacyForwardingMarkIsSaved(Path dir) throws Exception {
Path file = fresh(dir);
ServerView legacy = new ServerView("legacy18", "old", "Running", true,
null, "Running", "ClusterIP", "10.43.7.1:25565", 0, 20, true);
answer = List.of(legacy, SURVIVAL);
new ServerListSource(ServerListSourceTest::fetch, file).next();
answer = null;
ServerListSource.Result r = new ServerListSource(ServerListSourceTest::fetch, file).next();
assertEq("restored", true, r.restored);
assertEq("the mark survives", true, r.servers.get(0).legacyForwarding());
assertEq("an unmarked server stays unmarked", false, r.servers.get(1).legacyForwarding());
}
// The exact shape GET /api/v1/servers answers with, extra fields included. // The exact shape GET /api/v1/servers answers with, extra fields included.
private static void theApiEnvelopeParses() { private static void theApiEnvelopeParses() {
List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\"," List<ServerView> v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\","
+ "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\"," + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\","
+ "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100," + "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100,"
+ "\"owner\":\"x\"},\"junk\"]}"); + "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}");
assertEq("non-object entries skipped", 1, v.size()); assertEq("non-object entries skipped", 2, v.size());
assertEq("lobby name", "lobby", v.get(0).name()); assertEq("lobby name", "lobby", v.get(0).name());
assertEq("lobby max", 100, v.get(0).playersMax()); assertEq("lobby max", 100, v.get(0).playersMax());
assertEq("an absent mark is false", false, v.get(0).legacyForwarding());
assertEq("the mark parses", true, v.get(1).legacyForwarding());
try { try {
ServerView.listFromJson("[]"); ServerView.listFromJson("[]");
} catch (IllegalArgumentException expected) { } catch (IllegalArgumentException expected) {