Skip to content

Commit ea003c7

Browse files
committed
Add cloud deploy for hosted CTFd instances
1 parent 30fc3c4 commit ea003c7

File tree

4 files changed

+189
-38
lines changed

4 files changed

+189
-38
lines changed

ctfcli/cli/challenges.py

+48-20
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import os
22
import subprocess
33
import sys
4+
import time
45
from pathlib import Path
56
from urllib.parse import urlparse
67

78
import click
89
import yaml
910
from cookiecutter.main import cookiecutter
11+
from slugify import slugify
1012

1113
from ctfcli.utils.challenge import (
1214
create_challenge,
@@ -17,15 +19,17 @@
1719
sync_challenge,
1820
)
1921
from ctfcli.utils.config import (
22+
generate_session,
2023
get_base_path,
2124
get_config_path,
2225
get_project_path,
2326
load_config,
2427
)
25-
from ctfcli.utils.deploy import DEPLOY_HANDLERS
28+
from ctfcli.utils.git import get_git_repo_head_branch
29+
from ctfcli.utils.images import build_image, push_image
2630
from ctfcli.utils.spec import CHALLENGE_SPEC_DOCS, blank_challenge_spec
2731
from ctfcli.utils.templates import get_template_dir
28-
from ctfcli.utils.git import get_git_repo_head_branch
32+
from ctfcli.utils.deploy import DEPLOY_HANDLERS
2933

3034

3135
class Challenge(object):
@@ -296,7 +300,7 @@ def lint(self, challenge=None):
296300

297301
lint_challenge(path)
298302

299-
def deploy(self, challenge, host=None):
303+
def deploy(self, challenge, host=None, protocol=None):
300304
if challenge is None:
301305
challenge = os.getcwd()
302306

@@ -307,35 +311,62 @@ def deploy(self, challenge, host=None):
307311

308312
challenge = load_challenge(path)
309313
image = challenge.get("image")
310-
target_host = host or challenge.get("host") or input("Target host URI: ")
311314
if image is None:
312315
click.secho(
313316
"This challenge can't be deployed because it doesn't have an associated image",
314317
fg="red",
315318
)
316319
return
320+
321+
target_host = host or challenge.get("host") or input("Target host URI: ")
317322
if bool(target_host) is False:
323+
# If we do not have a host we should set to cloud
318324
click.secho(
319-
"This challenge can't be deployed because there is no target host to deploy to",
320-
fg="red",
325+
"No host specified, defaulting to cloud deployment", fg="yellow",
321326
)
322-
return
323-
url = urlparse(target_host)
327+
scheme = "cloud"
328+
else:
329+
url = urlparse(target_host)
330+
if bool(url.netloc) is False:
331+
click.secho(
332+
"Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://",
333+
fg="red",
334+
)
335+
return
336+
scheme = url.scheme
324337

325-
if bool(url.netloc) is False:
326-
click.secho(
327-
"Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://",
328-
fg="red",
329-
)
330-
return
338+
protocol = protocol or challenge.get("protocol")
331339

