fix(limbo): 经 felis:control 放行玩家并在 felis-api 故障时退避重试
This commit is contained in:
1 file changed
+127
-49
@@ -1,5 +1,7 @@
|
|||||||
package best.lolicon.felis.limbo;
|
package best.lolicon.felis.limbo;
|
||||||
|
|
||||||
|
import best.lolicon.felis.link.Control;
|
||||||
|
import best.lolicon.felis.link.ControlFrame;
|
||||||
import best.lolicon.felis.link.FelisApiClient;
|
import best.lolicon.felis.link.FelisApiClient;
|
||||||
import best.lolicon.felis.link.LinkClient;
|
import best.lolicon.felis.link.LinkClient;
|
||||||
import best.lolicon.felis.link.LinkCode;
|
import best.lolicon.felis.link.LinkCode;
|
||||||
@@ -21,13 +23,12 @@ import net.kyori.adventure.text.Component;
|
|||||||
import net.kyori.adventure.text.event.ClickEvent;
|
import net.kyori.adventure.text.event.ClickEvent;
|
||||||
import net.kyori.adventure.text.format.NamedTextColor;
|
import net.kyori.adventure.text.format.NamedTextColor;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.DataOutputStream;
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Set;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
@@ -61,12 +62,25 @@ import java.util.logging.Logger;
|
|||||||
* browser — never the WeChat/QQ in-app browser, where WebAuthn/passkey does not
|
* browser — never the WeChat/QQ in-app browser, where WebAuthn/passkey does not
|
||||||
* work (the web entry additionally guards this, see internal/panel);</li>
|
* work (the web entry additionally guards this, see internal/panel);</li>
|
||||||
* <li>polls {@code link/status/{uuid}} until the player redeems the code on the web
|
* <li>polls {@code link/status/{uuid}} until the player redeems the code on the web
|
||||||
* console, then transfers them to the lobby via a BungeeCord {@code Connect}
|
* console, then asks the proxy to move them to the lobby with a
|
||||||
* plugin message;</li>
|
* {@code felis:control} {@code LoginRelease} frame, re-sent with backoff until
|
||||||
* <li>disconnects (fail-closed) on blacklist, on a mint/transport failure, or when
|
* the player has left (the proxy re-checks the link before it lets them go);</li>
|
||||||
* the login window elapses — "rather refuse than admit unauthenticated".</li>
|
* <li>disconnects (fail-closed) on blacklist, on a refused mint, or when the login
|
||||||
|
* window elapses — "rather refuse than admit unauthenticated".</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
*
|
*
|
||||||
|
* <p>A felis-api outage at join (transport error or 5xx) is retried with backoff for
|
||||||
|
* {@link #START_RETRY_WINDOW_MILLIS} before the player is turned away. Each retry
|
||||||
|
* also sends a {@code LoginRelease}: the proxy keeps recent link confirmations for
|
||||||
|
* exactly this case, so a player who was signed in minutes ago can still get
|
||||||
|
* through a felis-api restart, and anyone else is refused there.
|
||||||
|
*
|
||||||
|
* <p>The release used to be a BungeeCord {@code Connect} on {@code bungeecord:main}.
|
||||||
|
* Velocity answers that channel itself, before any plugin can see it, so leaving it
|
||||||
|
* on for the gate left it on for every user backend (KickPlayer, ConnectOther). The
|
||||||
|
* installer now switches it off and the proxy accepts {@code LoginRelease} only from
|
||||||
|
* this server.
|
||||||
|
*
|
||||||
* <p><b>Config (deployment inputs, never compiled in).</b> The API base URL and
|
* <p><b>Config (deployment inputs, never compiled in).</b> The API base URL and
|
||||||
* service token come from {@code FELIS_API_BASE_URL} / {@code FELIS_SERVICE_TOKEN}
|
* service token come from {@code FELIS_API_BASE_URL} / {@code FELIS_SERVICE_TOKEN}
|
||||||
* (env wins, else a {@code felis-link.properties} template in the plugin data dir) via
|
* (env wins, else a {@code felis-link.properties} template in the plugin data dir) via
|
||||||
@@ -96,9 +110,13 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
private static final long MIN_TIMEOUT_SECONDS = 30L;
|
private static final long MIN_TIMEOUT_SECONDS = 30L;
|
||||||
private static final long MAX_TIMEOUT_SECONDS = 3600L;
|
private static final long MAX_TIMEOUT_SECONDS = 3600L;
|
||||||
|
|
||||||
// The BungeeCord plugin-message channel Velocity intercepts to move a player to
|
// Backoff between retries, in ticks (≈1s, 2s, 4s, then every 8s): used both for a
|
||||||
// another backend. "Connect" + the target server name is the classic transfer.
|
// felis-api outage at join and for re-sending the release until the player leaves.
|
||||||
private static final String BUNGEE_CHANNEL = "bungeecord:main";
|
private static final long[] BACKOFF_TICKS = {20L, 40L, 80L, 160L};
|
||||||
|
// How long a felis-api outage at join is retried before the player is turned away.
|
||||||
|
static final long START_RETRY_WINDOW_MILLIS = 60_000L;
|
||||||
|
// How long the release is re-sent after sign-in before giving up with a message.
|
||||||
|
private static final long RELEASE_WINDOW_MILLIS = 120_000L;
|
||||||
|
|
||||||
// ---- readiness state ----
|
// ---- readiness state ----
|
||||||
private final AtomicBoolean ready = new AtomicBoolean(false);
|
private final AtomicBoolean ready = new AtomicBoolean(false);
|
||||||
@@ -112,9 +130,13 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
private volatile String lobbyServer;
|
private volatile String lobbyServer;
|
||||||
private volatile long timeoutMillis;
|
private volatile long timeoutMillis;
|
||||||
|
|
||||||
// Per-player poll task ids, so a completed/abandoned login cancels its own timer
|
// Per-player task ids (the link poll, a join retry, or the next release), so a
|
||||||
// rather than polling a departed UUID forever.
|
// completed/abandoned login cancels its own timer rather than polling a departed
|
||||||
|
// UUID forever.
|
||||||
private final ConcurrentHashMap<UUID, Integer> pollTasks = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<UUID, Integer> pollTasks = new ConcurrentHashMap<>();
|
||||||
|
// Players whose release loop is running; the async link poll can observe "linked"
|
||||||
|
// twice before its cancellation lands, and the release must start once.
|
||||||
|
private final Set<UUID> releasing = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
@@ -141,6 +163,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
// cancelTask(plugin) tears down every scheduled task this plugin owns.
|
// cancelTask(plugin) tears down every scheduled task this plugin owns.
|
||||||
getServer().getScheduler().cancelTask(this);
|
getServer().getScheduler().cancelTask(this);
|
||||||
pollTasks.clear();
|
pollTasks.clear();
|
||||||
|
releasing.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- readiness endpoint (unchanged behavior) ----
|
// ---- readiness endpoint (unchanged behavior) ----
|
||||||
@@ -227,12 +250,17 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
public void onJoin(PlayerJoinEvent event) {
|
public void onJoin(PlayerJoinEvent event) {
|
||||||
Player player = event.getPlayer();
|
Player player = event.getPlayer();
|
||||||
UUID id = player.getUniqueId();
|
UUID id = player.getUniqueId();
|
||||||
|
releasing.remove(id); // a reconnect starts a fresh login
|
||||||
|
long startedAt = System.currentTimeMillis();
|
||||||
// Everything below touches the network; run it off the tick thread so a slow
|
// Everything below touches the network; run it off the tick thread so a slow
|
||||||
// felis-api never stalls the server loop. The player waits in the limbo world.
|
// felis-api never stalls the server loop. The player waits in the limbo world.
|
||||||
getServer().getScheduler().runTaskAsync(this, () -> beginLogin(id));
|
getServer().getScheduler().runTaskAsync(this, () -> beginLogin(id, 0, startedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void beginLogin(UUID id) {
|
private void beginLogin(UUID id, int attempt, long startedAt) {
|
||||||
|
if (online(id) == null) {
|
||||||
|
return; // left while a retry was pending
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (apiClient.isBlacklisted(id)) {
|
if (apiClient.isBlacklisted(id)) {
|
||||||
disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff.");
|
disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff.");
|
||||||
@@ -243,16 +271,37 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
// useless code. Only unlinked players get one. The on-demand /link
|
// useless code. Only unlinked players get one. The on-demand /link
|
||||||
// command (proxy + lobby) stays the door to a fresh web session.
|
// command (proxy + lobby) stays the door to a fresh web session.
|
||||||
if (apiClient.linkStatus(id)) {
|
if (apiClient.linkStatus(id)) {
|
||||||
getServer().getScheduler().runTask(this, () -> transferToLobby(id));
|
getServer().getScheduler().runTask(this, () -> startRelease(id));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
LinkCode code = linkClient.requestCode(id);
|
LinkCode code = linkClient.requestCode(id);
|
||||||
getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code));
|
getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code));
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
// Fail closed: we could not reach the auth backend, so we cannot admit the
|
boolean outage = e.statusCode() == 0 || e.statusCode() >= 500;
|
||||||
// player. Refuse the connection rather than let them idle unauthenticated.
|
if (!outage || System.currentTimeMillis() - startedAt > START_RETRY_WINDOW_MILLIS) {
|
||||||
LOG.warning("FelisLimbo: login start failed for " + id + " — " + e.getMessage());
|
// Fail closed: felis-api refused, or stayed unreachable for the whole
|
||||||
disconnectOnMain(id, "登录服务暂不可用,请稍后重连 / Login service unavailable, please reconnect shortly.");
|
// retry window. Refuse the connection rather than let them idle
|
||||||
|
// unauthenticated.
|
||||||
|
LOG.warning("FelisLimbo: login start failed for " + id + " after " + (attempt + 1)
|
||||||
|
+ " attempt(s) — " + e.getMessage());
|
||||||
|
disconnectOnMain(id, "登录服务暂不可用,请稍后重连 / Login service unavailable, please reconnect shortly.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
LOG.info("FelisLimbo: felis-api unavailable at login for " + id + " (attempt " + (attempt + 1)
|
||||||
|
+ "): " + e.getMessage());
|
||||||
|
getServer().getScheduler().runTask(this, () -> {
|
||||||
|
Player player = online(id);
|
||||||
|
if (player == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt == 0) {
|
||||||
|
player.sendMessage("§e[Felis] 登录服务繁忙,正在重试… / The login service is busy — retrying…");
|
||||||
|
}
|
||||||
|
sendRelease(player);
|
||||||
|
});
|
||||||
|
int taskId = getServer().getScheduler().runTaskLaterAsync(
|
||||||
|
this, () -> beginLogin(id, attempt + 1, startedAt), backoff(attempt));
|
||||||
|
track(id, taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,11 +330,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||||
int taskId = getServer().getScheduler().runTaskTimerAsync(
|
int taskId = getServer().getScheduler().runTaskTimerAsync(
|
||||||
this, () -> pollOnce(id, deadline), POLL_PERIOD_TICKS, POLL_PERIOD_TICKS);
|
this, () -> pollOnce(id, deadline), POLL_PERIOD_TICKS, POLL_PERIOD_TICKS);
|
||||||
// Replace any prior task for this UUID (a reconnect) and cancel the stale one.
|
track(id, taskId);
|
||||||
Integer previous = pollTasks.put(id, taskId);
|
|
||||||
if (previous != null) {
|
|
||||||
getServer().getScheduler().cancelTask(previous);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void pollOnce(UUID id, long deadline) {
|
private void pollOnce(UUID id, long deadline) {
|
||||||
@@ -301,11 +346,7 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (apiClient.linkStatus(id)) {
|
if (apiClient.linkStatus(id)) {
|
||||||
// Keep polling until the player actually leaves this backend. The
|
getServer().getScheduler().runTask(this, () -> startRelease(id));
|
||||||
// proxy independently re-checks link status before releasing the
|
|
||||||
// gate; a transient failure there denies this attempt, so retrying
|
|
||||||
// avoids stranding an authenticated player in limbo.
|
|
||||||
getServer().getScheduler().runTask(this, () -> transferToLobby(id));
|
|
||||||
}
|
}
|
||||||
} catch (LinkException e) {
|
} catch (LinkException e) {
|
||||||
// A transient poll failure is not fatal — keep trying until the deadline.
|
// A transient poll failure is not fatal — keep trying until the deadline.
|
||||||
@@ -313,18 +354,67 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void transferToLobby(UUID id) {
|
// startRelease runs on the main thread once the player is known to be linked: stop
|
||||||
Player player = getServer().getPlayer(id);
|
// the link poll, say so once, and start asking the proxy for the lobby.
|
||||||
if (player == null || !player.isValid()) {
|
private void startRelease(UUID id) {
|
||||||
|
Player player = online(id);
|
||||||
|
if (player == null || !releasing.add(id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
cancelPoll(id);
|
||||||
player.sendMessage("§a[Felis] 登录成功,正在进入大厅… / Signed in — sending you to the lobby…");
|
player.sendMessage("§a[Felis] 登录成功,正在进入大厅… / Signed in — sending you to the lobby…");
|
||||||
player.sendPluginMessage(BUNGEE_CHANNEL, bungeeConnect(lobbyServer));
|
releaseAttempt(id, 0, System.currentTimeMillis() + RELEASE_WINDOW_MILLIS);
|
||||||
} catch (IOException | RuntimeException e) {
|
}
|
||||||
LOG.warning("FelisLimbo: transfer to lobby failed for " + id + " — " + e.getMessage());
|
|
||||||
disconnectOnMain(id, "进入大厅失败,请重连 / Could not reach the lobby. Please reconnect.");
|
// releaseAttempt sends one LoginRelease and schedules the next. The proxy re-checks
|
||||||
|
// the link before it moves the player, and a transient failure there only denies
|
||||||
|
// that one attempt, so the gate keeps asking (with backoff, silently) until the
|
||||||
|
// player is gone or the window closes.
|
||||||
|
private void releaseAttempt(UUID id, int attempt, long deadline) {
|
||||||
|
Player player = online(id);
|
||||||
|
if (player == null) {
|
||||||
|
releasing.remove(id);
|
||||||
|
cancelPoll(id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (System.currentTimeMillis() > deadline) {
|
||||||
|
releasing.remove(id);
|
||||||
|
LOG.warning("FelisLimbo: " + id + " was not released to the lobby within "
|
||||||
|
+ (RELEASE_WINDOW_MILLIS / 1000) + "s");
|
||||||
|
disconnectOnMain(id, "进入大厅失败,请重连 / Could not reach the lobby. Please reconnect.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendRelease(player);
|
||||||
|
int taskId = getServer().getScheduler().runTaskLater(
|
||||||
|
this, () -> releaseAttempt(id, attempt + 1, deadline), backoff(attempt));
|
||||||
|
track(id, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendRelease(Player player) {
|
||||||
|
try {
|
||||||
|
player.sendPluginMessage(Control.CHANNEL, Control.encode(ControlFrame.loginRelease(player.getName())));
|
||||||
|
} catch (IOException | RuntimeException e) {
|
||||||
|
// The next attempt sends again; the window bounds how long we keep trying.
|
||||||
|
LOG.warning("FelisLimbo: could not send the lobby release for " + player.getUniqueId()
|
||||||
|
+ " — " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Player online(UUID id) {
|
||||||
|
Player player = getServer().getPlayer(id);
|
||||||
|
return player != null && player.isValid() ? player : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// track records the player's current task, cancelling the one it replaces.
|
||||||
|
private void track(UUID id, int taskId) {
|
||||||
|
Integer previous = pollTasks.put(id, taskId);
|
||||||
|
if (previous != null && previous != taskId) {
|
||||||
|
getServer().getScheduler().cancelTask(previous);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long backoff(int attempt) {
|
||||||
|
return BACKOFF_TICKS[Math.min(attempt, BACKOFF_TICKS.length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cancelPoll(UUID id) {
|
private void cancelPoll(UUID id) {
|
||||||
@@ -361,18 +451,6 @@ public final class FelisLimboPlugin extends LimboPlugin implements Listener {
|
|||||||
page);
|
page);
|
||||||
}
|
}
|
||||||
|
|
||||||
// bungeeConnect frames a BungeeCord "Connect" sub-channel message: the UTF string
|
|
||||||
// "Connect" followed by the target server name. Velocity intercepts this on the
|
|
||||||
// bungeecord:main channel and moves the player to that backend.
|
|
||||||
private static byte[] bungeeConnect(String server) throws IOException {
|
|
||||||
ByteArrayOutputStream buf = new ByteArrayOutputStream();
|
|
||||||
try (DataOutputStream out = new DataOutputStream(buf)) {
|
|
||||||
out.writeUTF("Connect");
|
|
||||||
out.writeUTF(server);
|
|
||||||
}
|
|
||||||
return buf.toByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- env helpers ----
|
// ---- env helpers ----
|
||||||
|
|
||||||
private static int healthPort() {
|
private static int healthPort() {
|
||||||
|
|||||||
Reference in new issue
Block a user