Initial Commit.

This commit is contained in:
Curle 2023-03-01 23:48:59 +00:00
commit 8e29aaaed2
34 changed files with 1389 additions and 0 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
#
# https://help.github.com/articles/dealing-with-line-endings/
#
# These are explicitly windows files and should use crlf
*.bat text eol=crlf

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
# Ignore Gradle project-specific cache directory
.gradle
# Ignore Gradle build output directory
build

21
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "java",
"name": "Current File",
"request": "launch",
"mainClass": "${file}"
},
{
"type": "java",
"name": "App",
"request": "launch",
"mainClass": "butler.App",
"projectName": "app"
}
]
}

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "interactive"
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

50
app/build.gradle Normal file
View File

@ -0,0 +1,50 @@
/*
* This file was generated by the Gradle 'init' task.
*
* This generated file contains a sample Java application project to get you started.
* For more details take a look at the 'Building Java & JVM projects' chapter in the Gradle
* User Manual available at https://docs.gradle.org/7.3/userguide/building_java_projects.html
*/
plugins {
// Apply the application plugin to add support for building a CLI application in Java.
id 'application'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
}
repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
maven {
url 'https://dogforce-games.com/maven'
}
}
dependencies {
// Use JUnit Jupiter for testing.
testImplementation 'org.junit.jupiter:junit-jupiter:5.7.2'
// This dependency is used by the application.
implementation 'net.sf.jopt-simple:jopt-simple:5.0.4'
implementation 'org.apache.logging.log4j:log4j-api:2.20.0'
implementation 'org.apache.logging.log4j:log4j-core:2.20.0'
implementation 'com.github.jgonian:commons-ip-math:1.32'
implementation 'dev.gigaherz.util.gddl:gddl2:2.0.0-alpha.2'
}
application {
// Define the main class for the application.
mainClass = 'butler.App'
}
tasks.named('test') {
// Use JUnit Platform for unit tests.
useJUnitPlatform()
}

View File

@ -0,0 +1,321 @@
package butler;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;
import com.sun.net.httpserver.HttpServer;
import butler.rule.Rule;
import butler.source.SourceData;
import joptsimple.OptionParser;
import joptsimple.OptionSet;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.config.Configurator;
import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import dev.gigaherz.util.gddl2.GDDL;
import dev.gigaherz.util.gddl2.exceptions.ParserException;
import dev.gigaherz.util.gddl2.structure.GddlDocument;
import dev.gigaherz.util.gddl2.structure.GddlElement;
import dev.gigaherz.util.gddl2.structure.GddlList;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/****************
*
* Butler
* A webhook filtering and forwarding tool
*
****************
*
* Behaviour is completely configuration-driven.
*
* The configuration allows you to specify the listening port and URL, as well as an array of rules.
* Rules determine what to do when an incoming webhook is received.
*
* Rules have two components; a list of filters, and a list of actions.
* Filters:
* - Allow you to narrow rules down to their source
* - Allow you to narrow rules down by webhook content
* - Allow you to narrow rules down by (optionally) the context sent by a Github Hookshot.
*
* Actions:
* - Allow you to rewrite webhook content
* - Allow you to forward a webhook via a different URL
* - Allow you to block a webhook so that it is not sent on
*
* Combined, this means that you can create rules that separate organization-wide Github webhooks to multiple Discord channels
* (the reason this project exists)
* or to simply not allow certain webhooks to be sent.
*
****************
*
* An example rule is provided that redirects Github Webhooks for the TheCurle/butler repository to example.com:
* ```
* {
* rules: [
* rule {
* name: "TheCurle/Butler to example.com",
* filters: [
* source { origin: "github.com" }
* github { user: "TheCurle", repo: "butler" }
* ]
* actions: {
* forward { url: "example.com/" }
* }
* }
* ]
* }
* ```
*
* An example rule is provided that prevents a custom webhook containing the word "milk" from being forwarded.
* ```
* {
* rules: [
* rule {
* name: "filter out milk",
* filters: [
* content { str: "milk" }
* ]
* actions: [
* reject {}
* ]
* }
* ]
* }
* ```
*
****************
*
* Rules and Names
*
* Rules are required to have names.
* This is an intentional decision designed to make it easier to navigate large configuration files with lots of rules.
* By string-searching the effect that a rule has, you can find the exact code that makes it happen.
*
* This is designed to put maintenance and longevity of the system first, though can seem like a strange and arbitrary decision at first.
*
****************
*
* Filter and Action Types
*
* Since filters and actions can have different parameters depending on what the type is, the type must be specified before any further parsing.
* This allows for an interesting kind of "dynamic dispatch", where the rest of the rule is parsed based on the type given.
*
* Eventually this will allow for custom types to be specified in addon code, but this is a very far-future idea.
*
****************
*
* Filtering Conditions
*
* Two "types" of filtering conditions are provided; source and content, with an extra GitHub integration provided.
*
* Source:
* - will attempt to match the URL where the webhook originated.
* the shortcut "github" is provided that matches a user-agent of "Github-Hookshot".
* other content is taken as a substring of the incoming URL.
* for example, if the remote IP has a R-DNS resolution, it will match that.
* Otherwise, the IP is matched directly.
* Content:
* - will attempt to match a substring of the webhook content.
* GitHub:
* github sources have a few shorthands:
* - "repository": matches the NAME of the repository that relates to this Github webhook
* - "user": matches the USER that OWNS THE REPOSITORY that relates to this Github webhook
* - "sender": matches the USER that TRIGGERED this Github webhook
* as an example, the user "octocat" pushing to "torvalds/linux" will have linux as the repository, torvalds as the user, and octocat as the sender.
*
****************
*
* Actions
*
* There are three main types of action: rewrite, forward, reject.
*
* Rewrite:
* - will replace all instances of a string in the JSON body with another string.
* Forward:
* - will send the webhook as-is to another URL (with an X-Proxy header added)
* Reject:
* - will reject the webhook and close the connection with a 403.
*
*/
public class App {
public static String version = "1.0f";
private static int DEFAULT_PORT = 6000;
private static String DEFAULT_CONFIG_PATH = "./config.gddl";
// The Web server listening for incoming webhook requests
private HttpServer server;
private ThreadPoolExecutor executor;
// The instance of the HttpHandler that actually redirects the webhooks.
private WebhookHandler handler;
// The configuration file set by the user, read once at read.
private GddlDocument config;
// The list of rules that set how the program should react to an incoming webhook.
private List<Rule> rules;
// The logger for this class
private static Logger logger = LogManager.getLogger(App.class);
public App(OptionSet options) {
int port = options.has("port") ? (int) options.valueOf("port") :
DEFAULT_PORT;
try {
server = HttpServer.create(new InetSocketAddress("localhost", port), 0);
} catch (IOException e) {
logger.error("[INIT] Failed to bind to port {}, aborting.", port);
System.exit(1);
}
String path = options.has("config") ? (String) options.valueOf("config") :
DEFAULT_CONFIG_PATH;
try {
config = GDDL.fromFile(path);
parseConfig();
} catch (ParserException | IOException e) {
logger.error("[INIT] Failed to load configuration file at path {}, aborting.", path);
System.exit(2);
}
executor = (ThreadPoolExecutor) Executors.newFixedThreadPool(5);
handler = new WebhookHandler();
logger.info("[INIT] Registering handler for all incoming webhooks.");
server.createContext("/", handler);
server.setExecutor(executor);
}
public void begin() {
server.start();
}
private void parseSources(List<SourceData> list, GddlElement<?> entry) {
logger.info("[CONF] Reading Sources list..");
if (!entry.isList()) {
logger.error("[CONF] Sources should be a list. Aborting..");
System.exit(11);
}
GddlList sources = entry.asList();
sources.forEach(source -> {
if (!source.isMap() || source.asMap().hasTypeName() || source.asMap().getTypeName() != "source") {
logger.error("[CONF] Source entry should be a map with type \"source\". Aborting.");
System.exit(12);
}
SourceData data = SourceData.from(source);
logger.info("[CONF] Adding source {} to list of known hosts.", data.host());
list.add(data);
});
}
private void parseRules(List<Rule> rules, List<SourceData> sources, GddlElement<?> entry) {
logger.info("[CONF] Reading Rules list..");
if (!entry.isList()) {
logger.error("[CONF] Rules should be a list. Aborting...");
System.exit(17);
}
GddlList list = entry.asList();
list.forEach(rule -> {
if (!rule.isMap() || !rule.asMap().hasTypeName() || !rule.asMap().getTypeName().equals("rule")) {
logger.error("[CONF] Rules entry should be a map with type \"rule\". Aborting.");
System.exit(18);
}
Rule data = Rule.from(rule, sources);
logger.info("[CONF] Adding rule {} to list of active rules.", data.getName());
rules.add(data);
});
}
private void parseConfig() {
GddlElement<?> root = config.getRoot();
if (!root.isMap() || !root.asMap().containsKey("rules") || !root.asMap().containsKey("sources")) {
logger.error("[INIT] Malformed configuration file! The root element should be a map of rules and sources.");
System.exit(3);
}
GddlMap cfgList = root.asMap();
rules = new ArrayList<Rule>();
List<SourceData> sources = new ArrayList<>();
cfgList.forEach((key, entry) -> {
if (key.equals("sources")) {
parseSources(sources, entry);
} else if (key.equals("rules")) {
parseRules(rules, sources, entry);
} else {
logger.error("[CONF] Unknown entry {} in configuration map. Aborting..", key);
System.exit(10);
}
});
}
public static void main(String[] args) {
Configurator.setAllLevels(LogManager.getRootLogger().getName(), Level.INFO);
logger.info("[INIT] Butler v{} starting.", version);
OptionParser parser = new OptionParser();
parser.accepts("port").withRequiredArg().ofType(Integer.class);
parser.accepts("config").withRequiredArg();
OptionSet opts = parser.parse(args);
App app = new App(opts);
int port = opts.has("port") ? (int) opts.valueOf("port") :
DEFAULT_PORT;
logger.info("[CORE] Listening for webhooks on port {}.", port);
app.begin();
}
/**
* The class that actually handles redirecting the webhooks.
* When a new request is received, the handle method is called.
* If the request is a GET, a 403 is returned.
* If the request is a PUT, it is handled as per the config.
* Generally, the webhook is ignored unless a specific flag is set, in which case it is modified and redirected to another server.
* This allows for easy webhook filtering and querying.
*/
class WebhookHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
logger.debug("[NETW] Incoming Request. {}:{}, from {}.", exchange.getRequestMethod(), exchange.getRequestURI(), exchange.getRemoteAddress());
if (exchange.getRequestMethod().equals("GET")) {
logger.info("[NETW] Refusing GET request from {}.", exchange.getRemoteAddress());
exchange.sendResponseHeaders(403, 0);
} else if (exchange.getRequestMethod().equals("POST")) {
logger.info("[NETW] POST from {}.", exchange.getRemoteAddress());
for (Rule rule : rules) {
logger.trace("[RULE] Testing rule {} against POST from {}.", rule.getName(), exchange.getRemoteAddress());
if (rule.tryFilters(exchange)) {
logger.info("[RULE] Rule {} matched. Applying actions.", rule.getName());
if (!rule.applyActions(exchange))
return;
}
}
exchange.sendResponseHeaders(200, 0);
exchange.close();
}
}
}
}

