Merge pull request #72 from funkemunky/copilot/add-vpn-detection-webhooks

Add webhook notifications for VPN detection events with Discord and Slack support
This commit is contained in:
Dawson
2026-02-20 09:30:39 -05:00
committed by GitHub
6 changed files with 650 additions and 3 deletions
@@ -20,6 +20,7 @@ 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;
@@ -92,6 +93,7 @@ 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);
@@ -135,6 +137,7 @@ 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,7 +44,10 @@ 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());
AntiVPN.getInstance()),
defaultWebhookUrl = new ConfigDefault<>("", "webhooks.url", AntiVPN.getInstance()),
defaultWebhookAuthToken = new ConfigDefault<>("", "webhooks.authToken", AntiVPN.getInstance()),
defaultWebhookFormat = new ConfigDefault<>("discord", "webhooks.format", AntiVPN.getInstance());
private final ConfigDefault<Boolean> cacheResultsDefault = new ConfigDefault<>(true,
"cachedResults", AntiVPN.getInstance()),
defaultUseCredentials = new ConfigDefault<>(true,
@@ -57,9 +60,12 @@ public class VPNConfig {
AntiVPN.getInstance()),
defaultWhitelistCountries = new ConfigDefault<>(true, "countries.whitelist",
AntiVPN.getInstance()),
defaultMetrics = new ConfigDefault<>(true, "bstats", 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());
private final ConfigDefault<Integer>
defaultPort = new ConfigDefault<>(-1, "database.port", AntiVPN.getInstance());
defaultPort = new ConfigDefault<>(-1, "database.port", AntiVPN.getInstance()),
defaultWebhookTimeout = new ConfigDefault<>(5, "webhooks.timeout", 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",
@@ -106,6 +112,18 @@ 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>
@@ -220,6 +238,12 @@ 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,6 +76,7 @@ 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()) {
@@ -0,0 +1,284 @@
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,6 +40,24 @@ 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
@@ -0,0 +1,317 @@
# 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.