diff --git a/plugins/limbo/build.gradle b/plugins/limbo/build.gradle new file mode 100644 index 0000000..c01b374 --- /dev/null +++ b/plugins/limbo/build.gradle @@ -0,0 +1,58 @@ +plugins { + id 'java' +} + +group = 'best.lolicon.felis' +version = '0.1.0' + +// Build with a JDK 21 toolchain but emit portable Java 17 bytecode (options.release +// below). Current LOOHP/Limbo releases ship Java 21 API classes (class-file major +// 65), so the compiler JDK must be >= 21 to *read* them — a JDK 17 fails with +// "wrong version 65.0, should be 61.0". Targeting release 17 keeps the plugin +// loadable on any Limbo running Java 17+. See deploy/limbo/Dockerfile (21-jdk). +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() + // LOOHP publishes the Limbo API artifact to their own repository. + maven { + name = 'loohp' + url = 'https://repo.loohpjames.com/repository' + } +} + +// Pull in the platform-neutral link core (best.lolicon.felis.link) the same way +// every other module does — a source include, not a jar dependency — so the +// account-link client (LinkClient), the internal read/drive client (FelisApiClient) +// and the env/properties config loader compile straight into this jar with nothing +// to shade. See plugins/README.md "Architecture". +sourceSets { + main { + java { + srcDir '../shared/src/main/java' + } + } +} + +// The Limbo API version this plugin compiles against. It is CODE-ONLY in this Go +// repo (not built by CI); a deployer building the image passes the release that +// matches the Limbo.jar they bundle, e.g. -PlimboVersion=0.7.11-ALPHA. Kept a +// property so no specific build number is hardcoded into the tree. +def limboVersion = project.findProperty('limboVersion') ?: '+' + +dependencies { + // Limbo provides its own API at runtime (the plugin is loaded into the Limbo + // JVM), so it is compile-only — nothing to shade. + compileOnly "com.loohp:Limbo:${limboVersion}" +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + // Portable output: read the Java 21 Limbo API with the JDK 21 toolchain above, + // but emit Java 17 bytecode so the plugin loads on any Limbo running Java 17+. + options.release = 17 +} diff --git a/plugins/limbo/settings.gradle b/plugins/limbo/settings.gradle new file mode 100644 index 0000000..3aadcfe --- /dev/null +++ b/plugins/limbo/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'felis-limbo' diff --git a/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java new file mode 100644 index 0000000..e58116b --- /dev/null +++ b/plugins/limbo/src/main/java/best/lolicon/felis/limbo/FelisLimboPlugin.java @@ -0,0 +1,395 @@ +package best.lolicon.felis.limbo; + +import best.lolicon.felis.link.FelisApiClient; +import best.lolicon.felis.link.LinkClient; +import best.lolicon.felis.link.LinkCode; +import best.lolicon.felis.link.LinkConfig; +import best.lolicon.felis.link.LinkConfigLoader; +import best.lolicon.felis.link.LinkException; + +import com.loohp.limbo.events.EventHandler; +import com.loohp.limbo.events.Listener; +import com.loohp.limbo.events.player.PlayerJoinEvent; +import com.loohp.limbo.player.Player; +import com.loohp.limbo.plugins.LimboPlugin; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; + +import net.kyori.adventure.inventory.Book; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * FelisLimboPlugin turns a LOOHP/Limbo instance into the Felis login gate + * (spec §B3): the always-on front door every fresh connection lands on. It does two + * jobs. + * + *

1. Readiness. Limbo has no RCON, so the operator cannot use its usual + * RCON readiness probe. Left to a plain TCP check the pod would report ready the + * instant the socket binds. This plugin serves {@code GET /healthz} on + * {@code FELIS_HEALTH_PORT} (default 8080) that flips from 503 to 200 only after the + * first server tick — the MinecraftServer CRD's HTTP readinessProbe follows that + * true signal. Fail-closed: if the endpoint cannot bind, readiness never turns green + * and the operator keeps the gate in Starting rather than advertising an unstarted + * auth gate. + * + *

