diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2c953de..bb9bd8b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 }} diff --git a/README.md b/README.md index d01fa29..046bcfb 100644 --- a/README.md +++ b/README.md @@ -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) 开始。 diff --git a/build.gradle.kts b/build.gradle.kts index 14a9c50..c85bbb6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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("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("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")) diff --git a/deployment/README.md b/deployment/README.md index 3021aa8..1a5ae62 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -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`。生产环境不包含管理端口、指标端点或数据包抓取功能。 diff --git a/deployment/container/Containerfile b/deployment/container/Containerfile index b79b6cc..51aa910 100644 --- a/deployment/container/Containerfile +++ b/deployment/container/Containerfile @@ -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 diff --git a/deployment/local-minecraft-1.7.10/start-proxy.ps1 b/deployment/local-minecraft-1.7.10/start-proxy.ps1 index 55e55db..f98adab 100644 --- a/deployment/local-minecraft-1.7.10/start-proxy.ps1 +++ b/deployment/local-minecraft-1.7.10/start-proxy.ps1 @@ -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 } diff --git a/docs/README.md b/docs/README.md index 107aaa9..040e867 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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) | diff --git a/docs/architecture.md b/docs/architecture.md index e033b62..07691a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.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 使用。 diff --git a/docs/configuration.md b/docs/configuration.md index 228dcc1..d88c4eb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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` 的密钥必须完全一致;心跳超时后代理会移除该适配器注册的后端。 diff --git a/docs/javadoc.md b/docs/javadoc.md index 12fd10f..e04992b 100644 --- a/docs/javadoc.md +++ b/docs/javadoc.md @@ -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 和底层实现辅助类只在行为不直观、或影响公开契约时补充注释。 diff --git a/docs/plugins.md b/docs/plugins.md index fb9777e..ea7bbf6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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: diff --git a/docs/quick-start.md b/docs/quick-start.md index 59d3111..7949c4f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -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)。 diff --git a/integrations/bukkit-backend-agent/README.md b/integrations/bukkit-backend-agent/README.md new file mode 100644 index 0000000..6b60c86 --- /dev/null +++ b/integrations/bukkit-backend-agent/README.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/` 目录后重启。 + +该端点只接受注册、心跳和下线,不能读取玩家、执行命令或修改其他代理设置。 diff --git a/integrations/bukkit-backend-agent/build.gradle b/integrations/bukkit-backend-agent/build.gradle new file mode 100644 index 0000000..edb422f --- /dev/null +++ b/integrations/bukkit-backend-agent/build.gradle @@ -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' + } + } + } +} diff --git a/integrations/bukkit-backend-agent/settings.gradle b/integrations/bukkit-backend-agent/settings.gradle new file mode 100644 index 0000000..58ee149 --- /dev/null +++ b/integrations/bukkit-backend-agent/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'strataproxy-bukkit-backend-agent' diff --git a/integrations/bukkit-backend-agent/src/main/java/dev/strataproxy/bukkit/StrataProxyBackendAgentPlugin.java b/integrations/bukkit-backend-agent/src/main/java/dev/strataproxy/bukkit/StrataProxyBackendAgentPlugin.java new file mode 100644 index 0000000..0b04f69 --- /dev/null +++ b/integrations/bukkit-backend-agent/src/main/java/dev/strataproxy/bukkit/StrataProxyBackendAgentPlugin.java @@ -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 values) { + List escaped = new ArrayList(); + for (String value : values) escaped.add("\"" + escape(value) + "\""); + return "\"" + key + "\":[" + join(escaped) + "]"; + } + private static String map(String key, ConfigurationSection values) { + List entries = new ArrayList(); + 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 values) { return values.isEmpty() ? "" : joinValues(values); } + private static String joinValues(List 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(); } + } +} diff --git a/integrations/bukkit-backend-agent/src/main/resources/config.yml b/integrations/bukkit-backend-agent/src/main/resources/config.yml new file mode 100644 index 0000000..6e08455 --- /dev/null +++ b/integrations/bukkit-backend-agent/src/main/resources/config.yml @@ -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 diff --git a/integrations/bukkit-backend-agent/src/main/resources/plugin.yml b/integrations/bukkit-backend-agent/src/main/resources/plugin.yml new file mode 100644 index 0000000..c211694 --- /dev/null +++ b/integrations/bukkit-backend-agent/src/main/resources/plugin.yml @@ -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. diff --git a/proxy-app/build.gradle.kts b/proxy-app/build.gradle.kts deleted file mode 100644 index 549a346..0000000 --- a/proxy-app/build.gradle.kts +++ /dev/null @@ -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("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("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("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("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") - } - } - } -} diff --git a/proxy-core/build.gradle.kts b/proxy-core/build.gradle.kts index 07bac58..10ee105 100644 --- a/proxy-core/build.gradle.kts +++ b/proxy-core/build.gradle.kts @@ -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("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("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("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("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") + } + } + } } diff --git a/proxy-core/src/main/java/dev/strataproxy/agent/BackendAgentServer.java b/proxy-core/src/main/java/dev/strataproxy/agent/BackendAgentServer.java new file mode 100644 index 0000000..4291c94 --- /dev/null +++ b/proxy-core/src/main/java/dev/strataproxy/agent/BackendAgentServer.java @@ -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 nonces = new ConcurrentHashMap<>(); + private final Map 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 tags; + public Set capabilities; + public Integer minProtocol; + public Integer maxProtocol; + public String protocolName; + public Integer weight; + public Integer softCapacity; + public Integer hardCapacity; + public boolean drainMode; + public Map 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); } + } +} diff --git a/proxy-app/src/main/java/dev/strataproxy/app/ConfigPathResolver.java b/proxy-core/src/main/java/dev/strataproxy/app/ConfigPathResolver.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/app/ConfigPathResolver.java rename to proxy-core/src/main/java/dev/strataproxy/app/ConfigPathResolver.java diff --git a/proxy-app/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java b/proxy-core/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java rename to proxy-core/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java diff --git a/proxy-app/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java b/proxy-core/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java similarity index 98% rename from proxy-app/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java rename to proxy-core/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java index deb97bc..16dc47f 100644 --- a/proxy-app/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java +++ b/proxy-core/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java @@ -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, diff --git a/proxy-app/src/main/java/dev/strataproxy/app/registry/JsonRegistryStore.java b/proxy-core/src/main/java/dev/strataproxy/app/registry/JsonRegistryStore.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/app/registry/JsonRegistryStore.java rename to proxy-core/src/main/java/dev/strataproxy/app/registry/JsonRegistryStore.java diff --git a/proxy-app/src/main/java/dev/strataproxy/app/registry/NoopRegistryStore.java b/proxy-core/src/main/java/dev/strataproxy/app/registry/NoopRegistryStore.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/app/registry/NoopRegistryStore.java rename to proxy-core/src/main/java/dev/strataproxy/app/registry/NoopRegistryStore.java diff --git a/proxy-app/src/main/java/dev/strataproxy/app/registry/RegistryPersistenceService.java b/proxy-core/src/main/java/dev/strataproxy/app/registry/RegistryPersistenceService.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/app/registry/RegistryPersistenceService.java rename to proxy-core/src/main/java/dev/strataproxy/app/registry/RegistryPersistenceService.java diff --git a/proxy-app/src/main/java/dev/strataproxy/app/registry/RegistryStore.java b/proxy-core/src/main/java/dev/strataproxy/app/registry/RegistryStore.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/app/registry/RegistryStore.java rename to proxy-core/src/main/java/dev/strataproxy/app/registry/RegistryStore.java diff --git a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java similarity index 95% rename from proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java rename to proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java index 8cc079d..9236e7b 100644 --- a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java +++ b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java @@ -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 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.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. */ diff --git a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java similarity index 100% rename from proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java rename to proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java diff --git a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java similarity index 94% rename from proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java rename to proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java index 2e4e1c6..d681da6 100644 --- a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java +++ b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java @@ -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 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 features, ArrayList 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"); } } diff --git a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java similarity index 90% rename from proxy-app/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java rename to proxy-core/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java index 5cde9c5..81e1c56 100644 --- a/proxy-app/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java +++ b/proxy-core/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java @@ -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)); + } } /** diff --git a/proxy-plugin/src/main/java/dev/strataproxy/plugin/loader/DefaultPluginContext.java b/proxy-core/src/main/java/dev/strataproxy/plugin/loader/DefaultPluginContext.java similarity index 100% rename from proxy-plugin/src/main/java/dev/strataproxy/plugin/loader/DefaultPluginContext.java rename to proxy-core/src/main/java/dev/strataproxy/plugin/loader/DefaultPluginContext.java diff --git a/proxy-plugin/src/main/java/dev/strataproxy/plugin/loader/LoadedPlugin.java b/proxy-core/src/main/java/dev/strataproxy/plugin/loader/LoadedPlugin.java similarity index 100% rename from proxy-plugin/src/main/java/dev/strataproxy/plugin/loader/LoadedPlugin.java rename to proxy-core/src/main/java/dev/strataproxy/plugin/loader/LoadedPlugin.java diff --git a/proxy-plugin/src/main/java/dev/strataproxy/plugin/loader/PluginManager.java b/proxy-core/src/main/java/dev/strataproxy/plugin/loader/PluginManager.java similarity index 100% rename from proxy-plugin/src/main/java/dev/strataproxy/plugin/loader/PluginManager.java rename to proxy-core/src/main/java/dev/strataproxy/plugin/loader/PluginManager.java diff --git a/proxy-app/src/main/resources/config/strataproxy-production.yml b/proxy-core/src/main/resources/config/strataproxy-production.yml similarity index 90% rename from proxy-app/src/main/resources/config/strataproxy-production.yml rename to proxy-core/src/main/resources/config/strataproxy-production.yml index b17df96..4d66fae 100644 --- a/proxy-app/src/main/resources/config/strataproxy-production.yml +++ b/proxy-core/src/main/resources/config/strataproxy-production.yml @@ -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 diff --git a/proxy-app/src/main/resources/config/strataproxy.yml b/proxy-core/src/main/resources/config/strataproxy.yml similarity index 87% rename from proxy-app/src/main/resources/config/strataproxy.yml rename to proxy-core/src/main/resources/config/strataproxy.yml index 454e472..2d8ae9b 100644 --- a/proxy-app/src/main/resources/config/strataproxy.yml +++ b/proxy-core/src/main/resources/config/strataproxy.yml @@ -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 diff --git a/proxy-app/src/main/resources/simplelogger.properties b/proxy-core/src/main/resources/simplelogger.properties similarity index 100% rename from proxy-app/src/main/resources/simplelogger.properties rename to proxy-core/src/main/resources/simplelogger.properties diff --git a/proxy-app/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java b/proxy-core/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java similarity index 100% rename from proxy-app/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java rename to proxy-core/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java diff --git a/proxy-app/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java b/proxy-core/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java similarity index 100% rename from proxy-app/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java rename to proxy-core/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java diff --git a/proxy-app/src/test/java/dev/strataproxy/app/registry/JsonRegistryStoreTest.java b/proxy-core/src/test/java/dev/strataproxy/app/registry/JsonRegistryStoreTest.java similarity index 100% rename from proxy-app/src/test/java/dev/strataproxy/app/registry/JsonRegistryStoreTest.java rename to proxy-core/src/test/java/dev/strataproxy/app/registry/JsonRegistryStoreTest.java diff --git a/proxy-plugin/src/test/java/dev/strataproxy/plugin/loader/PluginManagerTest.java b/proxy-core/src/test/java/dev/strataproxy/plugin/loader/PluginManagerTest.java similarity index 100% rename from proxy-plugin/src/test/java/dev/strataproxy/plugin/loader/PluginManagerTest.java rename to proxy-core/src/test/java/dev/strataproxy/plugin/loader/PluginManagerTest.java diff --git a/proxy-plugin-api/build.gradle.kts b/proxy-plugin-api/build.gradle.kts index a7dd257..96f57f0 100644 --- a/proxy-plugin-api/build.gradle.kts +++ b/proxy-plugin-api/build.gradle.kts @@ -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() + } } } } diff --git a/proxy-plugin/build.gradle.kts b/proxy-plugin/build.gradle.kts deleted file mode 100644 index a6ca8c9..0000000 --- a/proxy-plugin/build.gradle.kts +++ /dev/null @@ -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")) -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 78f5417..626d3a3 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -22,6 +22,4 @@ rootProject.name = "strataproxy" include( "proxy-core", "proxy-plugin-api", - "proxy-plugin", - "proxy-app", )