View File

@ -0,0 +1,10 @@
package butler.action;
import com.sun.net.httpserver.HttpExchange;
public interface Action {
/**
* Perform the given action against the given HttpExchange.
*/
boolean perform(HttpExchange exc);
}

View File

@ -0,0 +1,109 @@
package butler.action;
import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.ProtocolException;
import java.net.URL;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.sun.net.httpserver.HttpExchange;
import butler.App;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/**
* Takes the webhook headers and content, and sends it on to another URL.
* An X-Proxy header is added with the value of "Butler" followed by the version number.
*/
public class ForwardAction implements Action {
private Logger logger = LogManager.getLogger(ForwardAction.class);
private String url;
public ForwardAction(GddlMap map) {
url = map.values().stream().findFirst().get().stringValue();
}
@Override
public boolean perform(HttpExchange exc) {
logger.info("[NETW] Redirecting request from {} (target {}) to {}.", exc.getRemoteAddress().getHostString(), exc.getLocalAddress().getHostName(), url);
ByteArrayOutputStream result = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
try {
for (int length; (length = exc.getRequestBody().read(buffer)) != -1; ) {
result.write(buffer, 0, length);
}
} catch (IOException e) {
logger.error("[NETW] Unable to read request body for request from {}. Aborting.", exc.getRemoteAddress().getHostString());
return false;
}
String content;
try {
content = result.toString("UTF-8");
} catch (UnsupportedEncodingException e) {
logger.error("[NETW] Unable to read request body for request from {}. Aborting.", exc.getRemoteAddress().getHostString());
return false;
}
URL req;
HttpURLConnection conn;
try {
req = new URL(url);
} catch (MalformedURLException e) {
logger.error("[NETW] Unable to parse destination url {}. Aborting.", url);
return false;
}
try {
conn = (HttpURLConnection) req.openConnection();
} catch (IOException e) {
logger.error("[NETW] Unable to open a connection to {}. Aborting.", url);
return false;
}
try {
conn.setRequestMethod("POST");
exc.getRequestHeaders().forEach((name, val) -> conn.setRequestProperty(name, val.get(0)));
conn.setRequestProperty("X-Proxy", "Butler " + App.version);
} catch (ProtocolException e) {
logger.error("[NETW] Unable to set request method. Java is broken??");
return false;
}
conn.setDoOutput(true);
DataOutputStream out;
try {
conn.setFixedLengthStreamingMode(content.length());
conn.connect();
out = new DataOutputStream(conn.getOutputStream());
out.write(content.getBytes());
} catch (IOException e) {
logger.error("[NETW] Unable to send a request to target url {}. Aborting.", url);
return false;
}
try (BufferedReader res = new BufferedReader(new InputStreamReader(conn.getInputStream()))) {
StringBuilder sb = new StringBuilder();
String line;
while ((line = res.readLine()) != null)
sb.append(line);
logger.trace("[NETW] POST to {} returned string {}.", url, sb.toString());
} catch (IOException e) {
logger.error("[NETW] Failed to read response from {}. Aborting.", url);
System.exit(37);
}
return true;
}
}

View File

@ -0,0 +1,23 @@
package butler.action;
import com.sun.net.httpserver.HttpExchange;
import butler.rule.Rule;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/**
* An action that rejects the given webhook.
* Allows active rejection (ie. block IP ranges from making otherwise valid requests).
*/
public class RejectAction implements Action {
public RejectAction(GddlMap entry) {
}
@Override
public boolean perform(HttpExchange exc) {
Rule.logger.info("[RULE] Rejecting POST from {}.", exc.getRemoteAddress());
exc.close();
return false;
}
}

View File

@ -0,0 +1,13 @@
package butler.filter;
import com.sun.net.httpserver.HttpExchange;
public interface Filter {
/**
* Run the filter against the given HTTP request.
* @param exchange an in-progress HTTP request with all context given.
* @return whether or not the filter matched the request
*/
boolean test(HttpExchange exchange);
}

View File

@ -0,0 +1,32 @@
package butler.filter;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.List;
import java.util.stream.Collectors;
import com.sun.net.httpserver.HttpExchange;
import butler.rule.Rule;
import butler.source.SourceData;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/**
* Matches the content of the request body against a given string.
*/
public class FilterContent implements Filter {
private String content = "";
public FilterContent(GddlMap map, List<SourceData> sources) {
content = map.values().stream().findFirst().get().stringValue();
}
@Override
public boolean test(HttpExchange exchange) {
Rule.logger.trace("[RULE] Testing content of request against {}.", content);
String body = new BufferedReader(new InputStreamReader(exchange.getRequestBody())).lines().collect(Collectors.joining("\n"));
return body.contains(content);
}
}

View File