2. In-game login. A player who reaches the limbo has been UUID-verified + * upstream (Velocity online-mode) but is not yet linked to a web account. On join + * the plugin, off the tick thread: + *

    + *
  1. checks the username-collision blacklist and disconnects a barred squatter + * UUID (the genuine Mojang player, different UUID, passes);
  2. + *
  3. mints a one-time Bind Code for the verified UUID via the felis-api internal + * face ({@link LinkClient});
  4. + *
  5. opens a book (clickable link to {@code console.}) plus a chat + * line carrying the code, and tells the player to finish in their system + * browser — never the WeChat/QQ in-app browser, where WebAuthn/passkey does not + * work (the web entry additionally guards this, see internal/panel);
  6. + *
  7. 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} + * plugin message;
  8. + *
  9. disconnects (fail-closed) on blacklist, on a mint/transport failure, or when + * the login window elapses — "rather refuse than admit unauthenticated".
  10. + *
+ * + *

Config (deployment inputs, never compiled in). The API base URL and + * 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 + * the shared {@link LinkConfigLoader}. {@code FELIS_ROOT_DOMAIN} builds the console + * link; {@code FELIS_LOBBY_SERVER} (default {@code lobby}) is the transfer target; + * {@code FELIS_LOGIN_TIMEOUT_SECONDS} (default 300) bounds the login window. If the + * link config or the root domain is absent the login flow stays OFF and the plugin + * runs readiness-only — the same "load un-crippled" fail-safe the other Felis plugins + * use — so a bare image still boots and serves readiness; production must supply the + * config for the gate to authenticate. + * + *

