From cc676a082d75a47d3566e0d5127608103c5fd1a1 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sun, 30 Jan 2022 11:10:34 +0100 Subject: [PATCH 1/8] Paginate status results and implement rate limiting Packages are chunked in groups of 100 to avoid generating too large messages, and each chunk is sent spaced by 100 ms, to avoid rate limiting. In parallel, avoid room flooding by refusing to print statuses with more than 50 repositories or 1000 packages. Fixes issue #1. --- oscbot/__init__.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/oscbot/__init__.py b/oscbot/__init__.py index 1469901..979fe56 100644 --- a/oscbot/__init__.py +++ b/oscbot/__init__.py @@ -2,10 +2,12 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from dataclasses import dataclass, field +from time import sleep from typing import Optional, List, Type, Tuple import aiohttp import cryptocode +import more_itertools as mit from lxml import objectify from jinja2 import BaseLoader, Environment @@ -22,10 +24,8 @@ Only showing packages with {{ state }} state. {% endif %}""" -REPO_TEMPLATE = """ -#### {{ repo.name }} - {{ repo.arch }} - -{% for package in repo.packages %} +STATUS_TEMPLATE = """ +{% for package in packages %} {%if package.status != "disabled" %} {% set build_log_url = "{0}/package/live_build_log/{1}/{2}/{3}/{4}/".format( base_url, project, package.name, repo.name, repo.arch) %} @@ -287,8 +287,29 @@ class OSCBot(Plugin): base_url = self.config["instance_url"] + if len(response) > 50: + await evt.respond(f"Too many repositories ({len(response)})" + f"to display in project {project}, " + "please narrow down your search.") + return + for repository in response: - body = self.template.from_string(REPO_TEMPLATE) - message = body.render(repo=repository, project=project, - base_url=base_url) - await evt.respond(message, markdown=True) + await evt.respond(f"#### {repository.name} - {repository.arch}", + markdown=True) + + # Just so that someone doesn't flood by listing the whole + # openSUSE:Factory + if len(repository.packages) > 1000: + await evt.respond( + f"Too many packages ({len(repository.packages)}) " + "to display, please narrow down your search.") + continue + # To avoid creating too large messages, we chunk packages in + # groups of 100 + for packagelist in mit.chunked(repository.packages, 100): + body = self.template.from_string(STATUS_TEMPLATE) + message = body.render(packages=packagelist, base_url=base_url, + project=project, repo=repository) + await evt.respond(message, markdown=True) + # Wait 100 milliseconds to avoid triggering rate limiting + sleep(0.1) From 485dffaafc5caba1d96ba294b393dd3bdd8ce961 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sun, 30 Jan 2022 11:16:46 +0100 Subject: [PATCH 2/8] And again I forgot to version bump... --- maubot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maubot.yaml b/maubot.yaml index 6bcfc78..c1456f6 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: CC0-1.0 maubot: 0.1.0 id: org.dennogumi.osc -version: 0.2.2 +version: 0.3.1 license: AGPL-3.0-or-later modules: - oscbot From aea53325adc10e03ead81225e7fe1934c85c452b Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Tue, 8 Feb 2022 23:38:26 +0100 Subject: [PATCH 3/8] Initial support for aliases maubot's mandatory positional arguments make things a little difficult. --- oscbot/__init__.py | 121 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 2 deletions(-) diff --git a/oscbot/__init__.py b/oscbot/__init__.py index 979fe56..02ff417 100644 --- a/oscbot/__init__.py +++ b/oscbot/__init__.py @@ -36,6 +36,21 @@ STATUS_TEMPLATE = """ {% endfor %} """ +ALIAS_TEMPLATE = """ +### Configured aliases + +{% for alias, contents in repo_aliases.items() %} +#### {{ alias }} + +- project: {{ contents.project }} +- repository: {{ contents.repository }} +- package(s): {{ contents.package }} +- architecture(s): {{ contents.arch }} +- state: {{ contents.state }} + +{% endfor %} +""" + @dataclass class BuildResult: @@ -200,6 +215,103 @@ class OSCBot(Plugin): async def osc(self) -> None: pass + @osc.subcommand("alias", help="Manage aliases") + async def alias(self) -> None: + pass + + @alias.subcommand("list", help="List configured aliases") + async def list_aliases(self, evt: MessageEvent) -> None: + + body = self.template.from_string(ALIAS_TEMPLATE) + + if self.config.get("repo_aliases", None) is None: + await evt.reply("No aliases defined.") + return + + message = body.render(repo_aliases=self.config["repo_aliases"]) + await evt.respond(message, markdown=True) + + @alias.subcommand("edit", help="Edit configured aliases") + @command.argument("alias", "alias name") + @command.argument("project", "project name") + @command.argument("package", "package name", required=False) + @command.argument("repository", "repository", required=False) + @command.argument("arch", "architecture", required=False) + @command.argument("state", "state", required=False) + async def edit_alias(self, evt: MessageEvent, + alias: str, + project: Optional[str] = None, + package: Optional[str] = None, + repository: Optional[str] = None, + arch: Optional[str] = None, + state: Optional[str] = None) -> None: + + if alias not in self.config["repo_aliases"]: + await evt.respond(f"Unknown alias {alias}") + return + + alias_data = self.config["repo_aliases"][alias] + + if project: + alias_data["project"] = project + + if repository: + alias_data["repository"] = repository + + if package: + alias_data["package"] = package + + if arch: + alias_data["arch"] = alias + + if state: + alias_data["state"] = state + + self.config["repo_aliases"][alias] = alias_data + self.config.save() + await evt.reply(f"Alias {alias} successfully changed.") + + @alias.subcommand("create", help="Create a new alias") + @command.argument("alias", "alias name") + @command.argument("project", "project name") + @command.argument("package", "package name", required=False) + @command.argument("repository", "repository", required=False) + @command.argument("arch", "architecture", required=False) + @command.argument("state", "state", required=False) + async def create_alias(self, evt: MessageEvent, + alias: str, + project: Optional[str] = None, + package: Optional[str] = None, + repository: Optional[str] = None, + arch: Optional[str] = None, + state: Optional[str] = None) -> None: + + repository = "all" if not repository else repository + package = "all" if not package else package + arch = "all" if not arch else arch + state = "all" if not state else state + alias_data = { + "project": project, + "package": package, + "repository": repository, + "arch": arch, + "state": state + } + self.config["repo_aliases"][alias] = alias_data + self.config.save() + await evt.reply(f"Alias {alias} successfully created.") + + @alias.subcommand("delete", help="Delete an alias", aliases=("rm", )) + @command.argument("alias", "alias name") + async def delete_alias(self, evt: MessageEvent, alias: str): + if alias not in self.config["repo_aliases"]: + await evt.respond(f"Unknown alias {alias}") + return + + del self.config["repo_aliases"][alias] + self.config.save() + await evt.reply(f"Alias {alias} successfully deleted.") + @osc.subcommand( "rebuildpac", aliases=("rb",), help="Rebuild a package or all packages in the repositories") @@ -213,9 +325,14 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: + if project in self.config["repo_aliases"]: + # We're not interested in state and we query package explicitly + project, _, repository, _, arch = self.get_alias(project) + else: + repository = None if repository == "all" else repository + arch = None if arch == "all" else arch + package = None if package == "all" else package - repository = None if repository == "all" else repository - arch = None if arch == "all" else arch result, status = await self.parse_rebuilpac(project, package, repository, From c9d4e0312d4149a5d4de3cbadae3b9bb8826dc3e Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:19:06 +0100 Subject: [PATCH 4/8] Support for ACLs Users in the "admin" level can set aliases, rebuild packages, and trigger services. On the other hand, users in the "user" level can only perform read-only operations (reading status at this point). --- base-config.yaml | 5 +++++ oscbot/__init__.py | 46 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/base-config.yaml b/base-config.yaml index 96c03f6..cf823be 100644 --- a/base-config.yaml +++ b/base-config.yaml @@ -34,3 +34,8 @@ repo_aliases: repository: "KDE_Unstable_Frameworks_openSUSE_Factory" state: "failed" arch: "all" +acl: + admin: + - "@CHANGE_ME" + user: [] + diff --git a/oscbot/__init__.py b/oscbot/__init__.py index 02ff417..73735b5 100644 --- a/oscbot/__init__.py +++ b/oscbot/__init__.py @@ -83,6 +83,7 @@ class Config(BaseProxyConfig): helper.copy("username") helper.copy("repo_aliases") + helper.copy("acl") class OSCBot(Plugin): @@ -114,6 +115,18 @@ class OSCBot(Plugin): return (project, package, repository, state, arch) + def check_acl(self, user_id: str, need_admin: bool = False) -> bool: + + acls = self.config["acl"] + if not need_admin: + if user_id in acls["admin"] or acls["user"]: + return True + else: + if user_id in acls["admin"]: + return True + self.log.debug(f"Denied operation as {user_id}") + return False + async def parse_rebuilpac( self, project: str, @@ -222,6 +235,10 @@ class OSCBot(Plugin): @alias.subcommand("list", help="List configured aliases") async def list_aliases(self, evt: MessageEvent) -> None: + if not self.check_acl(evt.sender, need_admin=False): + await evt.reply("You are not authorized to perform this action.") + return + body = self.template.from_string(ALIAS_TEMPLATE) if self.config.get("repo_aliases", None) is None: @@ -246,6 +263,10 @@ class OSCBot(Plugin): arch: Optional[str] = None, state: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + if alias not in self.config["repo_aliases"]: await evt.respond(f"Unknown alias {alias}") return @@ -286,6 +307,10 @@ class OSCBot(Plugin): arch: Optional[str] = None, state: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + repository = "all" if not repository else repository package = "all" if not package else package arch = "all" if not arch else arch @@ -304,6 +329,11 @@ class OSCBot(Plugin): @alias.subcommand("delete", help="Delete an alias", aliases=("rm", )) @command.argument("alias", "alias name") async def delete_alias(self, evt: MessageEvent, alias: str): + + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + if alias not in self.config["repo_aliases"]: await evt.respond(f"Unknown alias {alias}") return @@ -325,6 +355,10 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + if project in self.config["repo_aliases"]: # We're not interested in state and we query package explicitly project, _, repository, _, arch = self.get_alias(project) @@ -353,6 +387,10 @@ class OSCBot(Plugin): project: str, package: str) -> None: + if not self.check_acl(evt.sender, need_admin=True): + await evt.reply("You are not authorized to perform this action.") + return + token = self.config["trigger_token"] trigger_url = f"{self.config['api_url']}/trigger/runservice" params = {"project": project, "package": package} @@ -382,6 +420,10 @@ class OSCBot(Plugin): repository: Optional[str] = None, arch: Optional[str] = None) -> None: + if not self.check_acl(evt.sender, need_admin=False): + await evt.reply("You are not authorized to perform this action.") + return + if project in self.config["repo_aliases"]: project, package, repository, state, arch = self.get_alias(project) else: @@ -428,5 +470,5 @@ class OSCBot(Plugin): message = body.render(packages=packagelist, base_url=base_url, project=project, repo=repository) await evt.respond(message, markdown=True) - # Wait 100 milliseconds to avoid triggering rate limiting - sleep(0.1) + # Wait 200 milliseconds to avoid triggering rate limiting + sleep(0.2) From 26d34967a06252b1da008eb6cdd1ebc81e41f9f0 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:20:14 +0100 Subject: [PATCH 5/8] Bump version --- maubot.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maubot.yaml b/maubot.yaml index c1456f6..403a0ee 100644 --- a/maubot.yaml +++ b/maubot.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: CC0-1.0 maubot: 0.1.0 id: org.dennogumi.osc -version: 0.3.1 +version: 0.4.0 license: AGPL-3.0-or-later modules: - oscbot From 34a24e599af2a226b97d518baf3f288a5bfaa932 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:30:58 +0100 Subject: [PATCH 6/8] [skip ci] Update README --- README.md | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 495cf87..98a580f 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Then you need to modify a few configuration parameters for your instance: - `trigger_token`: An OBS access token with the `runservice` capability. See "Creating tokens" below. - `rebuild_token`: An OBS access token with the `rebuild` capability. See "Creating tokens" below. - `secret`: A random string used to encrypt the password in the configuration file. +- `acl`: User IDs (`@user:homeserver.com`) which are allowed to interact with the bot. See "Access control lists" below. ### Creating tokens @@ -59,9 +60,26 @@ Save both tokens in the configuration. Should you need to, you can view them lat Make sure your bot instance is in a room (refer to the maubot docs for how) and then type `!osc help` for help. +### Access control lists (ACLs) + +By default, no user is allowed to perform any operation. You need to change the `acl` section of the configuration to add user IDs (`@user:homeserver.com`) allowed to interact with the bot. Users in the `admin` list have full powers over the bot, while users in the `user` list can only perform read-only (e.g. status querying) actions. + +This example shows how it works in practice: + +```yaml +acl: + admin: + # Fred and Sue will be able to perform all commands + - @fred:myhome.com + - @sue:otherserver.com + user: + # Phil will only be able to run read-only (status, etc.) commands + - @phil:elsewhere.com +``` + ### Notice -I made this in about three or four hours for my own use. Don't expect high quality code or a lot of polish. +I made this for my own use. Don't expect high quality code or a lot of polish. ### License From 8089dd36e5a0c455a1ee41b1b17d13f499af7492 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:34:07 +0100 Subject: [PATCH 7/8] [skip ci] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 98a580f..cc659e3 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ These are in addition to what maubot requires by itself. - lxml - jinja2 +- [cryptocode](https://github.com/gdavid7/cryptocode) ### Installation From f4bc26ac1328ded5ee6f0e580ac0f18051a073f2 Mon Sep 17 00:00:00 2001 From: Luca Beltrame Date: Sat, 12 Feb 2022 10:35:38 +0100 Subject: [PATCH 8/8] [skip ci] Update README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cc659e3..559df52 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ SPDX-License-Identifier: CC0-1.0 ## maubot-osc +![status-badge](https://ci.dennogumi.org/api/badges/einar/maubot-osc/status.svg) + `maubot-osc` is a simple [maubot](https://maubot.xyz/) plugin to query an [Open Build Service](https://openbuildservice.org/) instance. Basically it replicates a few of the features of the `osc` command line tool used to interact with the OBS. ### Features