Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ These minimums come from BlueMap's release notes. Only BlueMap 5.29 was tested,
## Install

1. Put `BlueMinimap-<version>.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`.
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ plugins {
}

group = "com.extantape"
version = "0.2.0"
version = "0.2.1"

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
Expand Down
14 changes: 14 additions & 0 deletions docs/releases/v0.2.1.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions docs/test-audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public record Players(int radius, boolean hideVanished, boolean hideSpectators,
public record Markers(boolean enabled, List<String> include, List<String> 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) {}

Expand Down Expand Up @@ -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),
Expand Down
31 changes: 27 additions & 4 deletions src/main/java/com/extantape/blueminimap/pack/PackService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
}
5 changes: 5 additions & 0 deletions src/main/resources/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
10 changes: 9 additions & 1 deletion test/live/suite.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
Loading