332-
status, domain, port = DEPLOY_HANDLERS[url.scheme](
333-
challenge=challenge, host=target_host
340+
status, domain, port, connect_info = DEPLOY_HANDLERS[scheme](
341+
challenge=challenge, host=target_host, protocol=protocol,
334342
)
335343

344+
challenge["connection_info"] = connect_info
345+
336346
if status:
347+
# Search for challenge
348+
installed_challenges = load_installed_challenges()
349+
for c in installed_challenges:
350+
# Sync challenge if it already exists
351+
if c["name"] == challenge["name"]:
352+
sync_challenge(
353+
challenge,
354+
ignore=[
355+
"flags",
356+
"topics",
357+
"tags",
358+
"files",
359+
"hints",
360+
"requirements",
361+
],
362+
)
363+
break
364+
else:
365+
# Install challenge
366+
create_challenge(challenge=challenge)
367+
337368
click.secho(
338-
f"Challenge deployed at {domain}:{port}", fg="green",
369+
f"Challenge deployed at {challenge['connection_info']}", fg="green",
339370
)
340371
else:
341372
click.secho(
@@ -363,9 +394,6 @@ def push(self, challenge=None):
363394
)
364395

365396
def healthcheck(self, challenge):
366-
config = load_config()
367-
challenges = config["challenges"]
368-
369397
# challenge_path = challenges[challenge]
370398
path = Path(challenge)
371399
if path.name.endswith(".yml") is False:

ctfcli/utils/deploy.py

+125-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
import os
22
import subprocess
3+
import time
4+
import click
35
from pathlib import Path
46
from urllib.parse import urlparse
7+
from slugify import slugify
8+
from ctfcli.utils.config import generate_session
59

6-
from ctfcli.utils.images import build_image, export_image, get_exposed_ports
10+
from ctfcli.utils.images import (
11+
build_image,
12+
export_image,
13+
get_exposed_ports,
14+
push_image,
15+
login_registry,
16+
)
717

818

9-
def ssh(challenge, host):
19+
def format_connection_info(protocol, hostname, tcp_hostname, tcp_port):
20+
if protocol is None:
21+
connection_info = hostname
22+
elif protocol.startswith("http"):
23+
connection_info = f"{protocol}://{hostname}"
24+
elif protocol == "tcp":
25+
connection_info = f"nc {tcp_hostname} {tcp_port}"
26+
else:
27+
connection_info = hostname
28+
29+
return connection_info
30+
31+
32+
def ssh(challenge, host, protocol):
1033
# Build image
1134
image_name = build_image(challenge=challenge)
1235
print(f"Built {image_name}")
@@ -39,17 +62,111 @@ def ssh(challenge, host):
3962
os.remove(image_path)
4063
print(f"Cleaned up {image_path}")
4164

42-
return True, domain, exposed_port
65+
status = True
66+
domain = domain
67+
port = exposed_port
68+
connect_info = format_connection_info(
69+
protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port,
70+
)
71+
return status, domain, port, connect_info
4372

4473

45-
def registry(challenge, host):
74+
def registry(challenge, host, protocol):
4675
# Build image
4776
image_name = build_image(challenge=challenge)
48-
print(f"Built {image_name}")
4977
url = urlparse(host)
5078
tag = f"{url.netloc}{url.path}"
51-
subprocess.call(["docker", "tag", image_name, tag])
52-
subprocess.call(["docker", "push", tag])
79+
push_image(local_tag=image_name, location=tag)
80+
status = True
81+
domain = ""
82+
port = ""
83+
connect_info = format_connection_info(
84+
protocol=protocol, hostname=domain, tcp_hostname=domain, tcp_port=port,
85+
)
86+
return status, domain, port, connect_info
87+
88+
89+
def cloud(challenge, host, protocol):
90+
name = challenge["name"]
91+
slug = slugify(name)
92+
93+
s = generate_session()
94+
# Detect whether we have the appropriate endpoints
95+
check = s.get(f"/api/v1/images", json=True)
96+
if check.ok is False:
97+
click.secho(
98+
f"Target instance does not have deployment endpoints", fg="red",
99+
)
100+
return False, domain, port, connect_info
101+
102+
# Try to find an appropriate image.
103+
images = s.get(f"/api/v1/images", json=True).json()["data"]
104+
image = None
105+
for i in images:
106+
if i["location"].endswith(f"/{slug}"):
107+
image = i
108+
break
109+
else:
110+
# Create the image if we did not find it.
111+
image = s.post(f"/api/v1/images", json={"name": slug}).json()["data"]
112+
113+
# Build image
114+
image_name = build_image(challenge=challenge)
115+
location = image["location"]
116+
117+
# TODO: Authenticate to Registry
118+
119+
# Push image
120+
push_image(image_name, location)
121+
122+
# Look for existing service
123+
services = s.get(f"/api/v1/services", json=True).json()["data"]
124+
service = None
125+
for srv in services:
126+
if srv["name"] == slug:
127+
service = srv
128+
# Update the service
129+
s.patch(
130+
f"/api/v1/services/{service['id']}", json={"image": location}
131+
).raise_for_status()
132+
service = s.get(f"/api/v1/services/{service['id']}", json=True).json()[
133+
"data"
134+
]
135+
break
136+
else:
137+
# Could not find the service. Create it using our pushed image.
138+
# Deploy the image by creating service
139+
service = s.post(
140+
f"/api/v1/services", json={"name": slug, "image": location,}
141+
).json()["data"]
142+
143+
# Get connection details
144+
service_id = service["id"]
145+
service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"]
146+
147+
while service["hostname"] is None:
148+
click.secho(
149+
f"Waiting for challenge hostname", fg="yellow",
150+
)
151+
service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"]
152+
time.sleep(10)
153+
154+
# Expose port if we are using tcp
155+
if protocol == "tcp":
156+
service = s.patch(f"/api/v1/services/{service['id']}", json={"expose": True})
157+
service.raise_for_status()
158+
service = s.get(f"/api/v1/services/{service_id}", json=True).json()["data"]
159+
160+
status = True
161+
domain = ""
162+
port = ""
163+
connect_info = format_connection_info(
164+
protocol=protocol,
165+
hostname=service["hostname"],
166+
tcp_hostname=service["tcp_hostname"],
167+
tcp_port=service["tcp_port"],
168+
)
169+
return status, domain, port, connect_info
53170

54171

55-
DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry}
172+
DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry, "cloud": cloud}

