diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index 70fe113..b4fdd12 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -816,7 +816,7 @@ def deploy( click.secho("Success!\n", fg="green") if len(skipped_deployments) > 0: - click.secho("Deployment skipped (no image specified) for:",fg="yellow") + click.secho("Deployment skipped (no image specified) for:", fg="yellow") for challenge_instance in skipped_deployments: click.echo(f" - {challenge_instance}") diff --git a/ctfcli/core/challenge.py b/ctfcli/core/challenge.py index 969426b..e39f2f4 100644 --- a/ctfcli/core/challenge.py +++ b/ctfcli/core/challenge.py @@ -198,6 +198,10 @@ def _process_challenge_image(self, challenge_image: Optional[str]) -> Optional[I if not challenge_image: return None + # Check if challenge_image is explicitly marked as __compose__ + if challenge_image == "__compose__": + return Image(challenge_image) + # Check if challenge_image is explicitly marked with registry:// prefix if challenge_image.startswith("registry://"): challenge_image = challenge_image.replace("registry://", "") @@ -881,8 +885,11 @@ def lint(self, skip_hadolint=False, flag_format="flag{") -> bool: issues["fields"].append(f"challenge.yml is missing required field: {field}") # Check that the image field and Dockerfile match - if (self.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") != ".": - issues["dockerfile"].append("Dockerfile exists but image field does not point to it") + if (self.challenge_directory / "Dockerfile").is_file() and challenge.get("image", "") not in [ + ".", + "__compose__", + ]: + issues["dockerfile"].append("Dockerfile exists but image field does not point to it or compose") # Check that Dockerfile exists and is EXPOSE'ing a port if challenge.get("image") == ".": diff --git a/ctfcli/core/deployment/registry.py b/ctfcli/core/deployment/registry.py index 0db741f..98fa9e1 100644 --- a/ctfcli/core/deployment/registry.py +++ b/ctfcli/core/deployment/registry.py @@ -25,6 +25,10 @@ def deploy(self, skip_login=False, *args, **kwargs) -> DeploymentResult: ) return DeploymentResult(False) + if self.challenge.image.compose: + click.secho("Cannot use registry deployer with __compose__ stacks", fg="red") + return DeploymentResult(False) + # resolve a location for the image push # e.g. registry.example.com/test-project/challenge-image-name # challenge image name is appended to the host provided for the deployment diff --git a/ctfcli/core/deployment/ssh.py b/ctfcli/core/deployment/ssh.py index 1530b5f..b58ced0 100644 --- a/ctfcli/core/deployment/ssh.py +++ b/ctfcli/core/deployment/ssh.py @@ -19,6 +19,47 @@ def deploy(self, *args, **kwargs) -> DeploymentResult: ) return DeploymentResult(False) + if self.challenge.image.compose: + return self._deploy_compose_stack(*args, **kwargs) + + return self._deploy_single_image(*args, **kwargs) + + def _deploy_compose_stack(self, *args, **kwargs) -> DeploymentResult: + host_url = urlparse(self.host) + target_path = str(host_url.path) + if target_path == "/": # Don't put challenges in the root of the filesystem. + target_path = "" + elif target_path == "//": # If you really want to, add a second slash as part of your path: ssh://1.1.1.1// + target_path = "/" + elif target_path.startswith("/~/"): # Support relative paths by starting your path with /~/ + target_path = target_path.removeprefix("/~/") + try: + subprocess.run(["ssh", host_url.netloc, f"mkdir -p '{target_path}/'"], check=True) + subprocess.run( + ["rsync", "-a", "--delete", self.challenge.challenge_directory, f"{host_url.netloc}:{target_path}"], + check=True, + ) + if not target_path: + remote_path = f"{self.challenge.challenge_directory.name}" + else: + remote_path = f"{target_path}/{self.challenge.challenge_directory.name}" + subprocess.run( + [ + "ssh", + host_url.netloc, + f"cd {remote_path} && " "docker compose up -d --build --remove-orphans -y", + ], + check=True, + ) + + except subprocess.CalledProcessError as e: + click.secho("Failed to deploy compose stack!", fg="red") + click.secho(str(e), fg="red") + return DeploymentResult(False) + + return DeploymentResult(True) + + def _deploy_single_image(self, *args, **kwargs) -> DeploymentResult: if self.challenge.image.built: if not self.challenge.image.pull(): click.secho("Could not pull the image. Please check docker output above.", fg="red") diff --git a/ctfcli/core/exceptions.py b/ctfcli/core/exceptions.py index 5c49d2d..d3a4632 100644 --- a/ctfcli/core/exceptions.py +++ b/ctfcli/core/exceptions.py @@ -39,6 +39,14 @@ class RemoteChallengeNotFound(ChallengeException): pass +class ImageException(ChallengeException): + pass + + +class InvalidComposeOperation(ImageException): + pass + + class LintException(Exception): def __init__(self, *args, issues: Dict[str, List[str]] = None): self.issues = issues if issues else {} diff --git a/ctfcli/core/image.py b/ctfcli/core/image.py index 025187f..4a1fca2 100644 --- a/ctfcli/core/image.py +++ b/ctfcli/core/image.py @@ -5,6 +5,8 @@ from pathlib import Path from typing import Optional, Union +from ctfcli.core.exceptions import InvalidComposeOperation + class Image: def __init__(self, name: str, build_path: Optional[Union[str, PathLike]] = None): @@ -16,6 +18,11 @@ def __init__(self, name: str, build_path: Optional[Union[str, PathLike]] = None) if "/" in self.name or ":" in self.name: self.basename = self.name.split(":")[0].split("/")[-1] + if self.name == "__compose__": + self.compose = True + else: + self.compose = False + self.built = True # if the image provides a build path, assume it is not built yet @@ -24,6 +31,9 @@ def __init__(self, name: str, build_path: Optional[Union[str, PathLike]] = None) self.built = False def build(self) -> Optional[str]: + if self.compose: + raise InvalidComposeOperation("Local build not supported for docker compose challenges") + docker_build = subprocess.call( ["docker", "build", "--load", "-t", self.name, "."], cwd=self.build_path.absolute() ) @@ -34,6 +44,9 @@ def build(self) -> Optional[str]: return self.name def pull(self) -> Optional[str]: + if self.compose: + raise InvalidComposeOperation("Local pull not supported for docker compose challenges") + docker_pull = subprocess.call(["docker", "pull", self.name]) if docker_pull != 0: return @@ -41,6 +54,9 @@ def pull(self) -> Optional[str]: return self.name def push(self, location: str) -> Optional[str]: + if self.compose: + raise InvalidComposeOperation("Local push not supported for docker compose challenges") + if not self.built: self.build() @@ -53,6 +69,9 @@ def push(self, location: str) -> Optional[str]: return location def export(self) -> Optional[str]: + if self.compose: + raise InvalidComposeOperation("Local export not supported for docker compose challenges") + if not self.built: self.build() diff --git a/ctfcli/spec/challenge-example.yml b/ctfcli/spec/challenge-example.yml index ecee287..9405d01 100644 --- a/ctfcli/spec/challenge-example.yml +++ b/ctfcli/spec/challenge-example.yml @@ -22,6 +22,8 @@ type: standard # Settings used for Dockerfile deployment # If not used, remove or set to null # If you have a Dockerfile set to . +# If you have a docker-compose.yaml file, set to __compose__. Note that this will send the entire challenge directory to the remote server and build it there. +# Only compatible with ssh, not registry. # If you have an imaged hosted on Docker set to the image url (e.g. python/3.8:latest, registry.gitlab.com/python/3.8:latest) # Follow Docker best practices and assign a tag image: null