diff --git a/docs/superpowers/plans/2026-10-19-network-maintenance.md b/docs/superpowers/plans/2026-10-19-network-maintenance.md new file mode 100644 index 0000000..c044018 --- /dev/null +++ b/docs/superpowers/plans/2026-10-19-network-maintenance.md @@ -0,0 +1,8 @@ +# Implementation plan + +1. Config Service: multi-document least-privilege grants; atomic create authorization; tests and verification. +2. Proxy: pure validated schedule engine and bypass admission/kick tests; Config polling; LoginEvent and server-list integration; permission manifest and localization; build. +3. Forge: strict typed contract; bounded private Config client/CAS and atomic create; environment-specific RBAC routes; tests/typecheck. +4. Portal: protected environment-aware network page; schedule/whitelist/announcements editor, explicit bypass kick behavior, MOTD editor and safe preview; tests/typecheck/build. +5. Infra: exact grants and NATS publisher subjects, explicit environment connections; relevant checks. +6. Strong primary security/correctness review of all domains, cross-proxy behavior, conflicts, unavailable Production, UUID handling and bypass kicks. Create reviewable PRs and record deployment prerequisites. Never turn maintenance on to test. diff --git a/docs/superpowers/specs/2026-10-19-network-maintenance-design.md b/docs/superpowers/specs/2026-10-19-network-maintenance-design.md new file mode 100644 index 0000000..19755cd --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-network-maintenance-design.md @@ -0,0 +1,41 @@ +# Network maintenance and MOTD + +Approved scope: Config Service backed maintenance, scheduled start, optional automatic end, separately estimated end, permission/UUID whitelist bypass, kick existing non-bypass players, configurable announcements, all proxies/Java/Bedrock, Portal maintenance and MOTD editors. Never enable live maintenance as a deployment test. + +## Contract + +Config app `velocity`, environment `stage` or `prod`, documents `maintenance/active` and existing `motd/active`. Stage and Production are separate connections, never fall back across environments. + +Maintenance JSON (strict at Forge, compatible explicit parser in proxy): +``` +{ + "schemaVersion": 1, + "enabled": false, + "id": "UUID", + "startsAt": "2026-10-19T20:00:00.000Z", + "endsAt": null, + "estimatedEndAt": null, + "reason": "Wartungsarbeiten", + "joinMessage": "Wartungsarbeiten\n{reason}\nVoraussichtliches Ende: {end}", + "motd": "Wartungsarbeiten\nVoraussichtliches Ende: {end}", + "kickExisting": true, + "whitelist": [], + "announcementSeconds": [10800,7200,3600,1800,600,300,120,60], + "announcementMessage": "Wartungsarbeiten in {remaining}: {reason}" +} +``` +Required fields above. UUID canonical, no UUID-version constraint (Floodgate support). Dates ISO UTC. endsAt/estimatedEndAt nullable, if set > startsAt. endsAt automatically disables effective maintenance; estimatedEndAt only displays ETA. Limits: reason 500 chars, messages/MOTD 4096 chars, whitelist 1000 UUIDs, offsets <=32 distinct integers 1..604800. Preserve ID on edits; create a new ID when intentionally starting a new episode. Offset threshold local announcements never replay after bootstrap/restart. Whitelist OR `grounds.maintenance.bypass` exempts joins AND existing-player kicks. Kick only on transition/episode entry, never merely because a config refresh changed metadata. Local player enumeration and delivery on every proxy ensures network-wide behavior without duplicate cross-proxy broadcasts. + +MOTD JSON preserves existing shape: {text:string,maxPlayers?:integer,source?:string,updatedBy?:string,updatedAt?:string}. text 1..4096, maxPlayers 1..1000000. Forge stamps actor/time. Preview is safely rendered React text, supported-format preview may be partial and must say so. + +Forge routes: +- GET /v1/admin/network/:environment => {environment,available,maintenance:Document|null,motd:Document|null}. Config missing document returns defaults in Document.settings and version:null. Unconfigured environment => available:false, both null. +- PUT /v1/admin/network/:environment/maintenance or /motd body {expectedVersion:string|null,settings:...} => Document. +- Document = {version:string|null,settings:...,updatedAt:string|null,updatedBy:string|null}. +- Decimal version strings preserve BIGINT precision. null means atomic create-if-absent using Config POST; existing updates require If-Match. Conflict 409. No unconditional writes or arbitrary config paths. +- Permissions ADMIN_AREA_ACCESS plus NETWORK_STAGE_VIEW/NETWORK_STAGE_MANAGE or NETWORK_PRODUCTION_VIEW/NETWORK_PRODUCTION_MANAGE. Manage implies ability to view via either-permission check. Service-account Forge JWTs cannot enter these routes. +- Forge dedicated env NETWORK_CONFIG_STAGE_URL/TOKEN_FILE and NETWORK_CONFIG_PRODUCTION_URL/TOKEN_FILE. Re-read projected token per request. Fixed paths, 5s timeout, no redirects, bounded responses, sanitized errors. + +Proxy caches valid documents, polls every 5s and refreshes on NATS invalidation hints. Pings/logins never do remote IO. Keep last valid on failure, deny ordinary new joins once read freshness >30s; bypass still allowed. No mass kick caused by config outages. HTTP 404 is known disabled initial configuration. LoginEvent uses verified UUID and permission provider. Maintenance ping overrides normal MOTD; Java may advertise Maintenance status with incompatible protocol while actual LoginEvent enforces admission. Bedrock sees MOTD and login denial. + +Infrastructure: Forge SA receives exactly existing network/stage/resourcepacks/global plus velocity/stage/{motd,maintenance}/active via pipe-separated grants. Config create endpoint accepts exact writers consistent with PUT. NATS Stage Config publisher adds config.velocity.stage.changed to existing subject. Preserve live bindings/flags and never change unrelated networking enforcement. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt index 7768efb..882d90b 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -2,7 +2,10 @@ package gg.grounds.proxy.velocity import com.google.gson.JsonParser import com.google.inject.Inject +import com.velocitypowered.api.event.PostOrder +import com.velocitypowered.api.event.ResultedEvent import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.LoginEvent import com.velocitypowered.api.event.connection.PostLoginEvent import com.velocitypowered.api.event.player.ServerPostConnectEvent import com.velocitypowered.api.event.proxy.ProxyInitializeEvent @@ -12,6 +15,7 @@ import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.proxy.Player import com.velocitypowered.api.proxy.ProxyServer import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.scheduler.ScheduledTask import gg.grounds.BuildInfo import gg.grounds.i18n.Translations import gg.grounds.presentation.VelocitySystemMessages @@ -31,6 +35,9 @@ import gg.grounds.proxy.velocity.command.RegionCommand import gg.grounds.proxy.velocity.handler.NatsHandler import gg.grounds.proxy.velocity.listener.LobbyCountsListener import gg.grounds.proxy.velocity.listener.ProxyHopListener +import gg.grounds.proxy.velocity.maintenance.MaintenanceAccess +import gg.grounds.proxy.velocity.maintenance.MaintenanceConfigStore +import gg.grounds.proxy.velocity.maintenance.MaintenanceEngine import gg.grounds.proxy.velocity.metrics.MetricsConfig import gg.grounds.proxy.velocity.metrics.ProxyMetrics import gg.grounds.proxy.velocity.metrics.ProxySnapshot @@ -44,6 +51,8 @@ import gg.grounds.proxy.velocity.tab.TabList import io.nats.client.Subscription import java.time.Duration import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import net.kyori.adventure.text.minimessage.MiniMessage import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer import org.slf4j.Logger @@ -207,6 +216,12 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { */ @Volatile private var motdManager: MotdManager? = null private var motdStore: MotdConfigStore? = null + @Volatile private var maintenanceStore: MaintenanceConfigStore? = null + private val maintenance = MaintenanceEngine() + private val maintenanceRefreshRunning = AtomicBoolean() + private val maintenanceClosed = AtomicBoolean() + private var maintenanceRefreshTask: ScheduledTask? = null + private var maintenanceApplyTask: ScheduledTask? = null @Subscribe fun onInitialize(event: ProxyInitializeEvent) { @@ -278,6 +293,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { } startMotd() + startMaintenance() subscribeForProxy() @@ -417,6 +433,78 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { ) } + /** Starts the shared maintenance reader. Every proxy polls and only acts on its own players. */ + private fun startMaintenance() { + val target = resolveConfigServiceTarget() ?: return + val app = env("CONFIG_APP") ?: DEFAULT_CONFIG_APP + val configEnv = env("CONFIG_ENV") ?: env("GROUNDS_PERMISSION_ENVIRONMENT") ?: return + maintenanceStore = MaintenanceConfigStore.open(app, configEnv, target) + // Hints shorten propagation after Portal saves; polling remains the correctness fallback. + crossProxySubscriptions += + natsHandler.subscribe("config.$app.$configEnv.changed") { + if (!maintenanceClosed.get()) { + proxy.scheduler + .buildTask(this, Runnable { refreshMaintenanceConfiguration() }) + .schedule() + } + } + maintenanceRefreshTask = + proxy.scheduler + .buildTask(this, Runnable { refreshMaintenanceConfiguration() }) + .repeat(5, TimeUnit.SECONDS) + .schedule() + // The local schedule still advances while Config is unavailable: end times and countdowns + // are decisions from the cached signed document, not from a successful request this tick. + maintenanceApplyTask = + proxy.scheduler + .buildTask(this, Runnable { applyMaintenance() }) + .repeat(1, TimeUnit.SECONDS) + .schedule() + } + + private fun refreshMaintenanceConfiguration() { + if (maintenanceClosed.get()) return + val store = maintenanceStore ?: return + if (!maintenanceRefreshRunning.compareAndSet(false, true)) return + try { + val document = store.read() + if (!maintenanceClosed.get()) maintenance.adopt(document) + } catch (failure: Exception) { + maintenance.refreshFailed() + logger.warn( + "Maintenance refresh failed (reason={}); retaining last valid state", + failure.message, + ) + } finally { + maintenanceRefreshRunning.set(false) + } + } + + private fun applyMaintenance() { + if (maintenanceClosed.get()) return + val now = java.time.Instant.now() + maintenance.announcementsAt(now).forEach { + maintenance.announcementMessage(now)?.let { message -> + val component = MiniMessage.miniMessage().deserialize(message) + proxy.allPlayers.forEach { player -> player.sendMessage(component) } + } + } + proxy.allPlayers.forEach { player -> + if ( + MaintenanceAccess.shouldKick( + maintenance, + player.uniqueId, + { player.hasPermission(MaintenanceAccess.BYPASS_PERMISSION) }, + now, + ) + ) { + player.disconnect( + MiniMessage.miniMessage().deserialize(maintenance.denialMessage(now)) + ) + } + } + } + private fun env(name: String): String? = System.getenv(name)?.trim()?.takeIf { it.isNotEmpty() } /** @@ -438,7 +526,8 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { fun onProxyPing(event: ProxyPingEvent) { val count = networkPlayerCount val motd = motdManager - if (count == null && motd == null) return + val maintenanceMotd = maintenanceStore?.let { maintenance.motd() } + if (count == null && motd == null && maintenanceMotd == null) return val builder = event.ping.asBuilder() count?.let { builder.onlinePlayers(it) } @@ -450,9 +539,30 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { builder.description(it) } } + maintenanceMotd?.let { builder.description(MiniMessage.miniMessage().deserialize(it)) } event.ping = builder.build() } + @Subscribe(order = PostOrder.LAST) + fun onLogin(event: LoginEvent) { + if (!event.result.isAllowed) return + if (maintenanceStore == null) return + val player = event.player + val admission = + MaintenanceAccess.admission( + maintenance, + player.uniqueId, + { player.hasPermission(MaintenanceAccess.BYPASS_PERMISSION) }, + java.time.Instant.now(), + ) + if (!admission.allowed) { + event.result = + ResultedEvent.ComponentResult.denied( + MiniMessage.miniMessage().deserialize(admission.message!!) + ) + } + } + @Subscribe fun onLogin(event: PostLoginEvent) { bedrockRoster?.refresh() @@ -477,6 +587,11 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { crossProxySubscriptions.forEach { natsHandler.unsubscribe(it) } crossProxySubscriptions.clear() motdStore?.close() + maintenanceClosed.set(true) + maintenanceRefreshTask?.cancel() + maintenanceApplyTask?.cancel() + maintenanceStore?.close() + maintenanceStore = null metrics?.let { runCatching { it.close() } } metrics = null if (this::natsHandler.isInitialized) { diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceAccess.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceAccess.kt new file mode 100644 index 0000000..f883a80 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceAccess.kt @@ -0,0 +1,33 @@ +package gg.grounds.proxy.velocity.maintenance + +import java.time.Instant +import java.util.UUID + +/** + * Keeps Velocity permission lookup at the listener boundary and makes whitelist precedence + * explicit. + */ +object MaintenanceAccess { + const val BYPASS_PERMISSION = "grounds.maintenance.bypass" + + fun admission( + engine: MaintenanceEngine, + uuid: UUID, + hasPermission: () -> Boolean, + now: Instant, + ): MaintenanceEngine.Admission = + engine.admission(uuid, bypass(engine, uuid, hasPermission), now) + + fun shouldKick( + engine: MaintenanceEngine, + uuid: UUID, + hasPermission: () -> Boolean, + now: Instant, + ): Boolean = engine.shouldKick(uuid, bypass(engine, uuid, hasPermission), now) + + private fun bypass( + engine: MaintenanceEngine, + uuid: UUID, + hasPermission: () -> Boolean, + ): Boolean = engine.whitelisted(uuid) || runCatching(hasPermission).getOrDefault(false) +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt new file mode 100644 index 0000000..a91aa58 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStore.kt @@ -0,0 +1,266 @@ +package gg.grounds.proxy.velocity.maintenance + +import com.google.gson.JsonParser +import java.io.ByteArrayOutputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.time.Instant +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.Flow +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** Read-only service-config client for the shared maintenance document. */ +class MaintenanceConfigStore +internal constructor( + private val app: String, + private val env: String, + private val baseUri: URI, + private val http: HttpClient, + private val deadline: Duration = DEADLINE, +) : AutoCloseable { + fun read(): MaintenanceDocument? { + val request = + HttpRequest.newBuilder(baseUri.resolve(path())) + .timeout(deadline) + .header("Accept", "application/json") + token()?.let { request.header("Authorization", "Bearer $it") } + val subscriber = BoundedBodySubscriber(MAX_RESPONSE_BYTES) + val pending = http.sendAsync(request.GET().build()) { subscriber } + val response = + try { + // JDK 25 request timeouts finish at headers. Bound the entire body exchange as + // well. + pending.get(deadline.toMillis(), TimeUnit.MILLISECONDS) + } catch (_: TimeoutException) { + subscriber.cancel() + pending.cancel(true) + throw MaintenanceStoreException("Maintenance request timed out") + } catch (_: InterruptedException) { + subscriber.cancel() + pending.cancel(true) + Thread.currentThread().interrupt() + throw MaintenanceStoreException("Maintenance request interrupted") + } catch (_: Exception) { + subscriber.cancel() + pending.cancel(true) + throw MaintenanceStoreException("Maintenance response could not be read") + } + val body = response.body() + if (response.statusCode() == 404) return null + if (response.statusCode() !in 200..299) + throw MaintenanceStoreException( + "Could not read maintenance (HTTP ${response.statusCode()})" + ) + val content = + JsonParser.parseString(body).asJsonObject.get("contentJson")?.asString + ?: throw MaintenanceStoreException("Maintenance response has no contentJson") + return parse(content) + } + + override fun close() = http.close() + + private fun path() = "/v1/config/apps/$app/envs/$env/namespaces/maintenance/documents/active" + + companion object { + private val DEADLINE = Duration.ofSeconds(5) + private const val MAX_RESPONSE_BYTES = 262_144 + private val UUID_PATTERN = + Regex("[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") + + private fun canonicalUuid(raw: String): UUID { + if (!UUID_PATTERN.matches(raw)) + throw MaintenanceStoreException("Maintenance UUID is not canonical") + return UUID.fromString(raw) + } + + fun open(app: String, env: String, target: String): MaintenanceConfigStore { + val uri = URI.create(if (target.contains("://")) target else "http://$target") + return MaintenanceConfigStore(app, env, uri, HttpClient.newHttpClient()) + } + + internal fun parse(json: String): MaintenanceDocument { + val root = + runCatching { JsonParser.parseString(json).asJsonObject } + .getOrElse { + throw MaintenanceStoreException("Maintenance document is not an object") + } + fun string(name: String): String = + root + .get(name) + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString } + ?.asString + ?: throw MaintenanceStoreException("Maintenance field '$name' is required") + fun bool(name: String): Boolean = + root + .get(name) + ?.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isBoolean } + ?.asBoolean + ?: throw MaintenanceStoreException("Maintenance field '$name' is required") + fun instant(name: String, nullable: Boolean = false): Instant? { + val value = + root.get(name) + ?: throw MaintenanceStoreException("Maintenance field '$name' is required") + if (nullable && value.isJsonNull) return null + return runCatching { Instant.parse(value.asString) } + .getOrElse { + throw MaintenanceStoreException("Maintenance field '$name' is not ISO UTC") + } + } + val schema = root.get("schemaVersion") + if ( + schema == null || + !schema.isJsonPrimitive || + !schema.asJsonPrimitive.isNumber || + schema.asString != "1" + ) + throw MaintenanceStoreException("Unsupported maintenance schema") + val idText = string("id") + val id = canonicalUuid(idText) + val startsAt = instant("startsAt")!! + val endsAt = instant("endsAt", true) + val estimatedEndAt = instant("estimatedEndAt", true) + if ( + (endsAt != null && !endsAt.isAfter(startsAt)) || + (estimatedEndAt != null && !estimatedEndAt.isAfter(startsAt)) + ) + throw MaintenanceStoreException("Maintenance end must be after start") + val reason = + string("reason").also { + if (it.isEmpty() || it.length > 500) + throw MaintenanceStoreException("Maintenance reason is invalid") + } + val join = + string("joinMessage").also { + if (it.isEmpty() || it.length > 4096) + throw MaintenanceStoreException("Maintenance joinMessage is invalid") + } + val motd = + string("motd").also { + if (it.isEmpty() || it.length > 4096) + throw MaintenanceStoreException("Maintenance motd is invalid") + } + val announcement = + string("announcementMessage").also { + if (it.isEmpty() || it.length > 4096) + throw MaintenanceStoreException( + "Maintenance announcementMessage is invalid" + ) + } + val whitelist = + root.getAsJsonArray("whitelist") + ?: throw MaintenanceStoreException("Maintenance field 'whitelist' is required") + if (whitelist.size() > 1000) + throw MaintenanceStoreException("Maintenance whitelist is too long") + val uuids = + whitelist.map { entry -> + if (!entry.isJsonPrimitive || !entry.asJsonPrimitive.isString) + throw MaintenanceStoreException("Whitelist entries must be UUID strings") + canonicalUuid(entry.asString) + } + if (uuids.distinct().size != uuids.size) + throw MaintenanceStoreException("Maintenance whitelist has duplicate UUIDs") + val offsets = + root.getAsJsonArray("announcementSeconds") + ?: throw MaintenanceStoreException( + "Maintenance field 'announcementSeconds' is required" + ) + val seconds = + offsets.map { entry -> + if ( + !entry.isJsonPrimitive || + !entry.asJsonPrimitive.isNumber || + !entry.asString.matches(Regex("[1-9][0-9]*")) + ) + throw MaintenanceStoreException("Invalid announcement seconds") + entry.asString.toLongOrNull() + ?: throw MaintenanceStoreException("Invalid announcement seconds") + } + if ( + seconds.size > 32 || + seconds.distinct().size != seconds.size || + seconds.any { it !in 1..604800 } + ) + throw MaintenanceStoreException("Invalid announcement seconds") + return MaintenanceDocument( + bool("enabled"), + id, + startsAt, + endsAt, + estimatedEndAt, + reason, + join, + motd, + bool("kickExisting"), + uuids.toSet(), + seconds, + announcement, + ) + } + + private fun token(): String? = + runCatching { + val file = + Path.of( + System.getenv("GROUNDS_TOKEN_FILE") ?: "/var/run/secrets/grounds/token" + ) + if (Files.exists(file)) Files.readString(file).trim().ifEmpty { null } else null + } + .getOrNull() + } +} + +class MaintenanceStoreException(message: String) : RuntimeException(message) + +/** Bounds memory during streaming, before the response future is allowed to complete. */ +private class BoundedBodySubscriber(private val limit: Int) : HttpResponse.BodySubscriber { + private val result = CompletableFuture() + private val bytes = ByteArrayOutputStream() + @Volatile private var subscription: Flow.Subscription? = null + + override fun getBody(): CompletionStage = result + + override fun onSubscribe(next: Flow.Subscription) { + subscription = next + if (result.isDone) next.cancel() else next.request(1) + } + + override fun onNext(items: List) { + if (result.isDone) return + for (buffer in items) { + if (buffer.remaining() > limit - bytes.size()) { + subscription?.cancel() + result.completeExceptionally( + MaintenanceStoreException("Maintenance response is too large") + ) + return + } + val chunk = ByteArray(buffer.remaining()) + buffer.get(chunk) + bytes.write(chunk) + } + subscription?.request(1) + } + + override fun onError(failure: Throwable) { + result.completeExceptionally(failure) + } + + override fun onComplete() { + result.complete(bytes.toString(StandardCharsets.UTF_8)) + } + + fun cancel() { + result.cancel(true) + subscription?.cancel() + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt new file mode 100644 index 0000000..54ff1dc --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngine.kt @@ -0,0 +1,163 @@ +package gg.grounds.proxy.velocity.maintenance + +import java.time.Clock +import java.time.Instant +import java.util.UUID +import net.kyori.adventure.text.minimessage.MiniMessage + +/** + * Local, side-effect-free maintenance state. Each proxy owns one instance: the shared document is + * only the decision input, while kicking and messaging intentionally remain local operations. + */ +class MaintenanceEngine(private val clock: Clock = Clock.systemUTC()) { + data class Admission(val allowed: Boolean, val message: String? = null) + + private var document: MaintenanceDocument? = null + private var lastSuccess: Instant? = null + private val kicked = mutableSetOf() + private val announced = mutableSetOf() + private var lastRemaining: Long? = null + + @Synchronized + fun adopt(next: MaintenanceDocument?, receivedAt: Instant = clock.instant()) { + val old = document + val oldId = old?.id + document = next + lastSuccess = receivedAt + if (next?.id != oldId) { + kicked.clear() + announced.clear() + // A fresh proxy must never replay old countdown notices on bootstrap. + lastRemaining = next?.startsAt?.let { it.epochSecond - receivedAt.epochSecond } + } else if ( + next != null && + (next.startsAt != old?.startsAt || (old?.enabled == false && next.enabled)) + ) { + // Rescheduling an existing episode preserves delivered notices and kick history, while + // establishing a new countdown baseline so earlier offsets do not replay at once. + lastRemaining = next.startsAt.epochSecond - receivedAt.epochSecond + } + } + + /** Records a failed or malformed fetch while retaining the last known valid snapshot. */ + @Synchronized + fun refreshFailed(at: Instant = clock.instant()) { + // The timestamp is intentionally not changed. Freshness is time since a valid read. + } + + @Synchronized + fun activeAt(now: Instant = clock.instant()): Boolean = + document?.let { + it.enabled && + !now.isBefore(it.startsAt) && + (it.endsAt == null || now.isBefore(it.endsAt)) + } ?: false + + @Synchronized fun whitelisted(uuid: UUID): Boolean = document?.whitelist?.contains(uuid) == true + + @Synchronized + fun admission(uuid: UUID, bypass: Boolean, now: Instant = clock.instant()): Admission { + if (bypass || document?.whitelist?.contains(uuid) == true) return Admission(true) + // A recorded end is a local schedule too: once it has elapsed, maintenance ends even if + // service-config is temporarily unavailable. + if (document?.endsAt?.let { !now.isBefore(it) } == true) return Admission(true) + if (unhealthy(now)) + return Admission(false, "Maintenance configuration is temporarily unavailable.") + if (!activeAt(now)) return Admission(true) + return Admission(false, denialMessage(now)) + } + + /** Returns true once per local player per maintenance episode. */ + @Synchronized + fun shouldKick(uuid: UUID, bypass: Boolean, now: Instant = clock.instant()): Boolean { + val current = document ?: return false + if (!current.kickExisting || bypass || uuid in current.whitelist || !activeAt(now)) + return false + return kicked.add(uuid) + } + + /** Announcements whose threshold was crossed since the previous local scheduler tick. */ + @Synchronized + fun announcementsAt(now: Instant = clock.instant()): List { + val current = document ?: return emptyList() + if (!current.enabled || !now.isBefore(current.startsAt)) return emptyList() + val remaining = current.startsAt.epochSecond - now.epochSecond + val previous = lastRemaining.also { lastRemaining = remaining } ?: return emptyList() + val crossed = + current.announcementSeconds.filter { + it < previous && it >= remaining && announced.add(it) + } + return crossed.minOrNull()?.let(::listOf) ?: emptyList() + } + + @Synchronized + fun motd(now: Instant = clock.instant()): String? = + document + ?.takeIf { + it.enabled && + !now.isBefore(it.startsAt) && + (it.endsAt == null || now.isBefore(it.endsAt)) + } + ?.let { render(it.motd, now) } + + @Synchronized + fun announcementMessage(now: Instant = clock.instant()): String? = + document + ?.takeIf { it.enabled && now.isBefore(it.startsAt) } + ?.let { render(it.announcementMessage, now) } + + private fun unhealthy(now: Instant): Boolean = + lastSuccess?.let { now.epochSecond - it.epochSecond > STALE_AFTER_SECONDS } ?: true + + @Synchronized + fun denialMessage(now: Instant = clock.instant()): String = + document?.let { render(it.joinMessage, now) }.orEmpty() + + private fun render(template: String, now: Instant): String { + val end = document?.endsAt ?: document?.estimatedEndAt + val values = + mapOf( + "reason" to escape(document?.reason.orEmpty()), + "end" to escape(end?.toString() ?: "to be announced"), + "remaining" to + formatRemaining( + (document?.startsAt?.epochSecond ?: now.epochSecond) - now.epochSecond + ), + ) + return PLACEHOLDER.replace(template) { values.getValue(it.groupValues[1]) } + } + + private fun escape(value: String): String = MiniMessage.miniMessage().escapeTags(value) + + private fun formatRemaining(seconds: Long): String { + val safe = seconds.coerceAtLeast(0) + val hours = safe / 3600 + val minutes = (safe % 3600) / 60 + val rest = safe % 60 + return when { + hours > 0 -> "${hours}h ${minutes}m" + minutes > 0 -> "${minutes}m" + else -> "${rest}s" + } + } + + companion object { + const val STALE_AFTER_SECONDS = 30L + private val PLACEHOLDER = Regex("\\{(reason|end|remaining)}") + } +} + +data class MaintenanceDocument( + val enabled: Boolean, + val id: UUID, + val startsAt: Instant, + val endsAt: Instant?, + val estimatedEndAt: Instant?, + val reason: String, + val joinMessage: String, + val motd: String, + val kickExisting: Boolean, + val whitelist: Set, + val announcementSeconds: List, + val announcementMessage: String, +) diff --git a/velocity/src/main/resources/META-INF/grounds/permissions.json b/velocity/src/main/resources/META-INF/grounds/permissions.json index 3d2c3e6..d28e710 100644 --- a/velocity/src/main/resources/META-INF/grounds/permissions.json +++ b/velocity/src/main/resources/META-INF/grounds/permissions.json @@ -1,6 +1,14 @@ { "source": "plugin-proxy", "permissions": [ + { + "key": "grounds.maintenance.bypass", + "label": "Bypass network maintenance", + "description": "Allows joining and remaining connected during network maintenance.", + "supportedScopes": [ + "GLOBAL" + ] + }, { "key": "grounds.proxy.debug", "label": "Proxy diagnostics", diff --git a/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json b/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json index 3aa9b43..09b37f5 100644 --- a/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json +++ b/velocity/src/main/resources/META-INF/grounds/permissions/plugin-proxy.json @@ -1,6 +1,14 @@ { "source": "plugin-proxy", "permissions": [ + { + "key": "grounds.maintenance.bypass", + "label": "Bypass network maintenance", + "description": "Allows joining and remaining connected during network maintenance.", + "supportedScopes": [ + "GLOBAL" + ] + }, { "key": "grounds.proxy.debug", "label": "Proxy diagnostics", diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt new file mode 100644 index 0000000..dbb382d --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceConfigStoreTest.kt @@ -0,0 +1,111 @@ +package gg.grounds.proxy.velocity.maintenance + +import com.sun.net.httpserver.HttpServer +import java.net.InetSocketAddress +import java.net.URI +import java.net.http.HttpClient +import java.time.Duration +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTimeoutPreemptively +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class MaintenanceConfigStoreTest { + @Test + fun `accepts canonical UUIDs of every version including floodgate UUIDs`() { + val document = MaintenanceConfigStore.parse(json("123e4567-e89b-12d3-a456-426614174000")) + assertEquals("123e4567-e89b-12d3-a456-426614174000", document.id.toString()) + } + + @Test + fun `normalizes UUID case and rejects fractional offsets`() { + assertEquals( + "123e4567-e89b-12d3-a456-426614174000", + MaintenanceConfigStore.parse(json("123E4567-E89B-12D3-A456-426614174000")).id.toString(), + ) + assertThrows { + MaintenanceConfigStore.parse( + json("123e4567-e89b-12d3-a456-426614174000").replace("[60]", "[60.5]") + ) + } + } + + @Test + fun `rejects wrong scalar types and duplicate whitelist entries`() { + val valid = json("123e4567-e89b-12d3-a456-426614174000") + assertThrows { + MaintenanceConfigStore.parse( + valid.replace("\"schemaVersion\":1", "\"schemaVersion\":\"1\"") + ) + } + assertThrows { + MaintenanceConfigStore.parse( + valid.replace( + "\"whitelist\":[]", + "\"whitelist\":[\"123e4567-e89b-12d3-a456-426614174000\",\"123e4567-e89b-12d3-a456-426614174000\"]", + ) + ) + } + } + + @Test + fun `a stalled response body times out and the next poll can recover`() { + val bodyStarted = CountDownLatch(1) + val release = CountDownLatch(1) + val requests = java.util.concurrent.atomic.AtomicInteger() + val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) + server.createContext("/") { exchange -> + if (requests.getAndIncrement() > 0) { + exchange.sendResponseHeaders(404, -1) + exchange.close() + return@createContext + } + exchange.sendResponseHeaders(200, 1000) + exchange.responseBody.write("{".toByteArray()) + exchange.responseBody.flush() + bodyStarted.countDown() + release.await(5, TimeUnit.SECONDS) + exchange.close() + } + server.start() + val http = HttpClient.newHttpClient() + val store = + MaintenanceConfigStore( + "velocity", + "stage", + URI("http://127.0.0.1:${server.address.port}"), + http, + Duration.ofMillis(750), + ) + try { + assertTimeoutPreemptively(Duration.ofSeconds(3)) { + assertThrows { store.read() } + } + assertTrue(bodyStarted.await(1, TimeUnit.SECONDS)) + release.countDown() + assertNull(store.read()) + } finally { + release.countDown() + server.stop(0) + store.close() + } + } + + @Test + fun `rejects shortened UUID forms instead of silently padding them`() { + assertThrows { MaintenanceConfigStore.parse(json("1-1-1-1-1")) } + assertThrows { + MaintenanceConfigStore.parse( + json("123e4567-e89b-12d3-a456-426614174000") + .replace("\"whitelist\":[]", "\"whitelist\":[\"1-1-1-1-1\"]") + ) + } + } + + private fun json(id: String) = + """{"schemaVersion":1,"enabled":false,"id":"$id","startsAt":"2026-10-19T20:00:00.000Z","endsAt":null,"estimatedEndAt":null,"reason":"work","joinMessage":"join","motd":"motd","kickExisting":true,"whitelist":[],"announcementSeconds":[60],"announcementMessage":"soon"}""" +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt new file mode 100644 index 0000000..cec369d --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/maintenance/MaintenanceEngineTest.kt @@ -0,0 +1,190 @@ +package gg.grounds.proxy.velocity.maintenance + +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.util.UUID +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MaintenanceEngineTest { + private val id = UUID.fromString("00000000-0000-0000-0000-000000000001") + private val player = UUID.fromString("00000000-0000-0000-0000-000000000002") + + @Test + fun `two proxies independently kick only their local non bypass players once`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val document = active(now) + val first = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + val second = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + + first.adopt(document, now) + second.adopt(document, now) + + assertTrue(first.shouldKick(player, bypass = false)) + assertTrue(second.shouldKick(player, bypass = false)) + assertFalse(first.shouldKick(player, bypass = false)) + } + + @Test + fun `permission and whitelist bypass permit joins and avoid kicks`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val whitelisted = UUID.fromString("00000000-0000-0000-0000-000000000003") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt(active(now, whitelist = setOf(whitelisted)), now) + + assertTrue(engine.admission(player, bypass = true).allowed) + assertTrue(engine.admission(whitelisted, bypass = false).allowed) + assertFalse(engine.shouldKick(player, bypass = true)) + assertFalse(engine.shouldKick(whitelisted, bypass = false)) + } + + @Test + fun `listener access uses permission but whitelist skips unavailable provider`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val whitelisted = UUID.fromString("00000000-0000-0000-0000-000000000003") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt(active(now, whitelist = setOf(whitelisted)), now) + + assertTrue(MaintenanceAccess.admission(engine, player, { true }, now).allowed) + assertFalse(MaintenanceAccess.shouldKick(engine, player, { true }, now)) + assertTrue( + MaintenanceAccess.admission( + engine, + whitelisted, + { error("permission provider must not be queried") }, + now, + ) + .allowed + ) + assertFalse( + MaintenanceAccess.shouldKick( + engine, + whitelisted, + { error("permission provider must not be queried") }, + now, + ) + ) + } + + @Test + fun `disabled scheduled start and automatic expiry change effective state`() { + val start = Instant.parse("2026-10-19T20:00:00Z") + val clock = java.time.Clock.fixed(start.minusSeconds(1), ZoneOffset.UTC) + val engine = MaintenanceEngine(clock) + engine.adopt(active(start, endsAt = start.plusSeconds(60)), start.minusSeconds(1)) + assertFalse(engine.activeAt(start.minusSeconds(1))) + assertTrue(engine.activeAt(start)) + assertFalse(engine.activeAt(start.plusSeconds(60))) + assertTrue(engine.admission(player, bypass = false, now = start.plusSeconds(60)).allowed) + } + + @Test + fun `invalid update keeps valid cache and stale cache denies ordinary joins only`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt(active(now), now) + engine.refreshFailed(now.plusSeconds(31)) + + assertFalse(engine.admission(player, bypass = false, now = now.plusSeconds(31)).allowed) + assertTrue(engine.admission(player, bypass = true, now = now.plusSeconds(31)).allowed) + assertTrue(engine.activeAt(now.plusSeconds(31))) + } + + @Test + fun `startup outage denies ordinary joins while still allowing bypass`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + + assertFalse(engine.admission(player, bypass = false, now = now).allowed) + assertTrue(engine.admission(player, bypass = true, now = now).allowed) + } + + @Test + fun `editing an episode does not re kick but a new id does`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt(active(now), now) + assertTrue(engine.shouldKick(player, false)) + engine.adopt(active(now, reason = "new wording"), now.plusSeconds(1)) + assertFalse(engine.shouldKick(player, false)) + engine.adopt( + active(now, id = UUID.fromString("00000000-0000-0000-0000-000000000004")), + now.plusSeconds(2), + ) + assertTrue(engine.shouldKick(player, false)) + } + + @Test + fun `message values are escaped once before MiniMessage rendering`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt( + active(now, reason = "{end}\\path", endsAt = null) + .copy(joinMessage = "{reason} / {end}"), + now, + ) + val rendered = + PlainTextComponentSerializer.plainText() + .serialize(MiniMessage.miniMessage().deserialize(engine.denialMessage(now))) + assertEquals("{end}\\path / to be announced", rendered) + } + + @Test + fun `announcement thresholds are crossed once and never replayed after restart bootstrap`() { + val startsAt = Instant.parse("2026-10-19T20:10:00Z") + val engine = MaintenanceEngine(Clock.fixed(startsAt.minusSeconds(700), ZoneOffset.UTC)) + engine.adopt(active(startsAt, offsets = listOf(600, 300)), startsAt.minusSeconds(700)) + assertEquals(emptyList(), engine.announcementsAt(startsAt.minusSeconds(700))) + assertEquals(listOf(600L), engine.announcementsAt(startsAt.minusSeconds(599))) + assertEquals(emptyList(), engine.announcementsAt(startsAt.minusSeconds(590))) + } + + @Test + fun `rescheduling same episode resets baseline without replaying past announcements`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt(active(now.plusSeconds(700), offsets = listOf(600, 300)), now) + assertEquals(listOf(600L), engine.announcementsAt(now.plusSeconds(100))) + engine.adopt(active(now.plusSeconds(500), offsets = listOf(600, 300)), now.plusSeconds(101)) + assertEquals(emptyList(), engine.announcementsAt(now.plusSeconds(102))) + } + + @Test + fun `a removed maintenance document clears a previous active window`() { + val now = Instant.parse("2026-10-19T20:00:00Z") + val engine = MaintenanceEngine(Clock.fixed(now, ZoneOffset.UTC)) + engine.adopt(active(now), now) + assertFalse(engine.admission(player, false, now).allowed) + engine.adopt(null, now.plusSeconds(1)) + assertTrue(engine.admission(player, false, now.plusSeconds(1)).allowed) + assertFalse(engine.shouldKick(player, false, now.plusSeconds(1))) + } + + private fun active( + startsAt: Instant, + id: UUID = this.id, + endsAt: Instant? = null, + whitelist: Set = emptySet(), + reason: String = "Maintenance", + offsets: List = emptyList(), + ) = + MaintenanceDocument( + true, + id, + startsAt, + endsAt, + null, + reason, + "join", + "motd", + true, + whitelist, + offsets, + "announce", + ) +}