ctfcli/utils/git.py

+2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ def get_git_repo_head_branch(repo):
1010
["git", "ls-remote", "--symref", repo, "HEAD"]
1111
).decode()
1212
head_branch = out.split()[1]
13+
if head_branch.startswith("refs/heads/"):
14+
head_branch = head_branch[11:]
1315
return head_branch
1416

1517

ctfcli/utils/images.py

+14-10
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,37 @@
22
import subprocess
33
import tempfile
44
from pathlib import Path
5+
from slugify import slugify
56

67

7-
def sanitize_name(name):
8-
"""
9-
Function to sanitize names to docker safe image names
10-
TODO: Good enough but probably needs to be more conformant with docker
11-
"""
12-
return name.lower().replace(" ", "-")
8+
def login_registry(host, username, password):
9+
subprocess.call(["docker", "login", "-u", username, "-p"], password, host)
1310

1411

1512
def build_image(challenge):
16-
name = sanitize_name(challenge["name"])
17-
path = Path(challenge.file_path).parent.absolute()
13+
name = slugify(challenge["name"])
14+
path = Path(challenge.file_path).parent.absolute() / challenge["image"]
1815
print(f"Building {name} from {path}")
1916
subprocess.call(["docker", "build", "-t", name, "."], cwd=path)
17+
print(f"Built {name}")
2018
return name
2119

2220

21+
def push_image(local_tag, location):
22+
print(f"Pushing {local_tag} to {location}")
23+
subprocess.call(["docker", "tag", local_tag, location])
24+
subprocess.call(["docker", "push", location])
25+
26+
2327
def export_image(challenge):
24-
name = sanitize_name(challenge["name"])
28+
name = slugify(challenge["name"])
2529
temp = tempfile.NamedTemporaryFile(delete=False, suffix=f"_{name}.docker.tar")
2630
subprocess.call(["docker", "save", "--output", temp.name, name])
2731
return temp.name
2832

2933

3034
def get_exposed_ports(challenge):
31-
image_name = sanitize_name(challenge["name"])
35+
image_name = slugify(challenge["name"])
3236
output = subprocess.check_output(
3337
["docker", "inspect", "--format={{json .Config.ExposedPorts }}", image_name,]
3438
)

0 commit comments

Comments
 (0)