diff --git a/src/me/despawningbone/discordbot/DiscordBot.java b/src/me/despawningbone/discordbot/DiscordBot.java index c77a14a..dab2fe3 100644 --- a/src/me/despawningbone/discordbot/DiscordBot.java +++ b/src/me/despawningbone/discordbot/DiscordBot.java @@ -1,269 +1,325 @@ package me.despawningbone.discordbot; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Modifier; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.EnumSet; import java.util.HashMap; import java.util.List; import java.util.Map.Entry; import java.util.Properties; import java.util.Set; import java.util.Stack; import java.util.StringJoiner; import java.util.TreeMap; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; +import javax.net.ssl.HttpsURLConnection; import javax.security.auth.login.LoginException; import org.apache.commons.lang3.StringUtils; import org.apache.log4j.PropertyConfigurator; import org.reflections.Reflections; import org.slf4j.Logger; import org.slf4j.LoggerFactory; //import com.sedmelluq.discord.lavaplayer.jdaudp.NativeAudioSendFactory; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; import me.despawningbone.discordbot.command.Command; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.entities.TextChannel; import net.dv8tion.jda.api.requests.GatewayIntent; public class DiscordBot { //TODO move to appropriate locations public static List ModID = new ArrayList(); public static List logExcemptID = new ArrayList(); //TODO synchronize below or change to concurrent //but why? its thread safe since its read only public static HashMap commands = new HashMap(); public static HashMap aliases = new HashMap(); //TODO temporary solution public static TreeMap> catCmds = new TreeMap>(); //order base on categories? public static ConcurrentHashMap lastMusicCmd = new ConcurrentHashMap(); //TODO store in guild configs //nah its fine public static final String prefix = "!desp "; //DONE allow guild change prefix? public static JDA mainJDA = null; public static String BotID; public static String OwnerID; public static Properties tokens = new Properties(); static final Logger logger = LoggerFactory.getLogger(DiscordBot.class); //package private public static HikariDataSource db; //DONE SQLite integration; check if program termination will screw up the connection //TODO SHARDING //DONE on shutdown alert those playing music or await? //alerted public static void main(String[] args) { PropertyConfigurator.configure(System.getProperty("user.dir") + File.separator + "log4j.properties"); + shuffleCipherSuites(); + //init tokens file try(FileInputStream in = new FileInputStream(new File(System.getProperty("user.dir") + File.separator + "tokens.properties"))){ tokens.load(in); } catch (IOException e) { e.printStackTrace(); return; } initCmds(); initDB(); //login try { JDA jda = JDABuilder.create(tokens.getProperty("bot"), GatewayIntent.getIntents(GatewayIntent.ALL_INTENTS)) //.setAudioSendFactory(new NativeAudioSendFactory()) //segfaults frequently somehow, disabling .addEventListeners(new me.despawningbone.discordbot.EventListener()).build(); jda.getPresence().setActivity(Activity.watching("Ping me for info!")); // for user bots, see // https://discordapp.com/developers/docs/topics/gateway#update-status // and // https://gist.github.com/MrPowerGamerBR/b8acccb9951b0d78a5115024e3ee0d03 //find owner id from bot owner BotID = jda.getSelfUser().getId(); OwnerID = jda.retrieveApplicationInfo().complete().getOwner().getId(); } catch (LoginException e) { e.printStackTrace(); return; } //initiate logs //handled by log4j? File directory = new File(System.getProperty("user.dir") + File.separator + "logs" + File.separator); if (!directory.exists()) { directory.mkdir(); } //DEPRECATED //or is it? make unmodifiable instead for(String id : tokens.getProperty("botmod").split(",")) { ModID.add(id); } ModID.add(OwnerID); //TODO put into sql and make guild setting to opt out logExcemptID.add(BotID); } private static void initDB() { //init guild settings when do !desp settings for the first time? //dont really need coz im doing upsert for all values anyways HikariConfig dbConf = new HikariConfig(); dbConf.setJdbcUrl("jdbc:sqlite:data.db"); dbConf.setIdleTimeout(45000); dbConf.setMaxLifetime(60000); dbConf.setMaximumPoolSize(10); //dbConf.setMaximumPoolSize(25); //for deployment in server db = new HikariDataSource(dbConf); try (Connection con = db.getConnection()){ Statement s = con.createStatement(); s.execute("CREATE TABLE IF NOT EXISTS settings" + "(id INTEGER PRIMARY KEY," //performance problem using text; integer can handle long anyways + "prefix TEXT DEFAULT '!desp '," + "premium INTEGER DEFAULT 0," + "mchannel TEXT," //allow people to set this manually? By default, use last music cmd place + "locale TEXT DEFAULT 'EN'," //or int? //user specific or guild specific, or user override guild? + "shortcuts TEXT," //use another method? + "votepct INTEGER DEFAULT 50," + "looplimit INTEGER DEFAULT 1," + "volume INTEGER DEFAULT 100," //premium?, is default actually 100? + "helpdm INTEGER DEFAULT 0);"); //send help to dm or not, excluding cmd help(?) //add logexempt? //init perms table PreparedStatement pragma = con.prepareStatement("SELECT name FROM pragma_table_info('perms_' || ?);"); //NOTE: sqlite does not support changing default values, beware when adding new commands for(Entry> entry : catCmds.entrySet()) { pragma.setString(1, entry.getKey().toLowerCase()); ResultSet rs = pragma.executeQuery(); //traverse cmd tree to obtain node names boolean isEmpty = true; HashMap> nodes = new HashMap<>(); Stack tree = new Stack<>(); for(Command cmd : entry.getValue()) tree.push(cmd); //populate stack coz there is no root node while(!tree.empty()) { Command cmd = tree.pop(); String name = cmd.getName(); for(Command iterate = cmd.getParent(); iterate != null; iterate = iterate.getParent()) { name = iterate.getName() + "." + name; } nodes.put(name, cmd.getDefaultPerms()); for(String sub : cmd.getSubCommandNames()) { tree.push(cmd.getSubCommand(sub)); } } while(rs.next()) { isEmpty = false; nodes.remove(rs.getString("name")); //remove already added node names } if(isEmpty) { String create = "CREATE TABLE perms_" + entry.getKey().toLowerCase() + " (id INTEGER PRIMARY KEY, _GLOBAL_ TEXT DEFAULT '0\n', "; //perms for a category are always default no restraints StringJoiner join = new StringJoiner(", "); for(Entry> node : nodes.entrySet()) { join.add("\"" + node.getKey() + "\" TEXT DEFAULT '" + ((0L << 32) | (Permission.getRaw(node.getValue()) & 0xffffffffL)) + "\n'"); //initialize with no permission deny override, permissions allow //needs to be text so that channel overrides delimiter can be stored } create += join.toString() + ");"; s.execute(create); //DONE? create table } else { if(!nodes.isEmpty()) { //which means there is new subCmd/cmd for(Entry> node : nodes.entrySet()) { s.execute("ALTER TABLE perms_" + entry.getKey().toLowerCase() + " ADD COLUMN \"" + node.getKey() + "\" TEXT DEFAULT '" + ((0L << 32) | (Permission.getRaw(node.getValue()) & 0xffffffffL)) + "\n';"); //not able to prep statement coz its column name s.close(); } } } } pragma.close(); //init users table //DONE TEST generate the nodes, retrieve default from fields? //what about additional commands added, use ALTER TABLE ADD COLUMN DEFAULT? I dont need to sort it according to category at all, i am not and will not be bulk printing permissions anywhere //actually i might be coz of list edited perms //fit subnodes into sub tables, split by category so its more balanced (category as table name)? //fields are gonna be sth like :| etc? //DONE store the permissions with long merged; <32bit = deny, >32bit = allow s.execute("CREATE TABLE IF NOT EXISTS users" + "(id INTEGER PRIMARY KEY," + "reports TEXT DEFAULT ''," + "game TEXT);"); //disposable, i can change it anytime //add botmod field? if so, i can deprecate the hard coded list, but it would require querying to this table on perm checks //i need to query it to check if the user is banned anyways //subcmd need to query again, so i didnt do it; besides the modlist is gonna be too small to be significant anyways //add user specific locale? might be confusing s.close(); } catch (SQLException e1) { e1.printStackTrace(); } } private static void initCmds() { Reflections reflections = new Reflections("me.despawningbone.discordbot.command"); Set> classes = reflections.getSubTypesOf(Command.class); for (Class s : classes) { try { //skip abstract classes if (Modifier.isAbstract(s.getModifiers())) { continue; } String pkName = s.getPackage().getName(); String cat = StringUtils.capitalize(pkName.substring(pkName.lastIndexOf(".") + 1)); Command c = s.getConstructor().newInstance(); //DONE use constructor to put name and cat instead //dont skip disabled commands - they should still show up but disabled //init name and cat try { Field nameF = Command.class.getDeclaredField("name"); nameF.setAccessible(true); if(nameF.get(c) == null) nameF.set(c, s.getSimpleName().toLowerCase()); //allow overrides Field catF = Command.class.getDeclaredField("cat"); catF.setAccessible(true); catF.set(c, cat); } catch (NoSuchFieldException e) { //should never throw e.printStackTrace(); } //check disable whole command category? //no non-perm-wise category wide disabling support List cmds = catCmds.get(cat); if(cmds == null) { cmds = new ArrayList(Arrays.asList(c)); } else { cmds.add(c); } catCmds.put(cat, cmds); commands.put(c.getName(), c); List l = c.getAliases(); if(l != null) { for(int i = 0; i < l.size(); i++) { aliases.put(l.get(i), c); } } } catch (ReflectiveOperationException e) { e.printStackTrace(); } } //sort each category alphabetically for(Entry> entry : catCmds.entrySet()) { Collections.sort(entry.getValue(), (a, b) -> a.getName().compareTo(b.getName())); } } + + //shuffles SSL cipher suites for the default SSL socket factory to avoid fingerprinting used by some sites (e.g. cloudflare-backed sites) + //current implementation is to disable medium strength ciphers (all 128 bit suites) and shuffle, since just shuffling is not enough to avoid fingerprinting + //removing medium strength ciphers should not have any impacts on modern sites, which all of the endpoints in this bot should be using + //sun.security.ssl.SSLSocketFactoryImpl dependent + private static void shuffleCipherSuites() { + try { + //obtain the SSLContext in use + Field contextField = HttpsURLConnection.getDefaultSSLSocketFactory().getClass().getDeclaredField("context"); + contextField.setAccessible(true); + Object context = contextField.get(HttpsURLConnection.getDefaultSSLSocketFactory()); + + //find clientDefaultCipherSuites/clientDefaultCipherSuiteList, which is ultimately obtained by getDefaultCipherSuites(false)/getDefaultCipherSuiteList(false) called by SSLSocketFactoryImpl + //recursively find getDefaultCipherSuiteList due to unknown class hierachy + Class contextClass = context.getClass(); + Field clientDefaultCipherSuitesField = null; + do { + for(Field field : contextClass.getDeclaredFields()) { + if(field.getName().contains("clientDefaultCipherSuite")) { + clientDefaultCipherSuitesField = field; + break; + } + } + contextClass = contextClass.getSuperclass(); + } while (clientDefaultCipherSuitesField == null); + + clientDefaultCipherSuitesField.setAccessible(true); + Object cipherSuitesObj = clientDefaultCipherSuitesField.get(context); + + //obtain the actual list depending on what type it is + ArrayList cipherSuiteList; + if(cipherSuitesObj.getClass().getSimpleName().equals("CipherSuiteList")) { + Arrays.asList(cipherSuitesObj.getClass().getDeclaredFields()).forEach(f -> System.out.println(f.getName())); + Field cipherSuiteCollection = cipherSuitesObj.getClass().getDeclaredField("cipherSuites"); + cipherSuiteCollection.setAccessible(true); + cipherSuiteList = (ArrayList) cipherSuiteCollection.get(cipherSuitesObj); + + //reset name list so it can regenerate after shuffling + Field suiteNamesField = cipherSuiteList.getClass().getDeclaredField("suiteNames"); + suiteNamesField.setAccessible(true); + suiteNamesField.set(cipherSuiteList, null); + } else { + cipherSuiteList = (ArrayList) cipherSuitesObj; + } + + //remove 128 bit ciphers and shuffle + cipherSuiteList.removeAll(cipherSuiteList.stream().filter(o -> !o.toString().contains("128")).collect(Collectors.toList())); + Collections.shuffle(cipherSuiteList); + } catch (Throwable e) { //methodhandle.invoke throws throwable as checked exception + logger.warn("Cannot shuffle SSL cipher suites - certain commands might not work due to SSL fingerprinting.", e); + } + } }