diff --git a/.gitignore b/.gitignore index e4bd32f..1a71411 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,6 @@ db.sqlite3 db.sqlite3-journal # Flask stuff: -instance/ .webassets-cache # Scrapy stuff: diff --git a/ctfcli/__main__.py b/ctfcli/__main__.py index 7557ed8..64dd432 100644 --- a/ctfcli/__main__.py +++ b/ctfcli/__main__.py @@ -11,6 +11,7 @@ from ctfcli.cli.challenges import ChallengeCommand from ctfcli.cli.config import ConfigCommand +from ctfcli.cli.instance import InstanceCommand from ctfcli.cli.pages import PagesCommand from ctfcli.cli.plugins import PluginsCommand from ctfcli.cli.templates import TemplatesCommand @@ -101,6 +102,9 @@ def init( def config(self): return COMMANDS.get("config") + def instance(self): + return COMMANDS.get("instance") + def challenge(self): return COMMANDS.get("challenge") @@ -120,6 +124,7 @@ def templates(self): "pages": PagesCommand(), "plugins": PluginsCommand(), "templates": TemplatesCommand(), + "instance": InstanceCommand(), "cli": CTFCLI(), } diff --git a/ctfcli/cli/instance.py b/ctfcli/cli/instance.py new file mode 100644 index 0000000..454049e --- /dev/null +++ b/ctfcli/cli/instance.py @@ -0,0 +1,69 @@ +import logging + +import click + +from ctfcli.core.config import Config +from ctfcli.core.instance.config import ServerConfig + +log = logging.getLogger("ctfcli.cli.instance") + + +class ConfigCommand: + def get(self, key): + """Get the value of a specific remote instance config key""" + log.debug(f"ConfigCommand.get: ({key=})") + return ServerConfig.get(key=key) + + def set(self, key, value): + """Set the value of a specific remote instance config key""" + log.debug(f"ConfigCommand.set: ({key=})") + ServerConfig.set(key=key, value=value) + click.secho(f"Successfully set '{key}' to '{value}'", fg="green") + + def pull(self): + """Copy remote instance configuration values to local config""" + log.debug("ConfigCommand.pull") + server_configs = ServerConfig.getall() + + config = Config() + if config.config.has_section("instance") is False: + config.config.add_section("instance") + + for k, v in server_configs.items(): + # We always store as a string because the CTFd Configs model is a string + if v == "None": + v = "null" + config.config.set("instance", k, str(v)) + + with open(config.config_path, "w+") as f: + config.write(f) + + click.secho("Successfully pulled configuration", fg="green") + + def push(self): + """Save local instance configuration values to remote CTFd instance""" + log.debug("ConfigCommand.push") + config = Config() + if config.config.has_section("instance") is False: + config.config.add_section("instance") + + configs = {} + for k in config["instance"]: + v = config["instance"][k] + if v == "null": + v = None + configs[k] = v + + failed_configs = ServerConfig.setall(configs=configs) + for f in failed_configs: + click.secho(f"Failed to push config {f}", fg="red") + + if not failed_configs: + click.secho("Successfully pushed config", fg="green") + else: + return 1 + + +class InstanceCommand: + def config(self): + return ConfigCommand diff --git a/ctfcli/core/exceptions.py b/ctfcli/core/exceptions.py index 1acfd35..bb5e4a4 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -55,3 +55,7 @@ class InvalidPageConfiguration(PageException): class IllegalPageOperation(PageException): pass + + +class InstanceConfigException(Exception): + pass diff --git a/ctfcli/core/instance/__init__.py b/ctfcli/core/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ctfcli/core/instance/config.py b/ctfcli/core/instance/config.py new file mode 100644 index 0000000..bd6f004 --- /dev/null +++ b/ctfcli/core/instance/config.py @@ -0,0 +1,65 @@ +from typing import List + +from ctfcli.core.api import API +from ctfcli.core.exceptions import InstanceConfigException + + +class ServerConfig: + @staticmethod + def get(key: str) -> str: + api = API() + resp = api.get(f"/api/v1/configs/{key}") + if resp.ok is False: + raise InstanceConfigException( + f"Could not get config {key=} because '{resp.content}' with {resp.status_code}" + ) + resp = resp.json() + return resp["data"]["value"] + + @staticmethod + def set(key: str, value: str) -> bool: + api = API() + data = { + "value": value, + } + resp = api.patch(f"/api/v1/configs/{key}", json=data) + if resp.ok is False: + raise InstanceConfigException( + f"Could not get config {key=} because '{resp.content}' with {resp.status_code}" + ) + resp = resp.json() + + return resp["success"] + + @staticmethod + def getall(): + api = API() + resp = api.get("/api/v1/configs") + if resp.ok is False: + raise InstanceConfigException(f"Could not get configs because '{resp.content}' with {resp.status_code}") + resp = resp.json() + configs = resp["data"] + + config = {} + for c in configs: + # Ignore alembic_version configs as they are managed by plugins + if c["key"].endswith("alembic_version") is False: + config[c["key"]] = c["value"] + + # Not much point in saving internal configs + del config["ctf_version"] + del config["version_latest"] + del config["next_update_check"] + del config["setup"] + + return config + + @staticmethod + def setall(configs) -> List[str]: + failed = [] + for k, v in configs.items(): + try: + ServerConfig.set(key=k, value=v) + except InstanceConfigException: + failed.append(k) + return failed