This commit is contained in:
2026-05-26 13:30:22 -04:00
parent 5714bec389
commit 1f5aa7b79c
9 changed files with 103 additions and 31 deletions
+12 -9
View File
@@ -13,6 +13,7 @@
"role": "lobby" "role": "lobby"
}, },
"redis": { "redis": {
"url": null,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 6379, "port": 6379,
"password": null, "password": null,
@@ -63,13 +64,14 @@
| Field | Default | Meaning | | Field | Default | Meaning |
|---|---|---| |---|---|---|
| `host` | `"127.0.0.1"` | Redis host | | `url` | `null` | Full Redis connection URI. Supports both `redis://` (plain) and `rediss://` (TLS). When set, **all other redis fields are ignored** (host, port, password, database, tls). This is what you want when your provider hands you a one-line connection string. Prefer the env var (below) over putting credentials in the file. |
| `port` | `6379` | Redis port | | `host` | `"127.0.0.1"` | Redis host. Only used when `url` is null. |
| `password` | `null` | Redis AUTH password. Prefer the env var (below). | | `port` | `6379` | Redis port. Only used when `url` is null. |
| `database` | `0` | Redis logical database (0-15 on stock Redis) | | `password` | `null` | Redis AUTH password. Only used when `url` is null. Prefer the env var. |
| `key_prefix` | `"network:"` | All keys NetworkCore writes start with this prefix. Server registry keys end up at `network:servers:<id>`. Change this only if you're sharing a Redis with something unrelated. | | `database` | `0` | Redis logical database (0-15 on stock Redis). Only used when `url` is null. |
| `tls` | `false` | Enables TLS for the Redis connection (Lettuce `rediss://` equivalent). **Set to true whenever Redis is on a different host than the Hytale server**, since the connection crosses untrusted network. | | `key_prefix` | `"network:"` | All keys NetworkCore writes start with this prefix. Server registry keys end up at `network:servers:<id>`. Change this only if you're sharing a Redis with something unrelated. **Applies regardless of `url`.** |
| `verify_peer` | `true` | When `tls` is on, verifies the Redis server's certificate against the JVM's default trust store. Set to false only in dev / against self-signed certs. Logs a warning when disabled. | | `tls` | `false` | Enables TLS for the Redis connection. Only used when `url` is null (the URI scheme picks TLS on/off when `url` is set). **Set to true whenever Redis is on a different host than the Hytale server**, since the connection crosses untrusted network. |
| `verify_peer` | `true` | When TLS is on (via `tls: true` or a `rediss://` url), verifies the Redis server's certificate against the JVM's default trust store. Set to false only in dev / against self-signed certs. Logs a warning when disabled. |
### network ### network
@@ -115,6 +117,7 @@ These take precedence over the config file at boot time. Use them for secrets so
| Env var | Overrides | | Env var | Overrides |
|---|---| |---|---|
| `REDIS_URL` | `redis.url` (full connection URI) |
| `REDIS_PASSWORD` | `redis.password` | | `REDIS_PASSWORD` | `redis.password` |
| `NETWORK_AUTH_TOKEN` | `network.auth_token` | | `NETWORK_AUTH_TOKEN` | `network.auth_token` |
| `MYSQL_PASSWORD` | `mysql.password` | | `MYSQL_PASSWORD` | `mysql.password` |
@@ -127,7 +130,7 @@ These take precedence over the config file at boot time. Use them for secrets so
**Multi-server staging or production:** **Multi-server staging or production:**
- `server.id` left null is fine if you're OK with the id changing on every boot. Set it to something stable like `lobby-prod-01` if you want logs and dashboards to keep continuity across restarts. - `server.id` left null is fine if you're OK with the id changing on every boot. Set it to something stable like `lobby-prod-01` if you want logs and dashboards to keep continuity across restarts.
- `server.role` set explicitly per server. This is what dashboards and routing use. - `server.role` set explicitly per server. This is what dashboards and routing use.
- `redis.host` pointed at your shared Redis. `redis.password` not in the file; set `REDIS_PASSWORD` env var. - **Easiest path for hosted Redis**: set `REDIS_URL` env var to the `rediss://user:pass@host:port/0` string your provider gives you. Leave the host/port/password/database fields at defaults. The `rediss://` scheme already implies TLS - no need to touch `tls`.
- `redis.tls` **on** whenever Redis is on a different host than the Hytale server. `verify_peer` stays true unless Redis uses a self-signed cert you haven't loaded into the JVM trust store. - **Alternative**: set `redis.host` / `redis.port` explicitly, put the password in `REDIS_PASSWORD`, and set `redis.tls: true`. Works the same; just more fields to keep in sync across servers.
- `network.auth_token` not in the file; set `NETWORK_AUTH_TOKEN` env var to the same value on every server. - `network.auth_token` not in the file; set `NETWORK_AUTH_TOKEN` env var to the same value on every server.
- `metrics.bind` stays `127.0.0.1`. Run Prometheus on the same host (or use an SSH tunnel / reverse proxy with auth to expose it elsewhere). - `metrics.bind` stays `127.0.0.1`. Run Prometheus on the same host (or use an SSH tunnel / reverse proxy with auth to expose it elsewhere).
+14 -4
View File
@@ -4,6 +4,20 @@ Currently one in-game admin command: `/networkcore`.
All subcommands are run by an in-world player. Console support isn't wired up yet. All subcommands are run by an in-world player. Console support isn't wired up yet.
## Permissions
Every subcommand is gated by a permission node, checked via Hytale's `PermissionsModule`. Assign these to your staff/admin groups in the Hytale server's own permission config.
| Subcommand | Required permission |
|---|---|
| `/networkcore status` | `networkcore.status` |
| `/networkcore servers` | `networkcore.servers` |
| `/networkcore publish` | `networkcore.publish` |
You can grant the whole namespace with `networkcore.*` (Hytale's permission system handles wildcards). A player without the relevant node sees Hytale's standard `You do not have permission for this command.` message - the command system itself blocks execution before our code runs.
Permissions are wired via `AbstractCommand.requirePermission(node)` in each subcommand constructor, and `canGeneratePermission()` is overridden to false on every command (including the root) so Hytale doesn't auto-generate a different one and clash with ours.
## /networkcore status ## /networkcore status
Prints local server identity, Redis state, and the metrics endpoint. Prints local server identity, Redis state, and the metrics endpoint.
@@ -49,10 +63,6 @@ The JSON is parsed and wrapped in the standard envelope (with your auth token if
This is dangerous in production - it lets anyone with command access spoof any message on any channel. Lock it down with permissions before exposing to non-admins. This is dangerous in production - it lets anyone with command access spoof any message on any channel. Lock it down with permissions before exposing to non-admins.
## Permissions
Subcommand permissions aren't enforced yet. If your server runs HytalePerms, gate `/networkcore` behind an admin group at the command-routing layer.
## Things this command does *not* do (and why) ## Things this command does *not* do (and why)
- **Restart Redis / reload config.** Restart the server. Reload is fragile - half-loaded state is worse than no state. - **Restart Redis / reload config.** Restart the server. Reload is fragile - half-loaded state is worse than no state.
@@ -0,0 +1,12 @@
package net.kewwbec.networkcore;
public final class NetworkCorePermissions {
public static final String ROOT = "networkcore";
public static final String STATUS = ROOT + ".status";
public static final String SERVERS = ROOT + ".servers";
public static final String PUBLISH = ROOT + ".publish";
private NetworkCorePermissions() {
}
}
@@ -55,20 +55,32 @@ public final class RedisMessageBus implements MessageBus {
this.serverId = serverId; this.serverId = serverId;
this.authToken = (authToken == null || authToken.isEmpty()) ? null : authToken; this.authToken = (authToken == null || authToken.isEmpty()) ? null : authToken;
RedisURI.Builder b = RedisURI.Builder.redis(redis.host, redis.port) RedisURI uri;
.withDatabase(redis.database) if (redis.url != null && !redis.url.isBlank()) {
.withTimeout(Duration.ofSeconds(5)); uri = RedisURI.create(redis.url.trim());
if (redis.password != null && !redis.password.isEmpty()) { if (uri.getTimeout() == null) {
b.withPassword(redis.password.toCharArray()); uri.setTimeout(Duration.ofSeconds(5));
} }
if (redis.tls) { if (uri.isSsl() && !redis.verify_peer) {
b.withSsl(true); uri.setVerifyPeer(io.lettuce.core.SslVerifyMode.NONE);
b.withVerifyPeer(redis.verify_peer);
if (!redis.verify_peer) {
LOGGER.at(Level.WARNING).log("Redis TLS is on but peer verification is OFF. Only acceptable in dev."); LOGGER.at(Level.WARNING).log("Redis TLS is on but peer verification is OFF. Only acceptable in dev.");
} }
} else {
RedisURI.Builder b = RedisURI.Builder.redis(redis.host, redis.port)
.withDatabase(redis.database)
.withTimeout(Duration.ofSeconds(5));
if (redis.password != null && !redis.password.isEmpty()) {
b.withPassword(redis.password.toCharArray());
}
if (redis.tls) {
b.withSsl(true);
b.withVerifyPeer(redis.verify_peer);
if (!redis.verify_peer) {
LOGGER.at(Level.WARNING).log("Redis TLS is on but peer verification is OFF. Only acceptable in dev.");
}
}
uri = b.build();
} }
RedisURI uri = b.build();
this.client = RedisClient.create(uri); this.client = RedisClient.create(uri);
this.commands = client.connect(); this.commands = client.connect();
@@ -99,8 +111,8 @@ public final class RedisMessageBus implements MessageBus {
this.rejectedCounter = (metrics == null) ? null : metrics.counter("messagebus_rejected_total", Map.of()); this.rejectedCounter = (metrics == null) ? null : metrics.counter("messagebus_rejected_total", Map.of());
LOGGER.at(Level.INFO).log("Connected to Redis at %s:%d (db %d, tls=%s, auth=%s)", LOGGER.at(Level.INFO).log("Connected to Redis at %s:%d (db %d, tls=%s, auth=%s)",
redis.host, redis.port, redis.database, uri.getHost(), uri.getPort(), uri.getDatabase(),
redis.tls ? "on" : "off", uri.isSsl() ? "on" : "off",
this.authToken != null ? "on" : "off"); this.authToken != null ? "on" : "off");
} }
@@ -15,6 +15,7 @@ import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World; import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore; import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import net.kewwbec.networkcore.NetworkCore; import net.kewwbec.networkcore.NetworkCore;
import net.kewwbec.networkcore.NetworkCorePermissions;
import net.kewwbec.networkcore.api.DatabaseService; import net.kewwbec.networkcore.api.DatabaseService;
import net.kewwbec.networkcore.api.MessageBus; import net.kewwbec.networkcore.api.MessageBus;
import net.kewwbec.networkcore.api.ServerInfo; import net.kewwbec.networkcore.api.ServerInfo;
@@ -33,6 +34,11 @@ public final class NetworkCoreCommand extends AbstractCommandCollection {
addSubCommand(new PublishCommand(plugin)); addSubCommand(new PublishCommand(plugin));
} }
@Override
protected boolean canGeneratePermission() {
return false;
}
public static final class StatusCommand extends AbstractPlayerCommand { public static final class StatusCommand extends AbstractPlayerCommand {
private final NetworkCore plugin; private final NetworkCore plugin;
@@ -40,6 +46,12 @@ public final class NetworkCoreCommand extends AbstractCommandCollection {
public StatusCommand(@Nonnull NetworkCore plugin) { public StatusCommand(@Nonnull NetworkCore plugin) {
super("status", "Show local server status"); super("status", "Show local server status");
this.plugin = plugin; this.plugin = plugin;
requirePermission(NetworkCorePermissions.STATUS);
}
@Override
protected boolean canGeneratePermission() {
return false;
} }
@Override @Override
@@ -89,6 +101,12 @@ public final class NetworkCoreCommand extends AbstractCommandCollection {
public ServersCommand(@Nonnull NetworkCore plugin) { public ServersCommand(@Nonnull NetworkCore plugin) {
super("servers", "List known servers in the network"); super("servers", "List known servers in the network");
this.plugin = plugin; this.plugin = plugin;
requirePermission(NetworkCorePermissions.SERVERS);
}
@Override
protected boolean canGeneratePermission() {
return false;
} }
@Override @Override
@@ -122,6 +140,12 @@ public final class NetworkCoreCommand extends AbstractCommandCollection {
public PublishCommand(@Nonnull NetworkCore plugin) { public PublishCommand(@Nonnull NetworkCore plugin) {
super("publish", "Publish a raw JSON payload to a channel"); super("publish", "Publish a raw JSON payload to a channel");
this.plugin = plugin; this.plugin = plugin;
requirePermission(NetworkCorePermissions.PUBLISH);
}
@Override
protected boolean canGeneratePermission() {
return false;
} }
@Override @Override
@@ -41,6 +41,10 @@ public final class ConfigLoader {
@Nonnull @Nonnull
private static CoreConfig applyEnvOverrides(@Nonnull CoreConfig config) { private static CoreConfig applyEnvOverrides(@Nonnull CoreConfig config) {
String envUrl = System.getenv("REDIS_URL");
if (envUrl != null && !envUrl.isEmpty()) {
config.redis.url = envUrl;
}
String envPw = System.getenv("REDIS_PASSWORD"); String envPw = System.getenv("REDIS_PASSWORD");
if (envPw != null && !envPw.isEmpty()) { if (envPw != null && !envPw.isEmpty()) {
config.redis.password = envPw; config.redis.password = envPw;
@@ -18,6 +18,13 @@ public final class CoreConfig {
} }
public static final class RedisSection { public static final class RedisSection {
/**
* Full Redis URI ("redis://..." or "rediss://..."). When set, host/port/password/database/tls
* fields below are ignored. Use this when your provider gives you a one-line connection string.
*/
@Nullable
public String url = null;
public String host = "127.0.0.1"; public String host = "127.0.0.1";
public int port = 6379; public int port = 6379;
@Nullable @Nullable
@@ -115,7 +115,7 @@ public final class RedisServerRegistry implements ServerRegistry {
ServerInfo previous = cache.put(p.id, info); ServerInfo previous = cache.put(p.id, info);
if (previous == null) { if (previous == null) {
try { try {
eventBus.dispatchFor(ServerJoinedNetworkEvent.class).dispatch((IBaseEvent) new ServerJoinedNetworkEvent(info)); eventBus.dispatchFor(ServerJoinedNetworkEvent.class).dispatch(new ServerJoinedNetworkEvent(info));
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
} }
} }
@@ -123,7 +123,7 @@ public final class RedisServerRegistry implements ServerRegistry {
ServerInfo removed = cache.remove(p.id); ServerInfo removed = cache.remove(p.id);
if (removed != null) { if (removed != null) {
try { try {
eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch((IBaseEvent) new ServerLeftNetworkEvent(removed)); eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch(new ServerLeftNetworkEvent(removed));
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
} }
} }
@@ -183,7 +183,7 @@ public final class RedisServerRegistry implements ServerRegistry {
ServerInfo info = latest.get(j); ServerInfo info = latest.get(j);
if (info != null) { if (info != null) {
try { try {
eventBus.dispatchFor(ServerJoinedNetworkEvent.class).dispatch((IBaseEvent) new ServerJoinedNetworkEvent(info)); eventBus.dispatchFor(ServerJoinedNetworkEvent.class).dispatch(new ServerJoinedNetworkEvent(info));
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
} }
} }
@@ -191,7 +191,7 @@ public final class RedisServerRegistry implements ServerRegistry {
for (String l : left) { for (String l : left) {
ServerInfo info = new ServerInfo(l, "unknown", null, 0, 0L, 0L); ServerInfo info = new ServerInfo(l, "unknown", null, 0, 0L, 0L);
try { try {
eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch((IBaseEvent) new ServerLeftNetworkEvent(info)); eventBus.dispatchFor(ServerLeftNetworkEvent.class).dispatch(new ServerLeftNetworkEvent(info));
} catch (RuntimeException ignored) { } catch (RuntimeException ignored) {
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@
"Name": "NetworkCore", "Name": "NetworkCore",
"Version": "0.1.0", "Version": "0.1.0",
"Description": "Cross-server messaging and metrics core", "Description": "Cross-server messaging and metrics core",
"IncludesAssetsPack": false, "IncludesAssetPack": false,
"Authors": [ "Authors": [
{"Name": "kewwbec"} {"Name": "kewwbec"}
], ],