@ -0,0 +1,107 @@
package butler.filter;
import java.io.IOException;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import com.sun.net.httpserver.HttpExchange;
import butler.source.SourceData;
import dev.gigaherz.util.gddl2.GDDL;
import dev.gigaherz.util.gddl2.exceptions.ParserException;
import dev.gigaherz.util.gddl2.structure.GddlDocument;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/**
* A Github filter.
* Allows matching by user/repository, sender, and string content of the webhook.
*/
public class FilterGithub implements Filter {
private Logger logger = LogManager.getLogger(FilterGithub.class);
private String user = "";
private String sender = "";
private String repo = "";
private String action = "";
private String content = "";
public FilterGithub(GddlMap map, List<SourceData> sources) {
if (map.containsKey("user"))
user = map.get("user").stringValue();
if (map.containsKey("sender"))
sender = map.get("sender").stringValue();
if (map.containsKey("repo"))
repo = map.get("repo").stringValue();
if (map.containsKey("action"))
action = map.get("action").stringValue();
if (map.containsKey("content"))
content = map.get("content").stringValue();
}
@Override
public boolean test(HttpExchange exc) {
GddlDocument gddl;
try {
gddl = GDDL.fromStream(exc.getRequestBody());
} catch (ParserException | IOException e) {
logger.error("[RULE] Unable to read JSON content for Github Filter on request from {}. Aborting.", exc.getRemoteAddress());
return false;
}
GddlMap map = gddl.getRoot().asMap();
if (action.length() > 0) {
logger.trace("[RULE] Testing GitHub webhook action..");
List<String> fields = List.of(action.split("/")[0].split(","));
String ctx = action.split("/")[1];
logger.trace("[RULE] Searching for an action {} on {}.", ctx, fields);
for (String field : fields) {
if (!map.containsKey(field)) {
logger.trace("[RULE] Webhook does not contain field {}, skipping.", field);
return false;
}
}
if (!map.get("action").stringValue().equals(ctx)) {
logger.trace("[RULE] Webhook is not applying to action {}, skipping.", ctx);
}
}
if (sender.length() > 0) {
logger.trace("[RULE] Testing Github webhook user..");
String senderLogin = map.get("sender").asMap().get("login").stringValue();
if (!senderLogin.equals(sender)) {
logger.trace("[RULE] GitHub webhook sender {} does not match specified user {}, skipping..", senderLogin, sender);
return false;
}
}
if (repo.length() > 0) {
logger.trace("[RULE] Testing Github webhook repository..");
String repositoryName = map.get("repository").asMap().get("name").stringValue();
if (!repositoryName.equals(repo)) {
logger.trace("[RULE] GitHub webhook repository {} does not match specified repo {}, skipping.", repositoryName, repo);
return false;
}
if (user.length() > 0) {
String repositoryFullName = map.get("repository").asMap().get("full_name").stringValue();
if (!(user + "/" + repositoryName).equals(repositoryFullName)) {
logger.trace("[RULE] GitHub webhook repository {} does not belong to specified user {}, skipping", repositoryFullName, user);
}
}
}
if (content.length() > 0) {
logger.warn("[RULE] Github Content filter is unsupported. Skipping.");
}
return true;
}
}

View File

@ -0,0 +1,51 @@
package butler.filter;
import java.util.List;
import com.sun.net.httpserver.HttpExchange;
import butler.source.SourceData;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/**
* Filter by the source of the webhook.
* The remote address of the request is checked agains the content of the Source Filter.
* Optionally, if the source is a domain, then it is resolved and checked against the incoming URL.
* If a match is found, then the associated action is triggered.
*/
public class FilterSource implements Filter {
private String origin;
private List<SourceData> sources;
public FilterSource(GddlMap data, List<SourceData> knownSources) {
// The origin can be under any name, but it must be the first (and only) entry in the filter.
origin = data.values().stream().findFirst().get().stringValue();
sources = knownSources;
}
public boolean test(HttpExchange exc) {
// Match the literal address, if given.
String addr = exc.getRemoteAddress().getAddress().getHostAddress();
if (origin.equals(addr))
return true;
// If a Reverse DNS ip can be resolved, try that.
String host = exc.getRemoteAddress().getHostName();
if (!host.equals(addr) && host.equals(origin))
return true;
// Also match the Host header.
if (exc.getRequestHeaders().get("Host").get(0).equals(origin))
return true;
for (SourceData source : sources) {
// If the literal address doesn't match, try it as a host/nickname in the list of known sources.
if (origin.equals(source.host())) {
return source.test(exc.getRemoteAddress().getHostName());
}
}
return false;
}
}

View File

