Skip to content

Commit cdabe54

Browse files
authored
Challenge Deploy v2 (#113)
* Add cloud deploy for hosted CTFd instances * Further define what other deployment methods should provide & return
1 parent 45ffa11 commit cdabe54

File tree

4 files changed

+184
-35
lines changed

4 files changed

+184
-35
lines changed

ctfcli/cli/challenges.py

+44-17
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@
2222
get_project_path,
2323
load_config,
2424
)
25-
from ctfcli.utils.deploy import DEPLOY_HANDLERS
25+
from ctfcli.utils.git import get_git_repo_head_branch
2626
from ctfcli.utils.spec import CHALLENGE_SPEC_DOCS, blank_challenge_spec
2727
from ctfcli.utils.templates import get_template_dir
28-
from ctfcli.utils.git import get_git_repo_head_branch
28+
from ctfcli.utils.deploy import DEPLOY_HANDLERS
2929

3030

3131
class Challenge(object):
@@ -296,7 +296,7 @@ def lint(self, challenge=None):
296296

297297
lint_challenge(path)
298298

299-
def deploy(self, challenge, host=None):
299+
def deploy(self, challenge, host=None, protocol=None):
300300
if challenge is None:
301301
challenge = os.getcwd()
302302

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

308308
challenge = load_challenge(path)
309309
image = challenge.get("image")
310-
target_host = host or challenge.get("host") or input("Target host URI: ")
311310
if image is None:
312311
click.secho(
313312
"This challenge can't be deployed because it doesn't have an associated image",
314313
fg="red",
315314
)
316315
return
316+
317+
target_host = host or challenge.get("host")
317318
if bool(target_host) is False:
319+
# If we do not have a host we should set to cloud
318320
click.secho(
319-
"This challenge can't be deployed because there is no target host to deploy to",
320-
fg="red",
321+
"No host specified, defaulting to cloud deployment", fg="yellow",
321322
)
322-
return
323-
url = urlparse(target_host)
323+
scheme = "cloud"
324+
else:
325+
url = urlparse(target_host)
326+
if bool(url.netloc) is False:
327+
click.secho(
328+
"Provided host has no URI scheme. Provide a URI scheme like ssh:// or registry://",
329+
fg="red",
330+
)
331+
return
332+
scheme = url.scheme
324333

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
334+
protocol = protocol or challenge.get("protocol")
331335

332-
status, domain, port = DEPLOY_HANDLERS[url.scheme](
333-
challenge=challenge, host=target_host
336+
status, domain, port, connect_info = DEPLOY_HANDLERS[scheme](
337+
challenge=challenge, host=target_host, protocol=protocol,
334338
)
335339

340+
challenge["connection_info"] = connect_info
341+
336342
if status:
343+
# Search for challenge
344+
installed_challenges = load_installed_challenges()
345+
for c in installed_challenges:
346+
# Sync challenge if it already exists
347+
if c["name"] == challenge["name"]:
348+
sync_challenge(
349+
challenge,
350+
ignore=[
351+
"flags",
352+
"topics",
353+
"tags",
354+
"files",
355+
"hints",
356+
"requirements",
357+
],
358+
)
359+
break
360+
else:
361+
# Install challenge
362+
create_challenge(challenge=challenge)
363+
337364
click.secho(
338-
f"Challenge deployed at {domain}:{port}", fg="green",
365+
f"Challenge deployed at {challenge['connection_info']}", fg="green",
339366
)
340367
else:
341368
click.secho(

ctfcli/utils/deploy.py

+124-8
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,34 @@
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+
)
716

817

9-
def ssh(challenge, host):
18+
def format_connection_info(protocol, hostname, tcp_hostname, tcp_port):
19+
if protocol is None:
20+
connection_info = hostname
21+
elif protocol.startswith("http"):
22+
connection_info = f"{protocol}://{hostname}"
23+
elif protocol == "tcp":
24+
connection_info = f"nc {tcp_hostname} {tcp_port}"
25+
else:
26+
connection_info = hostname
27+
28+
return connection_info
29+
30+
31+
def ssh(challenge, host, protocol):
1032
# Build image
1133
image_name = build_image(challenge=challenge)
1234
print(f"Built {image_name}")
@@ -39,17 +61,111 @@ def ssh(challenge, host):
3961
os.remove(image_path)
4062
print(f"Cleaned up {image_path}")
4163

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

4472

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

54170

55-
DEPLOY_HANDLERS = {"ssh": ssh, "registry": registry}
171+
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)