CODE-ONLY in the Go repo: it compiles against the Limbo API + the shared link + * core and is bundled into the login image (deploy/limbo), not built by the Go CI. + */ +public final class FelisLimboPlugin extends LimboPlugin implements Listener { + + private static final Logger LOG = Logger.getLogger("FelisLimbo"); + private static final String HEALTH_PATH = "/healthz"; + private static final int DEFAULT_PORT = 8080; + + // Poll cadence and window. 20 ticks ≈ 1s at Limbo's tick rate; polling once a + // second is responsive without hammering felis-api. The default window (5 min) + // matches the Bind Code TTL — no point holding a player past code expiry. + private static final long POLL_PERIOD_TICKS = 20L; + private static final long DEFAULT_TIMEOUT_SECONDS = 300L; + private static final long MIN_TIMEOUT_SECONDS = 30L; + private static final long MAX_TIMEOUT_SECONDS = 3600L; + + // The BungeeCord plugin-message channel Velocity intercepts to move a player to + // another backend. "Connect" + the target server name is the classic transfer. + private static final String BUNGEE_CHANNEL = "bungeecord:main"; + + // ---- readiness state ---- + private final AtomicBoolean ready = new AtomicBoolean(false); + private volatile HttpServer http; + + // ---- login state (populated only when the flow is configured) ---- + private volatile boolean loginEnabled; + private volatile LinkClient linkClient; + private volatile FelisApiClient apiClient; + private volatile String consoleUrl; + private volatile String lobbyServer; + private volatile long timeoutMillis; + + // Per-player poll task ids, so a completed/abandoned login cancels its own timer + // rather than polling a departed UUID forever. + private final ConcurrentHashMap pollTasks = new ConcurrentHashMap<>(); + + @Override + public void onEnable() { + startReadiness(); + configureLogin(); + if (loginEnabled) { + getServer().getEventsManager().registerEvents(this, this); + LOG.info("FelisLimbo: login flow ON — console=" + consoleUrl + + ", lobby=" + lobbyServer + ", window=" + (timeoutMillis / 1000) + "s"); + } else { + LOG.warning("FelisLimbo: login flow OFF (missing FELIS_API_BASE_URL/FELIS_SERVICE_TOKEN " + + "or FELIS_ROOT_DOMAIN) — serving readiness only; this gate will NOT authenticate players"); + } + } + + @Override + public void onDisable() { + ready.set(false); + HttpServer server = this.http; + if (server != null) { + server.stop(0); + this.http = null; + } + // cancelTask(plugin) tears down every scheduled task this plugin owns. + getServer().getScheduler().cancelTask(this); + pollTasks.clear(); + } + + // ---- readiness endpoint (unchanged behavior) ---- + + private void startReadiness() { + int port = healthPort(); + try { + HttpServer server = HttpServer.create(new InetSocketAddress(port), 0); + server.createContext(HEALTH_PATH, this::handleHealth); + server.setExecutor(null); // default executor: this endpoint is trivial + server.start(); + this.http = server; + LOG.info("FelisLimbo: readiness endpoint on :" + port + HEALTH_PATH + " (503 until the first tick)"); + } catch (IOException e) { + // Fail closed: no health server → never ready → pod stays NotReady. + LOG.log(Level.SEVERE, "FelisLimbo: could not start readiness endpoint on :" + port + + " — the login gate will stay NotReady", e); + return; + } + + // onEnable runs just before Limbo opens its game socket and no "started" + // event exists, so mark ready one tick later: by the time the scheduler runs + // the task the server loop is ticking and the socket is open. + getServer().getScheduler().runTaskLater(this, () -> { + if (ready.compareAndSet(false, true)) { + LOG.info("FelisLimbo: server started — readiness now 200"); + } + }, 1L); + } + + private void handleHealth(HttpExchange exchange) throws IOException { + boolean up = ready.get(); + byte[] body = (up ? "ok" : "starting").getBytes(StandardCharsets.UTF_8); + int status = up ? 200 : 503; + exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8"); + exchange.sendResponseHeaders(status, body.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(body); + } + } + + // ---- login configuration ---- + + private void configureLogin() { + LinkConfig cfg; + try { + File props = new File(getDataFolder(), "felis-link.properties"); + cfg = LinkConfigLoader.load(props.toPath()); + } catch (IOException e) { + // Missing/half config: like the other Felis plugins, load un-crippled — + // readiness stays up, the login flow just never turns on. + LOG.warning("FelisLimbo: " + e.getMessage()); + loginEnabled = false; + return; + } + + String rootDomain = trimmed(System.getenv("FELIS_ROOT_DOMAIN")); + if (rootDomain == null) { + LOG.warning("FelisLimbo: FELIS_ROOT_DOMAIN unset — cannot build the console login link"); + loginEnabled = false; + return; + } + + this.consoleUrl = "https://console." + rootDomain; + String lobby = trimmed(System.getenv("FELIS_LOBBY_SERVER")); + this.lobbyServer = lobby != null ? lobby : "lobby"; + this.timeoutMillis = loginTimeoutSeconds() * 1000L; + this.linkClient = new LinkClient(cfg); + this.apiClient = new FelisApiClient(cfg); + this.loginEnabled = true; + } + + // ---- login flow ---- + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + UUID id = player.getUniqueId(); + // 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. + getServer().getScheduler().runTaskAsync(this, () -> beginLogin(id)); + } + + private void beginLogin(UUID id) { + try { + if (apiClient.isBlacklisted(id)) { + disconnectOnMain(id, "该用户名已被回收保护 / This username is under reclaim protection. Contact staff."); + return; + } + LinkCode code = linkClient.requestCode(id); + getServer().getScheduler().runTask(this, () -> presentAndPoll(id, code)); + } catch (LinkException e) { + // Fail closed: we could not reach the auth backend, so we cannot admit the + // player. Refuse the connection rather than let them idle unauthenticated. + LOG.warning("FelisLimbo: login start failed for " + id + " — " + e.getMessage()); + disconnectOnMain(id, "登录服务暂不可用,请稍后重连 / Login service unavailable, please reconnect shortly."); + } + } + + private void presentAndPoll(UUID id, LinkCode code) { + Player player = getServer().getPlayer(id); + if (player == null || !player.isValid()) { + return; // player left during the async mint + } + + try { + player.openBook(loginBook(code)); + } catch (RuntimeException e) { + // A client that refuses the book (rare) still gets the chat instructions + // below, so a book failure is not fatal to the flow. + LOG.fine("FelisLimbo: openBook failed for " + id + " — " + e.getMessage()); + } + player.sendMessage("§e[Felis] 绑定码 / Code: §6" + code.code()); + player.sendMessage("§e[Felis] 用系统浏览器打开 §b" + consoleUrl + + " §e完成登录(勿用微信/QQ内置浏览器)。"); + player.sendMessage("§7Open " + consoleUrl + " in your system browser (not WeChat/QQ) to finish."); + + long deadline = System.currentTimeMillis() + timeoutMillis; + int taskId = getServer().getScheduler().runTaskTimerAsync( + this, () -> pollOnce(id, deadline), POLL_PERIOD_TICKS, POLL_PERIOD_TICKS); + // Replace any prior task for this UUID (a reconnect) and cancel the stale one. + Integer previous = pollTasks.put(id, taskId); + if (previous != null) { + getServer().getScheduler().cancelTask(previous); + } + } + + private void pollOnce(UUID id, long deadline) { + Player player = getServer().getPlayer(id); + if (player == null || !player.isValid()) { + cancelPoll(id); // player left; stop polling their UUID + return; + } + if (System.currentTimeMillis() > deadline) { + cancelPoll(id); + disconnectOnMain(id, "登录超时,请重连 / Login timed out. Please reconnect."); + return; + } + try { + if (apiClient.linkStatus(id)) { + cancelPoll(id); + getServer().getScheduler().runTask(this, () -> transferToLobby(id)); + } + } catch (LinkException e) { + // A transient poll failure is not fatal — keep trying until the deadline. + LOG.fine("FelisLimbo: link status poll failed for " + id + " — " + e.getMessage()); + } + } + + private void transferToLobby(UUID id) { + Player player = getServer().getPlayer(id); + if (player == null || !player.isValid()) { + return; + } + try { + player.sendMessage("§a[Felis] 登录成功,正在进入大厅… / Signed in — sending you to the lobby…"); + player.sendPluginMessage(BUNGEE_CHANNEL, bungeeConnect(lobbyServer)); + } catch (IOException | RuntimeException e) { + LOG.warning("FelisLimbo: transfer to lobby failed for " + id + " — " + e.getMessage()); + disconnectOnMain(id, "进入大厅失败,请重连 / Could not reach the lobby. Please reconnect."); + } + } + + private void cancelPoll(UUID id) { + Integer taskId = pollTasks.remove(id); + if (taskId != null) { + getServer().getScheduler().cancelTask(taskId); + } + } + + private void disconnectOnMain(UUID id, String message) { + getServer().getScheduler().runTask(this, () -> { + cancelPoll(id); + Player player = getServer().getPlayer(id); + if (player != null && player.isValid()) { + player.disconnect(message); + } + }); + } + + // ---- rendering / wire ---- + + private Book loginBook(LinkCode code) { + Component page = Component.text("Felis 登录 / Login\n\n") + .append(Component.text("绑定码 / Code:\n")) + .append(Component.text(code.code() + "\n\n").color(NamedTextColor.GOLD)) + .append(Component.text("▶ 点此打开登录页\n▶ Open login page\n") + .color(NamedTextColor.AQUA) + .clickEvent(ClickEvent.openUrl(consoleUrl))) + .append(Component.text("\n在系统浏览器中完成。\nUse your SYSTEM browser —\nnot WeChat / QQ (passkey\nwon't work there).") + .color(NamedTextColor.GRAY)); + return Book.book( + Component.text("Felis Login"), + Component.text("Felis"), + 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 ---- + + private static int healthPort() { + String raw = System.getenv("FELIS_HEALTH_PORT"); + if (raw != null && !raw.isBlank()) { + try { + int p = Integer.parseInt(raw.trim()); + if (p > 0 && p < 65536) { + return p; + } + } catch (NumberFormatException ignored) { + // fall through to the default + } + } + return DEFAULT_PORT; + } + + private static long loginTimeoutSeconds() { + String raw = System.getenv("FELIS_LOGIN_TIMEOUT_SECONDS"); + if (raw != null && !raw.isBlank()) { + try { + long s = Long.parseLong(raw.trim()); + if (s < MIN_TIMEOUT_SECONDS) { + return MIN_TIMEOUT_SECONDS; + } + if (s > MAX_TIMEOUT_SECONDS) { + return MAX_TIMEOUT_SECONDS; + } + return s; + } catch (NumberFormatException ignored) { + // fall through to the default + } + } + return DEFAULT_TIMEOUT_SECONDS; + } + + private static String trimmed(String raw) { + if (raw == null) { + return null; + } + String t = raw.trim(); + return t.isEmpty() ? null : t; + } +} diff --git a/plugins/limbo/src/main/resources/limbo.yml b/plugins/limbo/src/main/resources/limbo.yml new file mode 100644 index 0000000..7d54068 --- /dev/null +++ b/plugins/limbo/src/main/resources/limbo.yml @@ -0,0 +1,4 @@ +name: FelisLimbo +main: best.lolicon.felis.limbo.FelisLimboPlugin +version: 0.1.0 +author: Felis diff --git a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java index 20c7c5f..a8269da 100644 --- a/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java +++ b/plugins/shared/src/main/java/best/lolicon/felis/link/FelisApiClient.java @@ -137,6 +137,42 @@ public final class FelisApiClient { return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + name + "/menu", 200)); } + /** + * linkStatus polls whether the verified UUID has finished web account-link — the + * completion leg of the in-game login flow (spec §B3). After the player redeems + * the Bind Code on {@code console.} the login limbo polls this until + * it flips true, then admits/transfers the player. {@code GET + * /api/v1/internal/account/link/status/{mc_uuid}} → {@code {"linked":bool,...}}; + * read-only and keyed by the verified UUID, so it consumes nothing and is safe to + * poll repeatedly. Anything but {@code linked:true} (including a missing field) is + * reported as not-yet-linked — the caller keeps waiting rather than admitting on + * an ambiguous body. + */ + public boolean linkStatus(UUID mcUuid) throws LinkException { + Objects.requireNonNull(mcUuid, "mcUuid"); + Map obj = getObject("/api/v1/internal/account/link/status/" + mcUuid, 200); + Object linked = obj.get("linked"); + return linked instanceof Boolean && (Boolean) linked; + } + + /** + * isBlacklisted reports whether a connecting UUID was barred by a + * username-collision reclaim (spec §B3): the login limbo checks this on join and + * refuses a barred squatter UUID before minting a code. {@code GET + * /api/v1/internal/player/blacklist/{mc_uuid}} → {@code {"blacklisted":bool}}. The + * bar is keyed by UUID, so the genuine Mojang player (same name, different UUID) + * always reads false and passes. A body that does not affirm {@code + * blacklisted:true} is treated as not-barred (fail-open on this specific read is + * deliberate: the authoritative gate is the mint/link flow, and a transport fault + * already surfaces as a LinkException the caller fails closed on). + */ + public boolean isBlacklisted(UUID mcUuid) throws LinkException { + Objects.requireNonNull(mcUuid, "mcUuid"); + Map obj = getObject("/api/v1/internal/player/blacklist/" + mcUuid, 200); + Object barred = obj.get("blacklisted"); + return barred instanceof Boolean && (Boolean) barred; + } + // ---- transport ---- private Map getObject(String path, int expect) throws LinkException {