@ -0,0 +1,190 @@
package butler.rule;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.util.ArrayList;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import butler.action.Action;
import butler.filter.Filter;
import butler.source.SourceData;
import dev.gigaherz.util.gddl2.structure.GddlElement;
import dev.gigaherz.util.gddl2.structure.GddlList;
import dev.gigaherz.util.gddl2.structure.GddlMap;
import com.sun.net.httpserver.HttpExchange;
/**
* The core class that encodes the information of a redirect rule.
* A rule contains:
* - A name (for easy identification)
* - A set of conditions (if all are matched, the action is performed)
* - A set of actions
*
* Actions are performed in order; so if a request is dropped and then redirected, the redirect will fail.
*
*/
public class Rule {
// The name of this rule
private String name;
// The list of filters that specify whether the current rule should apply
private List<Filter> filters;
// The list of actions that should be executed when the current rule applies
private List<Action> actions;
public static Logger logger = LogManager.getLogger(Rule.class);
public Rule(String name, List<Filter> filters, List<Action> actions) {
this.name = name;
this.filters = filters;
this.actions = actions;
}
public String getName() {
return name;
}
/**
* Try all the filters in the rule against the given exchange.
* @param exc an active HttpExchange for an incoming webhook request.
* @return false if any filter failed, true if all filters matched.
*/
public boolean tryFilters(HttpExchange exc) {
for (Filter filter : filters) {
logger.trace("[RULE] ({}) Trying filter {} against {}.", name, filter.getClass().getSimpleName(), exc.getRemoteAddress());
if (!filter.test(exc)) {
logger.trace("[RULE] Filter failed.");
return false;
}
}
logger.trace("[RULE] All filters passed for rule {}.", name);
return true;
}
/**
* Apply all relevant actions of the rule against the given exchange.
*/
public boolean applyActions(HttpExchange exc) {
for (Action action : actions) {
logger.trace("[RULE] ({}) Applying action {} for request from {}.", name, action.getClass().getSimpleName(), exc.getRemoteAddress().getHostName());
// Terminal actions return false; stop processing if that happens.
if (!action.perform(exc))
return false;
}
return true;
}
public static Rule from(GddlElement<?> entry, List<SourceData> sources) {
GddlMap map = entry.asMap();
if (!map.containsKey("name")) {
Rule.logger.error("[CONF] Rules must have a name. Aborting..");
System.exit(4);
}
String name = map.get("name").asValue().stringValue();
if (!map.containsKey("filters") && !map.get("filters").isList() ) {
Rule.logger.error("[CONF] Rule {} must have a list of filters. Aborting..", name);
System.exit(5);
}
List<Filter> filters = new ArrayList<>();
GddlList filterList = map.get("filters").asList();
filterList.forEach(listEntry -> {
if (!listEntry.isMap()) {
Rule.logger.error("[CONF] Entry in the filters list is not a Map. Aborting..");
System.exit(6);
}
GddlMap eMap = listEntry.asMap();
if (!eMap.hasTypeName()) {
Rule.logger.error("[CONF] Entry in the filters list does not have a type. Aborting..");
System.exit(7);
}
String filterType = eMap.getTypeName();
String filterClassName = "butler.filter.Filter" + filterType.substring(0, 1).toUpperCase() + filterType.substring(1);
try {
Class<?> filterClass = Class.forName(filterClassName);
Constructor<?> filterCtor = filterClass.getConstructor(GddlMap.class, List.class);
filters.add((Filter) filterCtor.newInstance(eMap, (List<?>) sources));
} catch (ClassNotFoundException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which does not exist. Aborting..", filterType, filterClassName);
System.exit(8);
} catch (NoSuchMethodException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which does not have the correct constructor (String,List). Aborting..", filterType, filterClassName);
System.exit(20);
} catch (SecurityException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which could not be instantiated ({}). Aborting..", filterType, filterClassName, e.getMessage());
System.exit(21);
} catch (InstantiationException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which could not be instantiated ({}). Aborting..", filterType, filterClassName, e.getMessage());
System.exit(22);
} catch (IllegalAccessException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which could not be instantiated ({}). Aborting..", filterType, filterClassName, e.getMessage());
System.exit(23);
} catch (IllegalArgumentException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which could not be instantiated ({}). Aborting..", filterType, filterClassName, e.getMessage());
System.exit(24);
} catch (InvocationTargetException e) {
Rule.logger.error("[CONF] Filter type {} resolves to {}.class, which could not be instantiated ({}). Aborting..", filterType, filterClassName, e.getMessage());
System.exit(25);
}
});
if (!map.containsKey("actions") && !map.get("actions").isList() ) {
Rule.logger.error("[CONF] Rule {} must have a list of actions. Aborting..");
System.exit(26);
}
List<Action> actions = new ArrayList<>();
GddlList actionList = map.get("actions").asList();
actionList.forEach(listEntry -> {
if (!listEntry.isMap()) {
Rule.logger.error("[CONF] Entry in the actions list is not a Map. Aborting..");
System.exit(27);
}
GddlMap aMap = listEntry.asMap();
if (!aMap.hasTypeName()) {
Rule.logger.error("[CONF] Entry in the actions list does not have a type. Aborting..");
System.exit(28);
}
String actionType = aMap.getTypeName();
String actionClassName = "butler.action." + actionType.substring(0, 1).toUpperCase() + actionType.substring(1) + "Action";
try {
Class<?> actionClass = Class.forName(actionClassName);
Constructor<?> actionCtor = actionClass.getConstructor(GddlMap.class);
actions.add((Action) actionCtor.newInstance(aMap));
} catch (ClassNotFoundException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which does not exist. Aborting..", actionType, actionClassName);
System.exit(29);
} catch (NoSuchMethodException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which does not have the correct constructor (String). Aborting..", actionType, actionClassName);
System.exit(30);
} catch (SecurityException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which could not be instantiated. Aborting..", actionType, actionClassName);
System.exit(31);
} catch (InstantiationException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which could not be instantiated. Aborting..", actionType, actionClassName);
System.exit(32);
} catch (IllegalAccessException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which could not be instantiated. Aborting..", actionType, actionClassName);
System.exit(33);
} catch (IllegalArgumentException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which could not be instantiated. Aborting..", actionType, actionClassName);
System.exit(34);
} catch (InvocationTargetException e) {
Rule.logger.error("[CONF] Action type {} resolves to {}.class, which could not be instantiated. Aborting..", actionType, actionClassName);
System.exit(35);
}
});
return new Rule(name, filters, actions);
}
}

