Merge pull request #73 from funkemunky/copilot/add-allowlist-view-command

Add allowlist `show` and `search` subcommands with pagination
This commit is contained in:
Dawson
2026-02-20 10:47:26 -05:00
committed by GitHub
13 changed files with 411 additions and 664 deletions
@@ -20,7 +20,6 @@ import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import dev.brighten.antivpn.AntiVPN;
import dev.brighten.antivpn.message.VpnString;
import dev.brighten.antivpn.webhook.WebhookNotifier;
import lombok.Getter;
import lombok.Setter;
@@ -93,7 +92,6 @@ public abstract class APIPlayer {
if(cachedResult.response().getIp().equals(ip.getHostAddress())) {
AntiVPN.getInstance().getExecutor().log(Level.FINE, "Cached result for " + ip.getHostAddress() + " is " + cachedResult.resultType());
if(cachedResult.resultType().isShouldBlock()) {
WebhookNotifier.sendWebhookNotification(this, cachedResult);
AntiVPN.getInstance().getExecutor().handleKickingOfPlayer(cachedResult, this);
}
onResult.accept(cachedResult);
@@ -137,7 +135,6 @@ public abstract class APIPlayer {
checkResultCache.put(ip.getHostAddress(), new CheckResult(checkResult.response(), checkResult.resultType(), true));
if(checkResult.resultType().isShouldBlock()) {
WebhookNotifier.sendWebhookNotification(this, checkResult);
AntiVPN.getInstance().getExecutor().handleKickingOfPlayer(checkResult, this);
}
onResult.accept(checkResult);
@@ -44,10 +44,7 @@ public class VPNConfig {
defaultIp = new ConfigDefault<>("localhost", "database.ip", AntiVPN.getInstance()),
defaultAlertMsg = new ConfigDefault<>("&8[&6KauriVPN&8] &e%player% &7has joined on a VPN/proxy" +
" &8(&f%reason%&8) &7in location &8(&f%city%&7, &f%country%&8)", "alerts.message",
AntiVPN.getInstance()),
defaultWebhookUrl = new ConfigDefault<>("", "webhooks.url", AntiVPN.getInstance()),
defaultWebhookAuthToken = new ConfigDefault<>("", "webhooks.authToken", AntiVPN.getInstance()),
defaultWebhookFormat = new ConfigDefault<>("discord", "webhooks.format", AntiVPN.getInstance());
AntiVPN.getInstance());
private final ConfigDefault<Boolean> cacheResultsDefault = new ConfigDefault<>(true,
"cachedResults", AntiVPN.getInstance()),
defaultUseCredentials = new ConfigDefault<>(true,
@@ -60,12 +57,9 @@ public class VPNConfig {
AntiVPN.getInstance()),
defaultWhitelistCountries = new ConfigDefault<>(true, "countries.whitelist",
AntiVPN.getInstance()),
defaultMetrics = new ConfigDefault<>(true, "bstats", AntiVPN.getInstance()),
defaultWebhookEnabled = new ConfigDefault<>(false, "webhooks.enabled", AntiVPN.getInstance()),
defaultWebhookUseAuth = new ConfigDefault<>(false, "webhooks.useAuthentication", AntiVPN.getInstance());
defaultMetrics = new ConfigDefault<>(true, "bstats", AntiVPN.getInstance());
private final ConfigDefault<Integer>
defaultPort = new ConfigDefault<>(-1, "database.port", AntiVPN.getInstance()),
defaultWebhookTimeout = new ConfigDefault<>(5, "webhooks.timeout", AntiVPN.getInstance());
defaultPort = new ConfigDefault<>(-1, "database.port", AntiVPN.getInstance());
private final ConfigDefault<List<String>> prefixWhitelistsDefault = new ConfigDefault<>(new ArrayList<>(),
"prefixWhitelists", AntiVPN.getInstance()), defaultCommands = new ConfigDefault<>(
Collections.singletonList("kick %player% VPNs are not allowed on our server!"), "commands.execute",
@@ -112,18 +106,6 @@ public class VPNConfig {
private boolean alertToStaff;
private boolean metrics;
private boolean whitelistCountries;
@Getter
private String webhookUrl;
@Getter
private String webhookAuthToken;
@Getter
private String webhookFormat;
@Getter
private boolean webhookEnabled;
@Getter
private boolean webhookUseAuth;
@Getter
private int webhookTimeout;
/**
* If true, results will be cached to reduce queries to <a href="https://funkemunky.cc">...</a>
@@ -238,12 +220,6 @@ public class VPNConfig {
whitelistCountries = defaultWhitelistCountries.get();
countryKickCommands = defCountryKickCommands.get();
countryVanillaKickReason = defaultCountryKickReason.get();
webhookEnabled = defaultWebhookEnabled.get();
webhookUrl = defaultWebhookUrl.get();
webhookUseAuth = defaultWebhookUseAuth.get();
webhookAuthToken = defaultWebhookAuthToken.get();
webhookTimeout = defaultWebhookTimeout.get();
webhookFormat = defaultWebhookFormat.get();
}
}
@@ -76,7 +76,6 @@ public abstract class VPNExecutor {
}
public void handleKickingOfPlayer(CheckResult result, APIPlayer player) {
// Send webhook notification if enabled
//Ensuring kick task is always running
if(kickTask == null || kickTask.isDone() || kickTask.isCancelled()) {
@@ -29,7 +29,7 @@ import java.util.stream.Collectors;
public class AllowlistCommand extends Command {
private static final String[] secondArgs = new String[] {"add", "remove"};
private static final String[] secondArgs = new String[] {"add", "remove", "show", "search"};
@Override
public String permission() {
@@ -53,7 +53,7 @@ public class AllowlistCommand extends Command {
@Override
public String usage() {
return "<add/remove> <player/uuid/ip>";
return "<add <player/uuid/ip> | remove <player/uuid/ip> | show [page] | search <query> [page]>";
}
@Override
@@ -72,6 +72,89 @@ public class AllowlistCommand extends Command {
return "&cUsage: /antivpn allowlist " + usage();
}
if(args[0].equalsIgnoreCase("show")) {
// args[1] = optional page number (defaults to 1)
int page = 1;
if (args.length > 1) {
try {
page = Integer.parseInt(args[1]);
if (page < 1) page = 1;
} catch (NumberFormatException e) {
page = 1;
}
}
boolean databaseEnabled = AntiVPN.getInstance().getVpnConfig().isDatabaseEnabled();
List<UUID> uuids = databaseEnabled
? AntiVPN.getInstance().getDatabase().getAllWhitelisted()
: new ArrayList<>(AntiVPN.getInstance().getExecutor().getWhitelisted());
List<CIDRUtils> ips = databaseEnabled
? AntiVPN.getInstance().getDatabase().getAllWhitelistedIps()
: new ArrayList<>(AntiVPN.getInstance().getExecutor().getWhitelistedIps());
List<String> entries = new ArrayList<>();
for (UUID uuid : uuids) {
entries.add("&7- &fUUID: &e" + uuid);
}
for (CIDRUtils cidr : ips) {
entries.add("&7- &fIP: &e" + cidr.getCidr());
}
return buildPage(entries, page, null, "show");
}
if(args[0].equalsIgnoreCase("search")) {
// args[1..n-1] = query terms; args[n] = optional page number if last arg is an integer
if (args.length < 2) {
return "&cUsage: /antivpn allowlist search <query> [page]";
}
// Detect optional trailing page number
int page = 1;
int queryEnd = args.length;
try {
int candidate = Integer.parseInt(args[args.length - 1]);
if (candidate >= 1 && args.length > 2) {
page = candidate;
queryEnd = args.length - 1;
}
} catch (NumberFormatException ignored) {}
String search = String.join(" ", Arrays.copyOfRange(args, 1, queryEnd)).toLowerCase();
// Strip color code characters to prevent formatting injection in output
String safeSearch = search.replace("&", "");
if (safeSearch.isEmpty()) {
return "&cUsage: /antivpn allowlist search <query> [page]";
}
boolean databaseEnabled = AntiVPN.getInstance().getVpnConfig().isDatabaseEnabled();
List<UUID> uuids = databaseEnabled
? AntiVPN.getInstance().getDatabase().getAllWhitelisted()
: new ArrayList<>(AntiVPN.getInstance().getExecutor().getWhitelisted());
List<CIDRUtils> ips = databaseEnabled
? AntiVPN.getInstance().getDatabase().getAllWhitelistedIps()
: new ArrayList<>(AntiVPN.getInstance().getExecutor().getWhitelistedIps());
List<String> entries = new ArrayList<>();
for (UUID uuid : uuids) {
String entry = uuid.toString();
if (entry.toLowerCase().contains(search)) {
entries.add("&7- &fUUID: &e" + entry);
}
}
for (CIDRUtils cidr : ips) {
String entry = cidr.getCidr();
if (entry.toLowerCase().contains(search)) {
entries.add("&7- &fIP: &e" + entry);
}
}
return buildPage(entries, page, safeSearch, "search " + safeSearch);
}
if(args.length == 1)
return "&cYou have to provide a player to allow or deny exemption.";
@@ -201,11 +284,51 @@ public class AllowlistCommand extends Command {
case 1 -> Arrays.stream(secondArgs)
.filter(narg -> narg.toLowerCase().startsWith(args[0].toLowerCase()))
.collect(Collectors.toList());
case 2 -> AntiVPN.getInstance().getPlayerExecutor().getOnlinePlayers().stream()
case 2 -> {
if (args[0].equalsIgnoreCase("show") || args[0].equalsIgnoreCase("search")) {
yield Collections.emptyList();
}
yield AntiVPN.getInstance().getPlayerExecutor().getOnlinePlayers().stream()
.map(APIPlayer::getName)
.filter(name -> name.toLowerCase().startsWith(args[1].toLowerCase()))
.collect(Collectors.toList());
}
default -> Collections.emptyList();
};
}
private String buildPage(List<String> entries, int page, String safeSearch, String subcommandPrefix) {
int pageSize = 10;
int totalPages = Math.max(1, (entries.size() + pageSize - 1) / pageSize);
if (page > totalPages) page = totalPages;
List<String> messages = new ArrayList<>();
messages.add("&8&m-----------------------------------------------------");
messages.add("&6&lAllowlist Entries &8(&7Page &f" + page + "&7/&f" + totalPages + "&8)"
+ (safeSearch != null ? " &7(search: &f" + safeSearch + "&7)" : ""));
messages.add("");
if (entries.isEmpty()) {
messages.add(safeSearch != null
? "&cNo allowlist entries matching &f\"" + safeSearch + "&c\" were found."
: "&cThe allowlist is empty.");
} else {
int start = (page - 1) * pageSize;
int end = Math.min(start + pageSize, entries.size());
for (int i = start; i < end; i++) {
messages.add(entries.get(i));
}
if (totalPages > 1) {
messages.add("");
if (page > 1) {
messages.add("&7Previous page: &f/antivpn allowlist " + subcommandPrefix + " " + (page - 1));
}
if (page < totalPages) {
messages.add("&7Next page: &f/antivpn allowlist " + subcommandPrefix + " " + (page + 1));
}
}
}
messages.add("&8&m-----------------------------------------------------");
return String.join("\n", messages);
}
}
@@ -207,7 +207,7 @@ public class H2VPN implements VPNDatabase {
return;
try(var statement = Query.prepare("insert into `whitelisted-ranges` (`cidr_string`, `ip_start`, `ip_end`) values (?, ?, ?)")
.append(cidr.toString()).append(cidr.getStartIpInt()).append(cidr.getEndIpInt())) {
.append(cidr.getCidr()).append(cidr.getStartIpInt()).append(cidr.getEndIpInt())) {
statement.execute();
} catch (SQLException e) {
@@ -220,7 +220,7 @@ public class H2VPN implements VPNDatabase {
if (!AntiVPN.getInstance().getVpnConfig().isDatabaseEnabled() || MySQL.isClosed())
return;
try(var statement = Query.prepare("delete from `whitelisted-ranges` where `cidr_string` = ?").append(cidr.toString())) {
try(var statement = Query.prepare("delete from `whitelisted-ranges` where `cidr_string` = ?").append(cidr.getCidr())) {
statement.execute();
} catch (SQLException e) {
@@ -253,7 +253,11 @@ public class H2VPN implements VPNDatabase {
try(var statement = Query.prepare("select `cidr_string`, `ip_start`, `ip_end` from `whitelisted-ranges`")) {
statement.execute(set -> {
try {
ips.add(new CIDRUtils(set.getString("cidr_string")));
String cidrString = set.getString("cidr_string");
AntiVPN.getInstance().getExecutor().log("CIDR String: %s", cidrString);
ips.add(new CIDRUtils(cidrString));
} catch (UnknownHostException e) {
AntiVPN.getInstance().getExecutor()
.logException("Could not format ip "
@@ -0,0 +1,115 @@
/*
* Copyright 2026 Dawson Hessler
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.brighten.antivpn.database.local.version;
import dev.brighten.antivpn.AntiVPN;
import dev.brighten.antivpn.database.DatabaseException;
import dev.brighten.antivpn.database.VPNDatabase;
import dev.brighten.antivpn.database.sql.utils.Query;
import dev.brighten.antivpn.database.version.Version;
import dev.brighten.antivpn.utils.CIDRUtils;
import dev.brighten.antivpn.utils.MiscUtils;
import java.math.BigInteger;
import java.net.UnknownHostException;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
public class Third implements Version<VPNDatabase> {
@Override
public void update(VPNDatabase database) throws DatabaseException {
List<CIDRUtils> ipRanges = new ArrayList<>();
List<CIDRUtils> rangesToInsert = new ArrayList<>();
List<BigInteger[]> rangesToRemove = new ArrayList<>();
try (var preparedQuery = Query.prepare("select ip_start, ip_end from `whitelist-ranges`")) {
preparedQuery.execute(set -> {
BigInteger start = set.getBigDecimal("ip_start").toBigInteger();
BigInteger end = set.getBigDecimal("ip_end").toBigInteger();
try {
var range = MiscUtils.rangeToCidrs(start, end);
if(range.size() > 1) {
rangesToRemove.add(new BigInteger[]{start, end});
rangesToInsert.addAll(range);
AntiVPN.getInstance().getExecutor().log(Level.WARNING, "Found multiple CIDR ranges for whitelist range for %s, %s!", start, end);
} else ipRanges.addAll(range);
} catch (UnknownHostException e) {
AntiVPN.getInstance().getExecutor().logException(
String.format("Could not convert ip range to CIDR! %s, %s", start, end), e);
}
});
} catch (SQLException e) {
AntiVPN.getInstance().getExecutor().logException("Could not get all whitelisted ranges due to SQL error.", e);
}
AntiVPN.getInstance().getExecutor().log("Inserting %s new ranges into database...", rangesToInsert.size());
for (CIDRUtils cidr : rangesToInsert) {
try(var statement = Query.prepare("insert into `whitelisted-ranges` (`cidr_string`, `ip_start`, `ip_end`) values (?, ?, ?)")
.append(cidr.getCidr()).append(cidr.getStartIpInt()).append(cidr.getEndIpInt())) {
statement.execute();
} catch (SQLException e) {
AntiVPN.getInstance().getExecutor().logException("Could not add cidr '" + cidr + "' to whitelist due to SQL error.", e);
}
}
AntiVPN.getInstance().getExecutor().log("Removing %s old ranges from database...", rangesToRemove.size());
for (BigInteger[] range : rangesToRemove) {
try(var statement = Query.prepare("delete from `whitelisted-ranges` where `ip_start` = ? and `ip_end` = ?")) {
statement.append(range[0]).append(range[1]).execute();
} catch (SQLException e) {
AntiVPN.getInstance().getExecutor().logException("Could not remove cidr range '" + range[0] + ", " + range[1] + "' from whitelist due to SQL error.", e);
}
}
AntiVPN.getInstance().getExecutor().log("Updating %s ranges to proper CIDR notation with the database", ipRanges.size());
for (CIDRUtils cidr : ipRanges) {
try(var statement = Query.prepare("update `whitelisted-ranges` set `cidr_string` = ? where `ip_start` = ? and `ip_end` = ?")) {
statement.append(cidr.getCidr()).append(cidr.getStartIpInt()).append(cidr.getEndIpInt()).execute();
} catch (SQLException e) {
AntiVPN.getInstance().getExecutor().logException("Could not update cidr '" + cidr + "' to proper CIDR notation in whitelist due to SQL error.", e);
}
}
try (var preparedStatement = Query.prepare("INSERT INTO `database_version` (`version`) VALUES (?)").append(versionNumber())) {
preparedStatement.execute();
} catch (SQLException e) {
AntiVPN.getInstance().getExecutor().logException("Could not update database version to 2 due to SQL error.", e);
}
}
@Override
public int versionNumber() {
return 2;
}
@Override
public boolean needsUpdate(VPNDatabase database) {
try (var statement = Query.prepare("select * from `database_version` where version = 2")) {
try(var set = statement.executeQuery()) {
return set.getFetchSize() == 0;
}
} catch (SQLException e) {
return true;
}
}
}
@@ -183,7 +183,7 @@ public class MongoVPN implements VPNDatabase {
Document doc = new Document("setting", "whitelist");
doc.append("ip_start", new Decimal128(new BigDecimal(cidr.getStartIpInt())));
doc.append("ip_end", new Decimal128(new BigDecimal(cidr.getEndIpInt())));
doc.append("cidr_string", cidr.toString());
doc.append("cidr_string", cidr.getCidr());
settingsDocument.insertOne(doc);
}
@@ -210,7 +210,7 @@ public class MongoVPN implements VPNDatabase {
public List<CIDRUtils> getAllWhitelistedIps() {
List<CIDRUtils> ips = new ArrayList<>();
settingsDocument.find(Filters.and(Filters.eq("setting", "whitelist"),
Filters.exists("ip"))).forEach((Consumer<? super Document>) doc -> {
Filters.exists("cidr_string"))).forEach((Consumer<? super Document>) doc -> {
try {
var cidr = new CIDRUtils(doc.getString("cidr_string"));
ips.add(cidr);
@@ -0,0 +1,108 @@
/*
* Copyright 2026 Dawson Hessler
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package dev.brighten.antivpn.database.mongo.version;
import com.mongodb.client.model.Filters;
import dev.brighten.antivpn.AntiVPN;
import dev.brighten.antivpn.database.DatabaseException;
import dev.brighten.antivpn.database.mongo.MongoVPN;
import dev.brighten.antivpn.database.version.Version;
import dev.brighten.antivpn.utils.CIDRUtils;
import dev.brighten.antivpn.utils.MiscUtils;
import org.bson.Document;
import org.bson.types.Decimal128;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.logging.Level;
public class MongoThird implements Version<MongoVPN> {
@Override
public void update(MongoVPN database) throws DatabaseException {
List<CIDRUtils> ipRanges = new ArrayList<>();
List<CIDRUtils> rangesToInsert = new ArrayList<>();
List<BigInteger[]> rangesToRemove = new ArrayList<>();
database.settingsDocument.find(Filters.and(Filters.eq("setting", "whitelist"), Filters.exists("cidr_string")))
.forEach((Consumer<? super Document>) doc -> {
BigInteger start = doc.get("ip_start", Decimal128.class).bigDecimalValue().toBigInteger();
BigInteger end = doc.get("ip_end", Decimal128.class).bigDecimalValue().toBigInteger();
try {
var range = MiscUtils.rangeToCidrs(start, end);
if(range.size() > 1) {
rangesToRemove.add(new BigInteger[]{start, end});
rangesToInsert.addAll(range);
AntiVPN.getInstance().getExecutor().log(Level.WARNING, "Found multiple CIDR ranges for whitelist range for %s, %s!", start, end);
} else ipRanges.addAll(range);
} catch (UnknownHostException e) {
AntiVPN.getInstance().getExecutor().logException(
String.format("Could not convert ip range to CIDR! %s, %s", start, end), e);
}
});
if(!rangesToInsert.isEmpty()) {
AntiVPN.getInstance().getExecutor().log("Inserting %s new ranges into database...", rangesToInsert.size());
var documentsToInsert = rangesToInsert.stream().map(cidr -> {
Document doc = new Document("setting", "whitelist");
doc.append("ip_start", new Decimal128(new BigDecimal(cidr.getStartIpInt())));
doc.append("ip_end", new Decimal128(new BigDecimal(cidr.getEndIpInt())));
doc.append("cidr_string", cidr.getCidr());
return doc;
}).toList();
database.settingsDocument.insertMany(documentsToInsert);
}
if(!rangesToRemove.isEmpty()) {
AntiVPN.getInstance().getExecutor().log("Removing %s old ranges from database...", rangesToRemove.size());
rangesToRemove.forEach(range -> database.settingsDocument
.deleteMany(Filters.and(
Filters.gte("ip_start", new Decimal128(new BigDecimal(range[0]))),
Filters.lte("ip_end", new Decimal128(new BigDecimal(range[1]))))));
}
if(!ipRanges.isEmpty()) {
AntiVPN.getInstance().getExecutor().log("Updating %s CIDRs in database with proper notation...", ipRanges.size());
ipRanges.forEach(cidr -> database.settingsDocument
.updateMany(Filters.and(Filters.eq("setting", "whitelist"),
Filters.eq("ip_start", new Decimal128(new BigDecimal(cidr.getStartIpInt()))),
Filters.eq("ip_end", new Decimal128(new BigDecimal(cidr.getEndIpInt())))),
new Document("$set", new Document("cidr_string", cidr.getCidr()))));
}
var versionCollect = database.antivpnDatabase.getCollection("version");
versionCollect.insertOne(new Document("version", versionNumber()));
}
@Override
public int versionNumber() {
return 2;
}
@Override
public boolean needsUpdate(MongoVPN database) {
var versionCollect = database.antivpnDatabase.getCollection("version");
return versionCollect.find(Filters.eq("version", versionNumber())).first() == null;
}
}
@@ -20,9 +20,11 @@ import dev.brighten.antivpn.database.DatabaseException;
import dev.brighten.antivpn.database.local.H2VPN;
import dev.brighten.antivpn.database.local.version.First;
import dev.brighten.antivpn.database.local.version.Second;
import dev.brighten.antivpn.database.local.version.Third;
import dev.brighten.antivpn.database.mongo.MongoVPN;
import dev.brighten.antivpn.database.mongo.version.MongoFirst;
import dev.brighten.antivpn.database.mongo.version.MongoSecond;
import dev.brighten.antivpn.database.mongo.version.MongoThird;
import dev.brighten.antivpn.database.sql.MySqlVPN;
import dev.brighten.antivpn.database.sql.version.MySQLFirst;
@@ -32,7 +34,7 @@ public interface Version<DB> {
int versionNumber();
boolean needsUpdate(DB database);
Version<MongoVPN>[] mongoDbVersions = new Version[] {new MongoFirst(), new MongoSecond()};
Version<MySqlVPN>[] mysqlVersions = new Version[] {new MySQLFirst(), new Second()};
Version<H2VPN>[] h2Versions = new Version[] {new First(), new Second()};
Version<MongoVPN>[] mongoDbVersions = new Version[] {new MongoFirst(), new MongoSecond(), new MongoThird()};
Version<MySqlVPN>[] mysqlVersions = new Version[] {new MySQLFirst(), new Second(), new Third()};
Version<H2VPN>[] h2Versions = new Version[] {new First(), new Second(), new Third()};
}
@@ -22,6 +22,11 @@ import dev.brighten.antivpn.utils.json.JSONObject;
import dev.brighten.antivpn.utils.json.JsonReader;
import java.io.*;
import java.math.BigInteger;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ThreadFactory;
import java.util.regex.Pattern;
@@ -72,6 +77,43 @@ public class MiscUtils {
};
}
public static List<CIDRUtils> rangeToCidrs(BigInteger start, BigInteger end) throws UnknownHostException {
List<CIDRUtils> cidrs = new ArrayList<>();
while (start.compareTo(end) <= 0) {
// Find the number of trailing zero bits — this determines max block size alignment
int trailingZeros = start.equals(BigInteger.ZERO)
? 128 // handle the edge case
: start.getLowestSetBit();
// Find the largest block that fits
BigInteger remaining = end.subtract(start).add(BigInteger.ONE);
int maxBits = remaining.bitLength() - 1;
int blockBits = Math.min(trailingZeros, maxBits);
int prefixLen = 32 - blockBits; // use 128 for IPv6
// Build the CIDR string
byte[] addrBytes = toFixedLengthBytes(start, 4); // use 16 for IPv6
String cidr = InetAddress.getByAddress(addrBytes).getHostAddress() + "/" + prefixLen;
cidrs.add(new CIDRUtils(cidr));
// Advance past this block
start = start.add(BigInteger.ONE.shiftLeft(blockBits));
}
return cidrs;
}
private static byte[] toFixedLengthBytes(BigInteger value, int length) {
byte[] raw = value.toByteArray();
byte[] result = new byte[length];
int srcPos = Math.max(0, raw.length - length);
int destPos = Math.max(0, length - raw.length);
System.arraycopy(raw, srcPos, result, destPos, Math.min(raw.length, length));
return result;
}
public static UUID lookupUUID(String playername) {
try {
JSONObject object = JsonReader
@@ -1,284 +0,0 @@
package dev.brighten.antivpn.webhook;
import dev.brighten.antivpn.AntiVPN;
import dev.brighten.antivpn.api.APIPlayer;
import dev.brighten.antivpn.api.CheckResult;
import dev.brighten.antivpn.utils.json.JSONException;
import dev.brighten.antivpn.utils.json.JSONObject;
import java.io.IOException;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
/**
* Handles sending webhook notifications when a VPN is detected.
*/
public class WebhookNotifier {
/**
* Sends a webhook notification asynchronously when a player is detected using a VPN.
*
* @param player The player that was detected
* @param result The check result containing VPN information
*/
public static void sendWebhookNotification(APIPlayer player, CheckResult result) {
if (!AntiVPN.getInstance().getVpnConfig().isWebhookEnabled()) {
return;
}
String webhookUrl = AntiVPN.getInstance().getVpnConfig().getWebhookUrl();
if (webhookUrl == null || webhookUrl.trim().isEmpty()) {
AntiVPN.getInstance().getExecutor().log(Level.WARNING,
"Webhook is enabled but no URL is configured. Please set webhooks.url in config.yml");
return;
}
// Send webhook asynchronously to avoid blocking
CompletableFuture.runAsync(() -> {
try {
sendWebhook(webhookUrl, player, result);
} catch (Exception e) {
AntiVPN.getInstance().getExecutor().logException("Failed to send webhook notification", e);
}
}, AntiVPN.getInstance().getExecutor().getThreadExecutor());
}
/**
* Actually sends the HTTP POST request to the webhook URL.
*
* @param webhookUrl The URL to send the webhook to
* @param player The player information
* @param result The check result
* @throws IOException If there's an error sending the request
* @throws JSONException If there's an error creating the JSON payload
*/
private static void sendWebhook(String webhookUrl, APIPlayer player, CheckResult result)
throws IOException, JSONException {
URL url = new URL(webhookUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
try {
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("User-Agent", "AntiVPN-Webhook/1.0");
// Add authentication header if configured
if (AntiVPN.getInstance().getVpnConfig().isWebhookUseAuth()) {
String token = AntiVPN.getInstance().getVpnConfig().getWebhookAuthToken();
if (token != null && !token.trim().isEmpty()) {
connection.setRequestProperty("Authorization", "Bearer " + token);
}
}
connection.setDoOutput(true);
connection.setConnectTimeout(AntiVPN.getInstance().getVpnConfig().getWebhookTimeout() * 1000);
connection.setReadTimeout(AntiVPN.getInstance().getVpnConfig().getWebhookTimeout() * 1000);
// Create JSON payload
JSONObject payload = createPayload(player, result);
byte[] payloadBytes = payload.toString().getBytes(StandardCharsets.UTF_8);
// Send request
try (OutputStream os = connection.getOutputStream()) {
os.write(payloadBytes);
os.flush();
}
// Check response
int responseCode = connection.getResponseCode();
if (responseCode >= 200 && responseCode < 300) {
AntiVPN.getInstance().getExecutor().log(Level.INFO,
"Successfully sent webhook notification for player %s (response: %d)",
player.getName(), responseCode);
} else {
AntiVPN.getInstance().getExecutor().log(Level.WARNING,
"Webhook notification returned non-success status code %d for player %s",
responseCode, player.getName());
}
} finally {
connection.disconnect();
}
}
/**
* Creates the JSON payload for the webhook notification.
*
* @param player The player information
* @param result The check result
* @return JSONObject containing the webhook payload
* @throws JSONException If there's an error creating the JSON
*/
private static JSONObject createPayload(APIPlayer player, CheckResult result) throws JSONException {
String format = AntiVPN.getInstance().getVpnConfig().getWebhookFormat().toLowerCase();
switch (format) {
case "discord":
return createDiscordPayload(player, result);
case "slack":
return createSlackPayload(player, result);
default:
return createGenericPayload(player, result);
}
}
/**
* Creates a Discord-formatted webhook payload with rich embeds.
*
* @param player The player information
* @param result The check result
* @return JSONObject containing the Discord-formatted payload
* @throws JSONException If there's an error creating the JSON
*/
private static JSONObject createDiscordPayload(APIPlayer player, CheckResult result) throws JSONException {
JSONObject payload = new JSONObject();
// Create embed
JSONObject embed = new JSONObject();
// Set title and color based on result type
if (result.resultType().name().equals("DENIED_PROXY")) {
embed.put("title", "🚫 VPN/Proxy Detection");
embed.put("color", 15158332); // Red color
} else if (result.resultType().name().equals("DENIED_COUNTRY")) {
embed.put("title", "🌍 Country Blocked");
embed.put("color", 15105570); // Orange color
}
// Add description
embed.put("description", "A player attempted to join using a VPN/proxy or from a blocked country.");
// Add fields with player and detection information
JSONObject[] fields;
if (result.response() != null) {
fields = new JSONObject[] {
createDiscordField("Player", player.getName(), true),
createDiscordField("UUID", player.getUuid().toString(), true),
createDiscordField("IP Address", player.getIp().getHostAddress(), true),
createDiscordField("Country", result.response().getCountryName() + " (" + result.response().getCountryCode() + ")", true),
createDiscordField("City", result.response().getCity(), true),
createDiscordField("ISP", result.response().getIsp(), true),
createDiscordField("ASN", result.response().getAsn(), true),
createDiscordField("Detection Method", result.response().getMethod() != null ? result.response().getMethod() : "N/A", true),
createDiscordField("Proxy Status", result.response().isProxy() ? "✓ Detected" : "✗ Not Detected", true)
};
} else {
fields = new JSONObject[] {
createDiscordField("Player", player.getName(), true),
createDiscordField("UUID", player.getUuid().toString(), true),
createDiscordField("IP Address", player.getIp().getHostAddress(), true)
};
}
embed.put("fields", fields);
// Add timestamp in ISO 8601 format
java.time.Instant instant = java.time.Instant.ofEpochMilli(System.currentTimeMillis());
embed.put("timestamp", instant.toString());
// Add footer
JSONObject footer = new JSONObject();
footer.put("text", "AntiVPN Detection System");
embed.put("footer", footer);
// Add embed to payload
payload.put("embeds", new JSONObject[] { embed });
return payload;
}
/**
* Helper method to create a Discord embed field.
*
* @param name Field name
* @param value Field value
* @param inline Whether the field should be inline
* @return JSONObject containing the field
* @throws JSONException If there's an error creating the JSON
*/
private static JSONObject createDiscordField(String name, String value, boolean inline) throws JSONException {
JSONObject field = new JSONObject();
field.put("name", name);
field.put("value", value != null ? value : "N/A");
field.put("inline", inline);
return field;
}
/**
* Creates a Slack-formatted webhook payload.
*
* @param player The player information
* @param result The check result
* @return JSONObject containing the Slack-formatted payload
* @throws JSONException If there's an error creating the JSON
*/
private static JSONObject createSlackPayload(APIPlayer player, CheckResult result) throws JSONException {
JSONObject payload = new JSONObject();
// Build text message
StringBuilder text = new StringBuilder();
text.append("*VPN/Proxy Detection Alert*\n");
text.append("Player: ").append(player.getName()).append("\n");
text.append("IP: ").append(player.getIp().getHostAddress()).append("\n");
if (result.response() != null) {
text.append("Country: ").append(result.response().getCountryName())
.append(" (").append(result.response().getCountryCode()).append(")\n");
text.append("City: ").append(result.response().getCity()).append("\n");
text.append("ISP: ").append(result.response().getIsp()).append("\n");
if (result.response().getMethod() != null) {
text.append("Method: ").append(result.response().getMethod()).append("\n");
}
}
payload.put("text", text.toString());
return payload;
}
/**
* Creates a generic JSON payload (original format).
*
* @param player The player information
* @param result The check result
* @return JSONObject containing the generic payload
* @throws JSONException If there's an error creating the JSON
*/
private static JSONObject createGenericPayload(APIPlayer player, CheckResult result) throws JSONException {
JSONObject payload = new JSONObject();
// Basic event information
payload.put("event", "vpn_detected");
payload.put("timestamp", System.currentTimeMillis());
payload.put("resultType", result.resultType().name());
// Player information
JSONObject playerInfo = new JSONObject();
playerInfo.put("uuid", player.getUuid().toString());
playerInfo.put("name", player.getName());
playerInfo.put("ip", player.getIp().getHostAddress());
payload.put("player", playerInfo);
// VPN detection information
if (result.response() != null) {
JSONObject detectionInfo = new JSONObject();
detectionInfo.put("isProxy", result.response().isProxy());
detectionInfo.put("countryCode", result.response().getCountryCode());
detectionInfo.put("countryName", result.response().getCountryName());
detectionInfo.put("city", result.response().getCity());
detectionInfo.put("isp", result.response().getIsp());
detectionInfo.put("asn", result.response().getAsn());
if (result.response().getMethod() != null) {
detectionInfo.put("method", result.response().getMethod());
}
payload.put("detection", detectionInfo);
}
return payload;
}
}
@@ -40,24 +40,6 @@ commands:
- kick %player% VPNs are not allowed on our server!
# Enable/disable the default kicking feature of KauriVPN.
kickPlayers: true
# Configure webhook notifications when VPN is detected
webhooks:
# Enable/disable webhook notifications
enabled: false
# The webhook URL to send POST requests to when a VPN is detected
url: ''
# Webhook format type: 'discord', 'slack', or 'generic'
# - discord: Formats payload for Discord webhooks with rich embeds
# - slack: Formats payload for Slack webhooks
# - generic: Sends raw JSON payload (for custom integrations)
format: 'discord'
# Optional: Set to true to include authentication header (Authorization: Bearer <token>)
useAuthentication: false
# The authentication token to use when useAuthentication is true
# Security Note: Token is stored in plaintext. Ensure proper file permissions on this file.
authToken: ''
# Timeout in seconds for webhook requests (default: 5)
timeout: 5
# Configure all alerting functionality
alerts:
# You may set to 'false' to disable all alerts functionality
-317
View File
@@ -1,317 +0,0 @@
# Webhook Integration Guide
This document explains how to configure and use the webhook feature in AntiVPN to receive notifications when a player is detected using a VPN.
## Overview
When a player is detected using a VPN or connecting from a blocked country, AntiVPN can send an HTTP POST request to a configured webhook URL with detailed information about the detection. AntiVPN supports **Discord**, **Slack**, and **generic** webhook formats.
## Configuration
Add the following configuration to your `config.yml`:
```yaml
webhooks:
# Enable/disable webhook notifications
enabled: false
# The webhook URL to send POST requests to when a VPN is detected
url: ''
# Webhook format type: 'discord', 'slack', or 'generic'
# - discord: Formats payload for Discord webhooks with rich embeds (default)
# - slack: Formats payload for Slack webhooks
# - generic: Sends raw JSON payload (for custom integrations)
format: 'discord'
# Optional: Set to true to include authentication header (Authorization: Bearer <token>)
useAuthentication: false
# The authentication token to use when useAuthentication is true
# Security Note: Token is stored in plaintext. Ensure proper file permissions on this file.
authToken: ''
# Timeout in seconds for webhook requests (default: 5)
timeout: 5
```
### Configuration Options
- **enabled**: Set to `true` to enable webhook notifications
- **url**: The complete URL where webhook POST requests will be sent
- **format**: The webhook format type (`discord`, `slack`, or `generic`)
- **useAuthentication**: Set to `true` to include an `Authorization: Bearer <token>` header
- **authToken**: The authentication token to use (only used when `useAuthentication` is true)
- **timeout**: Connection and read timeout in seconds (default: 5)
## Webhook Formats
### Discord Format (format: 'discord')
Discord webhooks receive rich embeds with color-coded alerts and organized fields. This is the **recommended and default format** for Discord webhooks.
**Example Discord Payload:**
```json
{
"embeds": [{
"title": "🚫 VPN/Proxy Detection",
"description": "A player attempted to join using a VPN/proxy or from a blocked country.",
"color": 15158332,
"fields": [
{
"name": "Player",
"value": "ExamplePlayer",
"inline": true
},
{
"name": "UUID",
"value": "550e8400-e29b-41d4-a716-446655440000",
"inline": true
},
{
"name": "IP Address",
"value": "192.0.2.1",
"inline": true
},
{
"name": "Country",
"value": "United States (US)",
"inline": true
},
{
"name": "City",
"value": "New York",
"inline": true
},
{
"name": "ISP",
"value": "Example ISP",
"inline": true
},
{
"name": "ASN",
"value": "AS12345",
"inline": true
},
{
"name": "Detection Method",
"value": "Blacklist",
"inline": true
},
{
"name": "Proxy Status",
"value": "✓ Detected",
"inline": true
}
],
"timestamp": "2024-02-04T12:00:00.000Z",
"footer": {
"text": "AntiVPN Detection System"
}
}]
}
```
**Features:**
- Color coding: Red for proxy detections, Orange for country blocks
- Rich embeds with organized fields
- Timestamp included
- All detection information in one message
### Slack Format (format: 'slack')
Slack webhooks receive simple text messages with Slack markdown formatting.
**Example Slack Payload:**
```json
{
"text": "*VPN/Proxy Detection Alert*\nPlayer: ExamplePlayer\nIP: 192.0.2.1\nCountry: United States (US)\nCity: New York\nISP: Example ISP\nMethod: Blacklist\n"
}
```
**Features:**
- Simple text format with markdown
- All essential information included
- Works with standard Slack incoming webhooks
### Generic Format (format: 'generic')
Generic webhooks receive the raw JSON structure for custom integrations.
**Example Generic Payload:**
```json
{
"event": "vpn_detected",
"timestamp": 1707022000000,
"resultType": "DENIED_PROXY",
"player": {
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": "ExamplePlayer",
"ip": "192.0.2.1"
},
"detection": {
"isProxy": true,
"countryCode": "US",
"countryName": "United States",
"city": "New York",
"isp": "Example ISP",
"asn": "AS12345",
"method": "Blacklist"
}
}
```
**Features:**
- Complete JSON structure
- All fields included
- Best for custom backend integrations
## Payload Field Reference (Generic Format)
- **event**: Always set to `"vpn_detected"`
- **timestamp**: Unix timestamp in milliseconds when the detection occurred
- **resultType**: The type of detection result. Possible values:
- `"DENIED_PROXY"`: Player is using a VPN/proxy
- `"DENIED_COUNTRY"`: Player is connecting from a blocked country
- **player**: Player information
- **uuid**: Player's Minecraft UUID
- **name**: Player's username
- **ip**: Player's IP address
- **detection**: VPN detection details
- **isProxy**: Boolean indicating if a VPN/proxy was detected
- **countryCode**: ISO country code (e.g., "US", "GB")
- **countryName**: Full country name
- **city**: City name
- **isp**: Internet Service Provider name
- **asn**: Autonomous System Number
- **method**: Detection method used (e.g., "Blacklist", "Datacenter")
## Example Configurations
### Discord Webhook
```yaml
webhooks:
enabled: true
url: 'https://discord.com/api/webhooks/YOUR_WEBHOOK_ID/YOUR_WEBHOOK_TOKEN'
format: 'discord'
useAuthentication: false
authToken: ''
timeout: 5
```
**Note:** With `format: 'discord'`, AntiVPN will automatically format the webhook payload with rich embeds that Discord understands natively. No proxy service is needed!
### Slack Webhook
```yaml
webhooks:
enabled: true
url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
format: 'slack'
useAuthentication: false
authToken: ''
timeout: 5
```
### Custom Backend with Authentication
```yaml
webhooks:
enabled: true
url: 'https://your-server.com/api/vpn-alerts'
format: 'generic'
useAuthentication: true
authToken: 'your-secret-token-here'
timeout: 10
```
### Local Development Server
```yaml
webhooks:
enabled: true
url: 'http://localhost:8080/webhooks/vpn'
format: 'generic'
useAuthentication: false
authToken: ''
timeout: 5
```
## Testing Your Webhook
### Using the Test Server
A simple Python test server is available to verify webhook functionality:
```python
#!/usr/bin/env python3
import json
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
print(f"\nWebhook received at {datetime.now().isoformat()}")
print("Payload:", json.dumps(json.loads(body), indent=2))
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(b'{"status": "received"}')
if __name__ == '__main__':
server = HTTPServer(('localhost', 8080), WebhookHandler)
print("Test server running on http://localhost:8080")
server.serve_forever()
```
1. Save the above script as `test_server.py`
2. Run it: `python3 test_server.py`
3. Configure AntiVPN to use `http://localhost:8080` as the webhook URL
4. The server will display incoming webhooks in the console
## Security Considerations
1. **HTTPS**: Always use HTTPS URLs in production to encrypt webhook data in transit
2. **Authentication**: Enable authentication to prevent unauthorized webhook receivers from impersonating your endpoint
3. **Token Storage**: The authentication token is stored in plaintext in `config.yml`. Ensure proper file system permissions (e.g., `chmod 600 config.yml`)
4. **IP Whitelisting**: Consider implementing IP whitelisting on your webhook endpoint to only accept requests from your Minecraft server
5. **Rate Limiting**: Implement rate limiting on your webhook endpoint to prevent abuse
## Troubleshooting
### Webhook Not Firing
- Verify `enabled: true` in configuration
- Check that `url` is properly set and accessible
- Look for error messages in server logs
- Verify network connectivity from your Minecraft server to the webhook URL
### Authentication Failures
- Verify `useAuthentication` and `authToken` are properly configured
- Check that your webhook endpoint expects the `Authorization: Bearer <token>` header format
- Review webhook endpoint logs for authentication errors
### Timeout Errors
- Increase the `timeout` value in configuration
- Verify your webhook endpoint responds quickly (within timeout period)
- Check network latency between Minecraft server and webhook endpoint
## Common Use Cases
### Log Aggregation
Send webhook data to a log aggregation service like Splunk, ELK, or Datadog for analysis and alerting.
### Discord/Slack Notifications
Use a webhook proxy service to format and forward alerts to Discord or Slack channels.
### Automated Banning
Integrate with your server management system to automatically apply additional penalties to detected players.
### Analytics
Store webhook data in a database for analytics on VPN usage patterns, geographic distribution, and ISP trends.
### Compliance Logging
Maintain an audit trail of VPN detections for compliance and security purposes.