feat(platform): add Discord onboarding and Velocity gate
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package games.twentyfaces.accountmanager;
|
||||
|
||||
record AccessDecision(boolean allowed, String message) {
|
||||
static AccessDecision denied(String message) {
|
||||
return new AccessDecision(false, message);
|
||||
}
|
||||
}
|
||||
+70
@@ -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
|
||||
) {}
|
||||
}
|
||||
+69
@@ -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.
|
||||
Reference in New Issue
Block a user