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

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

No files matched your search

+224
View File
@@ -0,0 +1,224 @@
# Felis server-side plugins
These are the in-cluster and edge plugins for Felis. Every module **except the
lobby** ships the in-game first leg of the §10 account-link flow: a player who is already online
(so Mojang has verified their UUID) runs `/link`; the plugin asks felis-api to
mint a one-time code for that UUID and shows it in chat. The player then enters
the code on the web panel → **Account** page (the second leg), which binds the
code to their logged-in account. The web side is already built.
The **Velocity** module additionally carries the §11 domain-autostart routing
loop — recognizing each server's subdomain, registering backends dynamically,
waking a sleeping target and holding the player until it is ready. It is a full
proxy plugin, not just `/link`; see **[Velocity routing](#velocity-routing-§11)**
below. The Fabric / Forge / NeoForge mods are `/link`-only.
The **Paper** module is different in kind: it is the §12 lobby UI face. It ships
**no** `/link` and holds **no** felis-api token — it only paints the `/menu`
(and `/server`) chest GUI and speaks the `felis:control` plugin-message channel
to Velocity, which is the only side that ever talks to felis-api. See
**[Lobby menu](#lobby-menu-§12)** below.
| Module | Platform | Target | Jar |
| ------------------ | --------------------------- | ----------------------------------- | ---------------------------- |
| `velocity/` | Velocity proxy plugin | velocity-api 3.3.0-SNAPSHOT | `felis-velocity-0.2.0.jar` |
| `fabric/` | Fabric server mod | MC 1.20.1 / fabric-loader 0.16.x | `felis-fabric-0.1.0.jar` |
| `forge/` | Forge server mod | MC 1.20.1 / Forge 47.3.0 | `felis-forge-0.1.0.jar` |
| `neoforge/` | NeoForge server mod | MC 1.20.4 / NeoForge 20.4.251 | `felis-neoforge-0.1.0.jar` |
| `paper/` | Paper server plugin (lobby) | paper-api 1.21.4-R0.1-SNAPSHOT | `felis-paper-0.1.0.jar` |
| `shared/` | *(not built on its own)* | — | source compiled into each |
## Architecture
Each platform is an **independent** Gradle build with its own `settings.gradle`,
not one root project mixing loader plugins (the loader Gradle plugins have
conflicting Gradle-version requirements — see below). The platform-neutral link
core lives in `shared/src/main/java` and is pulled into every module via:
```groovy
sourceSets { main { java { srcDir '../shared/src/main/java' } } }
```
The core (`best.lolicon.felis.link`) has **zero third-party dependencies** — it
uses the JDK's `java.net.http.HttpClient` and a small hand-written JSON parser —
so there is nothing to shade and each jar is self-contained.
- `LinkClient` — `POST {apiBaseUrl}/api/v1/internal/account/link/code` with
`Authorization: Bearer <service-token>` and body `{"mc_uuid":"<uuid>"}`;
`201 → {code, expires_at}`, otherwise the `{error:{code,message}}` envelope.
- `LinkConfigLoader` — reads `FELIS_API_BASE_URL` / `FELIS_SERVICE_TOKEN` (env
wins) or a `felis-link.properties` file written as a commented template on
first run. **The API URL and service token are deployment inputs and are never
compiled in.**
Threading: the command runs on the server thread; the HTTP call is dispatched to
a daemon single-thread executor and the reply is hopped back onto the server
thread, so a slow felis-api never stalls the tick loop. If config is missing the
plugin loads but never registers `/link`, so the server runs un-crippled.
All three mods use **official Mojang mappings**, so the MC class/method names are
identical across Fabric/Forge/NeoForge and the command handler is uniform; only
the `@Mod`/event-bus/config-dir glue differs per loader.
## Velocity routing (§11)
Velocity sits on the player-facing edge, off-cluster, so it is where
domain-autostart routing lives. Beyond `/link`, the Velocity plugin recognizes
each felis server by its subdomain, registers backends into Velocity's dynamic
server registry, and decides — per join — whether to send the player straight in,
wake a sleeping server and park them, or ask them to reconnect. It drives §9 wake
and §11 routing over the felis-api **internal** face (service-token auth), and
additionally terminates the `felis:control` plugin-message channel that backs the
§12 lobby menu — translating each lobby frame into the same wake/claim/status
calls, against the player's connection-derived identity rather than anything the
lobby claims. See **[Lobby menu](#lobby-menu-§12)** below.
Two preconditions gate routing, **each fails safe** (routing turns off, `/link`
keeps working):
- **online mode** — `online-mode=true` in `velocity.toml`. The autostartPolicy
and allowlist gates trust Mojang-verified UUIDs; under offline mode the plugin
refuses to route on spoofable identities and logs an error.
- **root-domain** — the deployment zone (e.g. `mc.example.net`). This is the only
place the zone enters the proxy and is **never compiled in**; without it,
host-based routing has nothing to match and stays off.
What it does when routing is active:
| Surface | Behavior |
| ------- | -------- |
| Backend registry | Polls `GET /api/v1/servers` every 15 s and reconciles Velocity's dynamic registry. A failed poll **keeps existing registrations** — a control-plane blip never deregisters live backends. Addresses are registered *unresolved* (a sleeping backend's Service DNS may not resolve yet). |
| Join (`PlayerChooseInitialServerEvent`) | Resolves `subdomain.<root-domain>` → server. **Ready** → send straight in. **Not ready + lobby** → park in the lobby, wake, and transfer when ready. **Not ready + no lobby** → disconnect with a "reconnect shortly" message, still firing the wake so the reconnect lands faster. |
| Waiting queue | One scheduled drain every 2 s polls status once per distinct waited-on server; a waiter drops out on transfer, on the player leaving, or after a 120 s timeout. |
| Wake gate | The wake is `POST /api/v1/internal/servers/{name}/wake` keyed on the player's online-mode UUID. **403** (policy refused) tells the player and stops; **429** (wake already in flight) keeps waiting. |
| Server-list ping (`ProxyPingEvent`) | Answers from the cached lifecycle view with a phase-aware MOTD (online / starting / sleeping) — **read-only, never wakes** anything. Mirroring each backend's own MOTD by background-pinging ready servers is a later slice. |
| Join report (`ServerConnectedEvent`) | Reports real joins to a felis backend via `POST …/join-event`, so the reaper sees activity and the player is auto-added to the server allowlist. |
| `/felis`, `/felis list` | Operator status: online-mode, root-domain, lobby, and the known server set with phase/ready. |
Velocity-only config keys (read from the same `felis-link.properties` / env as
`/link`; env wins):
| Key | Env | Meaning |
| --- | --- | ------- |
| `root-domain` | `FELIS_ROOT_DOMAIN` | Routing zone, e.g. `mc.example.net`. Unset → routing off. |
| `lobby-server` | `FELIS_LOBBY_SERVER` | A `velocity.toml` static server to park players in while a backend wakes. Unset → players are asked to reconnect instead. Its name must not collide with a felis server name. |
## Lobby menu (§12)
The `paper/` module is the lobby's player-facing face for §27 scenario 10
(`/menu → plugin msg → velocity → api → 共用等待队列 → ready 后 Connect`). It runs
on the Paper lobby server and gives players a chest GUI instead of a command
line: `/menu` (alias `/server`) opens a grid of one tile per configured server,
and clicking a tile wakes, claims, or joins that backend.
**Pure UI face.** The lobby holds no felis-api token, opens no HTTP connection,
and keeps no waiting queue. Every action it takes is a single frame on the
`felis:control` plugin-message channel; every piece of state it shows arrives as
a frame on the same channel. Velocity (the `ControlChannel`, above) is the only
side that talks to felis-api. This is enforced **physically** by the build, not
just by convention: the module's `sourceSets` include-filter compiles in only the
paper package plus the three codec classes, so the lobby jar contains exactly
five classes —
```
best/lolicon/felis/link/Control.class (channel framing)
best/lolicon/felis/link/ControlFrame.class (the frame model)
best/lolicon/felis/link/Json.class (codec)
best/lolicon/felis/paper/FelisPaperPlugin.class
best/lolicon/felis/paper/MenuHolder.class
```
— and **no** `FelisApiClient`, `LinkClient`, or token-config class. If a codec
class ever grew a dependency on the API client, compilation would fail here
rather than silently widen the lobby's reach.
**Frames.** Upstream (lobby → velocity) carries `WakeRequest`, `ClaimRequest`,
and `StatusQuery`; downstream (velocity → lobby) carries `StatusUpdate`,
`TransferReady`, and `Error`. Opening the menu paints a grey "loading" tile per
server and fires a `StatusQuery` for each; the proxy answers with `StatusUpdate`
frames that repaint each tile by phase + ownership.
**Anti-spoof (§14).** The `player` field a lobby puts in a frame is **not**
trusted. Velocity derives the acting player and UUID from the `ServerConnection`
the plugin message arrived on, and the server-side autostartPolicy / ownership
gates authorize against that verified identity. The frame's `server` field is the
trusted payload — it only names *which* tile was clicked. A fully compromised
lobby therefore cannot act as another player or reach the API directly.
**Button rules** (the tile a click sends depends on the last `StatusUpdate`):
| Tile state | Label | Frame sent |
| ---------- | ----- | ---------- |
| ownerless + stopped (`claimable`) | **Claim & Start** | `ClaimRequest{server}` |
| owned + running (`ready`) | **Join** | `WakeRequest{server}` |
| owned + stopped | **Wake** | `WakeRequest{server}` |
"Join" and "Wake" are the **same** upstream frame (`WakeRequest`) — only the
label differs; the proxy treats a wake of an already-running owned server as a
join. A refusal comes back as an `Error` frame (`not_linked` / `quota_exceeded` /
`already_claimed` → a friendly message), which is the only place a claim/quota/
policy failure surfaces to the player; readiness arrives as `TransferReady` just
before the proxy Connects them.
> **Status.** This slice is **code-complete and compile-verified** (paper jar
> builds green on a Java-21 toolchain; the velocity end compiles the full shared
> tree; the wire codec round-trips). It is **not** live-verified — there is no
> running Paper + Velocity + real players in this environment — so §27 scenario 10
> stays **FAIL (live-unverified)** in the spec matrix until it can be exercised
> end-to-end on a real deployment.
## Building
The platforms need different Gradle versions (a real, measured constraint, not a
preference):
| Module | Gradle | Why |
| ----------- | ----------- | --------------------------------------------------------------- |
| `velocity` | 9.5.1 (system) | plain `java` plugin — no loader Gradle plugin |
| `fabric` | 8.8 (wrapper) | loom 1.7.4 uses `Problems.forNamespace`, removed in Gradle 9 |
| `forge` | 8.8 (wrapper) | ForgeGradle 6 is Gradle-8-only |
| `neoforge` | 8.14 (wrapper) | NeoGradle 7.1.38 requires Gradle API ≥ 8.14 |
| `paper` | 9.5.1 (system), **JDK 21 toolchain** | plain `java` plugin, but paper-api 1.21.4 is published for Java 21, so it declares a `JavaLanguageVersion.of(21)` toolchain — Gradle picks a detected JDK 21 to compile regardless of which JDK runs Gradle |
```bash
# Velocity — system Gradle is fine
gradle -p plugins/velocity build
# Paper — system Gradle too, but it compiles on a Java-21 toolchain (see table)
gradle -p plugins/paper build
# Fabric / Forge / NeoForge — use the per-module wrapper
plugins/fabric/gradlew -p plugins/fabric build
plugins/forge/gradlew -p plugins/forge build
plugins/neoforge/gradlew -p plugins/neoforge build
```
Requires JDK 17 — **except `paper`, which needs a Java-21 toolchain available to
Gradle** (paper-api 1.21.4 is a Java-21 artifact; the rest of the suite is Java
17). The first build of each mod downloads and remaps/decompiles Minecraft, so it
takes a few minutes; subsequent builds are fast. Jars land in each module's
`build/libs/`.
## Deploying
Drop the matching jar into the server/proxy mods or plugins directory, start
once to generate `config/felis-link.properties` (or `plugins/felis-link/…` on
Velocity), then set `api-base-url` and `service-token` — or provide
`FELIS_API_BASE_URL` and `FELIS_SERVICE_TOKEN` in the environment, which take
precedence. The service token is the same one felis-api compares for its
internal endpoints; treat it as a secret.
On **Velocity**, also set `root-domain` (and optionally `lobby-server`) in the
same file to turn on §11 routing, and make sure `online-mode=true` in
`velocity.toml` — without either, the proxy still serves `/link` but routing
stays off (see **[Velocity routing](#velocity-routing-§11)**). The config dir is
`plugins/felis-link/` because the plugin id is `felis-link` (kept stable across
the 0.1 → 0.2 jar so existing config carries over).
On the **Paper lobby** there is no token to set, because the lobby never talks to
felis-api. Drop `felis-paper-…jar` into `plugins/`, start once to generate
`plugins/FelisPaper/config.yml`, and list the felis server names (the CRD
`metadata.name`, not the display title) you want as tiles under `servers:`. The
lobby must sit behind the same Velocity proxy as the backends — it reaches the
control plane only through the proxy's `felis:control` terminus — so it needs no
`api-base-url` and no `service-token` of its own.
+44
View File
@@ -0,0 +1,44 @@
plugins {
id 'fabric-loom' version '1.7.4'
id 'java'
}
group = 'best.lolicon.felis'
version = '0.1.0'
// MC 1.20.1 is a Java-17 line — the ceiling this JDK can build.
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
}
dependencies {
minecraft 'com.mojang:minecraft:1.20.1'
// Official Mojang mappings keep MC class/method names identical to the
// Forge/NeoForge modules, so the command handlers stay near-uniform.
mappings loom.officialMojangMappings()
modImplementation 'net.fabricmc:fabric-loader:0.16.5'
// fabric-command-api-v2 (CommandRegistrationCallback) ships in fabric-api.
modImplementation 'net.fabricmc.fabric-api:fabric-api:0.92.2+1.20.1'
}
// The zero-dependency link core is compiled straight into the remapped jar.
sourceSets {
main {
java {
srcDir '../shared/src/main/java'
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
Binary file not shown.
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+12
View File
@@ -0,0 +1,12 @@
pluginManagement {
repositories {
maven {
name = 'Fabric'
url = 'https://maven.fabricmc.net/'
}
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = 'felis-fabric'
@@ -0,0 +1,93 @@
package best.lolicon.felis.fabric;
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.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.logging.LogUtils;
import net.fabricmc.api.DedicatedServerModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* FelisFabricMod is the Fabric (dedicated-server) leg of the §10 account-link
* flow. A server-side {@code /link} command takes the player's already-verified
* UUID, asks felis-api for a one-time code, and shows it in chat; the player then
* redeems it on the web panel. The HTTP call is pushed onto a daemon I/O thread
* and the reply is hopped back onto the server thread, so a slow felis-api never
* stalls the tick loop. Failures collapse to a generic chat line with details
* confined to the server log.
*/
public final class FelisFabricMod implements DedicatedServerModInitializer {
private static final Logger LOGGER = LogUtils.getLogger();
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "felis-link-io");
t.setDaemon(true);
return t;
});
private LinkClient linkClient;
@Override
public void onInitializeServer() {
try {
LinkConfig config = LinkConfigLoader.load(
FabricLoader.getInstance().getConfigDir().resolve("felis-link.properties"));
this.linkClient = new LinkClient(config);
} catch (IOException e) {
LOGGER.error("Felis link disabled: {}", e.getMessage());
return;
}
CommandRegistrationCallback.EVENT.register(
(dispatcher, registry, environment) -> register(dispatcher));
LOGGER.info("Felis link ready; /link is registered.");
}
private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("link").executes(ctx -> {
CommandSourceStack source = ctx.getSource();
ServerPlayer player;
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
return 1;
}));
}
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
}
@@ -0,0 +1,19 @@
{
"schemaVersion": 1,
"id": "felis-link",
"version": "0.1.0",
"name": "Felis Link",
"description": "In-game /link command: mints a one-time account-link code from felis-api.",
"authors": ["Felis"],
"license": "MIT",
"environment": "server",
"entrypoints": {
"server": ["best.lolicon.felis.fabric.FelisFabricMod"]
},
"depends": {
"fabricloader": ">=0.15.0",
"minecraft": "~1.20.1",
"java": ">=17",
"fabric-api": "*"
}
}
+39
View File
@@ -0,0 +1,39 @@
plugins {
id 'net.minecraftforge.gradle' version '[6.0,6.2)'
id 'java'
}
group = 'best.lolicon.felis'
version = '0.1.0'
// MC 1.20.1 / Forge 47.x is a Java-17 line.
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
minecraft {
// Official Mojang mappings — same MC names as the Fabric/NeoForge modules.
mappings channel: 'official', version: '1.20.1'
}
repositories {
mavenCentral()
}
dependencies {
minecraft 'net.minecraftforge:forge:1.20.1-47.3.0'
}
// The zero-dependency link core is compiled in and reobfuscated with the mod.
sourceSets {
main {
java {
srcDir '../shared/src/main/java'
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
Binary file not shown.
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+11
View File
@@ -0,0 +1,11 @@
pluginManagement {
repositories {
gradlePluginPortal()
maven {
name = 'MinecraftForge'
url = 'https://maven.minecraftforge.net/'
}
}
}
rootProject.name = 'felis-forge'
@@ -0,0 +1,98 @@
package best.lolicon.felis.forge;
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.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.logging.LogUtils;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.RegisterCommandsEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.loading.FMLPaths;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* FelisForgeMod is the Forge server-side leg of the §10 account-link flow. On
* {@link RegisterCommandsEvent} it installs a {@code /link} command that mints a
* one-time code from felis-api for the player's already-verified UUID. As on the
* other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped
* back onto the server thread; failures collapse to a generic chat line with
* details kept to the server log. If config is missing the mod stays loaded but
* never registers the command, so the proxy/server runs un-crippled.
*/
@Mod("felis_link")
public final class FelisForgeMod {
private static final Logger LOGGER = LogUtils.getLogger();
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "felis-link-io");
t.setDaemon(true);
return t;
});
private LinkClient linkClient;
public FelisForgeMod() {
try {
LinkConfig config = LinkConfigLoader.load(
FMLPaths.CONFIGDIR.get().resolve("felis-link.properties"));
this.linkClient = new LinkClient(config);
MinecraftForge.EVENT_BUS.register(this);
LOGGER.info("Felis link ready; /link will be registered.");
} catch (IOException e) {
LOGGER.error("Felis link disabled: {}", e.getMessage());
}
}
@SubscribeEvent
public void onRegisterCommands(RegisterCommandsEvent event) {
register(event.getDispatcher());
}
private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("link").executes(ctx -> {
CommandSourceStack source = ctx.getSource();
ServerPlayer player;
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
return 1;
}));
}
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
}
@@ -0,0 +1,25 @@
modLoader = "javafml"
loaderVersion = "[47,)"
license = "MIT"
issueTrackerURL = "https://example.invalid/felis"
[[mods]]
modId = "felis_link"
version = "0.1.0"
displayName = "Felis Link"
description = "In-game /link command: mints a one-time account-link code from felis-api."
authors = "Felis"
[[dependencies.felis_link]]
modId = "forge"
mandatory = true
versionRange = "[47,)"
ordering = "NONE"
side = "SERVER"
[[dependencies.felis_link]]
modId = "minecraft"
mandatory = true
versionRange = "[1.20.1,1.20.2)"
ordering = "NONE"
side = "SERVER"
@@ -0,0 +1,6 @@
{
"pack": {
"description": "Felis Link",
"pack_format": 15
}
}
+41
View File
@@ -0,0 +1,41 @@
plugins {
id 'net.neoforged.gradle.userdev' version '7.1.38'
id 'java'
}
group = 'best.lolicon.felis'
version = '0.1.0'
// NeoForge 20.4 (MC 1.20.4) is a Java-17 line. The Gradle daemon runs on JDK 17,
// so toolchain 17 resolves to the running JVM with no auto-provisioning.
java {
toolchain.languageVersion = JavaLanguageVersion.of(17)
}
repositories {
mavenCentral()
maven {
name = 'NeoForged'
url = 'https://maven.neoforged.net/releases'
}
}
dependencies {
// NeoForge bundles MC at Mojang mappings — same MC class/method names as the
// Fabric and Forge modules, so the command handler below is uniform with them.
implementation 'net.neoforged:neoforge:20.4.251'
}
// The zero-dependency link core is compiled in. NeoForge runs Mojang mappings at
// runtime, so there is no reobf step — the jar straight out of `build` is the deliverable.
sourceSets {
main {
java {
srcDir '../shared/src/main/java'
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
Binary file not shown.
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute Gradle
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+11
View File
@@ -0,0 +1,11 @@
pluginManagement {
repositories {
gradlePluginPortal()
maven {
name = 'NeoForged'
url = 'https://maven.neoforged.net/releases'
}
}
}
rootProject.name = 'felis-neoforge'
@@ -0,0 +1,102 @@
package best.lolicon.felis.neoforge;
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.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.logging.LogUtils;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import net.neoforged.bus.api.IEventBus;
import net.neoforged.bus.api.SubscribeEvent;
import net.neoforged.fml.common.Mod;
import net.neoforged.fml.loading.FMLPaths;
import net.neoforged.neoforge.common.NeoForge;
import net.neoforged.neoforge.event.RegisterCommandsEvent;
import org.slf4j.Logger;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* FelisNeoForgeMod is the NeoForge server-side leg of the §10 account-link flow. On
* {@link RegisterCommandsEvent} it installs a {@code /link} command that mints a
* one-time code from felis-api for the player's already-verified UUID. As on the
* other loaders, the HTTP call runs on a daemon I/O thread and the reply is hopped
* back onto the server thread; failures collapse to a generic chat line with details
* kept to the server log. If config is missing the mod stays loaded but never
* registers the command, so the server runs un-crippled.
*
* <p>NeoForge constructs the mod with the mod event bus injected; the command event
* fires on the game bus ({@link NeoForge#EVENT_BUS}), which is what we subscribe to.
*/
@Mod("felis_link")
public final class FelisNeoForgeMod {
private static final Logger LOGGER = LogUtils.getLogger();
private final ExecutorService io = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "felis-link-io");
t.setDaemon(true);
return t;
});
private LinkClient linkClient;
public FelisNeoForgeMod(IEventBus modEventBus) {
try {
LinkConfig config = LinkConfigLoader.load(
FMLPaths.CONFIGDIR.get().resolve("felis-link.properties"));
this.linkClient = new LinkClient(config);
NeoForge.EVENT_BUS.register(this);
LOGGER.info("Felis link ready; /link will be registered.");
} catch (IOException e) {
LOGGER.error("Felis link disabled: {}", e.getMessage());
}
}
@SubscribeEvent
public void onRegisterCommands(RegisterCommandsEvent event) {
register(event.getDispatcher());
}
private void register(CommandDispatcher<CommandSourceStack> dispatcher) {
dispatcher.register(Commands.literal("link").executes(ctx -> {
CommandSourceStack source = ctx.getSource();
ServerPlayer player;
try {
player = source.getPlayerOrException();
} catch (CommandSyntaxException e) {
source.sendFailure(Component.literal("/link can only be run by a player."));
return 0;
}
requestAndReply(source.getServer(), player);
return 1;
}));
}
private void requestAndReply(MinecraftServer server, ServerPlayer player) {
UUID uuid = player.getUUID();
player.sendSystemMessage(Component.literal("Requesting a link code…"));
io.submit(() -> {
try {
LinkCode code = linkClient.requestCode(uuid);
server.execute(() -> player.sendSystemMessage(Component.literal(
"Your link code: " + code.code()
+ " — enter it on the web panel → Account (valid a few minutes).")));
} catch (LinkException e) {
LOGGER.warn("link code request failed for {} (status={}, code={}): {}",
uuid, e.statusCode(), e.errorCode(), e.getMessage());
server.execute(() -> player.sendSystemMessage(Component.literal(
"Couldn't get a link code right now. Please try again in a moment.")));
}
});
}
}
@@ -0,0 +1,25 @@
modLoader = "javafml"
loaderVersion = "[1,)"
license = "MIT"
issueTrackerURL = "https://example.invalid/felis"
[[mods]]
modId = "felis_link"
version = "0.1.0"
displayName = "Felis Link"
description = "In-game /link command: mints a one-time account-link code from felis-api."
authors = "Felis"
[[dependencies.felis_link]]
modId = "neoforge"
type = "required"
versionRange = "[20.4,)"
ordering = "NONE"
side = "SERVER"
[[dependencies.felis_link]]
modId = "minecraft"
type = "required"
versionRange = "[1.20.4,1.20.5)"
ordering = "NONE"
side = "SERVER"
@@ -0,0 +1,6 @@
{
"pack": {
"description": "Felis Link",
"pack_format": 22
}
}
+56
View File
@@ -0,0 +1,56 @@
plugins {
id 'java'
}
group = 'best.lolicon.felis'
version = '0.1.0'
// Paper 1.21 runs on Java 21, and its API is published as a Java-21 artifact, so
// this one module needs a Java-21 toolchain (the rest of the suite is 17). The
// toolchain block makes that requirement explicit and self-enforcing: Gradle uses a
// detected JDK 21 to compile, regardless of which JDK runs Gradle, and fails loudly
// if none is present. The shared codec is plain Java-17 source, which 21 compiles
// forward-compatibly.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
maven {
name = 'papermc'
url = 'https://repo.papermc.io/repository/maven-public/'
}
}
dependencies {
// paper-api is compile-only (the server provides it at runtime). It pulls in
// Bukkit + Adventure, which is all the lobby face needs.
compileOnly 'io.papermc.paper:paper-api:1.21.4-R0.1-SNAPSHOT'
}
// The lobby is a PURE UI face (spec §12): it speaks only the felis:control
// plugin-message channel and never holds a felis-api token or talks to felis-api
// directly. We enforce that physically here — the shared source root is on the
// path, but the include filter ships ONLY the paper package and the three codec
// files (Control + ControlFrame + Json). FelisApiClient, LinkClient and the token
// config are not compiled in at all, so the lobby cannot reach the API even by
// mistake. If a codec class grows a new dependency, compilation fails loudly here
// rather than silently widening the lobby's reach.
sourceSets {
main {
java {
srcDir '../shared/src/main/java'
include 'best/lolicon/felis/paper/**'
include 'best/lolicon/felis/link/Control.java'
include 'best/lolicon/felis/link/ControlFrame.java'
include 'best/lolicon/felis/link/Json.java'
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'felis-paper'
@@ -0,0 +1,291 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.plugin.messaging.PluginMessageListener;
import java.util.ArrayList;
import java.util.List;
/**
* FelisPaperPlugin is the felis-paper lobby face (spec §12): the {@code /menu} (and
* {@code /server}) chest GUI players use to pick, wake or claim a backend without
* ever touching the command line. It is the player-facing end of the §27 scenario-10
* path — {@code /menu → plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
*
* <p><b>Pure UI face.</b> This plugin deliberately holds no felis-api token, opens no
* HTTP connection, and keeps no waiting queue. Every action it takes is a single
* {@link ControlFrame} written to the {@code felis:control} plugin-message channel,
* and every piece of state it shows arrives as a frame on the same channel. The
* Velocity proxy ({@code ControlChannel}) is the only thing that talks to felis-api,
* and it derives the acting player's identity from the backend connection rather than
* anything this lobby sends (spec §14) — so even a fully compromised lobby cannot act
* as another player or reach the API directly. The build enforces this physically:
* only the channel codec ({@code Control}/{@code ControlFrame}/{@code Json}) is
* compiled in from the shared core; {@code FelisApiClient} and the token config are
* not on the lobby's classpath at all.
*
* <p><b>Flow.</b> Opening the menu paints a "loading" tile per configured server and
* fires a {@code StatusQuery} for each; the proxy answers with {@code StatusUpdate}
* frames that repaint each tile by phase + ownership. Clicking a tile sends a
* {@code ClaimRequest} when it is claimable (ownerless + stopped → "Claim &amp;
* Start") or a {@code WakeRequest} otherwise (the single frame behind both the "Join"
* of a running owned server and the "Wake" of a stopped owned one), then closes the
* menu. A refusal comes back as an {@code Error} frame and is shown to the player —
* the only place claim/quota/policy failures surface — and readiness arrives as
* {@code TransferReady} just before the proxy Connects them.
*/
public final class FelisPaperPlugin extends JavaPlugin implements Listener, PluginMessageListener {
private static final Component MENU_TITLE =
Component.text("Felis Servers", NamedTextColor.AQUA).decoration(TextDecoration.ITALIC, false);
private static final int MAX_TILES = 54; // a double chest, the GUI ceiling
/** Server names to show as tiles, in display order; loaded from config. */
private final List<String> servers = new ArrayList<>();
@Override
public void onEnable() {
saveDefaultConfig();
servers.clear();
servers.addAll(getConfig().getStringList("servers"));
// Open both ends of felis:control. Outgoing carries Wake/Claim/StatusQuery to
// the proxy; incoming receives StatusUpdate/TransferReady/Error back.
getServer().getMessenger().registerOutgoingPluginChannel(this, Control.CHANNEL);
getServer().getMessenger().registerIncomingPluginChannel(this, Control.CHANNEL, this);
getServer().getPluginManager().registerEvents(this, this);
getLogger().info("felis-paper enabled: " + servers.size()
+ " server tile(s), felis:control open. Pure UI face — no felis-api token.");
}
// ---- commands: /menu and /server both open the GUI ----
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!(sender instanceof Player)) {
sender.sendMessage(Component.text("Only a player can open the server menu.", NamedTextColor.RED));
return true;
}
openMenu((Player) sender);
return true;
}
private void openMenu(Player player) {
if (servers.isEmpty()) {
player.sendMessage(Component.text(
"No servers are configured yet — ask an operator to set up felis-paper.",
NamedTextColor.YELLOW));
return;
}
int shown = Math.min(servers.size(), MAX_TILES);
List<String> view = new ArrayList<>(servers.subList(0, shown));
MenuHolder holder = new MenuHolder(view);
Inventory inv = Bukkit.createInventory(holder, invSize(shown), MENU_TITLE);
holder.setInventory(inv);
for (int i = 0; i < shown; i++) {
inv.setItem(i, loadingTile(view.get(i)));
}
player.openInventory(inv);
// Ask the proxy for live status of every tile; answers repaint them.
for (String server : view) {
sendUpstream(player, ControlFrame.statusQuery(server));
}
}
// ---- click: a tile is a button, never an item to pick up ----
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
Inventory top = event.getView().getTopInventory();
if (!(top.getHolder() instanceof MenuHolder)) {
return; // not our GUI
}
// Every slot in our GUI is a button: cancel unconditionally so nothing can be
// taken out, even on clicks in empty slots or the player's own inventory.
event.setCancelled(true);
if (event.getClickedInventory() != top) {
return; // click landed in the player's inventory, not a tile
}
if (!(event.getWhoClicked() instanceof Player)) {
return;
}
Player player = (Player) event.getWhoClicked();
MenuHolder holder = (MenuHolder) top.getHolder();
int slot = event.getSlot();
if (slot < 0 || slot >= holder.servers().size()) {
return; // padding slot
}
String server = holder.servers().get(slot);
ControlFrame state = holder.latest(server);
if (state == null) {
return; // still loading — no status yet, so we don't know which frame to send
}
// Claimable (ownerless + stopped) → Claim & Start; everything else → Wake
// (which the proxy treats as Join when the owned server is already running).
if (state.claimable()) {
sendUpstream(player, ControlFrame.claimRequest(player.getName(), server));
} else {
sendUpstream(player, ControlFrame.wakeRequest(player.getName(), server));
}
player.closeInventory();
}
@EventHandler
public void onInventoryDrag(InventoryDragEvent event) {
// A drag can deposit into or sweep across our tiles without ever firing a
// single InventoryClickEvent on them, so the click guard alone is not enough:
// cancel any drag that touches our GUI so a tile can never be grabbed or smeared.
if (event.getView().getTopInventory().getHolder() instanceof MenuHolder) {
event.setCancelled(true);
}
}
// ---- downstream: felis:control frames from the proxy ----
@Override
public void onPluginMessageReceived(String channel, Player player, byte[] message) {
if (!Control.CHANNEL.equals(channel)) {
return;
}
ControlFrame frame;
try {
frame = Control.decode(message);
} catch (IllegalArgumentException e) {
getLogger().fine("Dropping malformed felis:control frame: " + e.getMessage());
return;
}
switch (frame.type()) {
case ControlFrame.STATUS_UPDATE:
applyStatus(player, frame);
break;
case ControlFrame.ERROR:
// The proxy already sanitizes transport faults; this is the only place
// a claim/quota/policy refusal becomes visible to the player.
player.sendMessage(Component.text("⚠ " + errorText(frame), NamedTextColor.RED));
break;
case ControlFrame.TRANSFER_READY:
// The proxy performs the actual Connect; just make sure a stale menu is
// not left open over the join.
closeIfMenu(player);
break;
default:
// Upstream-only types (Wake/Claim/StatusQuery) are never expected back.
}
}
private void applyStatus(Player player, ControlFrame frame) {
Inventory top = player.getOpenInventory().getTopInventory();
if (!(top.getHolder() instanceof MenuHolder)) {
return; // the player closed the menu before the answer arrived
}
MenuHolder holder = (MenuHolder) top.getHolder();
int slot = holder.servers().indexOf(frame.server());
if (slot < 0) {
return; // a server we are not showing
}
holder.put(frame.server(), frame);
top.setItem(slot, tile(frame));
}
private void closeIfMenu(Player player) {
if (player.getOpenInventory().getTopInventory().getHolder() instanceof MenuHolder) {
player.closeInventory();
}
}
// ---- rendering ----
private ItemStack tile(ControlFrame f) {
Material material;
String action;
NamedTextColor color;
if (f.claimable()) {
material = Material.GOLD_BLOCK;
action = "Claim & Start";
color = NamedTextColor.GOLD;
} else if (f.ready()) {
material = Material.LIME_CONCRETE;
action = "Join";
color = NamedTextColor.GREEN;
} else {
material = Material.RED_CONCRETE;
action = "Wake";
color = NamedTextColor.RED;
}
ItemStack item = new ItemStack(material);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(action + " · " + f.server(), color)
.decoration(TextDecoration.ITALIC, false));
List<Component> lore = new ArrayList<>();
lore.add(line("Status", f.phase() == null || f.phase().isEmpty() ? "?" : f.phase()));
lore.add(line("Players", f.playersOnline() + "/" + f.playersMax()));
meta.lore(lore);
item.setItemMeta(meta);
return item;
}
private ItemStack loadingTile(String server) {
ItemStack item = new ItemStack(Material.GRAY_STAINED_GLASS_PANE);
ItemMeta meta = item.getItemMeta();
meta.displayName(Component.text(server, NamedTextColor.GRAY).decoration(TextDecoration.ITALIC, false));
meta.lore(List.of(Component.text("Loading…", NamedTextColor.DARK_GRAY)
.decoration(TextDecoration.ITALIC, false)));
item.setItemMeta(meta);
return item;
}
private static Component line(String key, String value) {
return Component.text(key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, NamedTextColor.WHITE))
.decoration(TextDecoration.ITALIC, false);
}
private static String errorText(ControlFrame f) {
String code = f.code();
if (code != null) {
switch (code) {
case "not_linked":
return "Link your account first — run /link, then finish on the web panel.";
case "quota_exceeded":
return "You've reached your server quota.";
case "already_claimed":
return "That server was just claimed by someone else.";
default:
break;
}
}
return f.message() != null && !f.message().isEmpty()
? f.message()
: (code != null ? code : "Request failed — please try again.");
}
// ---- helpers ----
private void sendUpstream(Player player, ControlFrame frame) {
player.sendPluginMessage(this, Control.CHANNEL, Control.encode(frame));
}
private static int invSize(int count) {
int rows = Math.max(1, (count + 8) / 9);
return Math.min(rows, 6) * 9;
}
}
@@ -0,0 +1,59 @@
package best.lolicon.felis.paper;
import best.lolicon.felis.link.ControlFrame;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* MenuHolder is the identity and state carried by a {@code /menu} inventory. Bukkit
* lets an {@link InventoryHolder} ride along with an {@link Inventory}, which is how
* {@link FelisPaperPlugin} tells "this is our GUI" from any other open chest — a click
* or a downstream frame only acts when {@code inventory.getHolder() instanceof
* MenuHolder}. Tying the state to the inventory instance (rather than a per-player map
* on the plugin) means it is garbage-collected with the menu and never leaks across
* reopen.
*
* <p>It holds two things: the ordered list of server names (the list index <em>is</em>
* the slot, so a click slot maps straight to a server) and the latest
* {@link ControlFrame} seen for each, so a click knows whether to send a Claim or a
* Wake without re-querying.
*/
final class MenuHolder implements InventoryHolder {
private final List<String> servers; // index = slot
private final Map<String, ControlFrame> latest = new HashMap<>();
private Inventory inventory;
MenuHolder(List<String> servers) {
this.servers = servers;
}
/** servers returns the tile order; the list index is the inventory slot. */
List<String> servers() {
return servers;
}
/** latest is the most recent StatusUpdate for a server, or null if none yet. */
ControlFrame latest(String server) {
return latest.get(server);
}
/** put records the latest StatusUpdate for a server. */
void put(String server, ControlFrame frame) {
latest.put(server, frame);
}
void setInventory(Inventory inventory) {
this.inventory = inventory;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,16 @@
# felis-paper — the lobby UI face (spec §12).
#
# This plugin is a PURE UI face: it speaks only the felis:control plugin-message
# channel to the Velocity proxy. It holds no felis-api token and never contacts
# felis-api directly. The list below is only which server tiles to show in the
# /menu GUI; the proxy (and felis-api behind it) stay the source of truth for
# status, ownership and autostart — every tile is filled in by a live StatusQuery
# over felis:control when the menu opens, and acting on a tile sends a Wake or
# Claim frame that the proxy authorizes against the player's verified identity.
#
# Replace the examples below with the names of your felis servers (the CRD
# metadata.name / the server's felis name, not its display title). Up to 54 are
# shown. An empty list makes /menu say there is nothing to show.
servers:
- smp
- creative
@@ -0,0 +1,13 @@
name: FelisPaper
version: 0.1.0
main: best.lolicon.felis.paper.FelisPaperPlugin
api-version: '1.21'
authors: [Felis]
description: Lobby UI face — /menu and /server open a chest GUI that drives the felis:control channel.
commands:
menu:
description: Open the Felis server menu.
usage: /menu
server:
description: Open the Felis server menu (alias of /menu).
usage: /server
@@ -0,0 +1,188 @@
package best.lolicon.felis.link;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* Control is the {@code felis:control} plugin-message codec (spec §12): it turns a
* {@link ControlFrame} into the raw bytes a plugin message carries, and back. Both
* the Velocity proxy and the felis-paper lobby source-share this class, so the wire
* format has exactly one definition and the two ends cannot drift.
*
* <p>Frames are encoded as <b>raw UTF-8 JSON bytes</b>, not via
* {@code DataOutputStream.writeUTF}. The namespaced channel hands the listener the
* exact byte array on both ends — Velocity's {@code PluginMessageEvent.getData()}
* and Bukkit's {@code PluginMessageListener} — so there is no length-prefix framing
* to agree on, and the 64&nbsp;KB ceiling {@code writeUTF} imposes is avoided.
*
* <p>The codec reuses the package-private {@link Json} reader (which is why this
* lives in {@code best.lolicon.felis.link}) and a tiny hand-rolled writer, keeping
* the shared core dependency-free. Decoding is strict on structure (a non-object or
* a missing/unknown {@code type} throws {@link IllegalArgumentException}) and
* tolerant on fields (absent fields degrade to null/zero, per {@link ControlFrame}).
*/
public final class Control {
/** The plugin-message channel both ends register (spec §12). */
public static final String CHANNEL = "felis:control";
private Control() {
}
/**
* encode renders {@code frame} as the channel's raw UTF-8 JSON bytes. Only the
* fields its {@code type} defines are written, so a round-trip through
* {@link #decode(byte[])} reproduces an equal frame.
*/
public static byte[] encode(ControlFrame frame) {
StringBuilder sb = new StringBuilder(96);
sb.append("{\"type\":");
jsonString(sb, frame.type());
switch (frame.type()) {
case ControlFrame.WAKE_REQUEST:
case ControlFrame.CLAIM_REQUEST:
case ControlFrame.TRANSFER_READY:
kv(sb, "player", frame.player());
kv(sb, "server", frame.server());
break;
case ControlFrame.STATUS_QUERY:
kv(sb, "server", frame.server());
break;
case ControlFrame.STATUS_UPDATE:
kv(sb, "server", frame.server());
kv(sb, "phase", frame.phase());
kvBool(sb, "ready", frame.ready());
kvInt(sb, "playersOnline", frame.playersOnline());
kvInt(sb, "playersMax", frame.playersMax());
kvBool(sb, "claimable", frame.claimable());
break;
case ControlFrame.ERROR:
kv(sb, "code", frame.code());
kv(sb, "message", frame.message());
// server is optional on Error: only emit it when the refusal is
// server-scoped, so a bare Error frame stays minimal.
if (frame.server() != null) {
kv(sb, "server", frame.server());
}
break;
default:
throw new IllegalArgumentException("control: cannot encode unknown frame type '" + frame.type() + "'");
}
sb.append('}');
return sb.toString().getBytes(StandardCharsets.UTF_8);
}
/**
* decode parses raw channel bytes back into a {@link ControlFrame}. A malformed
* body, a non-object root, a missing {@code type}, or an unrecognized
* {@code type} all throw {@link IllegalArgumentException} — the caller treats a
* bad frame as a dropped message, never a crash.
*/
public static ControlFrame decode(byte[] data) {
Object root;
try {
root = Json.parse(new String(data, StandardCharsets.UTF_8));
} catch (RuntimeException e) {
throw new IllegalArgumentException("control: malformed frame", e);
}
if (!(root instanceof Map)) {
throw new IllegalArgumentException("control: frame is not a JSON object");
}
Map<?, ?> o = (Map<?, ?>) root;
String type = str(o, "type");
if (type == null) {
throw new IllegalArgumentException("control: frame missing 'type'");
}
switch (type) {
case ControlFrame.WAKE_REQUEST:
return ControlFrame.wakeRequest(str(o, "player"), str(o, "server"));
case ControlFrame.CLAIM_REQUEST:
return ControlFrame.claimRequest(str(o, "player"), str(o, "server"));
case ControlFrame.STATUS_QUERY:
return ControlFrame.statusQuery(str(o, "server"));
case ControlFrame.STATUS_UPDATE:
return ControlFrame.statusUpdate(str(o, "server"), str(o, "phase"), bool(o, "ready"),
intval(o, "playersOnline"), intval(o, "playersMax"), bool(o, "claimable"));
case ControlFrame.TRANSFER_READY:
return ControlFrame.transferReady(str(o, "player"), str(o, "server"));
case ControlFrame.ERROR:
return ControlFrame.error(str(o, "code"), str(o, "message"), str(o, "server"));
default:
throw new IllegalArgumentException("control: unknown frame type '" + type + "'");
}
}
// ---- JSON writer (every field after "type" is preceded by a comma) ----
private static void kv(StringBuilder sb, String key, String value) {
sb.append(",\"").append(key).append("\":");
if (value == null) {
sb.append("null");
} else {
jsonString(sb, value);
}
}
private static void kvBool(StringBuilder sb, String key, boolean value) {
sb.append(",\"").append(key).append("\":").append(value);
}
private static void kvInt(StringBuilder sb, String key, int value) {
sb.append(",\"").append(key).append("\":").append(value);
}
private static void jsonString(StringBuilder sb, String s) {
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '"':
sb.append("\\\"");
break;
case '\\':
sb.append("\\\\");
break;
case '\n':
sb.append("\\n");
break;
case '\r':
sb.append("\\r");
break;
case '\t':
sb.append("\\t");
break;
case '\b':
sb.append("\\b");
break;
case '\f':
sb.append("\\f");
break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
sb.append('"');
}
// ---- JSON readers (mirror ServerView's tolerant coercion) ----
private static String str(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof String ? (String) v : null;
}
private static boolean bool(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof Boolean && (Boolean) v;
}
private static int intval(Map<?, ?> o, String key) {
Object v = o.get(key);
// Json parses every number as Double; the player counts are int32 server-side.
return v instanceof Number ? ((Number) v).intValue() : 0;
}
}
@@ -0,0 +1,179 @@
package best.lolicon.felis.link;
import java.util.Objects;
/**
* ControlFrame is one message on the {@code felis:control} plugin-message channel
* (spec §12): the wire contract between the felis-paper lobby and the Velocity
* proxy. The lobby is a pure UI face — it holds no felis-api token and maintains no
* queue — so every lobby action travels to Velocity as one of these frames, and
* Velocity answers with one back. {@link Control} encodes/decodes them; this class
* is just the immutable value, source-shared into both the velocity and paper jars
* so the two ends can never drift on field names.
*
* <p>There are six frame types, discriminated by {@link #type()}:
* <ul>
* <li><b>Upstream</b> (lobby → velocity): {@link #WAKE_REQUEST} and
* {@link #CLAIM_REQUEST} carry {@code player}+{@code server};
* {@link #STATUS_QUERY} carries {@code server}.</li>
* <li><b>Downstream</b> (velocity → lobby): {@link #STATUS_UPDATE} is the tile
* projection; {@link #TRANSFER_READY} tells the lobby a parked player's
* backend is up; {@link #ERROR} reports a refusal.</li>
* </ul>
*
* <p>The {@code player} field is informational only on the upstream frames:
* Velocity derives the real identity from the {@code ServerConnection} the message
* arrived on, never from this field, so a compromised backend cannot act as another
* player (spec §14). The lobby still fills it in for symmetry and logging.
*
* <p>The spec sketches {@code StatusUpdate} as {@code {server,phase,players,
* claimable}}; this refines {@code players} into {@link #ready()} +
* {@link #playersOnline()} + {@link #playersMax()}, mapping the frame 1:1 onto the
* {@code GET …/menu} endpoint so the GUI can render both the phase button and a
* "3/20" player count from a single frame.
*
* <p>Accessors degrade to {@code null}/{@code 0}/{@code false} for fields absent on
* a given type, mirroring {@link ServerView}'s tolerant philosophy: a frame is read
* for the fields its type defines and no others.
*/
public final class ControlFrame {
/** Upstream: park-and-wake a server the player may already own (player, server). */
public static final String WAKE_REQUEST = "WakeRequest";
/** Upstream: claim an ownerless server, then wake it (player, server). */
public static final String CLAIM_REQUEST = "ClaimRequest";
/** Upstream: ask for a fresh {@link #STATUS_UPDATE} for one server (server). */
public static final String STATUS_QUERY = "StatusQuery";
/** Downstream: the tile projection (server, phase, ready, players, claimable). */
public static final String STATUS_UPDATE = "StatusUpdate";
/** Downstream: a parked player's backend is ready; the lobby may release them (player, server). */
public static final String TRANSFER_READY = "TransferReady";
/** Downstream: a refusal (code, message, optional server). */
public static final String ERROR = "Error";
private final String type;
private final String player;
private final String server;
private final String phase;
private final boolean ready;
private final int playersOnline;
private final int playersMax;
private final boolean claimable;
private final String code;
private final String message;
private ControlFrame(String type, String player, String server, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable, String code, String message) {
this.type = type;
this.player = player;
this.server = server;
this.phase = phase;
this.ready = ready;
this.playersOnline = playersOnline;
this.playersMax = playersMax;
this.claimable = claimable;
this.code = code;
this.message = message;
}
// ---- factories (tolerant: no field validation, so decode can always rebuild) ----
public static ControlFrame wakeRequest(String player, String server) {
return new ControlFrame(WAKE_REQUEST, player, server, null, false, 0, 0, false, null, null);
}
public static ControlFrame claimRequest(String player, String server) {
return new ControlFrame(CLAIM_REQUEST, player, server, null, false, 0, 0, false, null, null);
}
public static ControlFrame statusQuery(String server) {
return new ControlFrame(STATUS_QUERY, null, server, null, false, 0, 0, false, null, null);
}
public static ControlFrame statusUpdate(String server, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable) {
return new ControlFrame(STATUS_UPDATE, null, server, phase, ready, playersOnline, playersMax, claimable, null, null);
}
public static ControlFrame transferReady(String player, String server) {
return new ControlFrame(TRANSFER_READY, player, server, null, false, 0, 0, false, null, null);
}
/** error reports a refusal; {@code server} is optional (null when not server-scoped). */
public static ControlFrame error(String code, String message, String server) {
return new ControlFrame(ERROR, null, server, null, false, 0, 0, false, code, message);
}
// ---- accessors ----
public String type() {
return type;
}
public String player() {
return player;
}
public String server() {
return server;
}
public String phase() {
return phase;
}
public boolean ready() {
return ready;
}
public int playersOnline() {
return playersOnline;
}
public int playersMax() {
return playersMax;
}
public boolean claimable() {
return claimable;
}
public String code() {
return code;
}
public String message() {
return message;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof ControlFrame)) {
return false;
}
ControlFrame f = (ControlFrame) o;
return ready == f.ready
&& playersOnline == f.playersOnline
&& playersMax == f.playersMax
&& claimable == f.claimable
&& Objects.equals(type, f.type)
&& Objects.equals(player, f.player)
&& Objects.equals(server, f.server)
&& Objects.equals(phase, f.phase)
&& Objects.equals(code, f.code)
&& Objects.equals(message, f.message);
}
@Override
public int hashCode() {
return Objects.hash(type, player, server, phase, ready, playersOnline, playersMax, claimable, code, message);
}
@Override
public String toString() {
return "ControlFrame{" + new String(Control.encode(this), java.nio.charset.StandardCharsets.UTF_8) + "}";
}
}
@@ -0,0 +1,221 @@
package best.lolicon.felis.link;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/**
* FelisApiClient is the proxy's read/drive client for the felis-api internal face
* (spec §7, §9). Where {@link LinkClient} mints account-link codes, this client
* drives domain-autostart routing: it lists the registrable servers, resolves a
* connecting virtual host to its server, polls a server's lifecycle status, pulls
* the wake lever, and reports real player joins. It shares the {@link LinkConfig}
* (same internal base URL + service token) and the same zero-dependency JDK HTTP
* stack, so it compiles straight into each loader jar with nothing to shade.
*
* <p>Every call authenticates with {@code Authorization: Bearer <serviceToken>}
* and surfaces a non-success status as a {@link LinkException} carrying the HTTP
* status, so the proxy can branch on it without parsing human text. The two that
* matter for routing:
* <ul>
* <li>{@code wake} → 403 means the autostartPolicy gate refused this UUID (do
* not enqueue the player); 429 means a wake is already cooling down
* ("already waking, keep waiting"), not a failure.</li>
* <li>{@code serverByHost} → 404 means the host maps to no server.</li>
* </ul>
*/
public final class FelisApiClient {
private final LinkConfig config;
private final HttpClient http;
public FelisApiClient(LinkConfig config) {
this.config = Objects.requireNonNull(config, "config");
this.http = HttpClient.newBuilder()
.connectTimeout(config.timeout())
.build();
}
/** listServers returns the lifecycle view of every MinecraftServer (GET /servers). */
public List<ServerView> listServers() throws LinkException {
Map<?, ?> obj = getObject("/api/v1/servers", 200);
Object arr = obj.get("servers");
List<ServerView> out = new ArrayList<>();
if (arr instanceof List) {
for (Object e : (List<?>) arr) {
if (e instanceof Map) {
out.add(ServerView.fromJson((Map<?, ?>) e));
}
}
}
return out;
}
/**
* serverByHost resolves {@code subdomain.<root_domain>} to its server view
* (GET /servers/by-host/{host}). A 404 surfaces as a LinkException with
* statusCode 404 so the caller can distinguish "unknown host" from a transport
* fault.
*/
public ServerView serverByHost(String host) throws LinkException {
return ServerView.fromJson(getObject("/api/v1/servers/by-host/" + Objects.requireNonNull(host, "host"), 200));
}
/** serverStatus reads one server's current lifecycle view (internal status). */
public ServerView serverStatus(String name) throws LinkException {
return ServerView.fromJson(getObject("/api/v1/internal/servers/" + Objects.requireNonNull(name, "name") + "/status", 200));
}
/**
* wake pulls the domain-autostart lever for {@code name} on behalf of the
* joining player (spec §9.1, §14). The reply (202) carries the current phase
* and ready flag so the caller can decide whether to wait. A 403 (policy gate)
* or 429 (cooldown) arrives as a LinkException the caller branches on.
*/
public ServerView wake(String name, UUID mcUuid) throws LinkException {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(mcUuid, "mcUuid");
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
return ServerView.fromJson(postObject("/api/v1/internal/servers/" + name + "/wake", body, 202));
}
/**
* reportJoin tells felis-api a real player joined {@code name} (spec §7
* /internal/.../join-event): it bumps last_active_at against the reaper and
* auto-appends the UUID to the allowlist. Expects 204.
*/
public void reportJoin(String name, UUID mcUuid) throws LinkException {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(mcUuid, "mcUuid");
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
HttpResponse<String> res = send(post("/api/v1/internal/servers/" + name + "/join-event", body));
int status = res.statusCode();
if (status != 204 && status != 200) {
throw parseError(status, res.body());
}
}
/**
* claim takes ownership of an ownerless server on behalf of a player driving the
* felis-paper lobby menu (spec §9.3, §12). It is the first of the menu's two
* rules — claim asserts ownership and quota; the autostartPolicy gate is enforced
* separately by the {@link #wake} that follows. Identity is the verified
* online-mode UUID Velocity derived from the connection, never a client-supplied
* value. Expects 200; the refusal cases surface as branchable LinkExceptions:
* 412 {@code not_linked}, 403 {@code quota_exceeded}, 409 {@code already_claimed},
* 404 unknown server.
*/
public void claim(String name, UUID mcUuid) throws LinkException {
Objects.requireNonNull(name, "name");
Objects.requireNonNull(mcUuid, "mcUuid");
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
Map<?, ?> res = postObject("/api/v1/internal/servers/" + name + "/claim", body, 200);
Object claimed = res.get("claimed");
if (!(claimed instanceof Boolean) || !((Boolean) claimed)) {
// A 200 that doesn't affirm the claim is a contract breach, not a refusal —
// every refusal (412/403/409/404) already threw above. Fail loud rather
// than wake a server the caller doesn't actually own.
throw new LinkException(200, "bad_response", "claim returned 200 without claimed=true");
}
}
/**
* menuStatus reads the lobby menu projection of one server (spec §12,
* {@code GET …/menu}): the §11 lifecycle view plus the ownership-derived
* {@code claimable} flag the lobby needs to choose a button. Velocity calls this
* for a {@code StatusQuery} and forwards the result downstream as a
* {@code StatusUpdate} frame. Expects 200; 404 means the server is unknown.
*/
public MenuStatus menuStatus(String name) throws LinkException {
Objects.requireNonNull(name, "name");
return MenuStatus.fromJson(getObject("/api/v1/internal/servers/" + name + "/menu", 200));
}
// ---- transport ----
private Map<?, ?> getObject(String path, int expect) throws LinkException {
HttpRequest req = base(path).GET().build();
return expectObject(send(req), expect);
}
private Map<?, ?> postObject(String path, String body, int expect) throws LinkException {
return expectObject(send(post(path, body)), expect);
}
private HttpRequest post(String path, String body) {
return base(path)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
}
private HttpRequest.Builder base(String path) {
return HttpRequest.newBuilder()
.uri(URI.create(config.apiBaseUrl() + path))
.timeout(config.timeout())
.header("Authorization", "Bearer " + config.serviceToken())
.header("Accept", "application/json");
}
private HttpResponse<String> send(HttpRequest req) throws LinkException {
try {
return http.send(req, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
throw new LinkException(0, "transport_error",
"could not reach felis-api: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LinkException(0, "interrupted", "felis-api request interrupted", e);
}
}
private Map<?, ?> expectObject(HttpResponse<String> res, int expect) throws LinkException {
int status = res.statusCode();
if (status != expect) {
throw parseError(status, res.body());
}
Object root;
try {
root = Json.parse(res.body());
} catch (RuntimeException e) {
throw new LinkException(status, "bad_response", "malformed body from felis-api", e);
}
if (!(root instanceof Map)) {
throw new LinkException(status, "bad_response", "expected a JSON object from felis-api");
}
return (Map<?, ?>) root;
}
// parseError mirrors LinkClient: extract the stable {"error":{"code","message"}}
// envelope when present, else fall back to the HTTP status. A separate copy here
// keeps the routing client independent of LinkClient's private internals.
private LinkException parseError(int status, String text) {
String code = "error";
String message = "felis-api returned HTTP " + status;
try {
Object root = Json.parse(text);
if (root instanceof Map) {
Object err = ((Map<?, ?>) root).get("error");
if (err instanceof Map) {
Object c = ((Map<?, ?>) err).get("code");
Object m = ((Map<?, ?>) err).get("message");
if (c instanceof String && !((String) c).isEmpty()) {
code = (String) c;
}
if (m instanceof String && !((String) m).isEmpty()) {
message = (String) m;
}
}
}
} catch (RuntimeException ignored) {
// Non-JSON error body (proxy 502, plain text, etc.): keep the fallback.
}
return new LinkException(status, code, message);
}
}
@@ -0,0 +1,228 @@
package best.lolicon.felis.link;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Json is a minimal, dependency-free JSON reader. It exists so the shared link
* core stays zero-dependency: the {@code srcDir}-sharing build model compiles
* this source straight into every platform jar, so pulling in Gson/Jackson would
* force shading the parser into four loaders. It parses the small, well-formed
* bodies the felis-api internal face returns — a flat success object, or the
* nested {@code {"error":{"code","message"}}} envelope — and nothing more exotic
* is required.
*
* <p>The parser is package-private and intentionally strict: callers wrap a parse
* failure as a "bad response from felis-api" condition rather than guessing.
*/
final class Json {
private final String s;
private int i;
private Json(String s) {
this.s = s;
}
/** parse reads a single JSON value from text, rejecting trailing garbage. */
static Object parse(String text) {
Json p = new Json(text);
p.ws();
Object v = p.value();
p.ws();
if (p.i < p.s.length()) {
throw p.err("trailing content");
}
return v;
}
private Object value() {
if (i >= s.length()) {
throw err("unexpected end of input");
}
char c = s.charAt(i);
switch (c) {
case '{':
return object();
case '[':
return array();
case '"':
return string();
case 't':
case 'f':
return bool();
case 'n':
return nul();
default:
return number();
}
}
private Map<String, Object> object() {
Map<String, Object> m = new LinkedHashMap<>();
expect('{');
ws();
if (peek() == '}') {
i++;
return m;
}
while (true) {
ws();
String key = string();
ws();
expect(':');
ws();
m.put(key, value());
ws();
char c = next();
if (c == '}') {
return m;
}
if (c != ',') {
throw err("expected ',' or '}' in object");
}
}
}
private List<Object> array() {
List<Object> l = new ArrayList<>();
expect('[');
ws();
if (peek() == ']') {
i++;
return l;
}
while (true) {
ws();
l.add(value());
ws();
char c = next();
if (c == ']') {
return l;
}
if (c != ',') {
throw err("expected ',' or ']' in array");
}
}
}
private String string() {
expect('"');
StringBuilder sb = new StringBuilder();
while (true) {
if (i >= s.length()) {
throw err("unterminated string");
}
char c = s.charAt(i++);
if (c == '"') {
return sb.toString();
}
if (c == '\\') {
if (i >= s.length()) {
throw err("unterminated escape");
}
char e = s.charAt(i++);
switch (e) {
case '"':
sb.append('"');
break;
case '\\':
sb.append('\\');
break;
case '/':
sb.append('/');
break;
case 'b':
sb.append('\b');
break;
case 'f':
sb.append('\f');
break;
case 'n':
sb.append('\n');
break;
case 'r':
sb.append('\r');
break;
case 't':
sb.append('\t');
break;
case 'u':
if (i + 4 > s.length()) {
throw err("truncated unicode escape");
}
sb.append((char) Integer.parseInt(s.substring(i, i + 4), 16));
i += 4;
break;
default:
throw err("invalid escape '\\" + e + "'");
}
} else {
sb.append(c);
}
}
}
private Object number() {
int start = i;
while (i < s.length() && "+-0123456789.eE".indexOf(s.charAt(i)) >= 0) {
i++;
}
String num = s.substring(start, i);
if (num.isEmpty()) {
throw err("invalid value");
}
return Double.parseDouble(num);
}
private Boolean bool() {
if (s.startsWith("true", i)) {
i += 4;
return Boolean.TRUE;
}
if (s.startsWith("false", i)) {
i += 5;
return Boolean.FALSE;
}
throw err("invalid literal");
}
private Object nul() {
if (s.startsWith("null", i)) {
i += 4;
return null;
}
throw err("invalid literal");
}
private void ws() {
while (i < s.length()) {
char c = s.charAt(i);
if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
i++;
} else {
break;
}
}
}
private char peek() {
return i < s.length() ? s.charAt(i) : '\0';
}
private char next() {
return i < s.length() ? s.charAt(i++) : '\0';
}
private void expect(char c) {
if (i >= s.length() || s.charAt(i) != c) {
throw err("expected '" + c + "'");
}
i++;
}
private IllegalArgumentException err(String msg) {
return new IllegalArgumentException("json: " + msg + " at index " + i);
}
}
@@ -0,0 +1,124 @@
package best.lolicon.felis.link;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
/**
* LinkClient calls the felis-api internal face to mint an account-link code for
* an already-authenticated Minecraft player (spec §10). It is platform-agnostic —
* Velocity (proxy) and the Fabric/Forge/NeoForge server mods all drive the same
* client — and depends only on the JDK, so this single source file compiles
* straight into each plugin jar with nothing to shade.
*
* <p>Contract (authoritative, mirrored from {@code internal/api}):
* <ul>
* <li>{@code POST {apiBaseUrl}/api/v1/internal/account/link/code}</li>
* <li>header {@code Authorization: Bearer <serviceToken>} (constant-time
* compared server-side; an empty token fails closed)</li>
* <li>request body {@code {"mc_uuid":"<uuid>"}}</li>
* <li>success: HTTP 201 with {@code {"code","expires_at"}}</li>
* <li>failure: the {@code {"error":{"code","message"}}} envelope</li>
* </ul>
*
* <p>The UUID must come from the platform's authenticated player identity, never
* from user input — the whole security model of the flow is that the in-game side
* proves the UUID before a code is ever minted.
*/
public final class LinkClient {
private static final String PATH = "/api/v1/internal/account/link/code";
private final LinkConfig config;
private final HttpClient http;
public LinkClient(LinkConfig config) {
this.config = Objects.requireNonNull(config, "config");
this.http = HttpClient.newBuilder()
.connectTimeout(config.timeout())
.build();
}
/** requestCode mints a one-time link code for the given verified UUID. */
public LinkCode requestCode(UUID mcUuid) throws LinkException {
Objects.requireNonNull(mcUuid, "mcUuid");
String body = "{\"mc_uuid\":\"" + mcUuid + "\"}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(config.apiBaseUrl() + PATH))
.timeout(config.timeout())
.header("Authorization", "Bearer " + config.serviceToken())
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> res;
try {
res = http.send(req, HttpResponse.BodyHandlers.ofString());
} catch (IOException e) {
throw new LinkException(0, "transport_error",
"could not reach felis-api: " + e.getMessage(), e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LinkException(0, "interrupted", "link request interrupted", e);
}
int status = res.statusCode();
String text = res.body();
if (status == 201) {
return parseCode(status, text);
}
throw parseError(status, text);
}
private LinkCode parseCode(int status, String text) throws LinkException {
Object root;
try {
root = Json.parse(text);
} catch (RuntimeException e) {
throw new LinkException(status, "bad_response",
"malformed success body from felis-api", e);
}
if (!(root instanceof Map)) {
throw new LinkException(status, "bad_response",
"expected a JSON object from felis-api");
}
Map<?, ?> obj = (Map<?, ?>) root;
Object code = obj.get("code");
if (!(code instanceof String) || ((String) code).isEmpty()) {
throw new LinkException(status, "bad_response",
"success body missing 'code'");
}
Object exp = obj.get("expires_at");
return new LinkCode((String) code, exp instanceof String ? (String) exp : null);
}
private LinkException parseError(int status, String text) {
String code = "error";
String message = "felis-api returned HTTP " + status;
try {
Object root = Json.parse(text);
if (root instanceof Map) {
Object err = ((Map<?, ?>) root).get("error");
if (err instanceof Map) {
Object c = ((Map<?, ?>) err).get("code");
Object m = ((Map<?, ?>) err).get("message");
if (c instanceof String && !((String) c).isEmpty()) {
code = (String) c;
}
if (m instanceof String && !((String) m).isEmpty()) {
message = (String) m;
}
}
}
} catch (RuntimeException ignored) {
// Non-JSON error body (proxy 502, plain text, etc.): keep the
// HTTP-status fallback message rather than guessing.
}
return new LinkException(status, code, message);
}
}
@@ -0,0 +1,30 @@
package best.lolicon.felis.link;
import java.util.Objects;
/**
* LinkCode is the one-time account-link code the internal face mints for a
* verified Minecraft UUID (spec §10). {@code expiresAt} is the raw RFC 3339
* timestamp string the server returned (or {@code null} if it was omitted); the
* plugins surface it to the player as an opaque "valid for a few minutes" hint
* rather than reformatting it, so the in-game side stays agnostic to the exact
* expiry policy the server enforces.
*/
public final class LinkCode {
private final String code;
private final String expiresAt;
public LinkCode(String code, String expiresAt) {
this.code = Objects.requireNonNull(code, "code");
this.expiresAt = expiresAt;
}
public String code() {
return code;
}
/** expiresAt is the raw RFC 3339 expiry string, or null if the server omitted it. */
public String expiresAt() {
return expiresAt;
}
}
@@ -0,0 +1,57 @@
package best.lolicon.felis.link;
import java.time.Duration;
import java.util.Objects;
/**
* LinkConfig is the immutable configuration a {@link LinkClient} needs to reach
* the felis-api internal face. Both the base URL and the service token are
* deployment inputs — operator config or a Secret-injected environment variable —
* and are <em>never</em> compiled in. Keeping them out of source is what lets the
* tree stay domain- and credential-free; each platform's config loader is
* responsible for sourcing them.
*/
public final class LinkConfig {
private final String apiBaseUrl;
private final String serviceToken;
private final Duration timeout;
public LinkConfig(String apiBaseUrl, String serviceToken, Duration timeout) {
this.apiBaseUrl = stripTrailingSlash(Objects.requireNonNull(apiBaseUrl, "apiBaseUrl"));
this.serviceToken = Objects.requireNonNull(serviceToken, "serviceToken");
this.timeout = Objects.requireNonNull(timeout, "timeout");
if (this.apiBaseUrl.isEmpty()) {
throw new IllegalArgumentException("apiBaseUrl is empty");
}
if (this.serviceToken.isEmpty()) {
throw new IllegalArgumentException("serviceToken is empty");
}
if (this.timeout.isZero() || this.timeout.isNegative()) {
throw new IllegalArgumentException("timeout must be positive");
}
}
public LinkConfig(String apiBaseUrl, String serviceToken) {
this(apiBaseUrl, serviceToken, Duration.ofSeconds(10));
}
public String apiBaseUrl() {
return apiBaseUrl;
}
public String serviceToken() {
return serviceToken;
}
public Duration timeout() {
return timeout;
}
private static String stripTrailingSlash(String u) {
String t = u.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
}
@@ -0,0 +1,86 @@
package best.lolicon.felis.link;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Properties;
/**
* LinkConfigLoader resolves a {@link LinkConfig} the same way on every platform:
* environment variables ({@code FELIS_API_BASE_URL}, {@code FELIS_SERVICE_TOKEN})
* win, falling back to a {@code felis-link.properties} file in the platform's
* config directory. Neither the API base URL nor the service token is ever
* compiled in — this loader is the single seam each loader's entrypoint calls, so
* the source tree stays domain- and credential-free. On first run it writes a
* commented template and then reports the values as missing, so an operator gets
* a file to fill in rather than a silent half-configured plugin.
*/
public final class LinkConfigLoader {
public static final String ENV_URL = "FELIS_API_BASE_URL";
public static final String ENV_TOKEN = "FELIS_SERVICE_TOKEN";
private static final String KEY_URL = "api-base-url";
private static final String KEY_TOKEN = "service-token";
private LinkConfigLoader() {
}
/**
* load resolves config for the given properties file path, writing a template
* if the file does not yet exist.
*
* @throws IOException if the file cannot be read/created, or if neither the
* environment nor the file supplies both required values.
*/
public static LinkConfig load(Path propertiesFile) throws IOException {
Properties props = new Properties();
if (Files.exists(propertiesFile)) {
try (InputStream in = Files.newInputStream(propertiesFile)) {
props.load(in);
}
} else {
writeTemplate(propertiesFile);
}
String url = firstNonBlank(System.getenv(ENV_URL), props.getProperty(KEY_URL));
String token = firstNonBlank(System.getenv(ENV_TOKEN), props.getProperty(KEY_TOKEN));
if (isBlank(url) || isBlank(token)) {
throw new IOException("set " + ENV_URL + "/" + ENV_TOKEN
+ " or fill in " + propertiesFile + " (" + KEY_URL + ", " + KEY_TOKEN + ")");
}
return new LinkConfig(url, token, Duration.ofSeconds(10));
}
private static void writeTemplate(Path file) throws IOException {
Path parent = file.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
String template =
"# Felis link configuration.\n"
+ "# Both values are normally injected via environment variables\n"
+ "# (" + ENV_URL + ", " + ENV_TOKEN + "); this file is the fallback.\n"
+ "#\n"
+ "# " + KEY_URL + ": base URL of the felis-api internal face, e.g.\n"
+ "# http://felis-api.felis.svc.cluster.local:8080\n"
+ KEY_URL + "=\n"
+ "#\n"
+ "# " + KEY_TOKEN + ": the internal service token (keep this secret).\n"
+ KEY_TOKEN + "=\n";
try (OutputStream out = Files.newOutputStream(file)) {
out.write(template.getBytes(StandardCharsets.UTF_8));
}
}
private static String firstNonBlank(String a, String b) {
return !isBlank(a) ? a : b;
}
private static boolean isBlank(String s) {
return s == null || s.trim().isEmpty();
}
}
@@ -0,0 +1,39 @@
package best.lolicon.felis.link;
/**
* LinkException is thrown when a link-code request does not succeed. It carries
* the HTTP status (0 for a transport/timeout failure that produced no response)
* and the server's stable {@code error.code} when one was returned, so callers
* can branch on a machine-readable cause without parsing human text. The message
* is safe to log server-side; the plugins deliberately do <em>not</em> echo it to
* the player, surfacing a generic "try again" line instead so nothing internal
* leaks into chat.
*/
public final class LinkException extends Exception {
private static final long serialVersionUID = 1L;
private final int statusCode;
private final String errorCode;
public LinkException(int statusCode, String errorCode, String message) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
}
public LinkException(int statusCode, String errorCode, String message, Throwable cause) {
super(message, cause);
this.statusCode = statusCode;
this.errorCode = errorCode;
}
/** statusCode is the HTTP status, or 0 if the request never completed. */
public int statusCode() {
return statusCode;
}
/** errorCode is the server's stable error.code, or null when unavailable. */
public String errorCode() {
return errorCode;
}
}
@@ -0,0 +1,93 @@
package best.lolicon.felis.link;
import java.util.Map;
/**
* MenuStatus is the proxy-side mirror of the felis-api lobby menu projection
* ({@code GET /api/v1/internal/servers/{name}/menu}, spec §12). It is deliberately
* <em>not</em> {@link ServerView}: the menu endpoint adds one field the §11 lifecycle
* views never carry — {@link #claimable()}, derived from ownership (an ownerless
* server can be claimed) — and drops the routing-only fields (subdomain, endpoint
* address, desiredState) the GUI has no use for. Keeping it a separate type means a
* future change to either contract can't silently corrupt the other.
*
* <p>Velocity reads this for a {@code StatusQuery} and projects it onto a
* {@link ControlFrame#STATUS_UPDATE} frame the felis-paper lobby renders as a tile:
* the {@code phase}/{@code ready}/{@code claimable} triple chooses the button
* (Claim&nbsp;&amp;&nbsp;Start / Join / Wake) and {@code playersOnline}/{@code
* playersMax} render the "3/20" count.
*
* <p>{@link #fromJson(Map)} is tolerant in the same way as {@link ServerView}: an
* absent field degrades to null/zero/false rather than throwing, so a partial body
* can never crash the proxy's event thread.
*/
public final class MenuStatus {
private final String name;
private final String phase;
private final boolean ready;
private final int playersOnline;
private final int playersMax;
private final boolean claimable;
public MenuStatus(String name, String phase, boolean ready,
int playersOnline, int playersMax, boolean claimable) {
this.name = name;
this.phase = phase;
this.ready = ready;
this.playersOnline = playersOnline;
this.playersMax = playersMax;
this.claimable = claimable;
}
/** fromJson builds a status from a parsed menu object, tolerating absent fields. */
public static MenuStatus fromJson(Map<?, ?> o) {
return new MenuStatus(
str(o, "name"),
str(o, "phase"),
bool(o, "ready"),
intval(o, "playersOnline"),
intval(o, "playersMax"),
bool(o, "claimable"));
}
public String name() {
return name;
}
public String phase() {
return phase;
}
public boolean ready() {
return ready;
}
public int playersOnline() {
return playersOnline;
}
public int playersMax() {
return playersMax;
}
/** claimable is true when the server has no owner yet (the menu's `Claim & Start`). */
public boolean claimable() {
return claimable;
}
private static String str(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof String ? (String) v : null;
}
private static boolean bool(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof Boolean && (Boolean) v;
}
private static int intval(Map<?, ?> o, String key) {
Object v = o.get(key);
// Json parses every number as Double; the player counts are int32 server-side.
return v instanceof Number ? ((Number) v).intValue() : 0;
}
}
@@ -0,0 +1,123 @@
package best.lolicon.felis.link;
import java.util.Map;
/**
* ServerView is the proxy-side mirror of the felis-api lifecycle view of one
* MinecraftServer (the {@code ServerInfo} the internal face emits for
* {@code GET /servers}, {@code GET /servers/by-host/{host}} and the internal
* status/wake replies). It is an immutable, dependency-free value object so the
* shared link core stays zero-dependency and source-shareable across all four
* loaders.
*
* <p>The name deliberately avoids {@code ServerInfo}: Velocity already owns
* {@code com.velocitypowered.api.proxy.server.ServerInfo} (name + address), and
* the routing code juggles both at once. {@code ServerView} is the felis lifecycle
* record; {@code ServerInfo} is Velocity's registration handle.
*
* <p>{@link #fromJson(Map)} is tolerant: the wake reply carries only a subset of
* the fields ({@code name}, {@code desiredState}, {@code phase}, {@code ready}),
* so every accessor degrades to a null/zero default rather than throwing when a
* field is absent. Routing decisions are driven off {@link #ready()} and
* {@link #phase()}, which the relevant endpoints always populate.
*/
public final class ServerView {
private final String name;
private final String subdomain;
private final String phase;
private final boolean ready;
private final String autostartPolicy;
private final String desiredState;
private final String endpointMode;
private final String endpointAddress;
private final int playersOnline;
private final int playersMax;
public ServerView(String name, String subdomain, String phase, boolean ready,
String autostartPolicy, String desiredState, String endpointMode,
String endpointAddress, int playersOnline, int playersMax) {
this.name = name;
this.subdomain = subdomain;
this.phase = phase;
this.ready = ready;
this.autostartPolicy = autostartPolicy;
this.desiredState = desiredState;
this.endpointMode = endpointMode;
this.endpointAddress = endpointAddress;
this.playersOnline = playersOnline;
this.playersMax = playersMax;
}
/** fromJson builds a view from a parsed felis-api object, tolerating absent fields. */
public static ServerView fromJson(Map<?, ?> o) {
return new ServerView(
str(o, "name"),
str(o, "subdomain"),
str(o, "phase"),
bool(o, "ready"),
str(o, "autostartPolicy"),
str(o, "desiredState"),
str(o, "endpointMode"),
str(o, "endpointAddress"),
intval(o, "playersOnline"),
intval(o, "playersMax"));
}
public String name() {
return name;
}
public String subdomain() {
return subdomain;
}
public String phase() {
return phase;
}
/** ready is the authoritative "RCON-confirmed up" gate the proxy routes on. */
public boolean ready() {
return ready;
}
public String autostartPolicy() {
return autostartPolicy;
}
public String desiredState() {
return desiredState;
}
public String endpointMode() {
return endpointMode;
}
/** endpointAddress is the {@code host[:port]} the proxy registers as a backend. */
public String endpointAddress() {
return endpointAddress;
}
public int playersOnline() {
return playersOnline;
}
public int playersMax() {
return playersMax;
}
private static String str(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof String ? (String) v : null;
}
private static boolean bool(Map<?, ?> o, String key) {
Object v = o.get(key);
return v instanceof Boolean && (Boolean) v;
}
private static int intval(Map<?, ?> o, String key) {
Object v = o.get(key);
// Json parses every number as Double; the player counts are int32 server-side.
return v instanceof Number ? ((Number) v).intValue() : 0;
}
}
@@ -0,0 +1,149 @@
package best.lolicon.felis.link;
import java.nio.charset.StandardCharsets;
/**
* ControlRoundTripTest is a hermetic, dependency-free check of the {@code
* felis:control} codec (spec §12). It lives outside {@code src/main/java} so it
* never ships in a module jar, and it has no test framework: a failed assertion
* throws and the process exits non-zero.
*
* <p>Because the velocity and paper jars source-share the very {@link Control} and
* {@link ControlFrame} this test exercises, a passing encode→decode round-trip
* proves wire compatibility <em>by construction</em>, not merely that the code
* compiles — the one part of the Java plugin layer that can be verified above
* "compiles" without a live proxy/lobby. So it asserts field-level equality for
* every frame type, the {@code type} discriminator on the wire, {@code Error} with
* its optional {@code server} both present and absent, and that a malformed or
* unknown frame is rejected, not silently mis-decoded.
*
* <p>Run: {@code javac -d <out> shared/src/main/java/best/lolicon/felis/link/*.java
* shared/test/best/lolicon/felis/link/ControlRoundTripTest.java && java -cp <out>
* best.lolicon.felis.link.ControlRoundTripTest}.
*/
public final class ControlRoundTripTest {
private static int checks;
public static void main(String[] args) {
roundTripsEveryFrameType();
wireCarriesTypeDiscriminator();
wireCarriesRefinedStatusFields();
errorOmitsServerWhenAbsentButRoundTrips();
escapesAwkwardStrings();
rejectsMalformedAndUnknownFrames();
System.out.println("ControlRoundTripTest OK (" + checks + " checks)");
}
// Every factory frame must survive encode→decode as an equal frame, so the two
// ends read back exactly what the other wrote.
private static void roundTripsEveryFrameType() {
roundTrip(ControlFrame.wakeRequest("Notch", "survival"));
roundTrip(ControlFrame.claimRequest("Notch", "creative"));
roundTrip(ControlFrame.statusQuery("survival"));
roundTrip(ControlFrame.statusUpdate("survival", "Running", true, 3, 20, false));
roundTrip(ControlFrame.statusUpdate("creative", "Stopped", false, 0, 20, true));
roundTrip(ControlFrame.transferReady("Notch", "survival"));
roundTrip(ControlFrame.error("quota_exceeded", "server quota exhausted", "survival"));
roundTrip(ControlFrame.error("not_linked", "link your account first", null));
}
// The discriminator the dispatch switch keys on must appear verbatim on the wire.
private static void wireCarriesTypeDiscriminator() {
assertContains(ControlFrame.wakeRequest("p", "s"), "\"type\":\"WakeRequest\"");
assertContains(ControlFrame.claimRequest("p", "s"), "\"type\":\"ClaimRequest\"");
assertContains(ControlFrame.statusQuery("s"), "\"type\":\"StatusQuery\"");
assertContains(ControlFrame.statusUpdate("s", "Running", true, 1, 2, false), "\"type\":\"StatusUpdate\"");
assertContains(ControlFrame.transferReady("p", "s"), "\"type\":\"TransferReady\"");
assertContains(ControlFrame.error("c", "m", null), "\"type\":\"Error\"");
}
// StatusUpdate refines the spec's "players" into ready + online + max; the GUI
// renders all three, so all three must survive the round-trip with exact values.
private static void wireCarriesRefinedStatusFields() {
ControlFrame f = decode(ControlFrame.statusUpdate("survival", "Running", true, 7, 40, false));
assertEq("server", "survival", f.server());
assertEq("phase", "Running", f.phase());
assertEq("ready", true, f.ready());
assertEq("playersOnline", 7, f.playersOnline());
assertEq("playersMax", 40, f.playersMax());
assertEq("claimable", false, f.claimable());
// And the booleans flip independently of one another.
ControlFrame g = decode(ControlFrame.statusUpdate("creative", "Stopped", false, 0, 8, true));
assertEq("ready(false)", false, g.ready());
assertEq("claimable(true)", true, g.claimable());
}
// Error's optional server: absent → not on the wire and decodes to null;
// present → on the wire and decodes back. Both round-trip to an equal frame.
private static void errorOmitsServerWhenAbsentButRoundTrips() {
ControlFrame bare = ControlFrame.error("not_linked", "link first", null);
String wire = new String(Control.encode(bare), StandardCharsets.UTF_8);
if (wire.contains("\"server\"")) {
throw new AssertionError("bare Error must not carry a server key: " + wire);
}
checks++;
assertEq("bare Error server", null, decode(bare).server());
ControlFrame scoped = ControlFrame.error("quota_exceeded", "no room", "survival");
assertContains(scoped, "\"server\":\"survival\"");
assertEq("scoped Error server", "survival", decode(scoped).server());
}
// Player names and error messages can carry quotes/backslashes/newlines; the
// hand-rolled writer must escape them so the reader recovers the original.
private static void escapesAwkwardStrings() {
String nasty = "a\"b\\c\nd\te";
ControlFrame f = ControlFrame.error("bad", nasty, "ser\"ver");
ControlFrame back = decode(f);
assertEq("escaped message", nasty, back.message());
assertEq("escaped server", "ser\"ver", back.server());
}
// A bad frame is a dropped message, never a crash or a silent mis-decode.
private static void rejectsMalformedAndUnknownFrames() {
assertRejected("not json at all".getBytes(StandardCharsets.UTF_8));
assertRejected("[1,2,3]".getBytes(StandardCharsets.UTF_8)); // root is not an object
assertRejected("{\"player\":\"p\"}".getBytes(StandardCharsets.UTF_8)); // missing type
assertRejected("{\"type\":\"Bogus\"}".getBytes(StandardCharsets.UTF_8)); // unknown type
}
// ---- harness ----
private static ControlFrame decode(ControlFrame f) {
return Control.decode(Control.encode(f));
}
private static void roundTrip(ControlFrame f) {
ControlFrame back = decode(f);
if (!f.equals(back)) {
throw new AssertionError("round-trip changed the frame:\n in: " + f + "\n out: " + back);
}
checks++;
}
private static void assertContains(ControlFrame f, String needle) {
String wire = new String(Control.encode(f), StandardCharsets.UTF_8);
if (!wire.contains(needle)) {
throw new AssertionError("wire " + wire + " is missing " + needle);
}
checks++;
}
private static void assertEq(String what, Object want, Object got) {
if (want == null ? got != null : !want.equals(got)) {
throw new AssertionError(what + " = " + got + ", want " + want);
}
checks++;
}
private static void assertRejected(byte[] data) {
try {
Control.decode(data);
} catch (IllegalArgumentException expected) {
checks++;
return;
}
throw new AssertionError("expected rejection of: " + new String(data, StandardCharsets.UTF_8));
}
}
+44
View File
@@ -0,0 +1,44 @@
plugins {
id 'java'
}
group = 'best.lolicon.felis'
version = '0.1.0'
// JDK 17 is the ceiling the whole plugin suite targets (Velocity 3.3.0 is a
// Java-17 line); we run Gradle on JDK 17 and compile to 17 bytecode rather than
// provisioning a separate toolchain.
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
maven {
name = 'papermc'
url = 'https://repo.papermc.io/repository/maven-public/'
}
}
dependencies {
// velocity-api is compile-only (the proxy provides it at runtime); the
// annotation processor turns @Plugin into the generated velocity-plugin.json.
compileOnly 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
annotationProcessor 'com.velocitypowered:velocity-api:3.3.0-SNAPSHOT'
}
// The platform-agnostic link core lives in ../shared and is compiled straight
// into this jar. The core has zero third-party dependencies, so source-sharing
// keeps every loader self-contained with nothing to shade.
sourceSets {
main {
java {
srcDir '../shared/src/main/java'
}
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'felis-velocity'
@@ -0,0 +1,207 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.Control;
import best.lolicon.felis.link.ControlFrame;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.MenuStatus;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.connection.PluginMessageEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.ServerConnection;
import com.velocitypowered.api.proxy.messages.ChannelIdentifier;
import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier;
import org.slf4j.Logger;
import java.util.UUID;
/**
* ControlChannel is the proxy end of the {@code felis:control} plugin-message channel
* (spec §12): the bridge between the felis-paper lobby's {@code /menu} GUI and the
* routing core. The lobby is a pure UI face — it holds no felis-api token and keeps
* no queue — so every menu action arrives here as a {@link ControlFrame}, this class
* drives the felis-api internal endpoints and the shared waiting queue, and answers
* downstream with another frame. It is the §27 scenario-10 path: {@code /menu →
* plugin msg → velocity → api → 共用等待队列 → ready 后 Connect}.
*
* <p><b>Anti-spoof (spec §14).</b> The acting identity is taken from the
* {@link ServerConnection} the message arrived on — {@code source.getPlayer()} — and
* never from the frame's {@code player} field, so a compromised backend cannot drive
* an action as another player. A frame whose source is not a backend server (e.g. a
* client) is consumed and dropped. The frame's {@code server} field is data, not
* identity: it names which backend the player asked for, and the autostartPolicy /
* ownership gates server-side decide whether this UUID may act on it.
*
* <p><b>Threading.</b> {@code felis:control} frames arrive on a Velocity event
* thread, but every felis-api call below blocks on HTTP. So each handler does the
* non-blocking work synchronously — decode, derive identity, and
* {@code setResult(handled())} to stop the frame being forwarded — then hands the
* blocking call to {@link FelisVelocityPlugin#async}, sending any downstream frame
* from inside that callback. {@code setResult} must run before the handler returns;
* it cannot be set from the async hop.
*
* <p>The three upstream frames map onto the menu's buttons (spec §12): a
* {@code StatusQuery} refreshes a tile ({@code StatusUpdate} back); a
* {@code WakeRequest} (owned server) wakes and parks; a {@code ClaimRequest}
* (ownerless server) runs the two-rule split — claim asserts ownership/quota, then
* the wake applies the autostartPolicy gate — and parks on success. Refusals come
* back as {@code Error}; readiness as {@code TransferReady} just before the proxy
* Connects the player (via {@link WaitingRouter.MenuTransferListener}).
*/
public final class ControlChannel implements WaitingRouter.MenuTransferListener {
/** The namespaced channel both ends register; shared with the codec's name. */
static final ChannelIdentifier CHANNEL = MinecraftChannelIdentifier.from(Control.CHANNEL);
private final ProxyServer proxy;
private final Logger log;
private final FelisApiClient api;
private final WaitingRouter router;
private final FelisVelocityPlugin plugin;
ControlChannel(ProxyServer proxy, Logger log, FelisApiClient api,
WaitingRouter router, FelisVelocityPlugin plugin) {
this.proxy = proxy;
this.log = log;
this.api = api;
this.router = router;
this.plugin = plugin;
}
/**
* register opens the channel and wires this instance as the waiting queue's
* menu-transfer listener. The caller still registers it as an event subscriber.
*/
void register() {
proxy.getChannelRegistrar().register(CHANNEL);
router.setMenuTransferListener(this);
}
@Subscribe
public void onPluginMessage(PluginMessageEvent event) {
if (!CHANNEL.getId().equals(event.getIdentifier().getId())) {
return; // not ours → leave Velocity's default handling alone
}
// We own this channel end to end: a felis:control frame is never relayed to
// the other side, whatever its source. Consume it before doing anything else.
event.setResult(PluginMessageEvent.ForwardResult.handled());
// Identity comes from the connection, never the frame (spec §14). A frame from
// anything but a backend server (e.g. a client) is not a legitimate lobby
// action — drop it.
if (!(event.getSource() instanceof ServerConnection)) {
return;
}
ServerConnection source = (ServerConnection) event.getSource();
Player player = source.getPlayer();
ControlFrame frame;
try {
frame = Control.decode(event.getData());
} catch (IllegalArgumentException e) {
log.debug("Felis: dropping malformed felis:control frame from {}: {}",
source.getServerInfo().getName(), e.getMessage());
return;
}
switch (frame.type()) {
case ControlFrame.STATUS_QUERY:
handleStatusQuery(source, frame.server());
break;
case ControlFrame.WAKE_REQUEST:
handleWake(source, player, frame.server());
break;
case ControlFrame.CLAIM_REQUEST:
handleClaim(source, player, frame.server());
break;
default:
// Downstream-only types (StatusUpdate/TransferReady/Error) are not
// actionable arriving upstream; a well-behaved lobby never sends them.
log.debug("Felis: ignoring non-actionable felis:control frame '{}' from {}",
frame.type(), player.getUsername());
}
}
// A StatusQuery refreshes one tile: read the menu projection and answer with a
// StatusUpdate, or an Error if felis-api refuses (e.g. 404 unknown server).
private void handleStatusQuery(ServerConnection source, String server) {
if (isBlank(server)) {
return; // nothing to look up
}
plugin.async(() -> {
try {
MenuStatus s = api.menuStatus(server);
send(source, ControlFrame.statusUpdate(
s.name(), s.phase(), s.ready(), s.playersOnline(), s.playersMax(), s.claimable()));
} catch (LinkException e) {
send(source, errorFrame(e, server));
}
});
}
// A WakeRequest is the menu's Join/Wake button on a server the player owns: wake
// it and park them in the shared queue. enqueueFromMenu does the HTTP off-thread
// and reports its own refusals to the player; nothing to await here.
private void handleWake(ServerConnection source, Player player, String server) {
if (isBlank(server)) {
send(source, ControlFrame.error("bad_request", "wake without a server", null));
return;
}
router.enqueueFromMenu(player, server);
}
// A ClaimRequest is the menu's Claim & Start on an ownerless server: the two-rule
// split. Claim first (ownership + quota); only on success wake-and-park (the
// autostartPolicy gate). A claim refusal answers with Error and never wakes.
private void handleClaim(ServerConnection source, Player player, String server) {
if (isBlank(server)) {
send(source, ControlFrame.error("bad_request", "claim without a server", null));
return;
}
UUID id = player.getUniqueId();
plugin.async(() -> {
try {
api.claim(server, id);
} catch (LinkException e) {
send(source, errorFrame(e, server));
return; // claim refused → do not wake a server the player doesn't own
}
// Owned now → run the second rule. enqueueFromMenu spawns its own async
// hop for the wake, which is fine from here.
router.enqueueFromMenu(player, server);
});
}
/**
* onReady fires when a menu-parked player's backend goes ready, just before the
* proxy Connects them. The lobby uses {@code TransferReady} to react (close the
* menu / show "joining"); the actual move is the proxy's Connect, not this frame.
*/
@Override
public void onReady(Player player, String serverName) {
player.getCurrentServer().ifPresent(sc ->
send(sc, ControlFrame.transferReady(player.getUsername(), serverName)));
}
private void send(ServerConnection connection, ControlFrame frame) {
connection.sendPluginMessage(CHANNEL, Control.encode(frame));
}
// errorFrame turns a LinkException into a downstream Error. felis-api's structured
// errors carry user-safe text (the same {code,message} the external API returns),
// but a transport failure (statusCode 0) carries internal IO detail — host names,
// refused ports — that must not reach a player's screen, so it is generalized.
private static ControlFrame errorFrame(LinkException e, String server) {
String code = e.errorCode() != null ? e.errorCode() : "error";
String message = e.statusCode() == 0
? "felis is temporarily unavailable — please try again."
: e.getMessage();
return ControlFrame.error(code, message, server);
}
private static boolean isBlank(String s) {
return s == null || s.isEmpty();
}
}
@@ -0,0 +1,91 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.LinkConfig;
import best.lolicon.felis.link.LinkConfigLoader;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Locale;
import java.util.Properties;
/**
* FelisVelocityConfig extends the shared link config with the two inputs only the
* full proxy needs: the {@code root-domain} the deployment serves (the zone
* subdomains are carved from) and the {@code lobby-server} waiters are parked in
* while their backend wakes. It reuses {@link LinkConfigLoader} for the API base
* URL + service token (and its first-run template), so {@code /link} keeps working
* exactly as before; these extra keys are read from the same properties file (or
* {@code FELIS_ROOT_DOMAIN} / {@code FELIS_LOBBY_SERVER}).
*
* <p>Both extras are optional at load time and the routing layer degrades rather
* than crashing: a missing {@code root-domain} disables routing (with a clear log
* line) while {@code /link} still runs, and a missing {@code lobby-server} means
* the proxy has nowhere to hold waiters, so it refuses the join with a "reconnect
* shortly" message instead of dropping the player onto a not-yet-ready backend.
* The root domain is the only place the deployment zone enters the proxy — it is
* never compiled in (CI red line).
*/
final class FelisVelocityConfig {
static final String ENV_ROOT_DOMAIN = "FELIS_ROOT_DOMAIN";
static final String ENV_LOBBY = "FELIS_LOBBY_SERVER";
private static final String KEY_ROOT_DOMAIN = "root-domain";
private static final String KEY_LOBBY = "lobby-server";
private final LinkConfig linkConfig;
private final String rootDomain; // null → routing disabled
private final String lobbyServer; // null → no lobby to park waiters in
private FelisVelocityConfig(LinkConfig linkConfig, String rootDomain, String lobbyServer) {
this.linkConfig = linkConfig;
this.rootDomain = rootDomain;
this.lobbyServer = lobbyServer;
}
static FelisVelocityConfig load(Path file) throws IOException {
LinkConfig link = LinkConfigLoader.load(file); // url + token (required) + template + validate
Properties props = new Properties();
if (Files.exists(file)) {
try (InputStream in = Files.newInputStream(file)) {
props.load(in);
}
}
String root = trimToNull(firstNonBlank(System.getenv(ENV_ROOT_DOMAIN), props.getProperty(KEY_ROOT_DOMAIN)));
String lobby = trimToNull(firstNonBlank(System.getenv(ENV_LOBBY), props.getProperty(KEY_LOBBY)));
return new FelisVelocityConfig(link, root == null ? null : root.toLowerCase(Locale.ROOT), lobby);
}
LinkConfig linkConfig() {
return linkConfig;
}
/** rootDomain is the deployment zone, or null when routing should stay disabled. */
String rootDomain() {
return rootDomain;
}
boolean routingEnabled() {
return rootDomain != null;
}
/** lobbyServer is the velocity.toml server name waiters are parked in, or null. */
String lobbyServer() {
return lobbyServer;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.trim().isEmpty()) {
return a;
}
return b;
}
private static String trimToNull(String s) {
if (s == null) {
return null;
}
String t = s.trim();
return t.isEmpty() ? null : t;
}
}
@@ -0,0 +1,256 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkClient;
import best.lolicon.felis.link.LinkCode;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.ServerView;
import com.google.inject.Inject;
import com.mojang.brigadier.Command;
import com.mojang.brigadier.tree.LiteralCommandNode;
import com.velocitypowered.api.command.BrigadierCommand;
import com.velocitypowered.api.command.CommandManager;
import com.velocitypowered.api.command.CommandMeta;
import com.velocitypowered.api.command.CommandSource;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collection;
import java.util.List;
/**
* FelisVelocityPlugin is the proxy-side of Felis (spec §10 account-link + §11
* domain-autostart routing). Velocity sits on the player-facing edge, off-cluster,
* and is where two responsibilities naturally live:
*
* <ul>
* <li><b>{@code /link}</b> — mints a one-time account-link code from the player's
* online-mode-verified UUID (the first leg of §10), unchanged.</li>
* <li><b>Domain-autostart routing</b> — recognizes each server's subdomain,
* registers backends dynamically, routes joins, wakes a sleeping target and
* holds the player in a lobby until it is ready, and reports real joins so
* the reaper and allowlist see them (§11, driving §9).</li>
* </ul>
*
* <p>Routing has two hard preconditions, each fails safe: the proxy must run in
* online mode (verified UUIDs are the whole basis of the autostartPolicy and
* allowlist gates — under offline mode routing is refused while {@code /link}
* keeps working), and a {@code root-domain} must be configured (the only place the
* deployment zone enters the proxy; never compiled in). With routing active the
* {@code felis:control} plugin-message channel (spec §12) is also opened: the
* felis-paper lobby's {@code /menu} drives the same waiting queue through
* {@link ControlChannel}, deriving identity from the backend connection rather than
* the frame so a compromised lobby cannot act as another player (spec §14).
*/
@Plugin(
id = "felis-link",
name = "Felis Velocity",
version = "0.2.0",
description = "In-game /link plus domain-autostart routing: recognizes server subdomains, "
+ "registers backends, wakes sleeping servers and holds players until ready.",
authors = {"Felis"}
)
public final class FelisVelocityPlugin {
private static final Duration REGISTRATION_REFRESH = Duration.ofSeconds(15);
private static final Duration WAIT_POLL = Duration.ofSeconds(2);
private final ProxyServer proxy;
private final Logger logger;
private final Path dataDirectory;
private FelisVelocityConfig config;
private LinkClient linkClient;
private FelisApiClient apiClient;
private ServerRegistry registry;
private boolean onlineMode;
private boolean routingActive;
@Inject
public FelisVelocityPlugin(ProxyServer proxy, Logger logger, @DataDirectory Path dataDirectory) {
this.proxy = proxy;
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onProxyInitialize(ProxyInitializeEvent event) {
try {
this.config = FelisVelocityConfig.load(dataDirectory.resolve("felis-link.properties"));
} catch (Exception e) {
logger.error("Felis disabled: {}", e.getMessage());
return;
}
this.linkClient = new LinkClient(config.linkConfig());
registerLinkCommand();
registerFelisCommand();
this.onlineMode = proxy.getConfiguration().isOnlineMode();
if (!onlineMode) {
logger.error("Felis routing DISABLED: the proxy is in offline mode (online-mode=false). "
+ "Domain autostart and the allowlist trust Mojang-verified UUIDs; refusing to route on "
+ "spoofable identities. /link remains available. Set online-mode=true to enable routing.");
return;
}
if (!config.routingEnabled()) {
logger.warn("Felis routing DISABLED: no root-domain configured. Add 'root-domain=' to "
+ "felis-link.properties (or set " + FelisVelocityConfig.ENV_ROOT_DOMAIN + ") to enable "
+ "host-based routing. /link remains available.");
return;
}
this.apiClient = new FelisApiClient(config.linkConfig());
this.registry = new ServerRegistry(proxy, logger, config.rootDomain());
WaitingRouter router = new WaitingRouter(proxy, logger, apiClient, registry, this, config.lobbyServer());
MotdResponder motd = new MotdResponder(registry);
proxy.getEventManager().register(this, router);
proxy.getEventManager().register(this, motd);
// The felis:control face (spec §12): the felis-paper lobby's /menu drives the
// same waiting queue through this channel. Opened only with routing active —
// it depends on the same online-mode + root-domain guards, and its claim/wake
// identity is the verified UUID off the backend connection (spec §14).
ControlChannel control = new ControlChannel(proxy, logger, apiClient, router, this);
control.register();
proxy.getEventManager().register(this, control);
// Prime registrations immediately, then keep them fresh; drain the queue often.
refreshRegistrations();
repeating(REGISTRATION_REFRESH, this::refreshRegistrations);
repeating(WAIT_POLL, router::tick);
this.routingActive = true;
if (config.lobbyServer() == null) {
logger.warn("Felis routing active without a lobby-server: a player whose target is asleep will be "
+ "asked to reconnect rather than parked. Set 'lobby-server=' to enable the waiting queue.");
}
logger.info("Felis routing ready: rootDomain={}, lobby={}. /link and /felis registered.",
config.rootDomain(), config.lobbyServer() == null ? "<none>" : config.lobbyServer());
}
/** async runs a task on Velocity's scheduler so felis-api I/O never blocks the proxy thread. */
void async(Runnable task) {
proxy.getScheduler().buildTask(this, task).schedule();
}
private void repeating(Duration interval, Runnable task) {
proxy.getScheduler().buildTask(this, task).delay(interval).repeat(interval).schedule();
}
private void refreshRegistrations() {
try {
List<ServerView> servers = apiClient.listServers();
registry.refresh(servers);
} catch (LinkException e) {
// Keep existing registrations on a control-plane blip (spec §11): a
// transient failure must never deregister live backends.
logger.warn("Felis: server list refresh failed (status={}): {}; keeping current registrations.",
e.statusCode(), e.getMessage());
}
}
// ---- /link (spec §10 first leg) ----
private void registerLinkCommand() {
CommandManager commands = proxy.getCommandManager();
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("link")
.executes(ctx -> {
CommandSource source = ctx.getSource();
if (!(source instanceof Player)) {
source.sendMessage(Component.text("/link can only be run by a player.", NamedTextColor.RED));
return Command.SINGLE_SUCCESS;
}
requestAndReply((Player) source);
return Command.SINGLE_SUCCESS;
})
.build();
CommandMeta meta = commands.metaBuilder("link").plugin(this).build();
commands.register(meta, new BrigadierCommand(node));
}
private void requestAndReply(Player player) {
player.sendMessage(Component.text("Requesting a link code…", NamedTextColor.GRAY));
async(() -> {
try {
LinkCode code = linkClient.requestCode(player.getUniqueId());
player.sendMessage(Component.text("Your link code: ", NamedTextColor.GREEN)
.append(Component.text(code.code(), NamedTextColor.YELLOW)));
player.sendMessage(Component.text(
"Enter it on the web panel → Account to finish linking (valid a few minutes).",
NamedTextColor.GRAY));
} catch (LinkException e) {
logger.warn("link code request failed for {} (status={}, code={}): {}",
player.getUniqueId(), e.statusCode(), e.errorCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't get a link code right now. Please try again in a moment.",
NamedTextColor.RED));
}
});
}
// ---- /felis (operator status) ----
private void registerFelisCommand() {
CommandManager commands = proxy.getCommandManager();
LiteralCommandNode<CommandSource> node = BrigadierCommand.literalArgumentBuilder("felis")
.executes(ctx -> {
sendSummary(ctx.getSource());
return Command.SINGLE_SUCCESS;
})
.then(BrigadierCommand.literalArgumentBuilder("list")
.executes(ctx -> {
sendList(ctx.getSource());
return Command.SINGLE_SUCCESS;
}))
.build();
CommandMeta meta = commands.metaBuilder("felis").plugin(this).build();
commands.register(meta, new BrigadierCommand(node));
}
private void sendSummary(CommandSource source) {
source.sendMessage(Component.text("Felis proxy", NamedTextColor.AQUA));
source.sendMessage(field("online-mode", String.valueOf(onlineMode)));
if (!routingActive) {
source.sendMessage(Component.text(
" routing: disabled" + (onlineMode ? " (no root-domain set)" : " (offline mode)"),
NamedTextColor.YELLOW));
return;
}
source.sendMessage(field("root-domain", config.rootDomain()));
source.sendMessage(field("lobby", config.lobbyServer() == null ? "<none>" : config.lobbyServer()));
source.sendMessage(field("servers", String.valueOf(registry.all().size())));
}
private void sendList(CommandSource source) {
if (!routingActive) {
source.sendMessage(Component.text("Felis routing is disabled.", NamedTextColor.YELLOW));
return;
}
Collection<ServerView> servers = registry.all();
if (servers.isEmpty()) {
source.sendMessage(Component.text("No felis servers known yet.", NamedTextColor.GRAY));
return;
}
source.sendMessage(Component.text("Felis servers:", NamedTextColor.AQUA));
for (ServerView v : servers) {
String phase = v.phase() == null ? "?" : v.phase();
source.sendMessage(Component.text(" " + v.name() + " ", NamedTextColor.WHITE)
.append(Component.text("[" + phase + (v.ready() ? ", ready" : "") + "]",
v.ready() ? NamedTextColor.GREEN : NamedTextColor.GRAY)));
}
}
private static Component field(String key, String value) {
return Component.text(" " + key + ": ", NamedTextColor.GRAY)
.append(Component.text(value == null ? "<unset>" : value, NamedTextColor.WHITE));
}
}
@@ -0,0 +1,76 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.proxy.ProxyPingEvent;
import com.velocitypowered.api.proxy.server.ServerPing;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import java.net.InetSocketAddress;
import java.util.Optional;
/**
* MotdResponder answers the server-list ping for a felis subdomain from cache, so
* a player sees the right server in their list — and whether it is awake — without
* the ping itself waking anything (spec §11 "ping MOTD:只读缓存,后台刷新"). The
* cache it reads is the {@link ServerRegistry}'s lifecycle view, refreshed in the
* background by the plugin's registration poll; the ping handler never touches the
* backend or the wake lever.
*
* <p>This is the read-only, phase-aware subset of the responsibility: the MOTD is
* synthesized from the server's lifecycle (online / starting / sleeping) and its
* cached player counts. Mirroring each backend's <em>own</em> MOTD string (by
* pinging ready servers in the background and caching the result) is a richer
* variant deferred to a later slice; nothing here ever pings a sleeping backend.
*/
public final class MotdResponder {
private final ServerRegistry registry;
MotdResponder(ServerRegistry registry) {
this.registry = registry;
}
@Subscribe
public void onProxyPing(ProxyPingEvent event) {
Optional<InetSocketAddress> vh = event.getConnection().getVirtualHost();
if (vh.isEmpty()) {
return; // no SRV host → leave the proxy's own MOTD
}
Optional<ServerView> viewOpt = registry.resolveByHost(vh.get().getHostString());
if (viewOpt.isEmpty()) {
return; // not a felis subdomain → leave the proxy's own MOTD
}
ServerView v = viewOpt.get();
ServerPing.Builder b = event.getPing().asBuilder();
b.description(Component.text("« " + v.name() + " » ", NamedTextColor.AQUA)
.append(Component.text(statusLine(v), statusColor(v))));
if (v.ready()) {
b.onlinePlayers(v.playersOnline());
b.maximumPlayers(Math.max(v.playersMax(), v.playersOnline()));
}
event.setPing(b.build());
}
private static String statusLine(ServerView v) {
if (v.ready()) {
return "online";
}
if ("Running".equals(v.desiredState())) {
return "starting…";
}
return "sleeping — join to wake";
}
private static NamedTextColor statusColor(ServerView v) {
if (v.ready()) {
return NamedTextColor.GREEN;
}
if ("Running".equals(v.desiredState())) {
return NamedTextColor.YELLOW;
}
return NamedTextColor.GRAY;
}
}
@@ -0,0 +1,150 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import com.velocitypowered.api.proxy.server.ServerInfo;
import org.slf4j.Logger;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* ServerRegistry mirrors the felis server set into Velocity's dynamic server
* registry and indexes it for host-based routing (spec §11). It owns two things:
* the felis lifecycle views keyed by name + subdomain, and the registration of
* each server's backend address as a Velocity {@link RegisteredServer}.
*
* <p>{@link #refresh(Collection)} is called only on a <em>successful</em> fetch
* from felis-api, so a name's absence is a genuine removal. On an API failure the
* caller skips the refresh entirely and the previous registrations survive
* untouched (spec §11 keep-old-on-failure) — a transient control-plane blip must
* never deregister live backends out from under connected players.
*
* <p>Only felis-managed servers live in this registry; servers defined statically
* in {@code velocity.toml} (notably the lobby) are never added here and so are
* never deregistered by a refresh. Static server names must therefore not collide
* with felis server names.
*/
final class ServerRegistry {
private static final int DEFAULT_PORT = 25565;
private final ProxyServer proxy;
private final Logger log;
private final String rootDomain;
private final Map<String, ServerView> byName = new ConcurrentHashMap<>();
private final Map<String, String> subdomainToName = new ConcurrentHashMap<>();
ServerRegistry(ProxyServer proxy, Logger log, String rootDomain) {
this.proxy = proxy;
this.log = log;
this.rootDomain = rootDomain.toLowerCase(Locale.ROOT);
}
/** refresh reconciles registrations against a freshly fetched server list. */
void refresh(Collection<ServerView> servers) {
Set<String> seen = new HashSet<>();
for (ServerView v : servers) {
String name = v.name();
if (name == null || name.isEmpty()) {
continue;
}
seen.add(name);
byName.put(name, v);
String sub = v.subdomain();
if (sub != null && !sub.isEmpty()) {
subdomainToName.put(sub.toLowerCase(Locale.ROOT), name);
}
ensureRegistered(v);
}
// Drop servers that vanished from a successful fetch (iterate a snapshot so
// deregister can mutate byName underneath us).
for (String name : new ArrayList<>(byName.keySet())) {
if (!seen.contains(name)) {
deregister(name);
}
}
subdomainToName.values().removeIf(n -> !seen.contains(n));
}
private void ensureRegistered(ServerView v) {
String addr = v.endpointAddress();
if (addr == null || addr.isEmpty()) {
return; // no backend address yet (server never started) → nothing to register
}
InetSocketAddress target = parseAddress(addr);
Optional<RegisteredServer> existing = proxy.getServer(v.name());
if (existing.isPresent()) {
if (existing.get().getServerInfo().getAddress().equals(target)) {
return; // already registered at this address
}
proxy.unregisterServer(existing.get().getServerInfo()); // address changed → re-register
}
proxy.registerServer(new ServerInfo(v.name(), target));
log.info("Felis: registered backend {} -> {}", v.name(), addr);
}
private void deregister(String name) {
byName.remove(name);
proxy.getServer(name).ifPresent(rs -> {
proxy.unregisterServer(rs.getServerInfo());
log.info("Felis: deregistered backend {}", name);
});
}
/** resolveByHost maps {@code subdomain.<root_domain>} to its current view. */
Optional<ServerView> resolveByHost(String host) {
if (host == null) {
return Optional.empty();
}
String h = host.toLowerCase(Locale.ROOT);
String suffix = "." + rootDomain;
if (!h.endsWith(suffix)) {
return Optional.empty();
}
String sub = h.substring(0, h.length() - suffix.length());
String name = subdomainToName.get(sub);
return name == null ? Optional.empty() : Optional.ofNullable(byName.get(name));
}
ServerView view(String name) {
return name == null ? null : byName.get(name);
}
boolean isManaged(String name) {
return name != null && byName.containsKey(name);
}
Optional<RegisteredServer> registered(String name) {
return proxy.getServer(name);
}
Collection<ServerView> all() {
return new ArrayList<>(byName.values());
}
/** parseAddress splits {@code host[:port]} into an unresolved socket address. */
static InetSocketAddress parseAddress(String addr) {
int idx = addr.lastIndexOf(':');
if (idx > 0 && idx < addr.length() - 1) {
try {
int port = Integer.parseInt(addr.substring(idx + 1));
// Unresolved: the backend's DNS (a K8s Service) may not resolve yet
// while the server is asleep; Velocity resolves at connect time.
return InetSocketAddress.createUnresolved(addr.substring(0, idx), port);
} catch (NumberFormatException ignored) {
// not host:port → fall through to the default Minecraft port
}
}
return InetSocketAddress.createUnresolved(addr, DEFAULT_PORT);
}
}
@@ -0,0 +1,281 @@
package best.lolicon.felis.velocity;
import best.lolicon.felis.link.FelisApiClient;
import best.lolicon.felis.link.LinkException;
import best.lolicon.felis.link.ServerView;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent;
import com.velocitypowered.api.event.player.ServerConnectedEvent;
import com.velocitypowered.api.proxy.Player;
import com.velocitypowered.api.proxy.ProxyServer;
import com.velocitypowered.api.proxy.server.RegisteredServer;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.slf4j.Logger;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* WaitingRouter implements the §11 domain-autostart routing loop and its waiting
* queue. It resolves the virtual host a player connected with to a felis server
* and decides what happens next:
*
* <pre>
* host has no felis subdomain → leave Velocity's default routing alone
* server ready + registered → set it as the initial server (straight in)
* server not ready, lobby configured → park in lobby, wake it, enqueue a transfer
* server not ready, no lobby → refuse cleanly ("reconnect shortly"), wake
* </pre>
*
* <p>The queue is drained by {@link #tick()}, scheduled by the plugin on the async
* pool. Each tick polls felis-api once per distinct waited-on server and, when one
* reports ready, transfers everyone waiting on it. A waiter drops out when it times
* out, when the player leaves the proxy, or on a successful transfer.
*
* <p>The wake is gated server-side by autostartPolicy keyed on the player's
* online-mode UUID: a 403 means this player may not start the server (we tell them
* and stop), a 429 means a wake is already in flight (we keep waiting). Real joins
* to a felis backend are reported back so the reaper sees activity and the player
* is auto-added to the allowlist.
*/
public final class WaitingRouter {
private static final long WAIT_TIMEOUT_MILLIS = 120_000L;
private final ProxyServer proxy;
private final Logger log;
private final FelisApiClient api;
private final ServerRegistry registry;
private final FelisVelocityPlugin plugin;
private final String lobbyServer; // may be null → no lobby
private final Map<UUID, Waiter> waiting = new ConcurrentHashMap<>();
// Notified just before a menu-originated waiter is transferred, so the lobby's
// felis:control face can tell the player's GUI the backend is ready. Null until
// the ControlChannel is wired in at proxy init; set once, read on the tick pool.
private volatile MenuTransferListener menuListener;
WaitingRouter(ProxyServer proxy, Logger log, FelisApiClient api, ServerRegistry registry,
FelisVelocityPlugin plugin, String lobbyServer) {
this.proxy = proxy;
this.log = log;
this.api = api;
this.registry = registry;
this.plugin = plugin;
this.lobbyServer = lobbyServer;
}
/**
* setMenuTransferListener wires the felis:control face so a menu-driven wait can
* notify the lobby when its backend is ready. Called once at proxy init.
*/
void setMenuTransferListener(MenuTransferListener listener) {
this.menuListener = listener;
}
/**
* enqueueFromMenu parks a player who is already on the proxy (sitting in the
* lobby) on a server they asked for through the felis-paper {@code /menu}, then
* wakes it and lets {@link #tick()} transfer them when ready — the same shared
* waiting queue used by host-based autostart routing (spec §12, §27 scenario 10).
* It differs from initial-server routing only in origin: the player drove it from
* a GUI button rather than a connecting virtual host, so the waiter is flagged to
* fire {@link MenuTransferListener} on transfer.
*/
void enqueueFromMenu(Player player, String serverName) {
wakeAndWait(player, serverName, true);
}
@Subscribe
public void onChooseInitialServer(PlayerChooseInitialServerEvent event) {
Player player = event.getPlayer();
Optional<String> host = virtualHost(player);
if (host.isEmpty()) {
return; // direct connect / no SRV host → leave default routing
}
Optional<ServerView> targetOpt = registry.resolveByHost(host.get());
if (targetOpt.isEmpty()) {
return; // host is not a felis subdomain → leave default routing
}
ServerView target = targetOpt.get();
Optional<RegisteredServer> backend = registry.registered(target.name());
if (target.ready() && backend.isPresent()) {
event.setInitialServer(backend.get()); // ready → straight in
return;
}
Optional<RegisteredServer> lobby = lobby();
if (lobby.isEmpty()) {
// Nowhere to hold the player while the backend wakes: refuse cleanly so
// they reconnect onto a ready server, rather than dropping them onto a
// backend that is still starting. Still fire the wake so the reconnect
// lands faster.
player.disconnect(Component.text(
"« " + target.name() + " » is starting up — please reconnect in a moment.",
NamedTextColor.YELLOW));
fireWake(player.getUniqueId(), target.name());
return;
}
event.setInitialServer(lobby.get()); // park in lobby
wakeAndWait(player, target.name(), false);
}
@Subscribe
public void onServerConnected(ServerConnectedEvent event) {
String name = event.getServer().getServerInfo().getName();
if (!registry.isManaged(name)) {
return; // lobby / static server → not a felis backend, nothing to report
}
UUID id = event.getPlayer().getUniqueId();
plugin.async(() -> {
try {
api.reportJoin(name, id);
} catch (LinkException e) {
log.debug("Felis: join-event {} failed (status={}): {}", name, e.statusCode(), e.getMessage());
}
});
}
/** tick drains the waiting queue; the plugin schedules it on the async pool. */
void tick() {
if (waiting.isEmpty()) {
return;
}
long now = System.currentTimeMillis();
Map<String, Boolean> readyCache = new HashMap<>(); // one status poll per distinct server
for (Map.Entry<UUID, Waiter> e : new ArrayList<>(waiting.entrySet())) {
UUID id = e.getKey();
Waiter w = e.getValue();
Optional<Player> po = proxy.getPlayer(id);
if (po.isEmpty()) {
waiting.remove(id); // player left the proxy
continue;
}
Player player = po.get();
if (now > w.deadlineMillis) {
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is taking longer than expected to start. "
+ "You can keep waiting in the lobby or try again later.", NamedTextColor.YELLOW));
continue;
}
Boolean ready = readyCache.get(w.serverName);
if (ready == null) {
try {
ready = api.serverStatus(w.serverName).ready();
} catch (LinkException ex) {
ready = Boolean.FALSE; // transient → keep waiting until the deadline
}
readyCache.put(w.serverName, ready);
}
if (!ready) {
continue;
}
Optional<RegisteredServer> backend = registry.registered(w.serverName);
if (backend.isEmpty()) {
continue; // ready but not yet registered → next tick
}
waiting.remove(id);
player.sendMessage(Component.text(
"« " + w.serverName + " » is ready — moving you in…", NamedTextColor.GREEN));
// Tell a menu-driven lobby its tile is live before we pull the player off
// it; the proxy still performs the actual Connect just below.
MenuTransferListener listener = menuListener;
if (w.fromMenu && listener != null) {
listener.onReady(player, w.serverName);
}
transfer(player, w.serverName, backend.get());
}
}
private void wakeAndWait(Player player, String serverName, boolean fromMenu) {
UUID id = player.getUniqueId();
plugin.async(() -> {
try {
api.wake(serverName, id);
} catch (LinkException e) {
switch (e.statusCode()) {
case 403:
player.sendMessage(Component.text(
"You're not allowed to start « " + serverName + " ».", NamedTextColor.RED));
return; // policy gate refused → do not enqueue
case 429:
break; // a wake is already in flight → fall through to waiting
default:
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
player.sendMessage(Component.text(
"Couldn't start « " + serverName + " » right now. Try again shortly.",
NamedTextColor.RED));
return;
}
}
player.sendMessage(Component.text(
"Starting « " + serverName + " » — you'll be moved in automatically.",
NamedTextColor.GRAY));
waiting.put(id, new Waiter(serverName, System.currentTimeMillis() + WAIT_TIMEOUT_MILLIS, fromMenu));
});
}
private void fireWake(UUID id, String serverName) {
plugin.async(() -> {
try {
api.wake(serverName, id);
} catch (LinkException e) {
if (e.statusCode() != 429 && e.statusCode() != 403) {
log.warn("Felis: wake {} failed (status={}): {}", serverName, e.statusCode(), e.getMessage());
}
}
});
}
private void transfer(Player player, String serverName, RegisteredServer backend) {
player.createConnectionRequest(backend).connect().whenComplete((result, err) -> {
if (err != null || (result != null && !result.isSuccessful())) {
player.sendMessage(Component.text(
"Couldn't connect you to « " + serverName + " ». Please try again.",
NamedTextColor.RED));
}
});
}
private Optional<RegisteredServer> lobby() {
return lobbyServer == null ? Optional.empty() : proxy.getServer(lobbyServer);
}
private static Optional<String> virtualHost(Player player) {
return player.getVirtualHost()
.map(InetSocketAddress::getHostString)
.map(s -> s.toLowerCase(Locale.ROOT));
}
private static final class Waiter {
final String serverName;
final long deadlineMillis;
final boolean fromMenu; // true → notify the felis:control face on transfer
Waiter(String serverName, long deadlineMillis, boolean fromMenu) {
this.serverName = serverName;
this.deadlineMillis = deadlineMillis;
this.fromMenu = fromMenu;
}
}
/**
* MenuTransferListener bridges the shared waiting queue to the felis:control face
* without {@link WaitingRouter} depending on the wire codec: it is told a
* menu-originated player's backend is ready, and the implementation owns encoding
* and sending the {@code TransferReady} frame. The proxy still performs the
* Connect itself ({@link #transfer}); this is only the lobby-UI notification.
*/
interface MenuTransferListener {
void onReady(Player player, String serverName);
}
}