View File

@ -0,0 +1,74 @@
package butler.source;
import java.util.ArrayList;
import java.util.List;
import com.github.jgonian.ipmath.Ipv4;
import com.github.jgonian.ipmath.Ipv4Range;
import butler.rule.Rule;
import dev.gigaherz.util.gddl2.structure.GddlElement;
import dev.gigaherz.util.gddl2.structure.GddlList;
import dev.gigaherz.util.gddl2.structure.GddlMap;
/**
* Provides a list of data that can be used to identify webhook sources.
* They are provided in config by a list of strings of form x.y.z.w/a, which encodes a subnet.
* These are mapped to a single domain by name, or alternatively to a nickname.
*
*/
public record SourceData(String host, List<String> subnets) {
public boolean test(String ip) {
for (String subnet : subnets) {
if (Ipv4Range.from(subnet.split("/")[0]).andPrefixLength(subnet.split("/")[1]).contains(Ipv4.of(ip)))
return true;
}
return false;
}
/**
* Create a SourceData from the given GddlMap.
* @param source the GddlMap of a single entry in the "sources" list of the configuration file.
* @return the SourceData representing the single entry given.
*/
public static SourceData from(GddlElement<?> entry) {
if (!entry.isMap()) {
Rule.logger.error("Sources list entry is not a map. Aborting..");
System.exit(36);
}
GddlMap source = entry.asMap();
if (!source.containsKey("host") || source.containsKey("ranges")) {
Rule.logger.error("Source map should contain a host string and a list of ranges. Aborting..");
System.exit(13);
}
if (!source.get("host").isString()) {
Rule.logger.error("Source host should be a string. Aborting..");
System.exit(14);
}
if (!source.get("ranges").isList() || source.get("ranges").asList().size() == 0 || !source.get("ranges").asList().get(0).isString()) {
Rule.logger.error("Source ranges should be a non-empty list of Strings. Aborting..");
System.exit(15);
}
String host = source.get("host").stringValue();
List<String> addresses = new ArrayList<>();
GddlList ranges = source.get("ranges").asList();
ranges.forEach(range -> {
if (!range.isString()) {
Rule.logger.error("Source range entry should be a string. Aborting..");
System.exit(16);
}
addresses.add(range.stringValue());
});
return new SourceData(host, addresses);
}
}

View File

@ -0,0 +1,12 @@
/*
* This Java source file was generated by the Gradle 'init' task.
*/
package butler;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AppTest {
@Test void appHasAGreeting() {
}
}

23
config.gddl Normal file
View File

@ -0,0 +1,23 @@
{
sources: []
rules: [
rule {
name: "Not in my Christian Minecraft server!",
filters: [
content { str: "poo" }
]
actions: [
reject {}
]
}
rule {
name: "Test",
filters: [
source { url: "127.0.0.1" }
]
actions: [
forward { url: "https://webhook.site/bd2e20cf-6b52-4134-8387-8b6107455b77" }
]
}
]
}

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

234
gradlew vendored Executable file
View File

@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# 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
#
# https://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.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

89
gradlew.bat vendored Normal file
View File

@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

11
settings.gradle Normal file
View File

@ -0,0 +1,11 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
*
* Detailed information about configuring a multi-project build in Gradle can be found
* in the user manual at https://docs.gradle.org/7.3/userguide/multi_project_builds.html
*/
rootProject.name = 'butler'
include('app')