mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
feat: add Bukkit backend agent and shared CI publishing
This commit is contained in:
1 parent
cfa3c8bc51
commit
71b15cd180
45 files changed
+798
-173
No files matched your search
+9
-25
@@ -1,4 +1,4 @@
|
||||
name: ci
|
||||
name: Build and publish
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -8,27 +8,11 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Java 25 verification
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 25
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: zulu
|
||||
java-version: "25"
|
||||
cache: gradle
|
||||
|
||||
- name: Make Gradle wrapper executable
|
||||
run: chmod +x ./gradlew
|
||||
|
||||
- name: Verify build, tests, release gates, and installed distributions
|
||||
run: ./gradlew --no-daemon --configuration-cache check installDist :proxy-admin-cli:installDist :proxy-query:installDist
|
||||
|
||||
- name: Build release bundle
|
||||
run: ./gradlew --no-daemon --configuration-cache release
|
||||
build:
|
||||
uses: https://git.nest.potatolab.uk:8443/TDLM/ci-templates/.gitea/workflows/build-publish.yml@main
|
||||
with:
|
||||
java-version: '25'
|
||||
gradle-tasks: 'check release'
|
||||
secrets:
|
||||
MAVEN_USER: ${{ secrets.MAVEN_USER }}
|
||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
@@ -1,14 +1,14 @@
|
||||
# StrataProxy
|
||||
|
||||
StrataProxy 是 Java 25 Minecraft 后端代理。它只做连接接入、静态后端路由、健康检查、认证/转发协议、Minecraft zlib 压缩和必要的 Forge/Bungee 兼容。
|
||||
StrataProxy 是 Java 25 Minecraft 后端代理。它负责连接接入、后端路由、健康检查、认证/转发协议、Minecraft zlib 压缩和必要的 Forge/Bungee 兼容;运行时后端可由代理插件或受限的 Bukkit 后端适配器注册。
|
||||
|
||||
没有 Admin HTTP API、管理 CLI、数据包分析/抓取、Prometheus/Grafana 资源或动态运维控制面。
|
||||
没有 Admin HTTP API、管理 CLI、数据包分析/抓取或 Prometheus/Grafana 资源。Bukkit 适配器仅拥有后端注册、心跳与下线权限,不是动态运维控制面。
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat --no-daemon check :proxy-app:installDist
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --validate-config .\proxy-app\build\install\strataproxy\config\strataproxy.yml
|
||||
.\gradlew.bat --no-daemon check :proxy-core:installDist
|
||||
.\proxy-core\build\install\strataproxy\bin\strataproxy.bat --validate-config .\proxy-core\build\install\strataproxy\config\strataproxy.yml
|
||||
```
|
||||
|
||||
第三方稳定边界只有 `proxy-plugin-api`。运行时由 `proxy-core`、`proxy-plugin-api`、`proxy-plugin` 与 `proxy-app` 组成。
|
||||
工程只保留两个模块:`proxy-core` 是完整的代理运行时(启动、配置、插件加载与网络转发),`proxy-plugin-api` 是唯一稳定的第三方插件契约。
|
||||
|
||||
中文文档从 [docs/README.md](docs/README.md) 开始。
|
||||
+32
-2
@@ -9,6 +9,7 @@ import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.InputFiles
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.Sync
|
||||
import org.gradle.api.tasks.Exec
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.api.tasks.bundling.Zip
|
||||
import java.security.MessageDigest
|
||||
@@ -30,6 +31,35 @@ tasks.register("check") {
|
||||
description = "Runs root-level StrataProxy verification tasks."
|
||||
}
|
||||
|
||||
val bukkitAgentDirectory = layout.projectDirectory.dir("integrations/bukkit-backend-agent").asFile
|
||||
val rootGradleWrapper = layout.projectDirectory.file(
|
||||
if (System.getProperty("os.name").lowercase().contains("windows")) "gradlew.bat" else "gradlew"
|
||||
).asFile
|
||||
|
||||
val buildBukkitBackendAgent = tasks.register<Exec>("buildBukkitBackendAgent") {
|
||||
group = "verification"
|
||||
description = "Builds the standalone Java 8-compatible Bukkit backend agent."
|
||||
workingDir = projectDir
|
||||
commandLine(rootGradleWrapper.absolutePath, "-p", bukkitAgentDirectory.absolutePath, "clean", "jar", "--no-daemon")
|
||||
}
|
||||
|
||||
val publishBukkitBackendAgent = tasks.register<Exec>("publishBukkitBackendAgent") {
|
||||
group = "publishing"
|
||||
description = "Publishes the standalone Bukkit backend agent through its native Gradle publication."
|
||||
workingDir = projectDir
|
||||
commandLine(rootGradleWrapper.absolutePath, "-p", bukkitAgentDirectory.absolutePath, "publish", "--no-daemon")
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(buildBukkitBackendAgent)
|
||||
}
|
||||
|
||||
tasks.register("publish") {
|
||||
group = "publishing"
|
||||
description = "Publishes StrataProxy plugin contracts and official Bukkit integration."
|
||||
dependsOn(":proxy-plugin-api:publish", publishBukkitBackendAgent)
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated {
|
||||
val releaseName = "strataproxy-${project.version}"
|
||||
val releaseStaging = layout.buildDirectory.dir("release/staging/$releaseName")
|
||||
@@ -37,7 +67,7 @@ gradle.projectsEvaluated {
|
||||
val releaseChecksum = layout.buildDirectory.file("release/$releaseName.zip.sha256")
|
||||
val releaseSbom = layout.buildDirectory.file("release/$releaseName.sbom.cdx.json")
|
||||
val releaseMetadata = layout.buildDirectory.file("release/$releaseName.metadata.json")
|
||||
val app = project(":proxy-app")
|
||||
val app = project(":proxy-core")
|
||||
val pluginApi = project(":proxy-plugin-api")
|
||||
val appDistZip = app.layout.buildDirectory.file("distributions/strataproxy-${project.version}.zip")
|
||||
val pluginApiJar = pluginApi.layout.buildDirectory.file("libs/proxy-plugin-api-${project.version}.jar")
|
||||
@@ -107,7 +137,7 @@ gradle.projectsEvaluated {
|
||||
from("examples") {
|
||||
into("examples")
|
||||
}
|
||||
from("proxy-app/src/main/resources/config") {
|
||||
from("proxy-core/src/main/resources/config") {
|
||||
into("config")
|
||||
}
|
||||
from(layout.buildDirectory.file("release/RELEASE-MANIFEST.txt"))
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# 部署
|
||||
|
||||
将 `proxy-app` 的 `installDist` 产物和 YAML 配置部署到目标主机即可。示例 systemd 与容器文件只暴露 Minecraft 监听端口 `25577`,注册表持久化路径通过 `STRATAPROXY_DATA_DIR` 控制。
|
||||
将 `proxy-core` 的 `installDist` 产物和 YAML 配置部署到目标主机即可。示例 systemd 与容器文件只暴露 Minecraft 监听端口 `25577`,注册表持久化路径通过 `STRATAPROXY_DATA_DIR` 控制。
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon :proxy-app:installDist
|
||||
./gradlew --no-daemon :proxy-core:installDist
|
||||
```
|
||||
|
||||
启动前执行 `strataproxy --validate-config /path/to/strataproxy.yml`。生产环境不包含管理端口、指标端点或数据包抓取功能。
|
||||
@@ -4,12 +4,12 @@ ARG RUNTIME_IMAGE=eclipse-temurin:25-jre
|
||||
FROM ${JAVA_IMAGE} AS build
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN ./gradlew --no-daemon :proxy-app:installDist
|
||||
RUN ./gradlew --no-daemon :proxy-core:installDist
|
||||
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
RUN useradd --system --home-dir /opt/strataproxy --shell /usr/sbin/nologin strataproxy
|
||||
WORKDIR /opt/strataproxy
|
||||
COPY --from=build /workspace/proxy-app/build/install/strataproxy/ /opt/strataproxy/
|
||||
COPY --from=build /workspace/proxy-core/build/install/strataproxy/ /opt/strataproxy/
|
||||
RUN mkdir -p /opt/strataproxy/data \
|
||||
&& cp /opt/strataproxy/config/strataproxy-production.yml /opt/strataproxy/config/strataproxy.yml \
|
||||
&& chown -R strataproxy:strataproxy /opt/strataproxy
|
||||
|
||||
@@ -6,13 +6,13 @@ $ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$Repo = Split-Path -Parent (Split-Path -Parent $Root)
|
||||
$Config = Join-Path $Root "strataproxy-1.7.10-local.yml"
|
||||
$Proxy = Join-Path $Repo "proxy-app\build\install\strataproxy\bin\strataproxy.bat"
|
||||
$Proxy = Join-Path $Repo "proxy-core\build\install\strataproxy\bin\strataproxy.bat"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $Proxy)) {
|
||||
Push-Location $Repo
|
||||
try {
|
||||
$env:JAVA_HOME = $JavaHome
|
||||
& .\gradlew.bat --no-daemon :proxy-app:installDist
|
||||
& .\gradlew.bat --no-daemon :proxy-core:installDist
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
| YAML 参数 | [配置说明](configuration.md) |
|
||||
| 部署与检查 | [运维说明](operations-manual.md) |
|
||||
| 插件与游戏内命令 | [插件开发](plugins.md) |
|
||||
| Bukkit 后端自动注册 | [Bukkit 适配器](../integrations/bukkit-backend-agent/README.md) |
|
||||
| 模块边界 | [架构](architecture.md) |
|
||||
| 协议支持范围 | [协议矩阵](protocol-version-matrix.md) |
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
```text
|
||||
第三方插件 ──> proxy-plugin-api
|
||||
│
|
||||
proxy-app ──> proxy-plugin ──> proxy-core
|
||||
proxy-core
|
||||
```
|
||||
|
||||
| 工程 | 职责 |
|
||||
| --- | --- |
|
||||
| `proxy-core` | Minecraft 协议、Netty 转发、路由、注册表、健康检查与兼容处理 |
|
||||
| `proxy-core` | 完整代理运行时:启动、YAML 配置、插件加载、Minecraft 协议、Netty 转发、路由、注册表、健康检查与兼容处理 |
|
||||
| `proxy-plugin-api` | 唯一稳定的第三方插件契约 |
|
||||
| `proxy-plugin` | 插件装载、生命周期与服务实现 |
|
||||
| `proxy-app` | YAML 配置、进程启动、静态后端与本地注册表持久化 |
|
||||
|
||||
模块边界按独立部署与稳定契约划分,不按每一种内部实现策略拆分。没有 `proxy-admin-cli`;运行中后端不通过 HTTP 修改,配置变更后重启代理生效。
|
||||
模块边界只按稳定契约划分,不按内部实现策略拆分。没有 `proxy-admin-cli` 或 HTTP 管理面;运行时后端只由已加载插件通过公开 API 管理。
|
||||
|
||||
`integrations/bukkit-backend-agent/` 是独立的 Java 8 Bukkit 适配器工程,不属于代理运行时模块。它只能通过受限的 `backendAgent` 端点注册、续租和下线自己的后端,不能作为管理 API 使用。
|
||||
+15
-1
@@ -33,4 +33,18 @@ servers:
|
||||
|
||||
`network` 控制监听、帧上限和连接限制;`registry` 控制静态后端、健康检查和本地持久化;`compression` 控制 Minecraft 压缩协商与重写;`observability.flushIntervalSeconds` 仅控制后端已观测负载写回间隔。
|
||||
|
||||
`status` 控制服务器列表响应,`auth` 控制 online-mode 登录校验,`forwarding` 可选 `none`、`velocity-modern`、`bungee-legacy` 或 `bungee-guard`,`native` 控制 Netty 原生传输偏好。完整默认值见 [strataproxy.yml](../proxy-app/src/main/resources/config/strataproxy.yml)。
|
||||
`status` 控制服务器列表响应,`auth` 控制 online-mode 登录校验,`forwarding` 可选 `none`、`velocity-modern`、`bungee-legacy` 或 `bungee-guard`,`native` 控制 Netty 原生传输偏好。完整默认值见 [strataproxy.yml](../proxy-core/src/main/resources/config/strataproxy.yml)。
|
||||
|
||||
## Bukkit 后端自动注册
|
||||
|
||||
`backendAgent` 默认关闭,且不是 Admin API。启用后只允许持有共享密钥的后端适配器注册、发送心跳或下线自己的服务:
|
||||
|
||||
```yaml
|
||||
backendAgent:
|
||||
enabled: true
|
||||
bind: "127.0.0.1:25578"
|
||||
sharedSecret: "至少32字符的随机密钥"
|
||||
heartbeatTimeout: "30s"
|
||||
```
|
||||
|
||||
若 Bukkit 服务在另一台受保护的主机,可把 `bind` 改为内网地址,并通过防火墙只允许该主机访问。适配器位于 [`integrations/bukkit-backend-agent`](../integrations/bukkit-backend-agent),其 `config.yml` 的密钥必须完全一致;心跳超时后代理会移除该适配器注册的后端。
|
||||
+1
-4
@@ -12,7 +12,6 @@
|
||||
|
||||
- `proxy-core/build/docs/javadoc`
|
||||
- `proxy-plugin-api/build/docs/javadoc`
|
||||
- `proxy-app/build/docs/javadoc`
|
||||
|
||||
大规模修改注释后,建议强制重跑,避免 Gradle up-to-date 状态隐藏文档问题:
|
||||
|
||||
@@ -24,10 +23,8 @@
|
||||
|
||||
本项目的 JavaDoc 重点覆盖公开契约:
|
||||
|
||||
- `proxy-core`:后端服务器描述、路由、协议元数据、Minecraft codec、Netty 生命周期、指标、诊断和运行时策略。这些包是代理内部实现,不单独作为 API 发布。
|
||||
- `proxy-core`:代理启动、YAML 配置、插件加载、后端服务器描述、路由、Minecraft codec、Netty 生命周期和运行时策略。这些包是代理内部实现,不单独作为 API 发布。
|
||||
- `proxy-plugin-api`:插件生命周期、命令、事件、调度器、玩家服务和服务器服务契约。
|
||||
- `proxy-plugin`:运行时插件加载和生命周期管理。
|
||||
- `proxy-app`:YAML 加载、配置校验、注册表持久化、管理 HTTP 视图、指标和诊断 DTO。
|
||||
|
||||
包私有 relay handler 和底层实现辅助类只在行为不直观、或影响公开契约时补充注释。
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
StrataProxy 会从当前配置文件旁边的 `plugins/` 目录加载代理插件。插件 jar 是可选的;目录不存在时代理照常启动。
|
||||
|
||||
公开插件接口是 `dev.strataproxy:proxy-plugin-api` artifact。插件项目只应该依赖这个 API;`proxy-core`、`proxy-app` 和其他运行时模块都是内部实现细节。
|
||||
公开插件接口是 `dev.strataproxy:proxy-plugin-api` artifact。插件项目只应该依赖这个 API;`proxy-core` 是内部实现细节。
|
||||
|
||||
在本仓库本地开发时,先发布 API 到本机 Maven:
|
||||
|
||||
|
||||
+6
-6
@@ -5,19 +5,19 @@ StrataProxy 是面向 Minecraft 的静态后端代理:监听客户端连接、
|
||||
## 构建与校验
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat --no-daemon check :proxy-app:installDist
|
||||
.\gradlew.bat --no-daemon check :proxy-core:installDist
|
||||
```
|
||||
|
||||
代理程序在 `proxy-app\build\install\strataproxy\`。
|
||||
代理程序在 `proxy-core\build\install\strataproxy\`。
|
||||
|
||||
## 配置并启动
|
||||
|
||||
```powershell
|
||||
Copy-Item proxy-app\src\main\resources\config\strataproxy.yml .\strataproxy.yml
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --config .\strataproxy.yml --validate-config
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --config .\strataproxy.yml
|
||||
Copy-Item proxy-core\src\main\resources\config\strataproxy.yml .\strataproxy.yml
|
||||
.\proxy-core\build\install\strataproxy\bin\strataproxy.bat --config .\strataproxy.yml --validate-config
|
||||
.\proxy-core\build\install\strataproxy\bin\strataproxy.bat --config .\strataproxy.yml
|
||||
```
|
||||
|
||||
至少配置一个 `servers` 后端。修改 YAML 后重启代理生效;没有 HTTP 管理端口、管理令牌或动态后端 API。
|
||||
至少配置一个 `servers` 后端。修改 YAML 后重启代理生效;没有 HTTP 管理端口或管理令牌。动态后端仅由已加载插件通过 API 注册、下线或切换 drain 状态。
|
||||
|
||||
详见[配置说明](configuration.md)和[运维说明](operations-manual.md)。
|
||||
@@ -0,0 +1,9 @@
|
||||
# StrataProxy Bukkit 后端适配器
|
||||
|
||||
这是独立的 Java 8 Bukkit 插件,不属于 StrataProxy 的 Gradle 多模块工程。它以 Bukkit/Spigot 1.8 API 编译,但只调用 Bukkit 1.7.10 已有的基础 API,因此可运行在 1.7.10 Bukkit 派生端。它在启动时向代理的 `backendAgent` 端点注册本服务器,随后发送心跳;关闭时可主动下线。
|
||||
|
||||
先在代理配置启用 `backendAgent`,并设置至少 32 字符的随机 `sharedSecret`。然后将同一密钥写入本插件 `config.yml`,按实际服务器修改 `backend.name`、`backend.address`、标签、容量和持久化选项。
|
||||
|
||||
构建:`gradle jar`。将生成的 `build/libs/strataproxy-bukkit-backend-agent-*.jar` 放入 Bukkit 的 `plugins/` 目录后重启。
|
||||
|
||||
该端点只接受注册、心跳和下线,不能读取玩家、执行命令或修改其他代理设置。
|
||||
@@ -0,0 +1,51 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
group = 'dev.strataproxy'
|
||||
version = '0.1.0-SNAPSHOT'
|
||||
|
||||
repositories {
|
||||
maven { url = 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The implementation deliberately uses only API methods already present in Bukkit 1.7.10.
|
||||
compileOnly('org.spigotmc:spigot-api:1.8-R0.1-SNAPSHOT') {
|
||||
// This historical snapshot declares a now-unavailable chat artifact.
|
||||
// The backend agent does not use Bukkit chat APIs.
|
||||
exclude group: 'net.md-5', module: 'bungeecord-chat'
|
||||
}
|
||||
}
|
||||
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
options.encoding = 'UTF-8'
|
||||
}
|
||||
|
||||
jar {
|
||||
archiveBaseName = 'strataproxy-bukkit-backend-agent'
|
||||
}
|
||||
|
||||
publishing {
|
||||
publications {
|
||||
bukkitAgent(MavenPublication) {
|
||||
from components.java
|
||||
artifactId = 'strataproxy-bukkit-backend-agent'
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
name = 'giteaPackages'
|
||||
url = uri(System.getenv('MAVEN_URL') ?: 'https://git.nest.potatolab.uk:8443/api/packages/layue13/maven')
|
||||
credentials {
|
||||
username = System.getenv('MAVEN_USER') ?: 'NONE'
|
||||
password = System.getenv('MAVEN_PASSWORD') ?: 'NONE'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
rootProject.name = 'strataproxy-bukkit-backend-agent'
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package dev.strataproxy.bukkit;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Bukkit-side adapter for the restricted StrataProxy backend-agent protocol. */
|
||||
public final class StrataProxyBackendAgentPlugin extends JavaPlugin {
|
||||
private BackendAgentClient client;
|
||||
private int heartbeatTask = -1;
|
||||
private boolean unregisterOnDisable;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
try {
|
||||
client = BackendAgentClient.from(getConfig());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
getLogger().severe("Invalid StrataProxy backend-agent configuration: " + exception.getMessage());
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
return;
|
||||
}
|
||||
unregisterOnDisable = getConfig().getBoolean("unregisterOnDisable", true);
|
||||
runAsync("register");
|
||||
long heartbeatTicks = Math.max(20L, getConfig().getLong("heartbeatSeconds", 10L) * 20L);
|
||||
heartbeatTask = getServer().getScheduler().scheduleAsyncRepeatingTask(this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
runAsync("heartbeat");
|
||||
}
|
||||
}, heartbeatTicks, heartbeatTicks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (heartbeatTask >= 0) {
|
||||
getServer().getScheduler().cancelTask(heartbeatTask);
|
||||
}
|
||||
if (client != null && unregisterOnDisable) {
|
||||
runAsync("unregister");
|
||||
}
|
||||
}
|
||||
|
||||
private void runAsync(final String operation) {
|
||||
getServer().getScheduler().runTaskAsynchronously(this, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
String response = client.call(operation);
|
||||
if (!response.contains("\"success\":true")) {
|
||||
getLogger().warning("StrataProxy backend-agent " + operation + " failed: " + response);
|
||||
}
|
||||
} catch (Exception exception) {
|
||||
getLogger().warning("StrataProxy backend-agent " + operation + " failed: " + exception.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static final class BackendAgentClient {
|
||||
private final String proxyHost;
|
||||
private final int proxyPort;
|
||||
private final String secret;
|
||||
private final String backendJson;
|
||||
private final String backendName;
|
||||
|
||||
private BackendAgentClient(String proxyHost, int proxyPort, String secret, String backendJson, String backendName) {
|
||||
this.proxyHost = proxyHost;
|
||||
this.proxyPort = proxyPort;
|
||||
this.secret = secret;
|
||||
this.backendJson = backendJson;
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
static BackendAgentClient from(org.bukkit.configuration.file.FileConfiguration config) {
|
||||
String proxyHost = required(config.getString("proxy.host"), "proxy.host");
|
||||
int proxyPort = config.getInt("proxy.port", 25578);
|
||||
String secret = required(config.getString("proxy.sharedSecret"), "proxy.sharedSecret");
|
||||
if (secret.length() < 32) throw new IllegalArgumentException("proxy.sharedSecret must be at least 32 characters");
|
||||
String name = required(config.getString("backend.name"), "backend.name");
|
||||
String[] address = required(config.getString("backend.address"), "backend.address").split(":", 2);
|
||||
if (address.length != 2) throw new IllegalArgumentException("backend.address must be host:port");
|
||||
int backendPort = Integer.parseInt(address[1]);
|
||||
if (proxyPort < 1 || proxyPort > 65535 || backendPort < 1 || backendPort > 65535) throw new IllegalArgumentException("ports must be between 1 and 65535");
|
||||
ConfigurationSection metadata = config.getConfigurationSection("backend.metadata");
|
||||
StringBuilder json = new StringBuilder();
|
||||
json.append('{').append(field("name", name)).append(',').append(field("host", address[0])).append(',')
|
||||
.append("\"port\":").append(backendPort).append(',')
|
||||
.append(array("tags", config.getStringList("backend.tags"))).append(',')
|
||||
.append(array("capabilities", config.getStringList("backend.capabilities"))).append(',')
|
||||
.append(field("protocolName", config.getString("backend.protocolName", "any"))).append(',')
|
||||
.append("\"minProtocol\":").append(config.getInt("backend.minProtocol", 0)).append(',')
|
||||
.append("\"maxProtocol\":").append(config.getInt("backend.maxProtocol", Integer.MAX_VALUE)).append(',')
|
||||
.append("\"weight\":").append(config.getInt("backend.weight", 100)).append(',')
|
||||
.append("\"softCapacity\":").append(config.getInt("backend.softCapacity", 0)).append(',')
|
||||
.append("\"hardCapacity\":").append(config.getInt("backend.hardCapacity", 0)).append(',')
|
||||
.append("\"drainMode\":").append(config.getBoolean("backend.drainMode", false)).append(',')
|
||||
.append("\"persistent\":").append(config.getBoolean("backend.persistent", false)).append(',')
|
||||
.append(map("metadata", metadata));
|
||||
json.append('}');
|
||||
return new BackendAgentClient(proxyHost, proxyPort, secret, json.toString(), name);
|
||||
}
|
||||
|
||||
String call(String operation) throws Exception {
|
||||
String payload = operation.equals("register") ? backendJsonWithOperation(operation) : "{\"operation\":\"" + operation + "\",\"name\":\"" + escape(backendName) + "\"}";
|
||||
String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString(payload.getBytes(StandardCharsets.UTF_8));
|
||||
long timestamp = System.currentTimeMillis();
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||
String signature = hex(hmac(timestamp + "\n" + nonce + "\n" + encoded));
|
||||
String envelope = "{\"timestamp\":" + timestamp + ",\"nonce\":\"" + nonce + "\",\"payload\":\"" + encoded + "\",\"signature\":\"" + signature + "\"}";
|
||||
Socket socket = new Socket(proxyHost, proxyPort);
|
||||
try {
|
||||
socket.setSoTimeout(5000);
|
||||
BufferedWriter out = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
|
||||
out.write(envelope); out.newLine(); out.flush();
|
||||
String response = in.readLine();
|
||||
if (response == null) throw new IllegalStateException("proxy closed the registration connection");
|
||||
return response;
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
private String backendJsonWithOperation(String operation) {
|
||||
return "{\"operation\":\"" + operation + "\"," + backendJson.substring(1);
|
||||
}
|
||||
|
||||
private byte[] hmac(String input) throws Exception {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return mac.doFinal(input.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static String required(String value, String key) {
|
||||
if (value == null || value.trim().isEmpty()) throw new IllegalArgumentException(key + " must not be blank");
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private static String field(String key, String value) { return "\"" + key + "\":\"" + escape(value) + "\""; }
|
||||
private static String array(String key, List<String> values) {
|
||||
List<String> escaped = new ArrayList<String>();
|
||||
for (String value : values) escaped.add("\"" + escape(value) + "\"");
|
||||
return "\"" + key + "\":[" + join(escaped) + "]";
|
||||
}
|
||||
private static String map(String key, ConfigurationSection values) {
|
||||
List<String> entries = new ArrayList<String>();
|
||||
if (values != null) for (String name : values.getKeys(false)) entries.add(field(name, String.valueOf(values.get(name))));
|
||||
return "\"" + key + "\":{" + join(entries) + "}";
|
||||
}
|
||||
private static String join(List<String> values) { return values.isEmpty() ? "" : joinValues(values); }
|
||||
private static String joinValues(List<String> values) { StringBuilder out = new StringBuilder(); for (String value : values) { if (out.length() > 0) out.append(','); out.append(value); } return out.toString(); }
|
||||
private static String escape(String value) { return value.replace("\\", "\\\\").replace("\"", "\\\""); }
|
||||
private static String hex(byte[] bytes) { StringBuilder out = new StringBuilder(); for (byte value : bytes) out.append(String.format("%02x", value & 0xff)); return out.toString(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
proxy:
|
||||
host: "127.0.0.1"
|
||||
port: 25578
|
||||
# Must match proxy backendAgent.sharedSecret. Generate a random value with at least 32 characters.
|
||||
sharedSecret: "replace-with-a-random-secret"
|
||||
|
||||
backend:
|
||||
name: "survival-1"
|
||||
address: "127.0.0.1:25565"
|
||||
tags: ["survival"]
|
||||
capabilities: []
|
||||
protocolName: "any"
|
||||
minProtocol: 0
|
||||
maxProtocol: 2147483647
|
||||
weight: 100
|
||||
softCapacity: 0
|
||||
hardCapacity: 0
|
||||
drainMode: false
|
||||
persistent: false
|
||||
metadata:
|
||||
group: "survival"
|
||||
|
||||
# The proxy removes this backend after backendAgent.heartbeatTimeout if it stops receiving heartbeats.
|
||||
heartbeatSeconds: 10
|
||||
# When true, disable/restart unregisters immediately rather than waiting for lease expiry.
|
||||
unregisterOnDisable: true
|
||||
@@ -0,0 +1,5 @@
|
||||
name: StrataProxyBackendAgent
|
||||
main: dev.strataproxy.bukkit.StrataProxyBackendAgentPlugin
|
||||
version: 0.1.0-SNAPSHOT
|
||||
author: StrataProxy
|
||||
description: Registers this Bukkit backend with a StrataProxy backend-agent endpoint.
|
||||
@@ -1,100 +0,0 @@
|
||||
import org.gradle.api.tasks.Exec
|
||||
|
||||
plugins {
|
||||
id("strataproxy.application-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proxy-core"))
|
||||
implementation(project(":proxy-plugin"))
|
||||
implementation(libs.findLibrary("jackson-databind").get())
|
||||
implementation(libs.findLibrary("jackson-yaml").get())
|
||||
runtimeOnly(libs.findLibrary("slf4j-simple").get())
|
||||
}
|
||||
|
||||
val installedDistSmokeTest = tasks.register<Exec>("installedDistSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution in --validate-config mode."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
val config = installDir.get().file("config/strataproxy.yml").asFile
|
||||
commandLine(script.absolutePath, "--validate-config", config.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistProductionConfigSmokeTest = tasks.register<Exec>("installedDistProductionConfigSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Validates the installed production StrataProxy config."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
val config = installDir.get().file("config/strataproxy-production.yml").asFile
|
||||
commandLine(script.absolutePath, "--validate-config", config.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistHelpSmokeTest = tasks.register<Exec>("installedDistHelpSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution help command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
commandLine(script.absolutePath, "--help")
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistVersionSmokeTest = tasks.register<Exec>("installedDistVersionSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution version command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
commandLine(script.absolutePath, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(installedDistSmokeTest)
|
||||
dependsOn(installedDistProductionConfigSmokeTest)
|
||||
dependsOn(installedDistHelpSmokeTest)
|
||||
dependsOn(installedDistVersionSmokeTest)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("dev.strataproxy.app.StrataProxyLauncher")
|
||||
applicationName = "strataproxy"
|
||||
}
|
||||
|
||||
distributions {
|
||||
main {
|
||||
contents {
|
||||
from("src/main/resources/config") {
|
||||
into("config")
|
||||
}
|
||||
from("../deployment") {
|
||||
into("deployment")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import org.gradle.api.tasks.Exec
|
||||
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
id("strataproxy.application-conventions")
|
||||
}
|
||||
|
||||
val nettyVersion = libs.findVersion("netty").get().requiredVersion
|
||||
@@ -18,4 +20,94 @@ dependencies {
|
||||
runtimeOnly("io.netty:netty-transport-native-epoll:$nettyVersion:linux-aarch_64")
|
||||
runtimeOnly("io.netty:netty-transport-native-kqueue:$nettyVersion:osx-x86_64")
|
||||
runtimeOnly("io.netty:netty-transport-native-kqueue:$nettyVersion:osx-aarch_64")
|
||||
implementation(libs.findLibrary("jackson-databind").get())
|
||||
implementation(libs.findLibrary("jackson-yaml").get())
|
||||
runtimeOnly(libs.findLibrary("slf4j-simple").get())
|
||||
}
|
||||
|
||||
val installedDistSmokeTest = tasks.register<Exec>("installedDistSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution in --validate-config mode."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
val config = installDir.get().file("config/strataproxy.yml").asFile
|
||||
commandLine(script.absolutePath, "--validate-config", config.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistProductionConfigSmokeTest = tasks.register<Exec>("installedDistProductionConfigSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Validates the installed production StrataProxy config."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
val config = installDir.get().file("config/strataproxy-production.yml").asFile
|
||||
commandLine(script.absolutePath, "--validate-config", config.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistHelpSmokeTest = tasks.register<Exec>("installedDistHelpSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution help command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
commandLine(script.absolutePath, "--help")
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistVersionSmokeTest = tasks.register<Exec>("installedDistVersionSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution version command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
commandLine(script.absolutePath, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(installedDistSmokeTest)
|
||||
dependsOn(installedDistProductionConfigSmokeTest)
|
||||
dependsOn(installedDistHelpSmokeTest)
|
||||
dependsOn(installedDistVersionSmokeTest)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("dev.strataproxy.app.StrataProxyLauncher")
|
||||
applicationName = "strataproxy"
|
||||
}
|
||||
|
||||
distributions {
|
||||
main {
|
||||
contents {
|
||||
from("src/main/resources/config") {
|
||||
into("config")
|
||||
}
|
||||
from("../deployment") {
|
||||
into("deployment")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package dev.strataproxy.agent;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.strataproxy.bootstrap.ProxyConfig;
|
||||
import dev.strataproxy.plugin.service.ServerMutationResult;
|
||||
import dev.strataproxy.plugin.service.ServerPersistence;
|
||||
import dev.strataproxy.plugin.service.ServerProtocolRange;
|
||||
import dev.strataproxy.plugin.service.ServerRegistration;
|
||||
import dev.strataproxy.plugin.service.ServerRemoval;
|
||||
import dev.strataproxy.plugin.service.ServerService;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Narrow authenticated endpoint for backend agents. It deliberately exposes only
|
||||
* backend registration, lease renewal and removal; it is not an administration API.
|
||||
*/
|
||||
public final class BackendAgentServer implements AutoCloseable {
|
||||
private static final int MAX_LINE_LENGTH = 64 * 1024;
|
||||
private static final long MAX_CLOCK_SKEW_MILLIS = 60_000L;
|
||||
|
||||
private final ProxyConfig.BackendAgentConfig config;
|
||||
private final ServerService servers;
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private final Map<String, Long> nonces = new ConcurrentHashMap<>();
|
||||
private final Map<String, Lease> leases = new ConcurrentHashMap<>();
|
||||
private final ExecutorService connections = Executors.newCachedThreadPool(runnable -> new Thread(runnable, "strataproxy-backend-agent"));
|
||||
private final ScheduledExecutorService maintenance = Executors.newSingleThreadScheduledExecutor(runnable -> new Thread(runnable, "strataproxy-backend-agent-lease"));
|
||||
private volatile ServerSocket socket;
|
||||
private volatile boolean closed;
|
||||
|
||||
public BackendAgentServer(ProxyConfig.BackendAgentConfig config, ServerService servers) {
|
||||
this.config = config;
|
||||
this.servers = servers;
|
||||
}
|
||||
|
||||
/** Starts the endpoint and the lease sweeper. */
|
||||
public void start() throws IOException {
|
||||
socket = new ServerSocket();
|
||||
socket.bind(config.bindAddress());
|
||||
connections.execute(this::acceptLoop);
|
||||
var delay = Math.max(1_000L, config.heartbeatTimeout().toMillis() / 2L);
|
||||
maintenance.scheduleWithFixedDelay(this::expireLeases, delay, delay, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
private void acceptLoop() {
|
||||
while (!closed) {
|
||||
try {
|
||||
var connection = socket.accept();
|
||||
connections.execute(() -> handle(connection));
|
||||
} catch (IOException exception) {
|
||||
if (!closed) {
|
||||
// The next accept is not useful after a listener failure.
|
||||
close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handle(Socket connection) {
|
||||
try (connection;
|
||||
var reader = new BufferedReader(new java.io.InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8));
|
||||
var writer = new BufferedWriter(new java.io.OutputStreamWriter(connection.getOutputStream(), StandardCharsets.UTF_8))) {
|
||||
connection.setSoTimeout(5_000);
|
||||
var line = reader.readLine();
|
||||
var response = line == null || line.length() > MAX_LINE_LENGTH
|
||||
? Response.failure("invalid_request", "request is missing or too large")
|
||||
: process(line);
|
||||
writer.write(mapper.writeValueAsString(response));
|
||||
writer.newLine();
|
||||
writer.flush();
|
||||
} catch (IOException ignored) {
|
||||
// A disconnected backend agent has no state to clean up here; lease expiry handles it.
|
||||
}
|
||||
}
|
||||
|
||||
private Response process(String line) {
|
||||
try {
|
||||
var envelope = mapper.readValue(line, Envelope.class);
|
||||
if (envelope == null || envelope.nonce == null || envelope.payload == null || envelope.signature == null) {
|
||||
return Response.failure("invalid_request", "missing authentication fields");
|
||||
}
|
||||
var now = System.currentTimeMillis();
|
||||
if (Math.abs(now - envelope.timestamp) > MAX_CLOCK_SKEW_MILLIS) {
|
||||
return Response.failure("unauthorized", "request timestamp is outside the allowed clock skew");
|
||||
}
|
||||
if (nonces.putIfAbsent(envelope.nonce, now) != null) {
|
||||
return Response.failure("unauthorized", "request nonce was already used");
|
||||
}
|
||||
var signed = envelope.timestamp + "\n" + envelope.nonce + "\n" + envelope.payload;
|
||||
var expected = hmac(signed);
|
||||
if (!MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), envelope.signature.getBytes(StandardCharsets.US_ASCII))) {
|
||||
return Response.failure("unauthorized", "invalid request signature");
|
||||
}
|
||||
var command = mapper.readValue(Base64.getUrlDecoder().decode(envelope.payload), Command.class);
|
||||
if (command == null || command.operation == null || command.name == null || command.name.isBlank()) {
|
||||
return Response.failure("invalid_request", "operation and backend name are required");
|
||||
}
|
||||
return switch (command.operation) {
|
||||
case "register" -> register(command);
|
||||
case "heartbeat" -> heartbeat(command);
|
||||
case "unregister" -> unregister(command);
|
||||
default -> Response.failure("invalid_request", "unsupported operation");
|
||||
};
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return Response.failure("invalid_request", exception.getMessage());
|
||||
} catch (Exception exception) {
|
||||
return Response.failure("request_failed", rootMessage(exception));
|
||||
} finally {
|
||||
var cutoff = System.currentTimeMillis() - MAX_CLOCK_SKEW_MILLIS;
|
||||
nonces.entrySet().removeIf(entry -> entry.getValue() < cutoff);
|
||||
}
|
||||
}
|
||||
|
||||
private Response register(Command command) throws Exception {
|
||||
if (command.host == null || command.host.isBlank() || command.port < 1 || command.port > 65535) {
|
||||
return Response.failure("invalid_request", "backend host and port are required");
|
||||
}
|
||||
var persistence = command.persistent ? ServerPersistence.PERSISTENT : ServerPersistence.EPHEMERAL;
|
||||
var registration = new ServerRegistration(
|
||||
command.name,
|
||||
new java.net.InetSocketAddress(command.host, command.port),
|
||||
command.tags == null ? Set.of() : Set.copyOf(command.tags),
|
||||
command.capabilities == null ? Set.of() : Set.copyOf(command.capabilities),
|
||||
command.minProtocol == null && command.maxProtocol == null
|
||||
? ServerProtocolRange.any()
|
||||
: new ServerProtocolRange(command.minProtocol == null ? 0 : command.minProtocol,
|
||||
command.maxProtocol == null ? Integer.MAX_VALUE : command.maxProtocol,
|
||||
command.protocolName == null ? "configured" : command.protocolName),
|
||||
command.weight == null ? 100 : command.weight,
|
||||
command.softCapacity == null ? 0 : command.softCapacity,
|
||||
command.hardCapacity == null ? 0 : command.hardCapacity,
|
||||
command.drainMode,
|
||||
command.metadata == null ? Map.of() : Map.copyOf(command.metadata),
|
||||
persistence);
|
||||
var result = servers.register(registration).toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
if (result.success()) {
|
||||
leases.put(command.name.trim(), new Lease(System.currentTimeMillis(), persistence));
|
||||
}
|
||||
return response(result);
|
||||
}
|
||||
|
||||
private Response heartbeat(Command command) {
|
||||
var lease = leases.get(command.name.trim());
|
||||
if (lease == null) {
|
||||
return Response.failure("not_registered", "backend must register before sending heartbeats");
|
||||
}
|
||||
leases.put(command.name.trim(), new Lease(System.currentTimeMillis(), lease.persistence));
|
||||
return Response.success("heartbeat", "");
|
||||
}
|
||||
|
||||
private Response unregister(Command command) throws Exception {
|
||||
var lease = leases.get(command.name.trim());
|
||||
var persistence = lease == null ? (command.persistent ? ServerPersistence.PERSISTENT : ServerPersistence.EPHEMERAL) : lease.persistence;
|
||||
var result = servers.unregister(command.name, new ServerRemoval(true, false, Duration.ZERO, persistence))
|
||||
.toCompletableFuture().get(5, TimeUnit.SECONDS);
|
||||
if (result.success()) {
|
||||
leases.remove(command.name.trim());
|
||||
}
|
||||
return response(result);
|
||||
}
|
||||
|
||||
private void expireLeases() {
|
||||
var cutoff = System.currentTimeMillis() - config.heartbeatTimeout().toMillis();
|
||||
leases.forEach((name, lease) -> {
|
||||
if (lease.lastSeen < cutoff && leases.remove(name, lease)) {
|
||||
servers.unregister(name, new ServerRemoval(true, false, Duration.ZERO, lease.persistence));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String hmac(String value) throws Exception {
|
||||
var mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(config.sharedSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private static Response response(ServerMutationResult result) {
|
||||
return new Response(result.success(), result.outcome(), result.message());
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable exception) {
|
||||
var cause = exception;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
try {
|
||||
if (socket != null) socket.close();
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
maintenance.shutdownNow();
|
||||
connections.shutdownNow();
|
||||
}
|
||||
|
||||
private record Lease(long lastSeen, ServerPersistence persistence) { }
|
||||
|
||||
public static final class Envelope {
|
||||
public long timestamp;
|
||||
public String nonce;
|
||||
public String payload;
|
||||
public String signature;
|
||||
}
|
||||
|
||||
public static final class Command {
|
||||
public String operation;
|
||||
public String name;
|
||||
public String host;
|
||||
public int port;
|
||||
public Set<String> tags;
|
||||
public Set<String> capabilities;
|
||||
public Integer minProtocol;
|
||||
public Integer maxProtocol;
|
||||
public String protocolName;
|
||||
public Integer weight;
|
||||
public Integer softCapacity;
|
||||
public Integer hardCapacity;
|
||||
public boolean drainMode;
|
||||
public Map<String, String> metadata;
|
||||
public boolean persistent;
|
||||
}
|
||||
|
||||
public record Response(boolean success, String outcome, String message) {
|
||||
static Response success(String outcome, String message) { return new Response(true, outcome, message); }
|
||||
static Response failure(String outcome, String message) { return new Response(false, outcome, message == null ? "" : message); }
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+11
@@ -4,6 +4,7 @@ import dev.strataproxy.app.registry.RegistryPersistenceService;
|
||||
import dev.strataproxy.app.registry.JsonRegistryStore;
|
||||
import dev.strataproxy.app.registry.NoopRegistryStore;
|
||||
import dev.strataproxy.app.registry.RegistryStore;
|
||||
import dev.strataproxy.agent.BackendAgentServer;
|
||||
import dev.strataproxy.bootstrap.ConfigLoader;
|
||||
import dev.strataproxy.bootstrap.ConfigValidationResult;
|
||||
import dev.strataproxy.bootstrap.ConfigValidator;
|
||||
@@ -256,6 +257,16 @@ public final class StrataProxyLauncher {
|
||||
started.add(loadReporter);
|
||||
out.println("StrataProxy observed load flush interval " + config.observability().flushInterval());
|
||||
|
||||
if (config.backendAgent().enabled()) {
|
||||
var backendAgent = new BackendAgentServer(
|
||||
config.backendAgent(),
|
||||
new PluginServerService(registry, registryPersistence, scheduler, "bukkit-agent"));
|
||||
backendAgent.start();
|
||||
started.add(backendAgent);
|
||||
out.println("StrataProxy backend-agent bound on " + config.backendAgent().bindAddress()
|
||||
+ " heartbeatTimeout=" + config.backendAgent().heartbeatTimeout());
|
||||
}
|
||||
|
||||
var server = new NettyProxyNetworkServer(
|
||||
config.resolvedWorkerThreads(),
|
||||
resolver,
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+23
-1
@@ -126,6 +126,8 @@ public final class ConfigLoader {
|
||||
/** Public field for native runtime. */
|
||||
@JsonProperty("native")
|
||||
public NativeFile nativeRuntime = new NativeFile();
|
||||
/** YAML value for the authenticated backend-agent endpoint. */
|
||||
public BackendAgentFile backendAgent = new BackendAgentFile();
|
||||
/** YAML value for servers. */
|
||||
/** Public field for servers. */
|
||||
public List<ServerFile> servers = List.of();
|
||||
@@ -133,6 +135,7 @@ public final class ConfigLoader {
|
||||
LoadedProxyConfig toLoadedConfig(Path configDirectory) {
|
||||
var nativeFile = nativeRuntime == null ? new NativeFile() : nativeRuntime;
|
||||
var statusFile = status == null ? new StatusFile() : status;
|
||||
var backendAgentFile = backendAgent == null ? new BackendAgentFile() : backendAgent;
|
||||
var bind = parseAddress(network.bind, 25577);
|
||||
var proxy = new ProxyConfig(
|
||||
bind,
|
||||
@@ -192,7 +195,12 @@ public final class ConfigLoader {
|
||||
nativeFile.preferOpenSslTls,
|
||||
nativeFile.preferNativeCompression,
|
||||
nativeFile.disabledFeatures,
|
||||
nativeFile.forcedFeatures));
|
||||
nativeFile.forcedFeatures),
|
||||
new ProxyConfig.BackendAgentConfig(
|
||||
backendAgentFile.enabled,
|
||||
parseAddress(backendAgentFile.bind, 25578),
|
||||
backendAgentFile.sharedSecret,
|
||||
java.time.Duration.ofMillis(parseDurationMillis(backendAgentFile.heartbeatTimeout, 30_000))));
|
||||
var descriptors = registry.staticServers
|
||||
? servers.stream().map(ServerFile::toDescriptor).toList()
|
||||
: List.<ServerDescriptor>of();
|
||||
@@ -222,6 +230,20 @@ public final class ConfigLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML configuration for the narrow backend-agent registration endpoint.
|
||||
*/
|
||||
public static final class BackendAgentFile {
|
||||
/** Enables the endpoint. */
|
||||
public boolean enabled = false;
|
||||
/** Bind address; keep loopback unless backend agents are on another protected host. */
|
||||
public String bind = "127.0.0.1:25578";
|
||||
/** Shared secret used to authenticate each request. */
|
||||
public String sharedSecret = "";
|
||||
/** Maximum interval without a heartbeat before an agent-owned backend is removed. */
|
||||
public String heartbeatTimeout = "30s";
|
||||
}
|
||||
|
||||
/**
|
||||
* YAML network section.
|
||||
*/
|
||||
File renamed without changes.
+13
-1
@@ -130,6 +130,7 @@ public final class ConfigValidator {
|
||||
validateStatus(config.status(), errors);
|
||||
validateForwarding(config.forwarding(), errors);
|
||||
validateNative(config.nativeRuntime(), errors, warnings);
|
||||
validateBackendAgent(config.backendAgent(), errors);
|
||||
|
||||
}
|
||||
|
||||
@@ -229,6 +230,17 @@ public final class ConfigValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateBackendAgent(ProxyConfig.BackendAgentConfig backendAgent, ArrayList<String> errors) {
|
||||
if (backendAgent == null || !backendAgent.enabled()) {
|
||||
return;
|
||||
}
|
||||
validateAddress("backendAgent.bind", backendAgent.bindAddress(), errors);
|
||||
if (backendAgent.sharedSecret().trim().length() < 32) {
|
||||
errors.add("backendAgent.sharedSecret must be at least 32 characters when backendAgent is enabled");
|
||||
}
|
||||
validatePositive("backendAgent.heartbeatTimeout", backendAgent.heartbeatTimeout(), errors);
|
||||
}
|
||||
|
||||
private static void validateFeatureSet(String field, java.util.Set<String> features, ArrayList<String> errors) {
|
||||
if (features == null) {
|
||||
return;
|
||||
@@ -263,7 +275,7 @@ public final class ConfigValidator {
|
||||
warnings.add("server." + server.name() + " starts in drain mode");
|
||||
}
|
||||
}
|
||||
if (count == 0) {
|
||||
if (count == 0 && (config.backendAgent() == null || !config.backendAgent().enabled())) {
|
||||
errors.add("at least one server must be configured");
|
||||
}
|
||||
}
|
||||
+42
-2
@@ -31,7 +31,25 @@ public record ProxyConfig(
|
||||
StatusConfig status,
|
||||
AuthConfig auth,
|
||||
ForwardingConfig forwarding,
|
||||
NativeConfig nativeRuntime) {
|
||||
NativeConfig nativeRuntime,
|
||||
BackendAgentConfig backendAgent) {
|
||||
/**
|
||||
* Compatibility constructor for callers that do not configure the backend-agent endpoint.
|
||||
*/
|
||||
public ProxyConfig(
|
||||
InetSocketAddress bindAddress,
|
||||
int workerThreads,
|
||||
boolean nativeTransport,
|
||||
NetworkConfig network,
|
||||
RegistryConfig registry,
|
||||
CompressionConfig compression,
|
||||
ObservabilityConfig observability,
|
||||
StatusConfig status,
|
||||
AuthConfig auth,
|
||||
ForwardingConfig forwarding,
|
||||
NativeConfig nativeRuntime) {
|
||||
this(bindAddress, workerThreads, nativeTransport, network, registry, compression, observability, status, auth, forwarding, nativeRuntime, BackendAgentConfig.defaults());
|
||||
}
|
||||
/**
|
||||
* Validates and normalizes record components.
|
||||
*/
|
||||
@@ -47,6 +65,7 @@ public record ProxyConfig(
|
||||
auth = auth == null ? AuthConfig.defaults() : auth;
|
||||
forwarding = forwarding == null ? ForwardingConfig.defaults() : forwarding;
|
||||
nativeRuntime = nativeRuntime == null ? NativeConfig.defaults() : nativeRuntime;
|
||||
backendAgent = backendAgent == null ? BackendAgentConfig.defaults() : backendAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,7 +94,28 @@ public record ProxyConfig(
|
||||
StatusConfig.defaults(),
|
||||
AuthConfig.defaults(),
|
||||
ForwardingConfig.defaults(),
|
||||
NativeConfig.defaults());
|
||||
NativeConfig.defaults(),
|
||||
BackendAgentConfig.defaults());
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated, authenticated endpoint used only by backend agents such as the Bukkit adapter.
|
||||
* It is disabled by default and is not a general-purpose administration API.
|
||||
*/
|
||||
public record BackendAgentConfig(
|
||||
boolean enabled,
|
||||
InetSocketAddress bindAddress,
|
||||
String sharedSecret,
|
||||
Duration heartbeatTimeout) {
|
||||
public BackendAgentConfig {
|
||||
bindAddress = bindAddress == null ? new InetSocketAddress("127.0.0.1", 25578) : bindAddress;
|
||||
sharedSecret = sharedSecret == null ? "" : sharedSecret;
|
||||
heartbeatTimeout = heartbeatTimeout == null ? Duration.ofSeconds(30) : heartbeatTimeout;
|
||||
}
|
||||
|
||||
public static BackendAgentConfig defaults() {
|
||||
return new BackendAgentConfig(false, new InetSocketAddress("127.0.0.1", 25578), "", Duration.ofSeconds(30));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
@@ -25,6 +25,13 @@ registry:
|
||||
# Use minecraft-status when backends should only receive traffic after a valid server-list response.
|
||||
healthCheckMode: "minecraft-status"
|
||||
|
||||
# Enable only when using an authenticated backend agent such as integrations/bukkit-backend-agent.
|
||||
backendAgent:
|
||||
enabled: false
|
||||
bind: "127.0.0.1:25578"
|
||||
sharedSecret: "${STRATAPROXY_BACKEND_AGENT_SECRET:}"
|
||||
heartbeatTimeout: "30s"
|
||||
|
||||
compression:
|
||||
mode: adaptive
|
||||
minThreshold: 256
|
||||
+8
@@ -25,6 +25,14 @@ registry:
|
||||
# tcp keeps compatibility; minecraft-status verifies the backend speaks the Minecraft status protocol.
|
||||
healthCheckMode: "tcp"
|
||||
|
||||
# Dedicated backend-agent endpoint for the official Bukkit adapter. This is not an Admin API.
|
||||
# Keep it disabled unless a backend agent is installed; use a random secret of at least 32 characters.
|
||||
backendAgent:
|
||||
enabled: false
|
||||
bind: "127.0.0.1:25578"
|
||||
sharedSecret: ""
|
||||
heartbeatTimeout: "30s"
|
||||
|
||||
compression:
|
||||
mode: adaptive
|
||||
minThreshold: 256
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -25,8 +25,14 @@ publishing {
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
name = "localPluginApi"
|
||||
url = uri(layout.buildDirectory.dir("repo"))
|
||||
name = "giteaPackages"
|
||||
url = uri(providers.environmentVariable("MAVEN_URL")
|
||||
.orElse("https://git.nest.potatolab.uk:8443/api/packages/layue13/maven")
|
||||
.get())
|
||||
credentials {
|
||||
username = providers.environmentVariable("MAVEN_USER").orElse("NONE").get()
|
||||
password = providers.environmentVariable("MAVEN_PASSWORD").orElse("NONE").get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-plugin-api"))
|
||||
api(libs.findLibrary("slf4j-api").get())
|
||||
testImplementation(project(":proxy-core"))
|
||||
}
|
||||
@@ -22,6 +22,4 @@ rootProject.name = "strataproxy"
|
||||
include(
|
||||
"proxy-core",
|
||||
"proxy-plugin-api",
|
||||
"proxy-plugin",
|
||||
"proxy-app",
|
||||
)
|
||||
Reference in new issue
Block a user