diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh index 824c4b3..17ad001 100644 --- a/deploy/bootstrap.sh +++ b/deploy/bootstrap.sh @@ -38,10 +38,12 @@ # 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 # port to that source only; unset, it opens nothing -# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that receive their identity -# through the handshake address instead of modern forwarding -# (default: legacy18). Read once at Velocity start, so changing it -# means re-running this script and restarting the proxy. +# FELIS_LEGACY_FORWARDING_SERVERS comma-separated backends that always receive their +# identity through the handshake address instead of modern forwarding +# (default: legacy18). A floor: any server whose MinecraftServer CR is +# 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 M or G (default: 1G; # 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 @@ -2741,12 +2743,13 @@ install_velocity_service() { # fork reads this list from -Dfelis.legacy-forwarding.servers and forwards those servers legacy; # 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 - # change needs a Velocity restart. FELIS_LEGACY_FORWARDING_SERVERS makes that reachable - # without editing this script, which is as far as a startup property can go. Having it follow - # the MinecraftServer CRs instead is a larger change: the forwarding decision lives in the - # fork's patch to Velocity core, not in the Felis plugin, so core would need to read state the - # plugin owns and refreshes. + # This value is the floor of the list. The felis-velocity plugin adds every server whose + # MinecraftServer CR is labelled felis.lolicon.best/forwarding=legacy by rewriting the same + # property on each server-list refresh (LegacyForwarding.java), and drops it again when the + # label goes; the floor always stays in. A fork carrying patch 0004 re-reads the property on + # every backend connection, so a label applies from the next connection. A fork with 0003 + # 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 # accepts "legacy18, legacy112", but systemd splits ExecStart on whitespace before java ever diff --git a/docs/openapi.yaml b/docs/openapi.yaml index d439051..1fae4c6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -482,6 +482,12 @@ components: playerCountUnknown: type: boolean 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: description: One row of the fleet-wide admin read (internal/api/handlers_user.go fleetServerView). diff --git a/docs/operations.md b/docs/operations.md index 8312ccc..0458449 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 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 felis.lolicon.best/forwarding=legacy +kubectl -n minecraft label minecraftserver 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 The procedures are in §16: what a database bundle holds, restoring one on the same host, diff --git a/internal/api/cluster.go b/internal/api/cluster.go index 10c6756..ec63ba9 100644 --- a/internal/api/cluster.go +++ b/internal/api/cluster.go @@ -34,6 +34,9 @@ type ServerInfo struct { // PlayerCountUnknown is true while the operator cannot read the player // count over RCON; idle auto-stop waits until it can. 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). diff --git a/internal/api/k8scluster.go b/internal/api/k8scluster.go index 0688e9f..0b872a8 100644 --- a/internal/api/k8scluster.go +++ b/internal/api/k8scluster.go @@ -446,6 +446,7 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo { IdleStopSeconds: idleStopSeconds(ms), PlayerCountUnknown: ms.Status.Phase == v1alpha1.PhaseRunning && meta.IsStatusConditionFalse(ms.Status.Conditions, v1alpha1.ConditionPlayersCounted), + LegacyForwarding: ms.Labels[v1alpha1.LabelForwarding] == v1alpha1.ForwardingLegacy, } } diff --git a/internal/api/k8scluster_test.go b/internal/api/k8scluster_test.go index 3fa8501..ee5dbe8 100644 --- a/internal/api/k8scluster_test.go +++ b/internal/api/k8scluster_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "errors" "sort" "strings" @@ -253,3 +254,39 @@ func TestSubdomainOf(t *testing.T) { 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) + } + } +} diff --git a/internal/apis/felis/v1alpha1/minecraftserver_types.go b/internal/apis/felis/v1alpha1/minecraftserver_types.go index d907def..a058d46 100644 --- a/internal/apis/felis/v1alpha1/minecraftserver_types.go +++ b/internal/apis/felis/v1alpha1/minecraftserver_types.go @@ -19,8 +19,22 @@ const ( // LabelSystemRole identifies setup-owned system servers. Its value is the // reserved role name (for example, "login" or "lobby"). 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). type DesiredState string diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index b7d0602..deb815a 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -2346,6 +2346,8 @@ export interface components { idleStopSeconds: number; /** @description Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can. */ 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). */ FleetServer: components["schemas"]["ServerInfo"] & { diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index eedbf82..ba8f377 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -62,6 +62,9 @@ export interface ServerStatus { idleStopSeconds: number; /** True while the operator cannot read the player count; idle stop waits. */ 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). diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java b/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java index a8d1e18..e661e1f 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/ServerView.java @@ -34,10 +34,19 @@ public final class ServerView { private final String endpointAddress; private final int playersOnline; private final int playersMax; + private final boolean legacyForwarding; public ServerView(String name, String subdomain, String phase, boolean ready, String autostartPolicy, String desiredState, String endpointMode, 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.subdomain = subdomain; this.phase = phase; @@ -48,6 +57,7 @@ public final class ServerView { this.endpointAddress = endpointAddress; this.playersOnline = playersOnline; this.playersMax = playersMax; + this.legacyForwarding = legacyForwarding; } /** 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, "endpointAddress"), 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); b.append(",\"playersOnline\":").append(v.playersOnline); b.append(",\"playersMax\":").append(v.playersMax); + b.append(",\"legacyForwarding\":").append(v.legacyForwarding); b.append('}'); } return b.append("]}").toString(); @@ -167,6 +179,15 @@ public final class ServerView { 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) { Object v = o.get(key); return v instanceof String ? (String) v : null; diff --git a/plugins/test.sh b/plugins/test.sh index 78c3e16..8ab14ce 100644 --- a/plugins/test.sh +++ b/plugins/test.sh @@ -20,7 +20,8 @@ # lasts and when it ends, the link-status outage # 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 -# 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 # prompt, and the op-login approval card names the account and leaves its # 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 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)" mkdir -p "$work/pool-classes" javac -d "$work/pool-classes" \ diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java index 125ce5a..76b7a85 100644 --- a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/FelisVelocityPlugin.java @@ -113,6 +113,7 @@ public final class FelisVelocityPlugin { private FelisApiClient apiClient; private ServerRegistry registry; private ServerListSource serverList; + private LegacyForwarding legacyForwarding; private WaitingRouter router; private boolean onlineMode; private boolean routingActive; @@ -158,6 +159,8 @@ public final class FelisVelocityPlugin { this.apiClient = new FelisApiClient(config.linkConfig()); this.registry = new ServerRegistry(proxy, logger, config.rootDomain()); 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, config.loginServer(), config.lobbyServer()); MotdResponder motd = new MotdResponder(registry); @@ -178,6 +181,9 @@ public final class FelisVelocityPlugin { repeating(REGISTRATION_REFRESH, this::refreshRegistrations); repeating(WAIT_POLL, router::tick); 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; 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(); 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); } if (r.failure == null) { diff --git a/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java new file mode 100644 index 0000000..3b3a0c6 --- /dev/null +++ b/plugins/velocity/src/main/java/best/lolicon/felis/velocity/LegacyForwarding.java @@ -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. + * + *

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: + *

    + *
  • stock Velocity has one proxy-wide mode and never reads the property, so a + * marked server keeps modern forwarding;
  • + *
  • 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;
  • + *
  • a fork carrying 0004 as well re-reads the property on every backend + * connection, so a change applies from the next one.
  • + *
