feat(platform): add Discord onboarding and Velocity gate

This commit is contained in:
dmg
2026-08-01 13:45:18 -04:00
parent 9d305e5dc9
commit c5de0a1810
80 changed files with 4840 additions and 1572 deletions
@@ -0,0 +1,7 @@
package games.twentyfaces.accountmanager;
record AccessDecision(boolean allowed, String message) {
static AccessDecision denied(String message) {
return new AccessDecision(false, message);
}
}
@@ -0,0 +1,70 @@
package games.twentyfaces.accountmanager;
import com.google.gson.Gson;
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.time.Instant;
import java.util.UUID;
final class AccountManagerClient {
private final PluginConfig config;
private final HttpClient httpClient;
private final Gson gson = new Gson();
AccountManagerClient(PluginConfig config) {
this(config, HttpClient.newBuilder().connectTimeout(config.timeout()).build());
}
AccountManagerClient(PluginConfig config, HttpClient httpClient) {
this.config = config;
this.httpClient = httpClient;
}
AccessDecision check(UUID minecraftUuid, String username, String ipAddress) {
String compactUuid = minecraftUuid.toString().replace("-", "").toLowerCase();
AccessRequest payload = new AccessRequest(
UUID.randomUUID().toString(),
config.serverId(),
compactUuid,
username,
ipAddress,
Instant.now().toString()
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(config.apiUrl() + "/api/velocity/access"))
.timeout(config.timeout())
.header("Authorization", "Bearer " + config.apiToken())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(payload)))
.build();
try {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) return AccessDecision.denied(config.registrationMessage());
AccessDecision decision = gson.fromJson(response.body(), AccessDecision.class);
if (decision == null) return AccessDecision.denied(config.registrationMessage());
if (!decision.allowed() && (decision.message() == null || decision.message().isBlank())) {
return AccessDecision.denied(config.registrationMessage());
}
return decision;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
return AccessDecision.denied(config.registrationMessage());
} catch (IOException | RuntimeException exception) {
return AccessDecision.denied(config.registrationMessage());
}
}
private record AccessRequest(
String requestId,
String serverId,
String minecraftUuid,
String username,
String ipAddress,
String occurredAt
) {}
}
@@ -0,0 +1,69 @@
package games.twentyfaces.accountmanager;
import com.google.inject.Inject;
import com.velocitypowered.api.event.EventTask;
import com.velocitypowered.api.event.Subscribe;
import com.velocitypowered.api.event.ResultedEvent;
import com.velocitypowered.api.event.connection.LoginEvent;
import com.velocitypowered.api.event.proxy.ProxyInitializeEvent;
import com.velocitypowered.api.plugin.Plugin;
import com.velocitypowered.api.plugin.annotation.DataDirectory;
import java.io.IOException;
import java.nio.file.Path;
import net.kyori.adventure.text.Component;
import org.slf4j.Logger;
@Plugin(
id = "minecraft-account-manager",
name = "Minecraft Account Manager",
version = "0.1.0",
description = "Fail-closed admission checks for registered Java accounts"
)
public final class MinecraftAccountManagerPlugin {
private final Logger logger;
private final Path dataDirectory;
private volatile AccountManagerClient accountManagerClient;
private volatile String fallbackMessage = "Please register your Minecraft account in Discord before joining.";
@Inject
public MinecraftAccountManagerPlugin(Logger logger, @DataDirectory Path dataDirectory) {
this.logger = logger;
this.dataDirectory = dataDirectory;
}
@Subscribe
public void onProxyInitialization(ProxyInitializeEvent event) {
try {
PluginConfig config = PluginConfig.load(dataDirectory);
fallbackMessage = config.registrationMessage();
accountManagerClient = new AccountManagerClient(config);
logger.info("Minecraft account admission checks configured for server {}", config.serverId());
} catch (IOException | RuntimeException exception) {
accountManagerClient = null;
logger.error("Account manager configuration failed; all joins will be denied", exception);
}
}
@Subscribe
public EventTask onLogin(LoginEvent event) {
return EventTask.async(() -> {
AccountManagerClient client = accountManagerClient;
if (client == null) {
event.setResult(ResultedEvent.ComponentResult.denied(Component.text(fallbackMessage)));
return;
}
String ipAddress = event.getPlayer().getRemoteAddress().getAddress().getHostAddress();
AccessDecision decision = client.check(
event.getPlayer().getUniqueId(),
event.getPlayer().getUsername(),
ipAddress
);
if (!decision.allowed()) {
event.setResult(ResultedEvent.ComponentResult.denied(Component.text(decision.message())));
logger.info("Denied Minecraft login for {} ({})", event.getPlayer().getUsername(), event.getPlayer().getUniqueId());
}
});
}
}
@@ -0,0 +1,42 @@
package games.twentyfaces.accountmanager;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Properties;
record PluginConfig(String apiUrl, String serverId, String apiToken, Duration timeout, String registrationMessage) {
static PluginConfig load(Path dataDirectory) throws IOException {
Files.createDirectories(dataDirectory);
Path configPath = dataDirectory.resolve("config.properties");
if (Files.notExists(configPath)) {
try (InputStream defaults = PluginConfig.class.getResourceAsStream("/config.properties")) {
if (defaults == null) throw new IOException("Bundled config.properties is missing");
Files.copy(defaults, configPath);
}
}
Properties properties = new Properties();
try (InputStream input = Files.newInputStream(configPath)) {
properties.load(input);
}
String apiUrl = required(properties, "api-url").replaceAll("/+$", "");
String serverId = required(properties, "server-id");
String apiToken = required(properties, "api-token");
long timeoutMillis = Long.parseLong(properties.getProperty("request-timeout-ms", "3000"));
String message = properties.getProperty(
"registration-message",
"Please register your Minecraft account in Discord before joining."
).trim();
return new PluginConfig(apiUrl, serverId, apiToken, Duration.ofMillis(timeoutMillis), message);
}
private static String required(Properties properties, String name) {
String value = properties.getProperty(name, "").trim();
if (value.isEmpty()) throw new IllegalArgumentException(name + " must be configured");
return value;
}
}
@@ -0,0 +1,6 @@
# Base URL of the Next.js service, without a trailing slash
api-url=http://localhost:3000
server-id=velocity-main
api-token=replace-with-generated-token
request-timeout-ms=3000
registration-message=Please register your Minecraft account in Discord before joining.