Skip to content

Commit

Permalink
Add cloud deploy for hosted CTFd instances
Browse files Browse the repository at this point in the history
  • Loading branch information
ColdHeat committed Mar 8, 2023
1 parent 30fc3c4 commit ea003c7
Show file tree
Hide file tree
Showing 4 changed files with 189 additions and 38 deletions.
68 changes: 48 additions & 20 deletions ctfcli/cli/challenges.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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):
Expand Down Expand Up @@ -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()

Expand All @@ -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: ")

This comment has been minimized.

Copy link
@pl4nty

pl4nty Mar 11, 2023

Contributor

@ColdHeat I can't have bool(target_host) is False without triggering input("Target host URI: "), so I have to use echo '' | ctf challenge deploy chal in my deploy pipeline

This comment has been minimized.

Copy link
@ColdHeat

ColdHeat Mar 11, 2023

Author Member

Good catch!

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(
Expand Down Expand Up @@ -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:
Expand Down
133 changes: 125 additions & 8 deletions ctfcli/utils/deploy.py
Original file line number Diff line number Diff line change
@@ -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}")
Expand Down Expand Up @@ -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)

This comment has been minimized.

Copy link
@pl4nty

pl4nty Mar 11, 2023

Contributor

Could there be an option to use the parent folder name instead? We have some multipart chals where the container is stored in part 1, but we want the hostname to be "chal-name.chals.io" rather than "chal-name-part-1.chals.io"

This comment has been minimized.

Copy link
@ColdHeat

ColdHeat Mar 11, 2023

Author Member

This would be a seperate issue. Currently almost everything in ctfcli relies on the challenge name.

I don't see why you couldn't make the challenge name in challenge.yml "Chal Name Part 1". Perhaps this is something to consider as part of #109.

This comment has been minimized.

Copy link
@pl4nty

pl4nty Mar 12, 2023

Contributor

Yeah, we have "Chal Name Part 1" in challenge.yml which slugs to "chal-name-part-1", but we want "chal-name" for chal-name.chals.io


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

This comment has been minimized.

Copy link
@pl4nty

pl4nty Mar 11, 2023

Contributor

This'll always fail because domain/port aren't declared until later. I assume they haven't been implemented yet?


# 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}
2 changes: 2 additions & 0 deletions ctfcli/utils/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
24 changes: 14 additions & 10 deletions ctfcli/utils/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,]
)
Expand Down

0 comments on commit ea003c7

Please sign in to comment.