Skip to content
Snippets Groups Projects
Commit 0ec98eb0 authored by Sven Mäder's avatar Sven Mäder :speech_balloon:
Browse files

Allow to run predefined shell commands

parent c19e39b9
No related branches found
No related tags found
No related merge requests found
# 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).
# 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.
......
from .bot import ExecBot
from .bot import ExecCmdBot
......@@ -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])
File moved
File moved
File moved
File moved
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
zip -9r exec-develop.mbp exec maubot.yaml base-config.yaml
# 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]
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment