diff --git a/ctfcli/cli/challenges.py b/ctfcli/cli/challenges.py index fd960cb..4355373 100644 --- a/ctfcli/cli/challenges.py +++ b/ctfcli/cli/challenges.py @@ -1,12 +1,14 @@ import os import subprocess import sys +import time from pathlib import Path from urllib.parse import urlparse import click import yaml from cookiecutter.main import cookiecutter +from slugify import slugify from ctfcli.utils.challenge import ( create_challenge, @@ -17,15 +19,17 @@ sync_challenge, ) from ctfcli.utils.config import ( + generate_session, get_base_path, get_config_path, get_project_path, load_config, ) -from ctfcli.utils.deploy import DEPLOY_HANDLERS +from ctfcli.utils.git import get_git_repo_head_branch +from ctfcli.utils.images import build_image, push_image from ctfcli.utils.spec import CHALLENGE_SPEC_DOCS, blank_challenge_spec from ctfcli.utils.templates import get_template_dir -from ctfcli.utils.git import get_git_repo_head_branch +from ctfcli.utils.deploy import DEPLOY_HANDLERS class Challenge(object): @@ -296,7 +300,7 @@ def lint(self, challenge=None): lint_challenge(path) - def deploy(self, challenge, host=None): + def deploy(self, challenge, host=None, protocol=None): if challenge is None: challenge = os.getcwd() @@ -307,35 +311,62 @@ def deploy(self, challenge, host=None): challenge = load_challenge(path) image = challenge.get("image") - target_host = host or challenge.get("host") or input("Target host URI: ") if image is None: click.secho( "This challenge can't be deployed because it doesn't have an associated image", fg="red", ) return + + target_host = host or challenge.get("host") or input("Target host URI: ") if bool(target_host) is False: + # If we do not have a host we should set to cloud click.secho( - "This challenge can't be deployed because there is no target host to deploy to", - fg="red", + "No host specified, defaulting to cloud deployment", fg="yellow", ) - return - url = urlparse(target_host) + scheme = "cloud" + else: + url = urlparse(target_host) + if bool(url.netloc) is False: + click.secho( + "Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://", + fg="red", + ) + return + scheme = url.scheme - if bool(url.netloc) is False: - click.secho( - "Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://", - fg="red", - ) - return + protocol = protocol or challenge.get("protocol") - status, domain, port = DEPLOY_HANDLERS[url.scheme]( - challenge=challenge, host=target_host + status, domain, port, connect_info = DEPLOY_HANDLERS[scheme]( + challenge=challenge, host=target_host, protocol=protocol, ) + challenge["connection_info"] = connect_info + if status: + # Search for challenge + installed_challenges = load_installed_challenges() + for c in installed_challenges: + # Sync challenge if it already exists + if c["name"] == challenge["name"]: + sync_challenge( + challenge, + ignore=[ + "flags", + "topics", + "tags", + "files", + "hints", + "requirements", + ], + ) + break + else: + # Install challenge + create_challenge(challenge=challenge) + click.secho( - f"Challenge deployed at {domain}:{port}", fg="green", + f"Challenge deployed at {challenge['connection_info']}", fg="green", ) else: click.secho( @@ -363,9 +394,6 @@ def push(self, challenge=None): ) def healthcheck(self, challenge): - config = load_config() - challenges = config["challenges"] - # challenge_path = challenges[challenge] path = Path(challenge) if path.name.endswith(".yml") is False: diff --git a/ctfcli/utils/deploy.py b/ctfcli/utils/deploy.py index 2945fe2..ffef874 100644 --- a/ctfcli/utils/deploy.py +++ b/ctfcli/utils/deploy.py @@ -1,12 +1,35 @@ import os import subprocess +import time +import click from pathlib import Path from urllib.parse import urlparse +from slugify import slugify +from ctfcli.utils.config import generate_session -from ctfcli.utils.images import build_image, export_image, get_exposed_ports +from ctfcli.utils.images import ( + build_image, + export_image, + get_exposed_ports, + push_image, + login_registry, +) -def ssh(challenge, host): +def format_connection_info(protocol, hostname, tcp_hostname, tcp_port): + if protocol is None: + connection_info = hostname + elif protocol.startswith("http"): + connection_info = f"{protocol}://{hostname}" + elif protocol == "tcp": + connection_info = f"nc {tcp_hostname} {tcp_port}" + else: + connection_info = hostname + + return connection_info + + +def ssh(challenge, host, protocol): # Build image image_name = build_image(challenge=challenge) print(f"Built {image_name}") @@ -39,17 +62,111 @@ def ssh(challenge, host): os.remove(image_path) print(f"Cleaned up {image_path}") - return True, domain, exposed_port + status = True + domain = domain + port = exposed_port + connect_info = format_connection_info( + protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port, + ) + return status, domain, port, connect_info -def registry(challenge, host): +def registry(challenge, host, protocol): # Build image image_name = build_image(challenge=challenge) - print(f"Built {image_name}") url = urlparse(host) tag = f"{url.netloc}{url.path}" - subprocess.call(["docker", "tag", image_name, tag]) - subprocess.call(["docker", "push", tag]) + push_image(local_tag=image_name, location=tag) + status = True + domain = "" + port = "" + connect_info = format_connection_info( + protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port, + ) + return status, domain, port, connect_info + + +def cloud(challenge, host, protocol): + name = challenge["name"] + slug = slugify(name) + + s = generate_session() + # Detect whether we have the appropriate endpoints + check = s.get(f"/api/v1/images", json=True) + if check.ok is False: + click.secho( + f"Target instance does not have deployment endpoints", fg="red", + ) + return False, domain, port, connect_info + + # Try to find an appropriate image. + images = s.get(f"/api/v1/images", json=True).json()["data"] + image = None + for i in images: + if i["location"].endswith(f"/{slug}"): + image = i + break + else: + # Create the image if we did not find it. + image = s.post(f"/api/v1/images", json={"name": slug}).json()["data"] + + # Build image + image_name = build_image(challenge=challenge) + location = image["location"] + + # TODO: Authenticate to Registry + + # Push image + push_image(image_name, location) + + # Look for existing service + services = s.get(f"/api/v1/services", json=True).json()["data"] + service = None + for srv in services: + if srv["name"] == slug: + service = srv + # Update the service + s.patch( + f"/api/v1/services/{service['id']}", json={"image": location} + ).raise_for_status() + service = s.get(f"/api/v1/services/{service['id']}", json=True).json()[ + "data" + ] + break + else: + # Could not find the service. Create it using our pushed image. + # Deploy the image by creating service + service = s.post( + f"/api/v1/services", json={"name": slug, "image": location,} + ).json()["data"] + + # Get connection details + service_id = service["id"] + service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + + while service["hostname"] is None: + click.secho( + f"Waiting for challenge hostname", fg="yellow", + ) + service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + time.sleep(10) + + # Expose port if we are using tcp + if protocol == "tcp": + service = s.patch(f"/api/v1/services/{service['id']}", json={"expose": True}) + service.raise_for_status() + service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"] + + status = True + domain = "" + port = "" + connect_info = format_connection_info( + protocol=protocol, + hostname=service["hostname"], + tcp_hostname=service["tcp_hostname"], + tcp_port=service["tcp_port"], + ) + return status, domain, port, connect_info -DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry} +DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry, "cloud": cloud} diff --git a/ctfcli/utils/git.py b/ctfcli/utils/git.py index d29640f..29a438d 100644 --- a/ctfcli/utils/git.py +++ b/ctfcli/utils/git.py @@ -10,6 +10,8 @@ def get_git_repo_head_branch(repo): ["git", "ls-remote", "--symref", repo, "HEAD"] ).decode() head_branch = out.split()[1] + if head_branch.startswith("refs/heads/"): + head_branch = head_branch[11:] return head_branch diff --git a/ctfcli/utils/images.py b/ctfcli/utils/images.py index 1b1e666..c06649c 100644 --- a/ctfcli/utils/images.py +++ b/ctfcli/utils/images.py @@ -2,33 +2,37 @@ import subprocess import tempfile from pathlib import Path +from slugify import slugify -def sanitize_name(name): - """ - Function to sanitize names to docker safe image names - TODO: Good enough but probably needs to be more conformant with docker - """ - return name.lower().replace(" ", "-") +def login_registry(host, username, password): + subprocess.call(["docker", "login", "-u", username, "-p"], password, host) def build_image(challenge): - name = sanitize_name(challenge["name"]) - path = Path(challenge.file_path).parent.absolute() + name = slugify(challenge["name"]) + path = Path(challenge.file_path).parent.absolute() / challenge["image"] print(f"Building {name} from {path}") subprocess.call(["docker", "build", "-t", name, "."], cwd=path) + print(f"Built {name}") return name +def push_image(local_tag, location): + print(f"Pushing {local_tag} to {location}") + subprocess.call(["docker", "tag", local_tag, location]) + subprocess.call(["docker", "push", location]) + + def export_image(challenge): - name = sanitize_name(challenge["name"]) + name = slugify(challenge["name"]) temp = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{name}.docker.tar") subprocess.call(["docker", "save", "--output", temp.name, name]) return temp.name def get_exposed_ports(challenge): - image_name = sanitize_name(challenge["name"]) + image_name = slugify(challenge["name"]) output = subprocess.check_output( ["docker", "inspect", "--format={{json .Config.ExposedPorts }}", image_name,] )