diff --git a/Dockerfile b/Dockerfile index 270e844..897bd5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,15 @@ -FROM python:3.11-slim +FROM python:3.12-slim -LABEL org.opencontainers.image.source https://git.i-am.nexus/nex/college-bot-v2 -LABEL org.opencontainers.image.url https://git.i-am.nexus/nex/college-bot-v2 -LABEL org.opencontainers.image.licenses AGPL-3.0 -LABEL org.opencontainers.image.title "College Bot v2" -LABEL org.opencontainers.image.description "Version 2 of jimmy." +LABEL org.opencontainers.image.source="https://git.i-am.nexus/nex/college-bot-v2" +LABEL org.opencontainers.image.url="https://git.i-am.nexus/nex/college-bot-v2" +LABEL org.opencontainers.image.licenses="AGPL-3.0" +LABEL org.opencontainers.image.title="College Bot v2" +LABEL org.opencontainers.image.description="Version 2 of jimmy." WORKDIR /app -RUN DEBIAN_FRONTEND=noninteractive apt-get update - # Install chrome dependencies -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ +RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y --no-install-recommends \ webext-ublock-origin-chromium \ gconf-service \ libasound2 \ @@ -49,35 +47,24 @@ fonts-liberation \ libappindicator1 \ libnss3 \ lsb-release \ -xdg-utils - -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - chromium-common \ - chromium-driver \ - chromium-sandbox - -# Install general utilities -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - traceroute \ - iputils-ping \ - dnsutils \ - net-tools \ - git \ - whois \ - curl \ - libmagic-dev - -# Install image dependencies -RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ffmpeg \ - imagemagick - -RUN python3 -m venv /app/venv -RUN /app/venv/bin/pip install --upgrade --no-input pip wheel setuptools +xdg-utils \ +chromium-common \ +chromium-driver \ +chromium-sandbox \ +traceroute \ +iputils-ping \ +dnsutils \ +net-tools \ +git \ +whois \ +curl \ +libmagic-dev \ +nmap \ +ffmpeg COPY requirements.txt /tmp/requirements.txt -RUN /app/venv/bin/pip install -Ur /tmp/requirements.txt --no-input +RUN pip install -Ur /tmp/requirements.txt --no-input COPY ./src/ /app/ -CMD ["/app/venv/bin/python", "main.py"] +CMD ["python", "main.py"] diff --git a/docker-compose.yml b/docker-compose.yml index 4a6619c..c8adbb4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,11 @@ services: - ./jimmy.log:/app/jimmy.log - /dev/dri:/dev/dri - jimmy-data:/app/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + - /usr/bin/nmap:/usr/bin/nmap:ro + - /usr/bin/ffmpeg:/usr/bin/ffmpeg:ro + - /usr/bin/ffprobe:/usr/bin/ffprobe:ro extra_hosts: - host.docker.internal:host-gateway depends_on: diff --git a/requirements.txt b/requirements.txt index 78aca47..073051b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ python-magic~=0.4 aiofiles~=23.2 fuzzywuzzy[speedup]~=0.18 tortoise-orm[asyncpg]~=0.21 +superpaste @ git+https://github.com/nexy7574/superpaste.git@e31eca6 diff --git a/src/cogs/auto_responder.py b/src/cogs/auto_responder.py index 44dd2b4..03f8329 100644 --- a/src/cogs/auto_responder.py +++ b/src/cogs/auto_responder.py @@ -1,5 +1,11 @@ +# In order to use commands in this system, the following things must be installed (ideally in /usr/local/bin): +# - `ffmpeg`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# - /dev/dri should be passed through to the container for hardware acceleration. +# - `ffprobe`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# In docker, you can just bind these as volumes, and read-only too. import asyncio import io +import shutil import typing from collections.abc import Iterable import logging @@ -105,6 +111,12 @@ class AutoResponder(commands.Cog): :param uri: The URI to transcode :return: A transcoded file """ + if not shutil.which("ffmpeg"): + self.log.error("ffmpeg not installed") + return + if not shutil.which("ffprobe"): + self.log.error("ffprobe not installed") + return last_reaction: str | None = None def update_reaction(new: str | None = None) -> None: @@ -118,9 +130,7 @@ class AutoResponder(commands.Cog): last_reaction = new self.log.info("Waiting for transcode lock to release") async with self.transcode_lock: - cog: FFMeta = self.bot.get_cog("FFMeta") - if not cog: - raise RuntimeError("FFMeta cog not loaded") + cog = FFMeta(self.bot) if not isinstance(uri, str): uri = str(uri) @@ -240,6 +250,12 @@ class AutoResponder(commands.Cog): *domains: str, additional: Iterable[str] = None ) -> None: + if not shutil.which("ffmpeg"): + self.log.error("ffmpeg not installed") + return + if not shutil.which("ffprobe"): + self.log.error("ffprobe not installed") + return if self.allow_hevc_to_h264 is False: self.log.debug("hevc_to_h264 is disabled, not engaging.") return diff --git a/src/cogs/election.py b/src/cogs/election.py index 9a71cc4..7f55190 100644 --- a/src/cogs/election.py +++ b/src/cogs/election.py @@ -2,7 +2,6 @@ This module is only meant to be loaded during election times. """ import asyncio -import datetime import logging import random import re diff --git a/src/cogs/ffmeta.py b/src/cogs/ffmeta.py index ef69879..06bda53 100644 --- a/src/cogs/ffmeta.py +++ b/src/cogs/ffmeta.py @@ -1,8 +1,14 @@ +# In order to use commands in this system, the following things must be installed (ideally in /usr/local/bin): +# - `ffmpeg`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# - /dev/dri should be passed through to the container for hardware acceleration. +# - `ffprobe`, usually `ffmpeg`, sometimes `ffmpeg-full`. +# In docker, you can just bind these as volumes, and read-only too. import asyncio import io import json import logging import pathlib +import shutil import sys import tempfile import typing @@ -56,6 +62,8 @@ class FFMeta(commands.Cog): @commands.slash_command() async def ffprobe(self, ctx: discord.ApplicationContext, url: str = None, attachment: discord.Attachment = None): """Runs ffprobe on a given URL or attachment""" + if not shutil.which("ffprobe"): + return await ctx.respond("ffprobe is not installed on this system.") if url is None: if attachment is None: return await ctx.respond("No URL or attachment provided") @@ -142,6 +150,10 @@ class FFMeta(commands.Cog): ] = False, ): """Converts a given URL or attachment to an Opus file""" + if not shutil.which("ffmpeg"): + return await ctx.respond("ffmpeg is not installed on this system.") + if not shutil.which("ffprobe"): + return await ctx.respond("ffprobe is not installed on this system.") if bitrate == 0: bitrate = 0.5 if mono: @@ -244,6 +256,8 @@ class FFMeta(commands.Cog): rbh = Path("assets/right-behind-you.ogg").resolve() if not rbh.exists(): return await ctx.respond("The file `right-behind-you.ogg` is missing.") + if not shutil.which("ffmpeg"): + return await ctx.respond("ffmpeg is not installed on this system.") if not image.content_type.startswith("image"): return await ctx.respond("That's not an image!") with tempfile.NamedTemporaryFile(suffix=Path(image.filename).suffix) as temp: diff --git a/src/cogs/net.py b/src/cogs/net.py index 7755d44..150148f 100644 --- a/src/cogs/net.py +++ b/src/cogs/net.py @@ -1,10 +1,20 @@ +# In order to use commands in this system, the following things must be installed (ideally in /usr/local/bin): +# - `ping`, usually iputils-ping +# - `whois`, usually whois +# - `traceroute`, usually traceroute +# - `nmap`, usually nmap +# In docker, you can just bind these as volumes, and read-only too. import asyncio import io import json import os import re +import shlex +import shutil +import tempfile import time import typing +import warnings from pathlib import Path import discord @@ -14,6 +24,8 @@ from dns import asyncresolver from rich.console import Console from rich.tree import Tree from conf import CONFIG +from superpaste import HstSHBackend +from superpaste.backends import GenericFile class GetFilteredTextView(discord.ui.View): @@ -43,6 +55,8 @@ class NetworkCog(commands.Cog): @commands.slash_command() async def ping(self, ctx: discord.ApplicationContext, target: str = None): """Get the bot's latency, or the network latency to a target.""" + if not shutil.which("ping"): + return await ctx.respond("Ping is not installed on this system.") if target is None: return await ctx.respond(f"Pong! {round(self.bot.latency * 1000)}ms") else: @@ -69,6 +83,8 @@ class NetworkCog(commands.Cog): @commands.slash_command() async def whois(self, ctx: discord.ApplicationContext, target: str): """Get information about a user.""" + if not shutil.which("whois"): + return await ctx.respond("Whois is not installed on this system.") async def run_command(with_disclaimer: bool = False): args = [] if with_disclaimer else ["-H"] @@ -215,6 +231,8 @@ class NetworkCog(commands.Cog): await ctx.defer() if re.search(r"\s+", url): return await ctx.respond("URL cannot contain spaces.") + if not shutil.which("traceroute"): + return await ctx.respond("Traceroute is not installed on this system.") args = ["sudo", "-E", "-n", "traceroute"] flags = { @@ -346,6 +364,132 @@ class NetworkCog(commands.Cog): await ctx.respond(embed=embed) + @commands.slash_command() + @commands.max_concurrency(1, commands.BucketType.user) + async def nmap( + self, + ctx: discord.ApplicationContext, + target: str, + technique: typing.Annotated[ + str, + discord.Option( + str, + choices=[ + discord.OptionChoice(name="TCP SYN", value="S"), + discord.OptionChoice(name="TCP Connect", value="T"), + discord.OptionChoice(name="TCP ACK", value="A"), + discord.OptionChoice(name="TCP Window", value="W"), + discord.OptionChoice(name="TCP Maimon", value="M"), + discord.OptionChoice(name="UDP", value="U"), + discord.OptionChoice(name="TCP Null", value="N"), + discord.OptionChoice(name="TCP FIN", value="F"), + discord.OptionChoice(name="TCP XMAS", value="X"), + ], + default="T" + ) + ] = "T", + treat_all_hosts_online: bool = False, + service_scan: bool = False, + fast_mode: bool = False, + enable_os_detection: bool = False, + timing: typing.Annotated[ + int, + discord.Option( + int, + description="Timing template to use 0 is slowest, 5 is fastest.", + choices=[0, 1, 2, 3, 4, 5], + default=3 + ) + ] = 3, + ports: str = None + ): + """Runs nmap on a target. You cannot specify multiple targets.""" + await ctx.defer() + if len(shlex.split(target)) > 1: + return await ctx.respond("You cannot specify multiple targets.") + if not shutil.which("nmap"): + warnings.warn( + "NMAP is not installed on this system, so the /nmap command is not enabled. " + "If you would like to enable it, install nmap, or mount the binary as a volume in docker." + ) + return await ctx.respond("Nmap is not installed on this system.") + + is_superuser = os.getuid() == 0 + if technique != "T" and not is_superuser: + return await ctx.respond("Only `TCP Connect` can be used on this system.") + if enable_os_detection and not is_superuser: + return await ctx.respond("OS detection is not available on this system.") + + with tempfile.TemporaryDirectory(prefix=f"nmap-{ctx.user.id}-{discord.utils.utcnow().timestamp():.0f}") as tmp: + tmp_dir = Path(tmp) + args = [ + "nmap", + "-oA", + str(tmp_dir.resolve() / target), + "-T", + str(timing), + "-s" + technique, + "--reason", + "--noninteractive" + ] + if treat_all_hosts_online: + args.append("-Pn") + if service_scan: + args.append("-sV") + if fast_mode: + args.append("-F") + if ports: + args.extend(("-p", ports)) + if enable_os_detection: + args.append("-O") + args.append(target) + + await ctx.respond( + embed=discord.Embed( + title="Running nmap...", + description="Command:\n" + "```{}```".format(shlex.join(args)), + ) + ) + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _, stderr = await process.communicate() + files = [discord.File(x, filename=x.name + ".txt") for x in tmp_dir.iterdir()] + if not files: + if len(stderr) <= 4089: + return await ctx.edit( + embed=discord.Embed( + title="Nmap failed.", + description="```\n" + stderr.decode() + "```", + color=discord.Color.red() + ) + ) + + result = await HstSHBackend().async_create_paste( + GenericFile(stderr.decode()) + ) + return await ctx.edit( + embed=discord.Embed( + title="Nmap failed.", + description=f"Output was too long. [View full output]({result.url})", + color=discord.Color.red() + ) + ) + await ctx.edit( + embed=discord.Embed( + title="Nmap finished!", + description="Result files are attached.\n" + "* `gnmap` is 'greppable'\n" + "* `xml` is XML output\n" + "* `nmap` is normal output", + color=discord.Color.green() + ), + files=files + ) + def setup(bot): bot.add_cog(NetworkCog(bot)) diff --git a/src/cogs/ytdl.py b/src/cogs/ytdl.py index c3023c5..833b1f2 100644 --- a/src/cogs/ytdl.py +++ b/src/cogs/ytdl.py @@ -3,6 +3,7 @@ import functools import hashlib import logging import math +import shutil import time import datetime @@ -190,6 +191,8 @@ class YTDLCog(commands.Cog): :param file: The file to convert :return: The converted file """ + if not shutil.which("ffmpeg"): + raise RuntimeError("ffmpeg is not installed.") new_file = file.with_suffix(".m4a") args = [ "-vn", @@ -319,8 +322,9 @@ class YTDLCog(commands.Cog): else: chosen_format = user_format + ffmpeg_installed = bool(shutil.which("ffmpeg")) options.setdefault("postprocessors", []) - if audio_only: + if audio_only and ffmpeg_installed: # Overwrite format here to be best audio under 25 megabytes. chosen_format = "ba[filesize<20M]" # Also force sorting by the best audio bitrate first. @@ -332,7 +336,7 @@ class YTDLCog(commands.Cog): options["format"] = chosen_format options["paths"] = paths - if subtitles: + if subtitles and ffmpeg_installed: subtitles, burn = subtitles.split("+", 1) if "+" in subtitles else (subtitles, "0") burn = burn[0].lower() in ("y", "1", "t") if subtitles.lower() == "auto":