From 5ace1083f168a000f1741c55ab526cf663df40fe Mon Sep 17 00:00:00 2001 From: Hash Borgir Date: Thu, 16 Oct 2025 11:55:31 -0500 Subject: [PATCH] New plugins --- README.md | 6 + funguy.py | 9 +- plugins/bitcoin.py | 109 ++++++++++++++ plugins/dns.py | 209 ++++++++++++++++++++++++++ plugins/help.py | 8 + plugins/loadplugin.py | 5 +- plugins/shodan.py | 330 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 672 insertions(+), 4 deletions(-) create mode 100644 plugins/bitcoin.py create mode 100644 plugins/dns.py create mode 100644 plugins/shodan.py diff --git a/README.md b/README.md index 65129e5..03423b5 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,12 @@ Fetches current weather information for any location using OpenWeatherMap API. **📖 !ud [term] [index]** Fetches definitions from Urban Dictionary. Use without arguments for random definition, or specify term and optional index. +**🔍 !dns [domain]** +Performs comprehensive DNS reconnaissance on a domain. Shows A, AAAA, MX, NS, TXT, CNAME, SOA, and other DNS records. + +**💰 !btc** +Fetches the current Bitcoin price in USD from bitcointicker.co API. + ### AI & Generation Commands **🤖 AI Commands (!tech, !music, !eth, etc.)** diff --git a/funguy.py b/funguy.py index 34c8d8f..38effcc 100755 --- a/funguy.py +++ b/funguy.py @@ -18,9 +18,12 @@ import toml # Library for parsing TOML configuration files from plugins.config import FunguyConfig # Whitelist of allowed plugins to prevent arbitrary code execution -ALLOWED_PLUGINS = {'ai', 'config', 'cron', 'date', 'fortune', 'help', 'isup', 'karma', - 'loadplugin', 'plugins', 'proxy', 'sd_text', 'stable-diffusion', - 'xkcd', 'youtube-preview', 'youtube-search', 'weather', 'urbandictionary'} +ALLOWED_PLUGINS = { + 'ai', 'config', 'cron', 'date', 'fortune', 'help', 'isup', 'karma', + 'loadplugin', 'plugins', 'proxy', 'sd_text', 'stable-diffusion', + 'xkcd', 'youtube-preview', 'youtube-search', 'weather', 'urbandictionary', + 'bitcoin', 'dns', 'shodan' + } class FunguyBot: """ diff --git a/plugins/bitcoin.py b/plugins/bitcoin.py new file mode 100644 index 0000000..3af9a99 --- /dev/null +++ b/plugins/bitcoin.py @@ -0,0 +1,109 @@ +""" +This plugin provides a command to fetch the current Bitcoin price. +""" + +import logging +import requests +import simplematrixbotlib as botlib + +BITCOIN_API_URL = "https://api.bitcointicker.co/trades/bitstamp/btcusd/60/" + + +async def handle_command(room, message, bot, prefix, config): + """ + Function to handle the !btc command. + + Args: + room (Room): The Matrix room where the command was invoked. + message (RoomMessage): The message object containing the command. + bot (Bot): The bot object. + prefix (str): The command prefix. + config (dict): Configuration parameters. + + Returns: + None + """ + match = botlib.MessageMatch(room, message, bot, prefix) + if match.is_not_from_this_bot() and match.prefix() and match.command("btc"): + logging.info("Received !btc command") + + try: + # Fetch Bitcoin price data + headers = { + 'Accept-Encoding': 'gzip, deflate', + 'User-Agent': 'FunguyBot/1.0' + } + + logging.info(f"Fetching Bitcoin price from {BITCOIN_API_URL}") + response = requests.get(BITCOIN_API_URL, headers=headers, timeout=10) + response.raise_for_status() + + data = response.json() + + if not data or len(data) == 0: + await bot.api.send_text_message( + room.room_id, + "No Bitcoin price data available." + ) + logging.warning("No Bitcoin price data returned from API") + return + + # Get the most recent trade (last item in the array) + latest_trade = data[-1] + price = latest_trade.get('price') + + if price is None: + await bot.api.send_text_message( + room.room_id, + "Could not extract Bitcoin price from API response." + ) + logging.error("Price field not found in API response") + return + + # Convert to float and format with commas + try: + price_float = float(price) + price_formatted = f"${price_float:,.2f}" + except (ValueError, TypeError): + price_formatted = f"${price}" + + # Optional: Get additional info if available + timestamp = latest_trade.get('timestamp', '') + volume = latest_trade.get('volume', '') + + # Build the message + message_text = f"₿ BTC/USD" + message_text += f" Current Price: {price_formatted}" + + message_text += ", bitcointicker.co" + + await bot.api.send_markdown_message(room.room_id, message_text) + logging.info(f"Sent Bitcoin price: {price_formatted}") + + except requests.exceptions.Timeout: + await bot.api.send_text_message( + room.room_id, + "Request timed out. Bitcoin price API may be slow or unavailable." + ) + logging.error("Bitcoin API timeout") + + except requests.exceptions.RequestException as e: + await bot.api.send_text_message( + room.room_id, + f"Error fetching Bitcoin price: {e}" + ) + logging.error(f"Error fetching Bitcoin price: {e}") + + except (KeyError, IndexError, ValueError) as e: + await bot.api.send_text_message( + room.room_id, + "Error parsing Bitcoin price data." + ) + logging.error(f"Error parsing Bitcoin API response: {e}", exc_info=True) + + except Exception as e: + await bot.api.send_text_message( + room.room_id, + "An unexpected error occurred while fetching Bitcoin price." + ) + logging.error(f"Unexpected error in Bitcoin plugin: {e}", exc_info=True) diff --git a/plugins/dns.py b/plugins/dns.py new file mode 100644 index 0000000..f158f4f --- /dev/null +++ b/plugins/dns.py @@ -0,0 +1,209 @@ +""" +This plugin provides a command to perform DNS reconnaissance on a domain. +""" + +import logging +import dns.resolver +import dns.reversename +import simplematrixbotlib as botlib +import re + +# Common DNS record types to query +RECORD_TYPES = ['A', 'AAAA', 'MX', 'NS', 'TXT', 'CNAME', 'SOA', 'PTR', 'SRV'] + + +def is_valid_domain(domain): + """ + Validate if the provided string is a valid domain name. + + Args: + domain (str): The domain to validate. + + Returns: + bool: True if valid, False otherwise. + """ + # Basic domain validation regex + pattern = r'^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$' + return re.match(pattern, domain) is not None + + +def format_dns_record(record_type, records): + """ + Format DNS records for display. + + Args: + record_type (str): The type of DNS record. + records (list): List of DNS record values. + + Returns: + str: Formatted HTML string. + """ + if not records: + return "" + + output = f"{record_type} Records:
" + for record in records: + output += f" • {record}
" + return output + + +async def query_dns_records(domain): + """ + Query all common DNS record types for a domain. + + Args: + domain (str): The domain to query. + + Returns: + dict: Dictionary with record types as keys and lists of records as values. + """ + results = {} + resolver = dns.resolver.Resolver() + resolver.timeout = 5 + resolver.lifetime = 5 + + for record_type in RECORD_TYPES: + try: + logging.info(f"Querying {record_type} records for {domain}") + answers = resolver.resolve(domain, record_type) + + records = [] + for rdata in answers: + if record_type == 'MX': + # MX records have preference and exchange + records.append(f"{rdata.preference} {rdata.exchange}") + elif record_type == 'SOA': + # SOA records have multiple fields + records.append(f"{rdata.mname} {rdata.rname}") + elif record_type == 'SRV': + # SRV records have priority, weight, port, and target + records.append(f"{rdata.priority} {rdata.weight} {rdata.port} {rdata.target}") + elif record_type == 'TXT': + # TXT records can have multiple strings + txt_data = ' '.join([s.decode() if isinstance(s, bytes) else str(s) for s in rdata.strings]) + records.append(txt_data) + else: + records.append(str(rdata)) + + if records: + results[record_type] = records + logging.info(f"Found {len(records)} {record_type} record(s)") + + except dns.resolver.NoAnswer: + logging.debug(f"No {record_type} records found for {domain}") + continue + except dns.resolver.NXDOMAIN: + logging.warning(f"Domain {domain} does not exist") + return None + except dns.resolver.Timeout: + logging.warning(f"Timeout querying {record_type} for {domain}") + continue + except Exception as e: + logging.error(f"Error querying {record_type} for {domain}: {e}") + continue + + return results + + +async def handle_command(room, message, bot, prefix, config): + """ + Function to handle the !dns command. + + Args: + room (Room): The Matrix room where the command was invoked. + message (RoomMessage): The message object containing the command. + bot (Bot): The bot object. + prefix (str): The command prefix. + config (dict): Configuration parameters. + + Returns: + None + """ + match = botlib.MessageMatch(room, message, bot, prefix) + if match.is_not_from_this_bot() and match.prefix() and match.command("dns"): + logging.info("Received !dns command") + + args = match.args() + + if len(args) != 1: + await bot.api.send_text_message( + room.room_id, + "Usage: !dns \nExample: !dns example.com" + ) + logging.info("Sent usage message for !dns") + return + + domain = args[0].lower().strip() + + # Remove protocol if present + domain = domain.replace('http://', '').replace('https://', '') + # Remove trailing slash if present + domain = domain.rstrip('/') + # Remove www. prefix if present (optional - you can keep it if you want) + # domain = domain.replace('www.', '') + + # Validate domain + if not is_valid_domain(domain): + await bot.api.send_text_message( + room.room_id, + f"Invalid domain name: {domain}" + ) + logging.warning(f"Invalid domain provided: {domain}") + return + + try: + logging.info(f"Starting DNS reconnaissance for {domain}") + + # Send "working on it" message for longer queries + await bot.api.send_text_message( + room.room_id, + f"🔍 Performing DNS reconnaissance on {domain}..." + ) + + # Query DNS records + results = await query_dns_records(domain) + + if results is None: + await bot.api.send_text_message( + room.room_id, + f"Domain {domain} does not exist (NXDOMAIN)" + ) + return + + if not results: + await bot.api.send_text_message( + room.room_id, + f"No DNS records found for {domain}" + ) + return + + # Format the output + output = f"🔍 DNS Records for {domain}

" + + # Order the records in a logical way + preferred_order = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SOA', 'SRV', 'PTR'] + + for record_type in preferred_order: + if record_type in results: + output += format_dns_record(record_type, results[record_type]) + output += "
" + + # Add any remaining record types not in preferred order + for record_type in results: + if record_type not in preferred_order: + output += format_dns_record(record_type, results[record_type]) + output += "
" + + # Wrap in collapsible details if output is large + if output.count('
') > 15: + output = f"
🔍 DNS Records for {domain}{output}
" + + await bot.api.send_markdown_message(room.room_id, output) + logging.info(f"Sent DNS records for {domain}") + + except Exception as e: + await bot.api.send_text_message( + room.room_id, + f"An error occurred while performing DNS lookup: {str(e)}" + ) + logging.error(f"Error in DNS plugin for {domain}: {e}", exc_info=True) diff --git a/plugins/help.py b/plugins/help.py index b690b5a..7fbcfb5 100644 --- a/plugins/help.py +++ b/plugins/help.py @@ -69,6 +69,14 @@ async def handle_command(room, message, bot, prefix, config):

Fetches definitions from Urban Dictionary. Use without arguments for random definition, or specify term and optional index number. Shows definition, example, author, votes, and permalink.

+
🔍 !dns [domain] +

Performs comprehensive DNS reconnaissance on a domain. Queries multiple DNS record types including A, AAAA, MX, NS, TXT, CNAME, SOA, and SRV records. Validates domain format and provides formatted results.

+
+ +
💰 !btc +

Fetches the current Bitcoin price in USD from bitcointicker.co API. Shows real-time BTC/USD price with proper formatting. Includes error handling for API timeouts and data parsing issues.

+
+
📸 !sd [prompt]

Generates images using self-hosted Stable Diffusion. Supports options: --steps, --cfg, --h, --w, --neg, --sampler. Uses queuing system to handle multiple requests. See available options using just '!sd'.

diff --git a/plugins/loadplugin.py b/plugins/loadplugin.py index a0b5bd5..d538939 100644 --- a/plugins/loadplugin.py +++ b/plugins/loadplugin.py @@ -57,7 +57,10 @@ async def load_plugin(plugin_name): 'youtube-preview': 'plugins.youtube-preview', 'youtube-search': 'plugins.youtube-search', 'weather': 'plugins.weather', - 'urbandictionary': 'plugins.urbandictionary' + 'urbandictionary': 'plugins.urbandictionary', + 'bitcoin':'plugins.bitcoin', + 'dns':'plugins.dns', + 'shodan':'plugins.shodan' } # Get the module path from the mapping diff --git a/plugins/shodan.py b/plugins/shodan.py new file mode 100644 index 0000000..569eb45 --- /dev/null +++ b/plugins/shodan.py @@ -0,0 +1,330 @@ +""" +This plugin provides Shodan.io integration for security research and reconnaissance. +""" + +import logging +import os +import requests +import simplematrixbotlib as botlib +from dotenv import load_dotenv + +# Load environment variables from .env file +plugin_dir = os.path.dirname(os.path.abspath(__file__)) +parent_dir = os.path.dirname(plugin_dir) +dotenv_path = os.path.join(parent_dir, '.env') +load_dotenv(dotenv_path) + +SHODAN_API_KEY = os.getenv("SHODAN_KEY", "") +SHODAN_API_BASE = "https://api.shodan.io" + +async def handle_command(room, message, bot, prefix, config): + """ + Function to handle Shodan commands. + + Args: + room (Room): The Matrix room where the command was invoked. + message (RoomMessage): The message object containing the command. + bot (Bot): The bot object. + prefix (str): The command prefix. + config (dict): Configuration parameters. + + Returns: + None + """ + match = botlib.MessageMatch(room, message, bot, prefix) + if match.is_not_from_this_bot() and match.prefix() and match.command("shodan"): + logging.info("Received !shodan command") + + # Check if API key is configured + if not SHODAN_API_KEY: + await bot.api.send_text_message( + room.room_id, + "Shodan API key not configured. Please set SHODAN_KEY environment variable." + ) + logging.error("Shodan API key not configured") + return + + args = match.args() + + if len(args) < 1: + await show_usage(room, bot) + return + + subcommand = args[0].lower() + + if subcommand == "ip": + if len(args) < 2: + await bot.api.send_text_message(room.room_id, "Usage: !shodan ip ") + return + ip = args[1] + await shodan_ip_lookup(room, bot, ip) + + elif subcommand == "search": + if len(args) < 2: + await bot.api.send_text_message(room.room_id, "Usage: !shodan search ") + return + query = ' '.join(args[1:]) + await shodan_search(room, bot, query) + + elif subcommand == "host": + if len(args) < 2: + await bot.api.send_text_message(room.room_id, "Usage: !shodan host ") + return + host = args[1] + await shodan_host(room, bot, host) + + elif subcommand == "count": + if len(args) < 2: + await bot.api.send_text_message(room.room_id, "Usage: !shodan count ") + return + query = ' '.join(args[1:]) + await shodan_count(room, bot, query) + + else: + await show_usage(room, bot) + +async def show_usage(room, bot): + """Display Shodan command usage.""" + usage = """ +🔍 Shodan Commands: + +!shodan ip <ip_address> - Get detailed information about an IP +!shodan search <query> - Search Shodan database +!shodan host <domain/ip> - Get host information +!shodan count <query> - Count results for a search query + +Search Examples: +• !shodan search apache +• !shodan search "port:22" +• !shodan search "country:US product:nginx" +• !shodan search "net:192.168.1.0/24" +""" + await bot.api.send_markdown_message(room.room_id, usage) + +async def shodan_ip_lookup(room, bot, ip): + """Look up information about a specific IP address.""" + try: + url = f"{SHODAN_API_BASE}/shodan/host/{ip}" + params = {"key": SHODAN_API_KEY} + + logging.info(f"Fetching Shodan IP info for: {ip}") + response = requests.get(url, params=params, timeout=15) + + if response.status_code == 404: + await bot.api.send_text_message(room.room_id, f"No information found for IP: {ip}") + return + elif response.status_code == 401: + await bot.api.send_text_message(room.room_id, "Invalid Shodan API key") + return + elif response.status_code != 200: + await bot.api.send_text_message(room.room_id, f"Shodan API error: {response.status_code}") + return + + data = response.json() + + # Format the response + output = f"🔍 Shodan IP Lookup: {ip}

" + + if data.get('country_name'): + output += f"📍 Location: {data.get('city', 'N/A')}, {data.get('country_name', 'N/A')}
" + + if data.get('org'): + output += f"🏢 Organization: {data['org']}
" + + if data.get('os'): + output += f"💻 Operating System: {data['os']}
" + + if data.get('ports'): + output += f"🔌 Open Ports: {', '.join(map(str, data['ports']))}
" + + output += f"🕒 Last Update: {data.get('last_update', 'N/A')}

" + + # Show services + if data.get('data'): + output += "📡 Services:
" + for service in data['data'][:5]: # Limit to first 5 services + port = service.get('port', 'N/A') + product = service.get('product', 'Unknown') + version = service.get('version', '') + banner = service.get('data', '')[:100] + "..." if len(service.get('data', '')) > 100 else service.get('data', '') + + output += f" • Port {port}: {product} {version}
" + if banner: + output += f" {banner}
" + + if len(data['data']) > 5: + output += f" • ... and {len(data['data']) - 5} more services
" + + # Wrap in collapsible if output is large + if len(output) > 500: + output = f"
🔍 Shodan IP Lookup: {ip}{output}
" + + await bot.api.send_markdown_message(room.room_id, output) + logging.info(f"Sent Shodan IP info for {ip}") + + except requests.exceptions.Timeout: + await bot.api.send_text_message(room.room_id, "Shodan API request timed out") + logging.error("Shodan API timeout") + except Exception as e: + await bot.api.send_text_message(room.room_id, f"Error fetching Shodan data: {str(e)}") + logging.error(f"Error in shodan_ip_lookup: {e}") + +async def shodan_search(room, bot, query): + """Search the Shodan database.""" + try: + url = f"{SHODAN_API_BASE}/shodan/host/search" + params = { + "key": SHODAN_API_KEY, + "query": query, + "minify": True, + "limit": 5 # Limit results to avoid huge responses + } + + logging.info(f"Searching Shodan for: {query}") + response = requests.get(url, params=params, timeout=15) + + if response.status_code != 200: + await handle_shodan_error(room, bot, response.status_code) + return + + data = response.json() + + if not data.get('matches'): + await bot.api.send_text_message(room.room_id, f"No results found for: {query}") + return + + output = f"🔍 Shodan Search: '{query}'
" + output += f"Total Results: {data.get('total', 0):,}

" + + for match in data['matches'][:5]: # Show first 5 results + ip = match.get('ip_str', 'N/A') + port = match.get('port', 'N/A') + org = match.get('org', 'Unknown') + product = match.get('product', 'Unknown') + + output += f"🌐 {ip}:{port}
" + output += f" • Organization: {org}
" + output += f" • Service: {product}
" + + if match.get('location'): + loc = match['location'] + if loc.get('city') and loc.get('country_name'): + output += f" • Location: {loc['city']}, {loc['country_name']}
" + + output += "
" + + if data.get('total', 0) > 5: + output += f"Showing 5 of {data['total']:,} results. Refine your search for more specific results." + + await bot.api.send_markdown_message(room.room_id, output) + logging.info(f"Sent Shodan search results for: {query}") + + except requests.exceptions.Timeout: + await bot.api.send_text_message(room.room_id, "Shodan API request timed out") + logging.error("Shodan API timeout") + except Exception as e: + await bot.api.send_text_message(room.room_id, f"Error searching Shodan: {str(e)}") + logging.error(f"Error in shodan_search: {e}") + +async def shodan_host(room, bot, host): + """Get host information (domain or IP).""" + try: + url = f"{SHODAN_API_BASE}/dns/domain/{host}" + params = {"key": SHODAN_API_KEY} + + logging.info(f"Fetching Shodan host info for: {host}") + response = requests.get(url, params=params, timeout=15) + + if response.status_code == 404: + # Try IP lookup instead + await shodan_ip_lookup(room, bot, host) + return + elif response.status_code != 200: + await handle_shodan_error(room, bot, response.status_code) + return + + data = response.json() + + output = f"🔍 Shodan Host: {host}

" + + if data.get('subdomains'): + output += f"🌐 Subdomains ({len(data['subdomains'])}):
" + for subdomain in sorted(data['subdomains'])[:10]: # Show first 10 + output += f" • {subdomain}.{host}
" + + if len(data['subdomains']) > 10: + output += f" • ... and {len(data['subdomains']) - 10} more
" + + if data.get('tags'): + output += f"
🏷️ Tags: {', '.join(data['tags'])}
" + + if data.get('data'): + output += f"
📊 Records Found: {len(data['data'])}
" + + await bot.api.send_markdown_message(room.room_id, output) + logging.info(f"Sent Shodan host info for: {host}") + + except requests.exceptions.Timeout: + await bot.api.send_text_message(room.room_id, "Shodan API request timed out") + logging.error("Shodan API timeout") + except Exception as e: + await bot.api.send_text_message(room.room_id, f"Error fetching host info: {str(e)}") + logging.error(f"Error in shodan_host: {e}") + +async def shodan_count(room, bot, query): + """Count results for a search query.""" + try: + url = f"{SHODAN_API_BASE}/shodan/host/count" + params = { + "key": SHODAN_API_KEY, + "query": query + } + + logging.info(f"Counting Shodan results for: {query}") + response = requests.get(url, params=params, timeout=15) + + if response.status_code != 200: + await handle_shodan_error(room, bot, response.status_code) + return + + data = response.json() + + output = f"🔍 Shodan Count: '{query}'

" + output += f"Total Results: {data.get('total', 0):,}
" + + # Show top countries if available + if data.get('facets') and 'country' in data['facets']: + output += "
🌍 Top Countries:
" + for country in data['facets']['country'][:5]: + output += f" • {country['value']}: {country['count']:,}
" + + # Show top organizations if available + if data.get('facets') and 'org' in data['facets']: + output += "
🏢 Top Organizations:
" + for org in data['facets']['org'][:5]: + output += f" • {org['value']}: {org['count']:,}
" + + await bot.api.send_markdown_message(room.room_id, output) + logging.info(f"Sent Shodan count for: {query}") + + except requests.exceptions.Timeout: + await bot.api.send_text_message(room.room_id, "Shodan API request timed out") + logging.error("Shodan API timeout") + except Exception as e: + await bot.api.send_text_message(room.room_id, f"Error counting Shodan results: {str(e)}") + logging.error(f"Error in shodan_count: {e}") + +async def handle_shodan_error(room, bot, status_code): + """Handle Shodan API errors.""" + error_messages = { + 401: "Invalid Shodan API key", + 403: "Access denied - check API key permissions", + 404: "No results found", + 429: "Rate limit exceeded - try again later", + 500: "Shodan API server error", + 503: "Shodan API temporarily unavailable" + } + + message = error_messages.get(status_code, f"Shodan API error: {status_code}") + await bot.api.send_text_message(room.room_id, message) + logging.error(f"Shodan API error: {status_code}")