diff --git a/README.md b/README.md index b7dd7dcaaa86a3d44cd009e516e1e408d2b34ae9..9a3b6d0baffb949221ff6ee06e15ff64d0373f9f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,116 @@ -# exec +# exec-cmd + +## cmd + +A [maubot](https://github.com/maubot/maubot) that executes predefined shell commands. + +### Setup + +Instructions to run the bot in [standalone](https://docs.mau.fi/maubot/usage/standalone.html) mode. + +Install dependencies and create a local user: + +```bash +apt install python3-pip python3-setuptools python3-wheel python3-venv +useradd -r -d /opt/maubot -s /usr/sbin/nologin maubot +mkdir /opt/maubot +chown -R maubot:maubot /opt/maubot +su - maubot -s /bin/bash +``` + +Install maubot as maubot system user in a venv: + +```bash +python3 -m venv env +source env/bin/activate +pip install --upgrade pip setuptools wheel +pip install --upgrade maubot[all] +``` + +Clone and setup the maubot plugin: + +```bash +git clone https://gitlab.phys.ethz.ch/isgphys/maubot-exec-cmd.git git +ln -s git/maubot.yaml +ln -s git/exec_cmd +cp standalone-example-config.yaml config.yaml +``` + +Generate an `access_token` and `device_id` (login) using the +[client-server api](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3login) +with a cli http tool such as [httpie](https://httpie.io/): + +```bash +http POST 'https://example.com/_matrix/client/v3/login' <<<'{"identifier":{"type":"m.id.user","user":"botusername"},"initial_device_display_name":"Standalone Bot","password":"ilovebananas","type":"m.login.password"}' +``` + +Invite the bot user to your room and note the `<roomid_or_alias>`. + +Manually join the bot user to the room using the +[client-server api](https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3joinroomidoralias): + +```bash +http POST 'https://example.com/_matrix/client/v3/join/<roomid_or_alias>' Authorization:"Bearer <access_token>" +``` + +### Configuration + +Configure the required bot user credentials: `id`, `homeserver`, `access_token` and `device_id`. + +It is recommended to leave `user.autojoin` on `false` and use a manual join as shown above. + +The command prefix can be configured with: + +```yaml +prefix_cmd: 'cmd' +``` + +The list of Matrix user IDs who are allowed to execute predefined shell commands: + +```yaml +whitelist_cmd: +- '@user:example.com' +``` + +The `commands` dictionary holds all predefined commands. +The keys represent the command names that can be sent to the bot. +For multi word bot commands `_` must be used as delimiter instead of spaces (` `). + +```yaml +commands: + ps: ps -ef | grep maubot + device_off: echo powering off + device_start: | + echo device power on + sleep 5 + echo device is starting + sleep 5 + echo device started +``` + +### Usage + +Start the bot: + +```bash +python -m maubot.standalone +``` + +Available bot commands with the above: + +```yaml +!cmd # lists available commands +!cmd ps +!cmd device off +!cmd device start +``` + + +## exec A [maubot](https://github.com/maubot/maubot) that executes code. exec is updated to be compatible with python 3.8+. -## Usage +### Usage The bot is triggered by a specific message prefix (defaults to `!exec`) and executes the code in the first code block. @@ -35,12 +143,3 @@ If running in userbot mode, the bot will edit your original message instead of making a new reply message. Currently, the bot supports `python` and `shell` as languages. - - -## Contributors - - <a href="https://github.com/YingzhouLi/exec/graphs/contributors"> - <img src="https://contrib.rocks/image?repo=YingzhouLi/exec" /> - </a> - - Made with [contrib.rocks](https://contrib.rocks). diff --git a/base-config.yaml b/base-config.yaml index e82cab89a3c8dcab57dc9426c79e6040ba11cd40..061a42b94542a6bde7c69b15b0aedd3dc9c06844 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -1,13 +1,31 @@ -# The message prefix to treat as exec commands. -prefix: '!exec' +# The message prefix to run predefined shell commands (excluding `!`). +prefix_cmd: 'cmd' +# The list of user IDs who are allowed to execute predefined shell commands. +# There is absolutely no sandboxing in maubot or this plugin, keep this list small. +whitelist_cmd: +- '@user:example.com' +# Dict with predefined shell commands +# Key: command name (use `_` as placeholder for spaces ` ` in multi word commands) +# Value: command or shell script to execute +commands: + ps: ps -ef | grep maubot + device_off: echo powering off + device_start: | + echo device power on + sleep 5 + echo device is starting + sleep 5 + echo device started + +# The message prefix to treat as exec commands (including `!`). +prefix_exec: '!exec' # Whether or not to enable "userbot" mode, where commands that the bot's user # sends are handled and responded to with edits instead of replies. # This is intended to be used when you run the plugin on your own account. userbot: false # The list of user IDs who are allowed to execute stuff. There is absolutely no # sandboxing in maubot or this plugin, keep this list small. -whitelist: -- '@user:example.com' +whitelist_exec: [] output: # Number of seconds to wait between output update edits. diff --git a/exec/__init__.py b/exec/__init__.py deleted file mode 100644 index 19d8add02fca1758eae8c3862ef374f7685f0bb0..0000000000000000000000000000000000000000 --- a/exec/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .bot import ExecBot diff --git a/exec_cmd/__init__.py b/exec_cmd/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6824790d1cb8ae80444cf81835188eae33610fe1 --- /dev/null +++ b/exec_cmd/__init__.py @@ -0,0 +1 @@ +from .bot import ExecCmdBot diff --git a/exec/bot.py b/exec_cmd/bot.py similarity index 77% rename from exec/bot.py rename to exec_cmd/bot.py index 329455011de6686659cba53403614ea7260be0c3..f81a8af5cee1b263f38546b205ab48a12564a7b7 100644 --- a/exec/bot.py +++ b/exec_cmd/bot.py @@ -13,7 +13,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -from typing import Type, Set, Optional, Any +from typing import Type, Set, Optional, Any, Dict from io import StringIO from html import escape as escape_orig from time import time @@ -24,7 +24,7 @@ from mautrix.types import EventType, UserID, TextMessageEventContent, MessageTyp from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper from mautrix.util.formatter import MatrixParser, EntityString, SimpleEntity, EntityType from maubot import Plugin, MessageEvent -from maubot.handlers import event +from maubot.handlers import event, command from .runner import PythonRunner, ShellRunner, OutputType @@ -33,28 +33,38 @@ def escape(val: Optional[str]) -> Optional[str]: return escape_orig(val) if val is not None else None +class ConfigValidationError(Exception): + pass + + class EntityParser(MatrixParser[EntityString]): fs = EntityString[SimpleEntity, EntityType] class Config(BaseProxyConfig): def do_update(self, helper: ConfigUpdateHelper) -> None: - helper.copy("prefix") + helper.copy("prefix_exec") + helper.copy("prefix_cmd") helper.copy("userbot") - helper.copy("whitelist") + helper.copy("whitelist_exec") + helper.copy("whitelist_cmd") helper.copy("output.interval") helper.copy("output.template_args") helper.copy("output.plaintext") helper.copy("output.html") + helper.copy("commands") -class ExecBot(Plugin): - whitelist: Set[UserID] +class ExecCmdBot(Plugin): + whitelist_exec: Set[UserID] + whitelist_cmd: Set[UserID] userbot: bool - prefix: str + prefix_exec: str + prefix_cmd: str output_interval: int plaintext_template: Template html_template: Template + commands: Dict @classmethod def get_config_class(cls) -> Type[BaseProxyConfig]: @@ -65,13 +75,18 @@ class ExecBot(Plugin): def on_external_config_update(self) -> None: self.config.load_and_update() - self.whitelist = set(self.config["whitelist"]) + self.whitelist_exec = set(self.config["whitelist_exec"]) + self.whitelist_cmd = set(self.config["whitelist_cmd"]) self.userbot = self.config["userbot"] - self.prefix = self.config["prefix"] + self.prefix_exec = self.config["prefix_exec"] + self.prefix_cmd = self.config["prefix_cmd"] self.output_interval = self.config["output.interval"] template_args = self.config["output.template_args"] self.plaintext_template = Template(self.config["output.plaintext"], **template_args) self.html_template = Template(self.config["output.html"], **template_args) + self.commands = self.config["commands"] + if any(" " in cmd for cmd in self.commands): + raise ConfigValidationError("commands keys contain spaces") def format_status(self, code: str, language: str, output: str = "", output_html: str = "", return_value: Any = None, exception_header: Optional[str] = None, @@ -89,29 +104,7 @@ class ExecBot(Plugin): exception_header=escape(exception_header))) return content - @event.on(EventType.ROOM_MESSAGE) - async def exec(self, evt: MessageEvent) -> None: - if ((evt.content.msgtype != MessageType.TEXT - or evt.sender not in self.whitelist - or not evt.content.body.startswith(self.prefix) - or not evt.content.formatted_body)): - return - - command = await EntityParser().parse(evt.content.formatted_body) - entity: SimpleEntity - code: Optional[str] = None - lang: Optional[str] = None - stdin: str = "" - for entity in command.entities: - if entity.type != EntityType.PREFORMATTED: - continue - current_lang = entity.extra_info["language"].lower() - value = command.text[entity.offset:entity.offset + entity.length] - if not code: - code = value - lang = current_lang - elif current_lang == "stdin" or current_lang == "input": - stdin += value + async def exec_runner(self, evt: MessageEvent, lang: str, code: str, stdin: str = "") -> None: if not code or not lang: return @@ -170,3 +163,51 @@ class ExecBot(Plugin): msgtype=msgtype) content.set_edit(output_event_id) await self.client.send_message(evt.room_id, content) + + @event.on(EventType.ROOM_MESSAGE) + async def exec(self, evt: MessageEvent) -> None: + if ((evt.content.msgtype != MessageType.TEXT + or evt.sender not in self.whitelist_exec + or not evt.content.body.startswith(self.prefix_exec) + or not evt.content.formatted_body)): + return + + command = await EntityParser().parse(evt.content.formatted_body) + entity: SimpleEntity + code: Optional[str] = None + lang: Optional[str] = None + stdin: str = "" + for entity in command.entities: + if entity.type != EntityType.PREFORMATTED: + continue + current_lang = entity.extra_info["language"].lower() + value = command.text[entity.offset:entity.offset + entity.length] + if not code: + code = value + lang = current_lang + elif current_lang == "stdin" or current_lang == "input": + stdin += value + if not code or not lang: + return + + await self.exec_runner(evt, lang, code, stdin=stdin) + + @command.new(name=lambda self: self.prefix_cmd) + @command.argument("command", pass_raw=True, required=True) + async def cmd(self, evt: MessageEvent, command: str) -> None: + if evt.sender not in self.whitelist_cmd: + return + + if not command: + available = ["`" + c.replace("_", " ") + "`" for c in self.commands.keys()] + available_list = "- " + "\n- ".join(available) + await evt.reply(f"available commands:\n{available_list}") + return + + key: str + key = command.replace(" ", "_") + if key not in self.commands.keys(): + await evt.reply("unknown command") + return + + await self.exec_runner(evt, "sh", self.commands[key]) diff --git a/exec/runner/__init__.py b/exec_cmd/runner/__init__.py similarity index 100% rename from exec/runner/__init__.py rename to exec_cmd/runner/__init__.py diff --git a/exec/runner/base.py b/exec_cmd/runner/base.py similarity index 100% rename from exec/runner/base.py rename to exec_cmd/runner/base.py diff --git a/exec/runner/python.py b/exec_cmd/runner/python.py similarity index 100% rename from exec/runner/python.py rename to exec_cmd/runner/python.py diff --git a/exec/runner/shell.py b/exec_cmd/runner/shell.py similarity index 100% rename from exec/runner/shell.py rename to exec_cmd/runner/shell.py diff --git a/maubot.yaml b/maubot.yaml index b316d5d91c75ed0b682d3e528b2bd695f76c04a8..d85de0311203587301ba7160f82e1f9abb42bc63 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -1,9 +1,9 @@ maubot: 0.1.0 -id: xyz.maubot.exec -version: 0.2.0 +id: ch.ethz.phys.exec-cmd +version: 0.0.1 license: AGPL-3.0-or-later modules: -- exec -main_class: ExecBot +- exec_cmd +main_class: ExecCmdBot extra_files: - base-config.yaml diff --git a/mbcbuild.sh b/mbcbuild.sh deleted file mode 100755 index 201a951d1e7871c47b89bc45c18b0ff5f610a9f0..0000000000000000000000000000000000000000 --- a/mbcbuild.sh +++ /dev/null @@ -1 +0,0 @@ -zip -9r exec-develop.mbp exec maubot.yaml base-config.yaml diff --git a/standalone-example-config.yaml b/standalone-example-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..265a8087de96cdb8edc59464aa04744145dddcfa --- /dev/null +++ b/standalone-example-config.yaml @@ -0,0 +1,165 @@ +# Bot account details +user: + credentials: + id: "@bot:example.com" + homeserver: https://example.com + access_token: foo + # If you want to enable encryption, set the device ID corresponding to the access token here. + # When using an appservice, you should use appservice login manually to generate a device ID and access token. + device_id: null + # Enable /sync? This is not needed for purely unencrypted webhook-based bots, but is necessary in most other cases. + sync: true + # Receive appservice transactions? This will add a /_matrix/app/v1/transactions endpoint on + # the HTTP server configured below. The base_path will not be applied for the /transactions path. + appservice: false + # When appservice mode is enabled, the hs_token for the appservice. + hs_token: null + # Automatically accept invites? + autojoin: false + # The displayname and avatar URL to set for the bot on startup. + # Set to "disable" to not change the the current displayname/avatar. + displayname: Standalone Bot + avatar_url: mxc://maunium.net/AKwRzQkTbggfVZGEqexbYLIO + + # Should events from the initial sync be ignored? This should usually always be true. + ignore_initial_sync: true + # Should events from the first sync after starting be ignored? This can be set to false + # if you want the bot to handle messages that were sent while the bot was down. + ignore_first_sync: true + +# Web server settings. These will only take effect if the plugin requests it using `webapp: true` in the meta file, +# or if user -> appservice is set to true. +server: + # The IP and port to listen to. + hostname: 0.0.0.0 + port: 8080 + # The base path where the plugin's web resources will be served. Unlike the normal mode, + # the webserver is dedicated for a single bot in standalone mode, so the default path + # is just /. If you want to emulate normal mode, set this to /_matrix/maubot/plugin/something + base_path: / + # The public URL where the resources are available. The base path is automatically appended to this. + public_url: https://example.com + +# The database for the plugin. Used for plugin data, the sync token and e2ee data (if enabled). +# SQLite and Postgres are supported. +database: sqlite:bot.db + +# Additional arguments for asyncpg.create_pool() or sqlite3.connect() +# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool +# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect +# For sqlite, min_size is used as the connection thread pool size and max_size is ignored. +database_opts: + min_size: 1 + max_size: 10 + +# Config for the plugin. Refer to the plugin's base-config.yaml to find what (if anything) to put here. +plugin_config: + # The message prefix to run predefined shell commands (excluding `!`). + prefix_cmd: 'cmd' + # The list of user IDs who are allowed to execute predefined shell commands. + # There is absolutely no sandboxing in maubot or this plugin, keep this list small. + whitelist_cmd: + - '@user:example.com' + # Dict with predefined shell commands + # Key: command name (use `_` as placeholder for spaces ` ` in multi word commands) + # Value: command or shell script to execute + commands: + ps: ps -ef | grep maubot + device_off: echo powering off + device_start: | + echo device power on + sleep 5 + echo device is starting + sleep 5 + echo device started + + # The message prefix to treat as exec commands (including `!`). + prefix_exec: '!exec' + # Whether or not to enable "userbot" mode, where commands that the bot's user + # sends are handled and responded to with edits instead of replies. + # This is intended to be used when you run the plugin on your own account. + userbot: false + # The list of user IDs who are allowed to execute stuff. There is absolutely no + # sandboxing in maubot or this plugin, keep this list small. + whitelist_exec: [] + + output: + # Number of seconds to wait between output update edits. + interval: 5 + # Arguments for the Jinja2 template initialization. + template_args: + lstrip_blocks: true + trim_blocks: yes + # Plaintext output template. + plaintext: | + Input ({{ input }}): + {{ code }} + {% if output %} + + Output: + {{ output }} + {% endif %} + {% if return_value != None %} + + Return: + {{ return_value }} + {% endif %} + {% if exception != None %} + + {% if exception_header %}{{ exception_header }}:{% endif %} + {{ exception }} + {% endif %} + {% if duration != None %} + + Took {{ duration | round(3) }} seconds + {% else %} + + Running... + {% endif %} + # HTML output template. + html: | + <h4>Input</h4> + <pre><code class="language-{{ language }}">{{ code }}</code></pre> + {% if output %} + <h4>Output</h4> + <pre>{{ output }}</pre> + {% endif %} + {% if return_value != None %} + {% if language in ("bash", "sh", "shell") %} + <h4>Return: <code>{{ return_value }}</code></h4> + {% else %} + <h4>Return</h4> + <pre>{{ return_value }}</pre> + {% endif %} + {% endif %} + {% if exception != None %} + {% if exception_header %}<h4>{{ exception_header }}</h4>{% endif %} + <pre><code class="language-pytb">{{ exception }}</code></pre> + {% endif %} + {% if duration != None %} + <h4>Took {{ duration | round(3) }} seconds</h4> + {% else %} + <h4>Running...</h4> + {% endif %} + +# Standard Python logging configuration +logging: + version: 1 + formatters: + colored: + (): maubot.lib.color_log.ColorFormatter + format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s" + handlers: + console: + class: logging.StreamHandler + formatter: colored + loggers: + maubot: + level: DEBUG + mau: + level: DEBUG + aiohttp: + level: INFO + root: + level: DEBUG + handlers: [console]