From 6af602bf5ea7272d409ffff036a0294d9914de41 Mon Sep 17 00:00:00 2001 From: Michael Gilardi <11632652+mgilardi@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:42:43 -0700 Subject: [PATCH] Release 0.2.1: pack.bind, so the pack server can stay on this machine The built-in pack server listened on every address, including a Mac's public IPv6 with its firewall off. The owner chose Mac-only for the map ports on 2026-11-10. pack.bind sets the address: empty (the default) listens everywhere as before, 127.0.0.1 keeps it local. Anything that isn't an IP address stops the pack server rather than widening. Players coming in through playit never reached 8164 anyway (the tunnel doesn't carry it), so binding it to loopback breaks nothing that works. Tested: 4 unit tests; the live suite now runs every group with pack.bind 127.0.0.1 and checks the port is refused on the machine's network address: 335/335 on Paper 26.1.2. Not run on 26.2 or 26.3. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- build.gradle.kts | 2 +- docs/releases/v0.2.1.md | 14 +++++++ docs/test-audit.md | 11 ++++++ .../blueminimap/config/HudConfig.java | 3 +- .../blueminimap/pack/PackService.java | 31 +++++++++++++-- src/main/resources/config.yml | 5 +++ .../blueminimap/pack/ListenAddressTest.java | 38 +++++++++++++++++++ test/live/suite.js | 10 ++++- 9 files changed, 108 insertions(+), 8 deletions(-) create mode 100644 docs/releases/v0.2.1.md create mode 100644 src/test/java/com/extantape/blueminimap/pack/ListenAddressTest.java diff --git a/README.md b/README.md index c9505ed..7b900b0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ These minimums come from BlueMap's release notes. Only BlueMap 5.29 was tested, ## Install 1. Put `BlueMinimap-.jar` in `plugins/`, next to BlueMap. -2. Open port `8164` so players can download the pack, or set `pack.public-url` to host it elsewhere. Servers sharing a machine each need their own `pack.port`. +2. Open port `8164` so players can download the pack, or set `pack.public-url` to host it elsewhere. Servers sharing a machine each need their own `pack.port`. `pack.bind` picks the address it listens on (empty means every address); `127.0.0.1` keeps it on the server's own machine, for when the pack reaches players another way. 3. Start the server. Settings are in `plugins/BlueMinimap/config.yml`. If BlueMap stores its maps in SQL, set `tiles.source: http`. diff --git a/build.gradle.kts b/build.gradle.kts index 00eaf7d..d8476b3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } group = "com.extantape" -version = "0.2.0" +version = "0.2.1" java { toolchain.languageVersion.set(JavaLanguageVersion.of(25)) diff --git a/docs/releases/v0.2.1.md b/docs/releases/v0.2.1.md new file mode 100644 index 0000000..b306611 --- /dev/null +++ b/docs/releases/v0.2.1.md @@ -0,0 +1,14 @@ +A small release: you can now choose which address the built-in pack server listens on. + +## New + +- **`pack.bind`.** The address the pack server listens on. Empty, the default, listens on every address, as before. `127.0.0.1` keeps it on the server's own machine: use that when players get the pack another way (`pack.public-url`, or `pack.send: false` with the files merged into your own pack), so the port is never open to the network. Anything that isn't an IP address stops the pack server, with a console line saying why, rather than falling back to every address. + +## Upgrading + +Replace the jar. `config.yml` gains `pack.bind: ""`, which changes nothing until you set it. The commands and permissions 0.2.0 marked as deprecated are still here; they go in 0.3.0, not in this patch release. + +## Tested + +- 4 new unit tests: empty means every address, IPv4 and IPv6 loopback stay local, names and typos are refused, and a real server listens only where told. +- The live suite now runs with `pack.bind: 127.0.0.1`, so every pack download in it went through the loopback-only server. A new check confirms the port answers on 127.0.0.1 and is refused on the machine's own network address. The live suite passed 335/335 on Paper 26.1.2 (one font check skipped, as before, without a client classpath). Not run on 26.2 or 26.3 for this release. diff --git a/docs/test-audit.md b/docs/test-audit.md index 9a6b079..1ebfcb5 100644 --- a/docs/test-audit.md +++ b/docs/test-audit.md @@ -2,6 +2,17 @@ Kept by this repo's owning session (Minecraft: Plugins Lead), per `~/Code/CLAUDE.md`, "Test self-audits". Newest first. +## 2026-11-10: pack.bind (0.2.1) + +| Promise | Covered by | +| --- | --- | +| Empty `pack.bind` listens on every address, as before | unit `ListenAddressTest.emptyListensOnEveryAddressAsBefore` | +| `127.0.0.1` keeps the pack server on this machine: it answers on loopback, refuses the machine's network address, and downloads from loopback still work | unit `loopbackStaysOnThisMachine`, `aServerReallyListensOnlyWhereTold`; live "pack.bind 127.0.0.1: the pack port answers on loopback", "…refused on this machine's network address", and every pack download in the suite (all groups now run with it) | +| A host name or typo never widens to every address | unit `aHostNameOrTypoIsRefusedNotWidened` | + +Live suite 335/335 on Paper 26.1.2. + + ## 2026-11-09: first full audit (at 0.2.0) The README's Testing section already lists what the live suite covers, check by check, so this audit starts from the promises that matter most for children's safety and privacy and for failing closed, then confirms the rest map to the Testing list. diff --git a/src/main/java/com/extantape/blueminimap/config/HudConfig.java b/src/main/java/com/extantape/blueminimap/config/HudConfig.java index e9e3e43..f58174a 100644 --- a/src/main/java/com/extantape/blueminimap/config/HudConfig.java +++ b/src/main/java/com/extantape/blueminimap/config/HudConfig.java @@ -48,7 +48,7 @@ public record Players(int radius, boolean hideVanished, boolean hideSpectators, public record Markers(boolean enabled, List include, List exclude, boolean edgeArrows, boolean showDeath) {} - public record Pack(boolean send, boolean required, String kickMessage, String prompt, int port, String host, + public record Pack(boolean send, boolean required, String kickMessage, String prompt, int port, String bind, String host, String publicUrl, boolean verifyPublicUrl, boolean hideBossBar, String bossBarColor, boolean bedrockExempt, boolean reSendOnRemoval) {} @@ -124,6 +124,7 @@ public static HudConfig from(FileConfiguration c) { c.getString("pack.kick-message", "This server needs the BlueMinimap resource pack."), c.getString("pack.prompt", ""), c.getInt("pack.port", 8164), + c.getString("pack.bind", "").trim(), c.getString("pack.host", ""), c.getString("pack.public-url", "").trim(), c.getBoolean("pack.verify-public-url", true), diff --git a/src/main/java/com/extantape/blueminimap/pack/PackService.java b/src/main/java/com/extantape/blueminimap/pack/PackService.java index 3406021..e752f08 100644 --- a/src/main/java/com/extantape/blueminimap/pack/PackService.java +++ b/src/main/java/com/extantape/blueminimap/pack/PackService.java @@ -49,6 +49,7 @@ public final class PackService { private HudConfig.Pack config; private volatile PackBuilder.Built pack; private HttpServer server; + private String serverBind = ""; private int serverPort = -1; private volatile String servedPath = ""; private volatile byte[] servedBytes = new byte[0]; @@ -68,7 +69,7 @@ public boolean install(HudConfig.Pack config, PackBuilder.Built built) throws IO Files.write(exportPath(), built.zip()); servedPath = "/blueminimap-" + built.sha1Hex() + ".zip"; servedBytes = built.zip(); - if (config.send() && config.publicUrl().isBlank()) startServer(config.port()); + if (config.send() && config.publicUrl().isBlank()) startServer(config.port(), config.bind()); else stopServer(); if (!config.send()) checkMergedPackAsync(); else if (!config.publicUrl().isBlank()) checkPublicUrlAsync(); @@ -193,17 +194,29 @@ private URI packUri(Player player) { return URI.create("http://" + host + ":" + serverPort + servedPath); } - private void startServer(int port) throws IOException { - if (server != null && serverPort == port) return; + private void startServer(int port, String bind) throws IOException { + if (server != null && serverPort == port && bind.equals(serverBind)) return; stopServer(); + InetSocketAddress address = listenAddress(port, bind); + if (address == null) { + // A typo must never fall back to listening on every address. + plugin.getLogger().severe("pack.bind is \"" + bind + "\", which isn't an IP address, so the resource pack isn't hosted and the map stays hidden." + + " Use an address such as 127.0.0.1, or leave it empty to listen on every address."); + return; + } try { - server = HttpServer.create(new InetSocketAddress(port), 16); + server = HttpServer.create(address, 16); } catch (BindException e) { // Sending a dead URL would kick everyone when the pack is required, so send nothing instead. plugin.getLogger().severe("Port " + port + " is already in use, so the resource pack can't be hosted and the map stays hidden." + " Set pack.port to a free port, or set pack.public-url."); return; } + serverBind = bind; + if (!bind.isEmpty() && address.getAddress().isLoopbackAddress()) { + plugin.getLogger().info("The resource pack is served on " + bind + ":" + port + " only, so only players on this machine can download it from here." + + " Players elsewhere need pack.public-url (or pack.send: false with the files merged into your own pack)."); + } server.setExecutor(Executors.newFixedThreadPool(2, runnable -> { Thread thread = new Thread(runnable, "BlueMinimap-pack"); thread.setDaemon(true); @@ -298,4 +311,14 @@ private void checkMergedPackAsync() { } }); } + + /** Where the pack server listens: every address when {@code bind} is empty, or that IP address. Null if it isn't one. */ + static InetSocketAddress listenAddress(int port, String bind) { + if (bind.isEmpty()) return new InetSocketAddress(port); + try { + return new InetSocketAddress(java.net.InetAddress.ofLiteral(bind), port); + } catch (IllegalArgumentException notAnAddress) { + return null; + } + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e2002e2..f912a25 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -104,6 +104,11 @@ pack: kick-message: "This server needs the BlueMinimap resource pack." prompt: "BlueMinimap draws the map with this small resource pack." port: 8164 + # Address the pack server listens on. Empty listens on every address, as before. 127.0.0.1 keeps it on + # this machine only: then only players on this machine can download from it, so use pack.public-url (or + # send: false with the files in your own pack) for everyone else. Anything that isn't an IP address stops + # the pack server rather than listening everywhere. + bind: "" # Host players download from. Empty uses the address they connected to. host: "" # Host the pack elsewhere, such as a CDN or GitHub release. Overrides the port. diff --git a/src/test/java/com/extantape/blueminimap/pack/ListenAddressTest.java b/src/test/java/com/extantape/blueminimap/pack/ListenAddressTest.java new file mode 100644 index 0000000..e5da435 --- /dev/null +++ b/src/test/java/com/extantape/blueminimap/pack/ListenAddressTest.java @@ -0,0 +1,38 @@ +package com.extantape.blueminimap.pack; + +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; + +import static org.junit.jupiter.api.Assertions.*; + +/** pack.bind: where the pack server listens, and that a typo never means "everywhere". */ +class ListenAddressTest { + @Test void emptyListensOnEveryAddressAsBefore() { + InetSocketAddress a = PackService.listenAddress(8164, ""); + assertTrue(a.getAddress().isAnyLocalAddress()); + assertEquals(8164, a.getPort()); + } + + @Test void loopbackStaysOnThisMachine() { + InetSocketAddress a = PackService.listenAddress(8164, "127.0.0.1"); + assertTrue(a.getAddress().isLoopbackAddress()); + assertTrue(PackService.listenAddress(8164, "::1").getAddress().isLoopbackAddress()); + } + + @Test void aHostNameOrTypoIsRefusedNotWidened() { + assertNull(PackService.listenAddress(8164, "localhost"), "names aren't looked up"); + assertNull(PackService.listenAddress(8164, "127.0.0.l")); + assertNull(PackService.listenAddress(8164, "0.0.0.0/0")); + } + + @Test void aServerReallyListensOnlyWhereTold() throws Exception { + var server = com.sun.net.httpserver.HttpServer.create(PackService.listenAddress(0, "127.0.0.1"), 1); + try { + server.start(); + assertTrue(server.getAddress().getAddress().isLoopbackAddress()); + } finally { + server.stop(0); + } + } +} diff --git a/test/live/suite.js b/test/live/suite.js index 44663a1..285de01 100644 --- a/test/live/suite.js +++ b/test/live/suite.js @@ -18,7 +18,7 @@ const liveBots = []; // Quit after each group, so a crash can't leave a duplicat /** The shipped config with this server's ports. The clock is off unless a group turns it on, so idle tests stay idle. */ function baseConfig(edits = {}) { fs.copyFileSync(path.join(REPO, 'src/main/resources/config.yml'), CFG); - editYaml(CFG, { 'pack.port': packPort, 'tiles.http-url': `http://127.0.0.1:${webPort}`, 'hud.clock.enabled': false, ...edits }); + editYaml(CFG, { 'pack.port': packPort, 'pack.bind': '127.0.0.1', 'tiles.http-url': `http://127.0.0.1:${webPort}`, 'hud.clock.enabled': false, ...edits }); } async function reload(expectPush) { const m = s.mark(); s.cmd('blueminimap reload'); @@ -497,6 +497,14 @@ const GROUPS = { check('Pack is marked required', b.pushes[0]?.forced === true); check('Pack URL uses the address the player connected to', /^http:\/\/127\.0\.0\.1:/.test(b.pushes[0]?.url || ''), b.pushes[0]?.url); check('Pack downloads with a matching SHA-1', b.packSha === b.pushes[0]?.hash, b.packSha); + // pack.bind: 127.0.0.1 (every group uses it). The download above came through loopback; this Mac's own + // network address must be refused, so the port never reaches the LAN or the internet. + const net = require('net'), os = require('os'); + const lan = Object.values(os.networkInterfaces()).flat().find((i) => i && !i.internal && i.family === 'IPv4')?.address; + const reach = (host) => new Promise((res) => { const c = net.connect(packPort, host); c.once('connect', () => { c.destroy(); res(true); }); c.once('error', () => res(false)); setTimeout(() => { c.destroy(); res(false); }, 3000); }); + check('pack.bind 127.0.0.1: the pack port answers on loopback', await reach('127.0.0.1')); + if (!lan) skip("pack.bind 127.0.0.1: refused on this machine's network address", 'no non-loopback IPv4 address here'); + else check("pack.bind 127.0.0.1: refused on this machine's network address", !(await reach(lan)), lan); const pk = packOf(b); const adv = gameAdvances(b.packBytes); if (!adv) skip("This test's advances match the game's own font code", 'set BMH_CLIENT_CP to a Minecraft client classpath to run it');