origin blacklist test

This commit is contained in:
LAX1DUDE 2022-05-25 23:27:29 -07:00
parent cd698532a7
commit bc3d87dd0a
13 changed files with 471 additions and 14 deletions

View File

@ -61,6 +61,10 @@ import net.md_5.bungee.command.CommandPerms;
import net.md_5.bungee.command.CommandBungee;
import net.md_5.bungee.command.CommandClearRatelimit;
import net.md_5.bungee.command.CommandConfirmCode;
import net.md_5.bungee.command.CommandDomain;
import net.md_5.bungee.command.CommandDomainBlock;
import net.md_5.bungee.command.CommandDomainBlockDomain;
import net.md_5.bungee.command.CommandDomainUnblock;
import net.md_5.bungee.command.CommandAlert;
import net.md_5.bungee.command.CommandIP;
import net.md_5.bungee.command.CommandServer;
@ -72,6 +76,7 @@ import net.md_5.bungee.command.CommandReload;
import net.md_5.bungee.scheduler.BungeeScheduler;
import net.md_5.bungee.config.YamlConfig;
import net.md_5.bungee.eaglercraft.BanList;
import net.md_5.bungee.eaglercraft.DomainBlacklist;
import net.md_5.bungee.eaglercraft.PluginEaglerSkins;
import net.md_5.bungee.eaglercraft.WebSocketListener;
@ -156,6 +161,10 @@ public class BungeeCord extends ProxyServer {
this.getPluginManager().registerCommand(null, new CommandFind());
this.getPluginManager().registerCommand(null, new CommandClearRatelimit());
this.getPluginManager().registerCommand(null, new CommandConfirmCode());
this.getPluginManager().registerCommand(null, new CommandDomain());
this.getPluginManager().registerCommand(null, new CommandDomainBlock());
this.getPluginManager().registerCommand(null, new CommandDomainBlockDomain());
this.getPluginManager().registerCommand(null, new CommandDomainUnblock());
this.registerChannel("BungeeCord");
Log.setOutput(new PrintStream(ByteStreams.nullOutputStream()));
AnsiConsole.systemInstall();
@ -243,9 +252,11 @@ public class BungeeCord extends ProxyServer {
BanList.maybeReloadBans(null);
}
}, 0L, TimeUnit.SECONDS.toMillis(3L));
DomainBlacklist.init();
this.closeInactiveSockets.scheduleAtFixedRate(new TimerTask() {
@Override
public void run() {
DomainBlacklist.update();
for(WebSocketListener lst : BungeeCord.this.wsListeners) {
lst.closeInactiveSockets();
ListenerInfo info = lst.getInfo();

View File

@ -24,6 +24,10 @@ public interface ConfigurationAdapter {
Collection<String> getPermissions(final String p0);
Collection<String> getBlacklistURLs();
boolean getBlacklistOfflineDownload();
AuthServiceInfo getAuthSettings();
Map<String, Object> getMap();

View File

@ -0,0 +1,37 @@
package net.md_5.bungee.command;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.plugin.Command;
public class CommandDomain extends Command {
public CommandDomain() {
super("domain", "bungeecord.command.eag.domain");
}
@Override
public void execute(CommandSender p0, String[] p1) {
if (p1.length < 1) {
p0.sendMessage(ChatColor.RED + "Please follow this command by a user name");
return;
}
final ProxiedPlayer user = ProxyServer.getInstance().getPlayer(p1[0]);
if (user == null) {
p0.sendMessage(ChatColor.RED + "That user is not online");
} else {
Object o = user.getAttachment().get("origin");
if(o != null) {
p0.sendMessage(ChatColor.BLUE + "Domain of " + p1[0] + " is " + o);
if(p0.hasPermission("bungeecord.command.eag.blockdomain")) {
p0.sendMessage(ChatColor.BLUE + "Type " + ChatColor.WHITE + "/block-domain " + p1[0] + ChatColor.BLUE + " to block this person");
}
}else {
p0.sendMessage(ChatColor.RED + "Domain of " + p1[0] + " is unknown");
}
}
}
}

View File

@ -0,0 +1,38 @@
package net.md_5.bungee.command;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.eaglercraft.DomainBlacklist;
public class CommandDomainBlock extends Command {
public CommandDomainBlock() {
super("block-domain", "bungeecord.command.eag.blockdomain");
}
@Override
public void execute(CommandSender p0, String[] p1) {
if (p1.length < 1) {
p0.sendMessage(ChatColor.RED + "Please follow this command by a username");
return;
}
final ProxiedPlayer user = ProxyServer.getInstance().getPlayer(p1[0]);
if (user == null) {
p0.sendMessage(ChatColor.RED + "That user is not online");
}else {
Object o = user.getAttachment().get("origin");
if(o != null) {
DomainBlacklist.addLocal((String)o);
p0.sendMessage(ChatColor.RED + "Domain of " + ChatColor.WHITE + p1[0] + ChatColor.RED + " is " + ChatColor.WHITE + o);
p0.sendMessage(ChatColor.RED + "It was added to the local block list.");
user.disconnect("client blocked");
}else {
p0.sendMessage(ChatColor.RED + "Domain of " + p1[0] + " is unknown");
}
}
}
}

View File

@ -0,0 +1,24 @@
package net.md_5.bungee.command;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.eaglercraft.DomainBlacklist;
public class CommandDomainBlockDomain extends Command {
public CommandDomainBlockDomain() {
super("block-domain-name", "bungeecord.command.eag.blockdomainname");
}
@Override
public void execute(CommandSender p0, String[] p1) {
if (p1.length < 1) {
p0.sendMessage(ChatColor.RED + "Please follow this command by a domain");
return;
}
DomainBlacklist.addLocal(p1[0]);
p0.sendMessage(ChatColor.GREEN + "The domain '" + ChatColor.WHITE + p1[0] + ChatColor.GREEN + "' was added to the block list");
}
}

View File

@ -0,0 +1,27 @@
package net.md_5.bungee.command;
import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.eaglercraft.DomainBlacklist;
public class CommandDomainUnblock extends Command {
public CommandDomainUnblock() {
super("unblock-domain", "bungeecord.command.eag.unblockdomain", "unblock-domain-name");
}
@Override
public void execute(CommandSender p0, String[] p1) {
if (p1.length < 1) {
p0.sendMessage(ChatColor.RED + "Please follow this command by a domain");
return;
}
if(DomainBlacklist.removeLocal(p1[0])) {
p0.sendMessage(ChatColor.GREEN + "The domain '" + p1[0] + "' was removed from the local block list");
}else {
p0.sendMessage(ChatColor.RED + "The domain was not removed, is it on the block list? Check '" + DomainBlacklist.localBlacklist.getName() + "' in your bungeecord directory");
}
}
}

View File

@ -70,10 +70,11 @@ public class YamlConfig implements ConfigurationAdapter {
}
final Map<String, Object> permissions = this.get("permissions", new HashMap<String, Object>());
if (permissions.isEmpty()) {
permissions.put("default", Arrays.asList("bungeecord.command.server", "bungeecord.command.list"));
permissions.put("default", Arrays.asList("bungeecord.command.server", "bungeecord.command.list", "bungeecord.command.eag.domain"));
permissions.put("admin", Arrays.asList("bungeecord.command.alert", "bungeecord.command.end", "bungeecord.command.ip", "bungeecord.command.reload",
"bungeecord.command.eag.ban", "bungeecord.command.eag.banwildcard", "bungeecord.command.eag.banip", "bungeecord.command.eag.banregex",
"bungeecord.command.eag.reloadban", "bungeecord.command.eag.banned", "bungeecord.command.eag.banlist", "bungeecord.command.eag.unban", "bungeecord.command.eag.ratelimit"));
"bungeecord.command.eag.reloadban", "bungeecord.command.eag.banned", "bungeecord.command.eag.banlist", "bungeecord.command.eag.unban", "bungeecord.command.eag.ratelimit",
"bungeecord.command.eag.blockdomain", "bungeecord.command.eag.blockdomainname", "bungeecord.command.eag.unblockdomain"));
}
this.get("groups", new HashMap<String, Object>());
}
@ -288,4 +289,25 @@ public class YamlConfig implements ConfigurationAdapter {
this.save();
}
@Override
public Collection<String> getBlacklistURLs() {
boolean blacklistEnable = this.getBoolean("enable_origin_blacklist", true);
if(!blacklistEnable) {
return null;
}
Collection<String> c = this.get("origin_blacklist_subscriptions", null);
if(c == null) {
c = new ArrayList();
c.add("https://g.eags.us/eaglercraft/origin_blacklist.txt");
c.add("https://raw.githubusercontent.com/LAX1DUDE/eaglercraft/main/stable-download/origin_blacklist.txt");
c = this.get("origin_blacklist_subscriptions", c);
}
return c;
}
@Override
public boolean getBlacklistOfflineDownload() {
return this.getBoolean("enable_offline_download_blacklist", false);
}
}

View File

@ -139,31 +139,39 @@ public class InitialHandler extends PacketHandler implements PendingConnection {
}
InetAddress sc = WebSocketProxy.localToRemote.get(this.ch.getHandle().remoteAddress());
if(sc == null) {
System.out.println("WARNING: player '" + un + "' doesn't have a websocket IP, remote address: " + this.ch.getHandle().remoteAddress().toString());
this.bungee.getLogger().log(Level.WARNING, "player '" + un + "' doesn't have a websocket IP, remote address: " + this.ch.getHandle().remoteAddress().toString());
}else {
BanCheck bc = BanList.checkIpBanned(sc);
if(bc.isBanned()) {
System.err.println("Player '" + un + "' [" + sc.toString() + "] is banned by IP: " + bc.match + " (" + bc.string + ")");
this.bungee.getLogger().log(Level.SEVERE, "Player '" + un + "' [" + sc.toString() + "] is banned by IP: " + bc.match + " (" + bc.string + ")");
this.disconnect("" + ChatColor.RED + "You are banned.\n" + ChatColor.DARK_GRAY + "Reason: " + bc.string);
return;
}else {
System.out.println("Player '" + un + "' [" + sc.toString() + "] has remote websocket IP: " + sc.getHostAddress());
this.bungee.getLogger().log(Level.INFO, "Player '" + un + "' [" + sc.toString() + "] has remote websocket IP: " + sc.getHostAddress());
}
}
String dnm = WebSocketProxy.origins.get(this.ch.getHandle().remoteAddress());
if(dnm != null) {
if(dnm.equalsIgnoreCase("null")) {
this.bungee.getLogger().log(Level.INFO, "Player '" + un + "' [" + sc.toString() + "] is using an offline download");
}else {
this.bungee.getLogger().log(Level.INFO, "Player '" + un + "' [" + sc.toString() + "] is using a client at: " + dnm);
}
}
BanCheck bc = BanList.checkBanned(un);
if(bc.isBanned()) {
switch(bc.reason) {
case USER_BANNED:
System.err.println("Player '" + un + "' is banned by username, because '" + bc.string + "'");
this.bungee.getLogger().log(Level.SEVERE, "Player '" + un + "' is banned by username, because '" + bc.string + "'");
break;
case WILDCARD_BANNED:
System.err.println("Player '" + un + "' is banned by wildcard: " + bc.match);
this.bungee.getLogger().log(Level.SEVERE, "Player '" + un + "' is banned by wildcard: " + bc.match);
break;
case REGEX_BANNED:
System.err.println("Player '" + un + "' is banned by regex: " + bc.match);
this.bungee.getLogger().log(Level.SEVERE, "Player '" + un + "' is banned by regex: " + bc.match);
break;
default:
System.err.println("Player '" + un + "' is banned: " + bc.string);
this.bungee.getLogger().log(Level.SEVERE, "Player '" + un + "' is banned: " + bc.string);
}
if(bc.reason == BanState.USER_BANNED || ((BungeeCord)bungee).config.shouldShowBanType()) {
this.disconnect("" + ChatColor.RED + "You are banned.\n" + ChatColor.DARK_GRAY + "Reason: " + bc.string);
@ -242,6 +250,10 @@ public class InitialHandler extends PacketHandler implements PendingConnection {
if(ins != null) {
userCon.getAttachment().put("remoteAddr", ins);
}
String origin = WebSocketProxy.origins.get(this.ch.getHandle().remoteAddress());
if(origin != null) {
userCon.getAttachment().put("origin", origin);
}
userCon.init();
this.bungee.getPluginManager().callEvent(new PostLoginEvent(userCon));
((HandlerBoss) this.ch.getHandle().pipeline().get((Class) HandlerBoss.class)).setHandler(new UpstreamBridge(this.bungee, userCon));

View File

@ -0,0 +1,249 @@
package net.md_5.bungee.eaglercraft;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLConnection;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import net.md_5.bungee.BungeeCord;
public class DomainBlacklist {
public static final Collection<Pattern> regexBlacklist = new HashSet();
public static final Collection<Pattern> regexLocalBlacklist = new HashSet();
public static final File localBlacklist = new File("origin_blacklist.txt");
private static final HashSet<String> brokenURLs = new HashSet();
private static final HashSet<String> brokenRegex = new HashSet();
private static int updateRate = 15 * 60 * 1000;
private static long lastLocalUpdate = 0l;
private static long lastUpdate = 0;
public static boolean test(String origin) {
synchronized(regexBlacklist) {
if(origin.equalsIgnoreCase("null") && BungeeCord.getInstance().getConfigurationAdapter().getBlacklistOfflineDownload()) {
return true;
}
for(Pattern m : regexBlacklist) {
if(m.matcher(origin).matches()) {
return true;
}
}
for(Pattern m : regexLocalBlacklist) {
if(m.matcher(origin).matches()) {
return true;
}
}
}
return false;
}
public static void init() {
synchronized(regexBlacklist) {
brokenURLs.clear();
brokenRegex.clear();
regexBlacklist.clear();
regexLocalBlacklist.clear();
lastLocalUpdate = 0l;
lastUpdate = System.currentTimeMillis() - updateRate - 1000l;
update();
}
}
public static void update() {
long ct = System.currentTimeMillis();
if((int)(ct - lastUpdate) > updateRate) {
lastUpdate = ct;
synchronized(regexBlacklist) {
Collection<String> blurls = BungeeCord.getInstance().getConfigurationAdapter().getBlacklistURLs();
if(blurls != null) {
ArrayList<Pattern> newBlacklist = new ArrayList();
HashSet<String> newBlacklistSet = new HashSet();
for(String str : blurls) {
try {
URL u;
try {
u = new URL(str);
}catch(MalformedURLException e) {
if(brokenURLs.add(str)) {
System.err.println("ERROR: the blacklist subscription URL '" + str + "' is invalid");
}
continue;
}
URLConnection cc = u.openConnection();
if(cc instanceof HttpURLConnection) {
HttpURLConnection ccc = (HttpURLConnection)cc;
ccc.setRequestProperty("Accept", "text/plain,text/html,application/xhtml+xml,application/xml");
ccc.setRequestProperty("User-Agent", "Mozilla/5.0 EaglercraftBungee/" + EaglercraftBungee.version);
}
cc.connect();
BufferedReader is = new BufferedReader(new InputStreamReader(cc.getInputStream()));
String firstLine = is.readLine();
if(firstLine == null) {
is.close();
throw new IOException("Could not read line");
}
firstLine = firstLine.trim();
if(!firstLine.startsWith("#") || !firstLine.substring(1).trim().toLowerCase().startsWith("eaglercraft domain blacklist")) {
throw new IOException("File does not contain a list of domains");
}
String ss;
while((ss = is.readLine()) != null) {
if((ss = ss.trim()).length() > 0) {
if(ss.startsWith("#")) {
continue;
}
if(newBlacklistSet.add(ss)) {
try {
newBlacklist.add(Pattern.compile(ss));
}catch(PatternSyntaxException shit) {
if(brokenRegex.add(ss)) {
System.err.println("ERROR: the blacklist regex '" + ss + "' is invalid");
continue;
}
}
brokenRegex.remove(ss);
}
}
}
is.close();
brokenURLs.remove(str);
}catch(Throwable t) {
if(brokenURLs.add(str)) {
System.err.println("ERROR: the blacklist subscription URL '" + str + "' is invalid");
}
t.printStackTrace();
}
}
if(!newBlacklist.isEmpty()) {
regexBlacklist.clear();
regexBlacklist.addAll(newBlacklist);
}
}else {
brokenURLs.clear();
brokenRegex.clear();
regexBlacklist.clear();
regexLocalBlacklist.clear();
lastLocalUpdate = 0l;
}
}
}
if(localBlacklist.exists()) {
long lastLocalEdit = localBlacklist.lastModified();
if(lastLocalEdit != lastLocalUpdate) {
lastLocalUpdate = lastLocalEdit;
synchronized(regexBlacklist) {
try {
BufferedReader is = new BufferedReader(new FileReader(localBlacklist));
regexLocalBlacklist.clear();
String ss;
while((ss = is.readLine()) != null) {
try {
if((ss = ss.trim()).length() > 0) {
regexLocalBlacklist.add(Pattern.compile(ss));
}
}catch(PatternSyntaxException shit) {
System.err.println("ERROR: the local blacklist regex '" + ss + "' is invalid");
}
}
is.close();
System.out.println("Reloaded '" + localBlacklist.getName() + "'.");
}catch(IOException ex) {
regexLocalBlacklist.clear();
System.err.println("ERROR: failed to read local blacklist file '" + localBlacklist.getName() + "'");
ex.printStackTrace();
}
}
}
}else {
synchronized(regexBlacklist) {
if(!regexLocalBlacklist.isEmpty()) {
System.err.println("WARNING: the blacklist file '" + localBlacklist.getName() + "' has been deleted");
}
regexLocalBlacklist.clear();
}
}
}
public static void addLocal(String o) {
String p = "^" + Pattern.quote(o.trim()) + "$";
ArrayList<String> lines = new ArrayList();
if(localBlacklist.exists()) {
try {
BufferedReader is = new BufferedReader(new FileReader(localBlacklist));
String ss;
while((ss = is.readLine()) != null) {
if((ss = ss.trim()).length() > 0) {
lines.add(ss);
}
}
is.close();
}catch(IOException ex) {
// ?
}
}
if(!lines.contains(p)) {
lines.add(p);
try {
PrintWriter os = new PrintWriter(new FileWriter(localBlacklist));
for(String s : lines) {
os.println(s);
}
os.close();
lastLocalUpdate = 0l;
update();
}catch(IOException ex) {
// ?
}
}
}
public static boolean removeLocal(String o) {
String p = "^" + Pattern.quote(o.trim()) + "$";
ArrayList<String> lines = new ArrayList();
if(localBlacklist.exists()) {
try {
BufferedReader is = new BufferedReader(new FileReader(localBlacklist));
String ss;
while((ss = is.readLine()) != null) {
if((ss = ss.trim()).length() > 0) {
lines.add(ss);
}
}
is.close();
}catch(IOException ex) {
// ?
}
}
if(lines.contains(p)) {
lines.remove(p);
try {
PrintWriter os = new PrintWriter(new FileWriter(localBlacklist));
for(String s : lines) {
os.println(s);
}
os.close();
lastLocalUpdate = 0l;
update();
return true;
}catch(IOException ex) {
System.err.println("Failed to save '" + localBlacklist.getName() + "'");
ex.printStackTrace();
}
}
return false;
}
}

View File

@ -4,7 +4,7 @@ public class EaglercraftBungee {
public static final String brand = "Eagtek";
public static final String name = "EaglercraftBungee";
public static final String version = "0.2.0";
public static final String version = "0.3.0"; // wtf does this even mean at this point
public static final boolean cracked = true;
}

View File

@ -31,10 +31,12 @@ public class WebSocketListener extends WebSocketServer {
public static class PendingSocket {
public long openTime;
public InetAddress realAddress;
public String origin;
public boolean bypassBan;
protected PendingSocket(long openTime, InetAddress realAddress, boolean bypassBan) {
protected PendingSocket(long openTime, InetAddress realAddress, String origin, boolean bypassBan) {
this.openTime = openTime;
this.realAddress = realAddress;
this.origin = origin;
this.bypassBan = bypassBan;
}
}
@ -192,7 +194,7 @@ public class WebSocketListener extends WebSocketServer {
return;
}
}
WebSocketProxy proxyObj = new WebSocketProxy(arg0, realAddr, bungeeProxy);
WebSocketProxy proxyObj = new WebSocketProxy(arg0, realAddr, ((PendingSocket)o).origin, bungeeProxy);
arg0.setAttachment(proxyObj);
if(!proxyObj.connect()) {
System.err.println("loopback to '" + bungeeProxy.toString() + "' failed - " + realAddr);
@ -212,6 +214,19 @@ public class WebSocketListener extends WebSocketServer {
@Override
public void onOpen(WebSocket arg0, ClientHandshake arg1) {
String origin = arg1.getFieldValue("Origin");
if(origin != null) {
int idx = origin.indexOf("://");
if(idx != -1) {
origin = origin.substring(idx + 3);
}
origin = origin.trim();
if(DomainBlacklist.test(origin)) {
arg0.send(createRawKickPacket("End of Stream (RIP)"));
arg0.close();
return;
}
}
InetAddress addr;
if(info.hasForwardedHeaders()) {
String s = arg1.getFieldValue("X-Real-IP");
@ -242,7 +257,7 @@ public class WebSocketListener extends WebSocketServer {
return;
}
}
arg0.setAttachment(new PendingSocket(System.currentTimeMillis(), addr, bypassBan));
arg0.setAttachment(new PendingSocket(System.currentTimeMillis(), addr, origin, bypassBan));
}
@Override

View File

@ -31,20 +31,24 @@ public class WebSocketProxy extends SimpleChannelInboundHandler<ByteBuf> {
private InetSocketAddress tcpListener;
private InetSocketAddress localAddress;
private InetAddress realRemoteAddr;
private String origin;
private NioSocketChannel tcpChannel;
private static final EventLoopGroup group = new NioEventLoopGroup(4);
public static final HashMap<InetSocketAddress,InetAddress> localToRemote = new HashMap();
public static final HashMap<InetSocketAddress,String> origins = new HashMap();
public WebSocketProxy(WebSocket w, InetAddress remoteAddr, InetSocketAddress addr) {
public WebSocketProxy(WebSocket w, InetAddress remoteAddr, String originz, InetSocketAddress addr) {
client = w;
realRemoteAddr = remoteAddr;
origin = originz;
tcpListener = addr;
tcpChannel = null;
}
public void killConnection() {
localToRemote.remove(localAddress);
origins.remove(localAddress);
if(tcpChannel != null && tcpChannel.isOpen()) {
try {
tcpChannel.disconnect().sync();
@ -69,12 +73,16 @@ public class WebSocketProxy extends SimpleChannelInboundHandler<ByteBuf> {
@Override
public void operationComplete(Future<? super Void> paramF) throws Exception {
localToRemote.remove(localAddress);
origins.remove(localAddress);
}
});
}
});
tcpChannel = (NioSocketChannel) clientBootstrap.connect().sync().channel();
localToRemote.put(localAddress = tcpChannel.localAddress(), realRemoteAddr);
if(origin != null) {
origins.put(localAddress, origin);
}
return true;
}
}catch(Throwable t) {
@ -104,6 +112,7 @@ public class WebSocketProxy extends SimpleChannelInboundHandler<ByteBuf> {
public void finalize() {
localToRemote.remove(localAddress);
origins.remove(localAddress);
}
}

View File

@ -0,0 +1,9 @@
# eaglercraft domain blacklist
# this is a fallback in case eags.us goes down
.*thecoderkid\.repl\.co$
# ayuncraft has not been removed because ayunami is removing the flyhack
# snitch other domains out at https://g.eags.us/eaglercraft/report.html
# or join the discord server at https://discord.com/invite/KMQW9Uvjyq and ping @Moderator with the domain and name of the client