feat(stealth): mask identities and notify admins on login
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m6s

This commit is contained in:
dmg
2026-09-06 21:19:59 -04:00
parent 831f6a2ce4
commit 1addc93062
17 changed files with 677 additions and 11 deletions
@@ -18,6 +18,18 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
private final Supplier<SleepCountPolicy> sleepCountPolicy;
private final Map<UUID, Player> concealedPlayers = new LinkedHashMap<>();
private final Map<UUID, Boolean> previousSleepingIgnored = new LinkedHashMap<>();
private final Map<UUID, String> previousDisplayNames = new LinkedHashMap<>();
private volatile java.util.Set<String> concealedNames = java.util.Set.of();
/** Immutable snapshot safe to read from outgoing packet threads. */
public java.util.Set<String> concealedNames() {
return concealedNames;
}
private void publishNames() {
concealedNames = concealedPlayers.values().stream().map(Player::getName)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
}
public BukkitIdentityPresentation(
Supplier<? extends Collection<? extends Player>> onlinePlayers,
@@ -40,12 +52,17 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
@Override
public void conceal(Player player) {
concealedPlayers.put(player.getUniqueId(), player);
publishNames();
previousDisplayNames.putIfAbsent(player.getUniqueId(), player.getDisplayName());
player.setDisplayName("§kAnonymous§r");
applySleepCountPolicy(player);
Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team == null) {
team = scoreboard.registerNewTeam(teamName(player.getUniqueId()));
}
team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.NEVER);
team.setOption(Team.Option.NAME_TAG_VISIBILITY, Team.OptionStatus.ALWAYS);
team.setPrefix("§k");
team.setSuffix("§r");
team.addEntry(player.getName());
for (Player observer : onlinePlayers.get()) {
if (!observer.getUniqueId().equals(player.getUniqueId())) {
@@ -57,6 +74,10 @@ public final class BukkitIdentityPresentation implements IdentityPresentation {
@Override
public void reveal(Player player) {
boolean wasConcealed = concealedPlayers.remove(player.getUniqueId()) != null;
publishNames();
if (previousDisplayNames.containsKey(player.getUniqueId())) {
player.setDisplayName(previousDisplayNames.remove(player.getUniqueId()));
}
restoreSleepingIgnored(player);
Team team = scoreboard.getTeam(teamName(player.getUniqueId()));
if (team != null) {
@@ -0,0 +1,46 @@
package games.dmg.spigotstealth;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.lang.reflect.RecordComponent;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.UnaryOperator;
/** Small reflective bridge: avoids a compile/runtime dependency on a specific NMS or Brigadier version. */
final class IdentityPacketFields {
private IdentityPacketFields() { }
static List<?> filterEntries(List<?> entries, StealthChatMasker masker) throws ReflectiveOperationException {
List<Object> filtered = new ArrayList<>();
for (Object entry : entries) {
Method text;
try {
text = entry.getClass().getMethod("text");
} catch (NoSuchMethodException exception) {
text = entry.getClass().getMethod("getText");
}
if (!masker.isConcealedName((String) text.invoke(entry))) {
filtered.add(entry);
}
}
return List.copyOf(filtered);
}
static Object mapRecord(Object record, UnaryOperator<Object> mapper) throws ReflectiveOperationException {
RecordComponent[] components = record.getClass().getRecordComponents();
if (components == null) {
throw new IllegalArgumentException("Unsupported non-record chat binding");
}
Class<?>[] types = new Class<?>[components.length];
Object[] values = new Object[components.length];
for (int i = 0; i < components.length; i++) {
types[i] = components[i].getType();
Object value = components[i].getAccessor().invoke(record);
values[i] = value instanceof Optional<?> optional ? optional.map(mapper) : mapper.apply(value);
}
Constructor<?> constructor = record.getClass().getDeclaredConstructor(types);
return constructor.newInstance(values);
}
}
@@ -0,0 +1,136 @@
package games.dmg.spigotstealth;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.PacketAdapter;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.reflect.StructureModifier;
import com.comphenix.protocol.utility.MinecraftReflection;
import com.comphenix.protocol.wrappers.WrappedChatComponent;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import java.util.function.Supplier;
import org.bukkit.plugin.Plugin;
/**
* Outgoing identity-only masking. Registration is deliberately left to the plugin.
* The supplier must return an immutable, safely published snapshot (for example AtomicReference::get).
* It must not inspect Bukkit players or mutable session collections on the packet thread.
*/
public final class ProtocolLibIdentityMaskingListener extends PacketAdapter {
private final Supplier<Set<String>> concealedNames;
private final Consumer<String> warningLog;
private final Set<PacketType> warned = ConcurrentHashMap.newKeySet();
public ProtocolLibIdentityMaskingListener(
Plugin plugin, Supplier<Set<String>> concealedNames, Consumer<String> warningLog) {
// ProtocolLib calls the vanilla PLAYER_CHAT packet CHAT.
super(plugin, ListenerPriority.HIGHEST, PacketType.Play.Server.TAB_COMPLETE,
PacketType.Play.Server.SYSTEM_CHAT, PacketType.Play.Server.CHAT,
PacketType.Play.Server.DISGUISED_CHAT);
this.concealedNames = Objects.requireNonNull(concealedNames, "concealedNames");
this.warningLog = Objects.requireNonNull(warningLog, "warningLog");
}
@Override
public void onPacketSending(PacketEvent event) {
if (event.isCancelled()) {
return;
}
try {
Set<String> snapshot = concealedNames.get();
if (snapshot.isEmpty()) {
return;
}
StealthChatMasker masker = new StealthChatMasker(snapshot);
PacketContainer packet = event.getPacket().shallowClone();
if (event.getPacketType().equals(PacketType.Play.Server.TAB_COMPLETE)) {
filterSuggestions(packet, masker);
} else if (event.getPacketType().equals(PacketType.Play.Server.SYSTEM_CHAT)) {
maskSystemChat(packet, masker);
} else {
maskBoundNames(packet, masker);
}
event.setPacket(packet);
} catch (ReflectiveOperationException | RuntimeException exception) {
// Keep delivery usable; do not cancel signed chat and break the acknowledgement chain.
if (warned.add(event.getPacketType())) {
warningLog.accept("Stealth identity masking unavailable for " + event.getPacketType()
+ "; original packet retained. Failure: " + exception.getClass().getSimpleName());
}
}
}
private static void filterSuggestions(PacketContainer packet, StealthChatMasker masker)
throws ReflectiveOperationException {
StructureModifier<Object> fields = packet.getModifier();
for (int i = 0; i < fields.size(); i++) {
Object value = fields.read(i);
if (value instanceof List<?> entries) {
// Modern ClientboundCommandSuggestionsPacket stores its Entry list directly.
fields.write(i, IdentityPacketFields.filterEntries(entries, masker));
return;
}
if (value != null && value.getClass().getName().equals("com.mojang.brigadier.suggestion.Suggestions")) {
Object range = value.getClass().getMethod("getRange").invoke(value);
List<?> entries = (List<?>) value.getClass().getMethod("getList").invoke(value);
List<?> filtered = IdentityPacketFields.filterEntries(entries, masker);
fields.write(i, value.getClass().getConstructor(range.getClass(), List.class).newInstance(range, filtered));
return;
}
}
// Pre-Brigadier protocol compatibility.
StructureModifier<String[]> arrays = packet.getStringArrays();
if (arrays.size() > 0) {
arrays.write(0, java.util.Arrays.stream(arrays.read(0))
.filter(name -> !masker.isConcealedName(name)).toArray(String[]::new));
return;
}
throw new IllegalStateException("Unsupported command suggestions layout");
}
private static void maskSystemChat(PacketContainer packet, StealthChatMasker masker) {
StructureModifier<WrappedChatComponent> components = packet.getChatComponents();
if (components.size() > 0) {
WrappedChatComponent original = components.read(0);
String json = original.getJson();
String masked = masker.maskAnnouncement(json);
if (!json.equals(masked)) {
components.write(0, WrappedChatComponent.fromJson(masked));
}
} else if (packet.getStrings().size() > 0) {
// Older SYSTEM_CHAT represents its component as JSON text.
String json = packet.getStrings().read(0);
packet.getStrings().write(0, masker.maskAnnouncement(json));
} else {
throw new IllegalStateException("Unsupported system chat layout");
}
}
private static void maskBoundNames(PacketContainer packet, StealthChatMasker masker)
throws ReflectiveOperationException {
StructureModifier<Object> fields = packet.getModifier();
for (int i = 0; i < fields.size(); i++) {
Object value = fields.read(i);
if (value != null && (value.getClass().getName().endsWith("ChatType$BoundNetwork")
|| value.getClass().getName().endsWith("ChatType$Bound"))) {
Object masked = IdentityPacketFields.mapRecord(value, field -> {
if (field != null && MinecraftReflection.getIChatBaseComponentClass().isInstance(field)) {
String json = WrappedChatComponent.fromHandle(field).getJson();
String replacement = masker.maskDisplayName(json);
return json.equals(replacement) ? field : WrappedChatComponent.fromJson(replacement).getHandle();
}
return field;
});
fields.write(i, masked);
return;
}
}
// Intentionally never touch signed body, unsigned body, UUID, signature, or filter mask.
throw new IllegalStateException("Unsupported player chat display-name layout");
}
}
@@ -77,11 +77,14 @@ public final class SpigotStealthPlugin extends JavaPlugin {
manager, settings.unlockThreshold(), System::nanoTime, notifier);
sessions = new StealthSessionService(manager, progression);
protocolManager = ProtocolLibrary.getProtocolManager();
identityPresentation = new BukkitIdentityPresentation(
BukkitIdentityPresentation bukkitPresentation = new BukkitIdentityPresentation(
getServer()::getOnlinePlayers,
Objects.requireNonNull(getServer().getScoreboardManager(), "scoreboard manager").getMainScoreboard(),
new ProtocolLibTabListController(protocolManager),
() -> manager.snapshot().sleepCountPolicy());
identityPresentation = bukkitPresentation;
protocolManager.addPacketListener(new ProtocolLibIdentityMaskingListener(
this, bukkitPresentation::concealedNames, getLogger()::warning));
protocolManager.addPacketListener(new ProtocolLibServerListPingListener(
this, sessions::concealedPlayerIds, getLogger()::warning));
getServer().getPluginManager().registerEvents(
@@ -90,6 +93,8 @@ public final class SpigotStealthPlugin extends JavaPlugin {
new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents(
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
getServer().getPluginManager().registerEvents(
new StealthAdminJoinListener(sessions, getServer()::getOnlinePlayers), this);
org.bukkit.command.PluginCommand stealthPluginCommand =
Objects.requireNonNull(getCommand("stealth"), "stealth command");
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
@@ -0,0 +1,49 @@
package games.dmg.spigotstealth;
import java.util.Collection;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
/** Private administrative notices after the HIGHEST-priority session transition. */
public final class StealthAdminJoinListener implements Listener {
private static final String PERMISSION = "spigotstealth.admin";
private static final String HINT = " Use /stealthadmin list to see who.";
private final StealthSessionService sessions;
private final Supplier<? extends Collection<? extends Player>> onlinePlayers;
public StealthAdminJoinListener(
StealthSessionService sessions,
Supplier<? extends Collection<? extends Player>> onlinePlayers) {
this.sessions = Objects.requireNonNull(sessions, "sessions");
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
Player joining = event.getPlayer();
Set<UUID> concealed = sessions.concealedPlayerIds();
int count = concealed.size();
if (count == 0) {
return;
}
String quantity = count + (count == 1 ? " player is" : " players are");
if (joining.hasPermission(PERMISSION)) {
joining.sendMessage("[Stealth] " + quantity + " invisible." + HINT);
}
if (concealed.contains(joining.getUniqueId())) {
String notice = "[Stealth] An invisible player joined. " + quantity + " now invisible." + HINT;
for (Player observer : onlinePlayers.get()) {
if (!observer.getUniqueId().equals(joining.getUniqueId()) && observer.hasPermission(PERMISSION)) {
observer.sendMessage(notice);
}
}
}
}
}
@@ -0,0 +1,117 @@
package games.dmg.spigotstealth;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParseException;
import com.google.gson.JsonParser;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/** Masks semantic name slots, never searching/replacing user-authored message text. */
public final class StealthChatMasker {
private static final Set<String> NAME_FIRST_TRANSLATIONS = Set.of(
"chat.type.text", "chat.type.announcement", "chat.type.emote",
"commands.message.display.incoming", "commands.message.display.outgoing",
"chat.type.advancement.task", "chat.type.advancement.goal", "chat.type.advancement.challenge");
private final Set<String> names;
public StealthChatMasker(Set<String> concealedNames) {
names = concealedNames.stream().map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toUnmodifiableSet());
}
public boolean isConcealedName(String text) {
return names.contains(text.toLowerCase(Locale.ROOT));
}
public String maskAnnouncement(String json) {
try {
JsonElement component = JsonParser.parseString(json);
return maskAnnouncement(component) ? component.toString() : json;
} catch (JsonParseException | IllegalStateException exception) {
return json;
}
}
public String maskDisplayName(String json) {
try {
return isConcealedName(plainName(JsonParser.parseString(json))) ? anonymous().toString() : json;
} catch (JsonParseException | IllegalStateException exception) {
return json;
}
}
private boolean maskAnnouncement(JsonElement component) {
boolean changed = false;
if (component.isJsonArray()) {
for (JsonElement child : component.getAsJsonArray()) {
changed |= maskAnnouncement(child);
}
if (changed && !component.getAsJsonArray().isEmpty()) {
clearInheritedActions(component.getAsJsonArray().get(0));
}
} else if (component.isJsonObject()) {
JsonObject object = component.getAsJsonObject();
// Translation arguments other than the explicitly known name slot are message content.
if (object.has("translate") && object.get("translate").isJsonPrimitive()
&& NAME_FIRST_TRANSLATIONS.contains(object.get("translate").getAsString())
&& object.has("with") && object.get("with").isJsonArray()) {
JsonArray arguments = object.getAsJsonArray("with");
if (!arguments.isEmpty() && isConcealedName(plainName(arguments.get(0)))) {
arguments.set(0, anonymous());
changed = true;
}
}
if (object.has("extra")) {
changed |= maskAnnouncement(object.get("extra"));
}
// Prevent masked descendants from inheriting an identifying action from their parent.
if (changed) {
clearInheritedActions(object);
}
}
return changed;
}
private static void clearInheritedActions(JsonElement component) {
if (component.isJsonObject()) {
for (String key : Set.of("hoverEvent", "clickEvent", "hover_event", "click_event", "insertion")) {
component.getAsJsonObject().remove(key);
}
} else if (component.isJsonArray() && !component.getAsJsonArray().isEmpty()) {
clearInheritedActions(component.getAsJsonArray().get(0));
}
}
private static String plainName(JsonElement component) {
if (component.isJsonPrimitive()) {
return component.getAsString();
}
StringBuilder text = new StringBuilder();
if (component.isJsonArray()) {
for (JsonElement child : component.getAsJsonArray()) {
text.append(plainName(child));
}
} else if (component.isJsonObject()) {
JsonObject object = component.getAsJsonObject();
if (object.has("text") && object.get("text").isJsonPrimitive()) {
text.append(object.get("text").getAsString());
}
if (object.has("extra")) {
text.append(plainName(object.get("extra")));
}
}
return text.toString();
}
private static JsonObject anonymous() {
JsonObject alias = new JsonObject();
alias.addProperty("text", "Anonymous");
alias.addProperty("obfuscated", true);
alias.add("hoverEvent", com.google.gson.JsonNull.INSTANCE);
alias.add("clickEvent", com.google.gson.JsonNull.INSTANCE);
alias.addProperty("insertion", "");
return alias;
}
}