+ */ +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 added; + final List 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 added, List 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 floor; + private final Proxy proxy; + private Set 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 servers) { + Set 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 added = new ArrayList<>(); + for (String n : next) { + if (!current.contains(n)) { + added.add(n); + } + } + List 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 parse(String value) { + Set 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; + } +} diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java new file mode 100644 index 0000000..238b7a2 --- /dev/null +++ b/plugins/velocity/test/best/lolicon/felis/velocity/LegacyForwardingTest.java @@ -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. + * + *

Run: {@code javac -d 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 + * 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 felisLegacyForwardingServers() { + return java.util.Set.of(); + } + } + + static final class HookOnlyConnection { + static { + initialized++; + } + + static java.util.Set 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++; + } +} diff --git a/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java b/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java index 3c0b3b5..2a68249 100644 --- a/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java +++ b/plugins/velocity/test/best/lolicon/felis/velocity/ServerListSourceTest.java @@ -43,6 +43,7 @@ public final class ServerListSourceTest { theSavedListIsHandedOutOnce(dir.resolve("c")); noSavedListMeansNothingToRestore(dir.resolve("d")); aMalformedSavedListIsReportedAndIgnored(dir.resolve("e")); + theLegacyForwardingMarkIsSaved(dir.resolve("f")); theApiEnvelopeParses(); } finally { try (Stream 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\"")); } + // 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. private static void theApiEnvelopeParses() { List v = ServerView.listFromJson("{\"servers\":[{\"name\":\"lobby\",\"subdomain\":\"\"," + "\"phase\":\"Running\",\"ready\":true,\"desiredState\":\"Running\",\"endpointMode\":\"ClusterIP\"," + "\"endpointAddress\":\"10.43.1.5:25565\",\"playersOnline\":1,\"playersMax\":100," - + "\"owner\":\"x\"},\"junk\"]}"); - assertEq("non-object entries skipped", 1, v.size()); + + "\"owner\":\"x\"},\"junk\",{\"name\":\"legacy18\",\"legacyForwarding\":true}]}"); + assertEq("non-object entries skipped", 2, v.size()); assertEq("lobby name", "lobby", v.get(0).name()); 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 { ServerView.listFromJson("[]"); } catch (IllegalArgumentException expected) {