diff --git a/.dockerignore b/.dockerignore index 8fce60300..b981dcfea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1 +1,2 @@ data/ +cache/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e0e5200b0..55ccad064 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: Integration Test +name: Integration Tests on: pull_request: push: @@ -8,7 +8,7 @@ on: - cron: '42 06 * * *' jobs: test: - name: Integration Test + name: Single-Node Integration Test runs-on: ubuntu-latest timeout-minutes: 20 steps: @@ -41,6 +41,7 @@ jobs: pip install \ pytest \ pytest-dependency \ + pytest-order \ pytest-github-actions-annotate-failures \ requests \ selenium @@ -49,30 +50,23 @@ jobs: run: | sudo chown $USER:$USER /mnt - - name: Restore docker data cache + - name: Restore docker and workspace cache if: github.event_name != 'schedule' uses: actions/cache/restore@v4 with: path: | - /mnt/data-docker.tar.gz - key: docker-data-${{ runner.os }}-${{ github.run_id }} + /mnt/dojo-docker + /mnt/dojo-workspace + key: dojo-cache-${{ runner.os }}-${{ github.run_id }} restore-keys: | - docker-data-${{ runner.os }}- - # TEMP: Disable for now, cache holds bad docker network state - # - name: Unpack docker data cache - # if: github.event_name != 'schedule' - # run: | - # if [ -f /mnt/data-docker.tar.gz ]; then - # mkdir -p /mnt/dojo-test-data - # sudo tar --use-compress-program=pigz -xf /mnt/data-docker.tar.gz -C /mnt/dojo-test-data - # fi + dojo-cache-${{ runner.os }}- - name: Build docker image if: github.event_name != 'schedule' uses: docker/build-push-action@v5 with: context: . - tags: pwncollege/dojo:test + tags: pwncollege/dojo load: true cache-from: type=gha - name: Build and cache docker image @@ -80,59 +74,159 @@ jobs: uses: docker/build-push-action@v5 with: context: . - tags: pwncollege/dojo:test + tags: pwncollege/dojo load: true cache-to: type=gha - - name: Run dojo + - name: Run single-node dojo tests + timeout-minutes: 10 run: | - docker run \ - --name dojo-test \ - --privileged \ - --detach \ - --rm \ - -v "/mnt/dojo-test-data:/data:shared" \ - -p 2222:22 -p 80:80 -p 443:443 \ - pwncollege/dojo:test - - name: Build and start services + export MOZ_HEADLESS=1 + echo "Starting single-node integration tests..." + test/local-tester.sh -c dojo-test -g -D /mnt/dojo-docker -W /mnt/dojo-workspace || { + echo "Single-node tests failed, checking container logs..." + echo "::group::{node logs}" + docker exec dojo-test dojo compose logs + echo "::endgroup::" + exit 1 + } + + - name: Save docker and workspace cache + if: github.event_name == 'schedule' + uses: actions/cache/save@v4 + with: + path: | + /mnt/dojo-docker + /mnt/dojo-workspace + key: dojo-cache-${{ runner.os }}-${{ github.run_id }} + + - name: Final filesystem information run: | - docker exec dojo-test dojo wait || (docker exec dojo-test dojo compose logs && false) - docker exec dojo-test docker pull pwncollege/challenge-simple - docker exec dojo-test docker tag pwncollege/challenge-simple pwncollege/challenge-legacy - docker exec dojo-test docker image ls + echo "::group::Filesystem" + df -h + echo "::endgroup::" - - name: Wait for services to start - timeout-minutes: 3 + test-multinode: + name: Multi-Node Integration Test + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Host information run: | - docker exec dojo-test dojo compose logs -f & - log_pid=$! + echo "::group::Host information" + echo "Hostname: $(hostname)" + echo "IP: $(hostname -I)" + echo "::endgroup::" + echo "::group::Filesystem" + df -h + echo "::endgroup::" + echo "::group::Memory" + free -h + echo "::endgroup::" + echo "::group::CPU" + lscpu + echo "::endgroup::" - until [[ "$(docker exec dojo-test docker inspect --format='{{.State.Health.Status}}' ctfd)" == "healthy" ]]; do - sleep 1 - done + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + cache: 'pip' + - name: Setup firefox + uses: browser-actions/setup-firefox@latest + - name: Install test dependencies + run: | + pip install \ + pytest \ + pytest-dependency \ + pytest-order \ + pytest-github-actions-annotate-failures \ + requests \ + selenium - kill $log_pid - exit 0 + - name: Free up disk space + run: | + echo "::group::Initial disk usage" + df -h + echo "::endgroup::" + + echo "::group::Cleaning up disk space" + # Remove unnecessary pre-installed software + sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /usr/local/share/boost + sudo apt-get clean + + # Clean up Docker + docker system prune -a -f + + echo "::endgroup::" + echo "::group::Final disk usage" + df -h + echo "::endgroup::" - - name: Run tests - timeout-minutes: 4 + - name: Make docker cache dir owned by our user run: | - export MOZ_HEADLESS=1 - timeout 3m pytest -vrpP --durations=0 ./test || (docker exec dojo-test dojo compose logs && false) + sudo chown $USER:$USER /mnt + + - name: Restore docker and workspace cache + if: github.event_name != 'schedule' + uses: actions/cache/restore@v4 + with: + path: | + /mnt/dojo-docker + /mnt/dojo-docker-node1 + /mnt/dojo-docker-node2 + /mnt/dojo-workspace + key: dojo-multinode-cache-${{ runner.os }}-${{ github.run_id }} + restore-keys: | + dojo-multinode-cache-${{ runner.os }}- - - name: Pack docker data cache + - name: Build docker image + if: github.event_name != 'schedule' + uses: docker/build-push-action@v5 + with: + context: . + tags: pwncollege/dojo + load: true + cache-from: type=gha + - name: Build and cache docker image if: github.event_name == 'schedule' + uses: docker/build-push-action@v5 + with: + context: . + tags: pwncollege/dojo + load: true + cache-to: type=gha + + - name: Run multi-node dojo tests + timeout-minutes: 15 run: | - docker exec dojo-test dojo compose down - sudo tar --use-compress-program=pigz -cf /mnt/data-docker.tar.gz -C /mnt/dojo-test-data ./docker + export MOZ_HEADLESS=1 + echo "Starting multi-node integration tests..." + test/local-tester.sh -c dojo-test -g -M -D /mnt/dojo-docker -W /mnt/dojo-workspace || { + echo "Multi-node tests failed, checking container logs..." + echo "::group::{main node logs}" + docker exec dojo-test dojo compose logs + echo "::endgroup::" + echo "::group::{worker node 1 logs}" + docker exec dojo-test-node1 dojo compose logs + echo "::endgroup::" + echo "::group::{worker node 2 logs}" + docker exec dojo-test-node2 dojo compose logs + echo "::endgroup::" + exit 1 + } - - name: Save docker data cache + - name: Save docker and workspace cache if: github.event_name == 'schedule' uses: actions/cache/save@v4 with: path: | - /mnt/data-docker.tar.gz - key: docker-data-${{ runner.os }}-${{ github.run_id }} + /mnt/dojo-docker + /mnt/dojo-docker-node1 + /mnt/dojo-docker-node2 + /mnt/dojo-workspace + key: dojo-multinode-cache-${{ runner.os }}-${{ github.run_id }} - name: Final filesystem information run: | diff --git a/.gitignore b/.gitignore index d36f8874a..25069e999 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ opt/ sensai/ data/ +cache/ diff --git a/Dockerfile b/Dockerfile index 9e773f1ce..fca789687 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,19 +31,10 @@ curl -fsSL https://get.docker.com | /bin/sh sed -i 's|-H fd:// ||' /lib/systemd/system/docker.service EOF -COPY </solves") class DojoSolveList(Resource): - @authed_only @dojo_route def get(self, dojo): - user = get_current_user() + username = request.args.get("username") + user = Users.query.filter_by(name=username, hidden=False).first() if username else get_current_user() + if not user: + return {"error": "User not found"}, 400 + solves_query = dojo.solves(user=user, ignore_visibility=True, ignore_admins=False) if after := request.args.get("after"): @@ -231,12 +234,11 @@ def get(self, dojo, module, challenge_id): return {"success": True, "type": "none"} response = { "success": True, - "type": survey["type"], "prompt": survey["prompt"], + "data": survey["data"], "probability": survey.get("probability", 1.0), + "type": "user-specified" } - if "options" in survey: - response["options"] = survey["options"] return response @authed_only @@ -255,23 +257,10 @@ def post(self, dojo, module, challenge_id): if "response" not in data: return {"success": False, "error": "Missing response"}, 400 - if survey["type"] == "thumb": - if data["response"] not in ["up", "down"]: - return {"success": False, "error": "Invalid response"}, 400 - elif survey["type"] == "multiplechoice": - if not isinstance(data["response"], int) or not (0 <= int(data["response"]) < len(survey["options"])): - return {"success": False, "error": "Invalid response"}, 400 - elif survey["type"] == "freeform": - if not isinstance(data["response"], str): - return {"success": False, "error": "Invalid response"}, 400 - else: - return {"success": False, "error": "Bad survey type"}, 400 - response = SurveyResponses( user_id=user.id, dojo_id=dojo_challenge.dojo_id, challenge_id=dojo_challenge.challenge_id, - type=survey["type"], prompt=survey["prompt"], response=data["response"], ) @@ -301,4 +290,4 @@ def get(self, dojo, module, challenge_id): return { "success": True, "description": render_markdown(dojo_challenge.description) - } \ No newline at end of file + } diff --git a/dojo_plugin/api/v1/ssh_key.py b/dojo_plugin/api/v1/ssh_key.py index bc74c2a82..fe71045dc 100644 --- a/dojo_plugin/api/v1/ssh_key.py +++ b/dojo_plugin/api/v1/ssh_key.py @@ -6,6 +6,9 @@ from CTFd.models import db from CTFd.utils.decorators import authed_only from CTFd.utils.user import get_current_user +from sshpubkeys import SSHKey, InvalidKeyError +import base64 +import markupsafe from ...models import SSHKeys @@ -23,17 +26,18 @@ def post(self): key_value = data.get("ssh_key", "") if key_value: - key_re = "ssh-(rsa|ed25519|dss) AAAA[0-9A-Za-z+/]{1,730}[=]{0,2}" - key_match = re.match(key_re, key_value) - if not key_match: + try: + key = SSHKey(key_value, strict=True) + key.parse() + key_value = f"{key.key_type.decode()} {base64.b64encode(key._decoded_key).decode()}" + except (InvalidKeyError, NotImplementedError) as e: return ( { "success": False, - "error": f"Invalid SSH Key, expected format:
{key_re}" + "error": f"Invalid SSH Key, error: {markupsafe.escape(e)}
Refer below for how to generate a valid ssh key" }, 400, ) - key_value = key_match.group() user = get_current_user() diff --git a/dojo_plugin/models/__init__.py b/dojo_plugin/models/__init__.py index 3bc2d5d44..8821782af 100644 --- a/dojo_plugin/models/__init__.py +++ b/dojo_plugin/models/__init__.py @@ -72,11 +72,12 @@ class Dojos(db.Model): password = db.Column(db.String(128)) data = db.Column(JSONB) - data_fields = ["type", "award", "course", "pages", "privileged", "importable", "comparator"] + data_fields = ["type", "award", "course", "pages", "privileged", "importable", "comparator", "show_scoreboard"] data_defaults = { "pages": [], "privileged": False, "importable": True, + "show_scoreboard": True, } users = db.relationship("DojoUsers", back_populates="dojo") @@ -111,7 +112,7 @@ def __init__(self, *args, **kwargs): def __getattr__(self, name): if name in self.data_fields: - return self.data.get(name, self.data_defaults.get(name)) + return (self.data or {}).get(name, self.data_defaults.get(name)) raise AttributeError(f"No attribute '{name}'") def __setattr__(self, name, value): @@ -294,7 +295,8 @@ class DojoUsers(db.Model): dojo = db.relationship("Dojos", back_populates="users", overlaps="admins,members,students") user = db.relationship("Users") - # survey_responses = db.relationship("SurveyResponses", back_populates="users", overlaps="admins,members,students") + def survey_responses(self): + return DojoChallenges.survey_responses(user=self.user) def solves(self, **kwargs): return DojoChallenges.solves(user=self.user, dojo=self.dojo, **kwargs) @@ -340,9 +342,11 @@ class DojoModules(db.Model): description = db.Column(db.Text) data = db.Column(JSONB) - data_fields = ["importable"] + data_fields = ["importable", "show_scoreboard", "show_challenges"] data_defaults = { - "importable": True + "importable": True, + "show_scoreboard": True, + "show_challenges": True, } dojo = db.relationship("Dojos", back_populates="_modules") @@ -393,7 +397,7 @@ def __init__(self, *args, **kwargs): def __getattr__(self, name): if name in self.data_fields: - return self.data.get(name, self.data_defaults.get(name)) + return (self.data or {}).get(name, self.data_defaults.get(name)) raise AttributeError(f"No attribute '{name}'") @classmethod @@ -419,7 +423,8 @@ def resources(self): @delete_before_insert("_resources") def resources(self, value): for resource_index, resource in enumerate(value): - resource.resource_index = resource_index + if not hasattr(resource, 'resource_index') or resource.resource_index is None: + resource.resource_index = resource_index self._resources = value @property @@ -429,6 +434,23 @@ def path(self): @property def assessments(self): return [assessment for assessment in (self.dojo.course or {}).get("assessments", []) if assessment.get("id") == self.id] + + @property + def unified_items(self): + items = [] + + for resource in self.resources: + items.append((resource.resource_index, resource)) + + for challenge in self.challenges: + if challenge.unified_index is not None: + index = challenge.unified_index + else: + index = 1000 + challenge.challenge_index + items.append((index, challenge)) + + items.sort(key=lambda x: x[0]) + return [item for _, item in items] def visible_challenges(self, user=None): return [challenge for challenge in self.challenges if challenge.visible() or self.dojo.is_admin(user=user)] @@ -457,6 +479,7 @@ def visible(cls, when=None): class DojoChallenges(db.Model): __tablename__ = "dojo_challenges" + item_type = "challenge" __table_args__ = ( db.ForeignKeyConstraint(["dojo_id"], ["dojos.dojo_id"], ondelete="CASCADE"), db.ForeignKeyConstraint(["dojo_id", "module_index"], @@ -475,7 +498,7 @@ class DojoChallenges(db.Model): description = db.Column(db.Text) data = db.Column(JSONB) - data_fields = ["image", "path_override", "importable", "allow_privileged", "progression_locked", "survey"] + data_fields = ["image", "path_override", "importable", "allow_privileged", "progression_locked", "survey", "unified_index"] data_defaults = { "importable": True, "allow_privileged": True, @@ -493,8 +516,6 @@ class DojoChallenges(db.Model): cascade="all, delete-orphan", back_populates="challenge") - # survey_responses = db.relationship("SurveyResponses", back_populates="challenge", cascade="all, delete-orphan") - def __init__(self, *args, **kwargs): default = kwargs.pop("default", None) @@ -519,7 +540,7 @@ def __init__(self, *args, **kwargs): def __getattr__(self, name): if name in self.data_fields: - return self.data.get(name, self.data_defaults.get(name)) + return (self.data or {}).get(name, self.data_defaults.get(name)) raise AttributeError(f"No attribute '{name}'") @classmethod @@ -542,6 +563,19 @@ def visible(cls, when=None): cls.visibility.has(or_(DojoChallengeVisibilities.stop == None, when <= DojoChallengeVisibilities.stop)), )) + # note: currently unused, may need future testing + @hybrid_method + def survey_responses(self, user=None): + result = SurveyResponses.query.filter( + SurveyResponses.dojo_id == self.dojo_id, + SurveyResponses.challenge_id == self.challenge_id + ) + + if user is not None: + result = result.filter(SurveyResponses.user_id == user.id) + + return result + @hybrid_method def solves(self, *, user=None, dojo=None, module=None, ignore_visibility=False, ignore_admins=True): result = ( @@ -617,23 +651,21 @@ def resolve(self): class SurveyResponses(db.Model): __tablename__ = "survey_responses" - + id = db.Column(db.Integer, primary_key=True, autoincrement=True) - dojo_id = db.Column(db.Integer, db.ForeignKey("dojos.dojo_id", ondelete="CASCADE")) - challenge_id = db.Column(db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")) - user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")) - - type = db.Column(db.String(64)) - prompt = db.Column(db.Text) - response = db.Column(db.Text) - timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow) + dojo_id = db.Column(db.Integer, nullable=False) + challenge_id = db.Column(db.Integer, index=True, nullable=False) + user_id = db.Column(db.Integer, nullable=False) + + prompt = db.Column(db.Text, nullable=False) + response = db.Column(db.Text, nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.datetime.utcnow, nullable=False) - # challenge = db.relationship("DojoChallenges", back_populates="survey_responses") - # users = db.relationship("DojoUsers", back_populates="survey_responses") class DojoResources(db.Model): __tablename__ = "dojo_resources" + item_type = "resource" __table_args__ = ( db.ForeignKeyConstraint(["dojo_id", "module_index"], diff --git a/dojo_plugin/pages/workspace.py b/dojo_plugin/pages/workspace.py index 56fb1f2db..94436e2e3 100644 --- a/dojo_plugin/pages/workspace.py +++ b/dojo_plugin/pages/workspace.py @@ -1,30 +1,53 @@ import hmac -from flask import request, Blueprint, render_template, url_for, abort +from flask import request, Blueprint, render_template, abort from CTFd.models import Users -from CTFd.utils.user import get_current_user, is_admin +from CTFd.utils.user import get_current_user from CTFd.utils.decorators import authed_only from CTFd.plugins import bypass_csrf_protection from ..models import Dojos from ..utils import redirect_user_socket, get_current_container, container_password from ..utils.dojo import get_current_dojo_challenge -from ..utils.workspace import exec_run, start_on_demand_service workspace = Blueprint("pwncollege_workspace", __name__) port_names = { "challenge": 80, + "terminal": 7681, "code": 8080, "desktop": 6080, "desktop-windows": 6082, } +@workspace.route("/workspace", methods=["GET"]) +@authed_only +def view_workspace(): + workspace_services = [ + "Terminal", + "Code", + "Desktop", + ] + + current_challenge = get_current_dojo_challenge() + if not current_challenge: + return render_template("error.html", error="No active challenge session; start a challenge!") + + practice = get_current_container().labels.get("dojo.mode") == "privileged" + + return render_template( + "workspace.html", + practice=practice, + challenge=current_challenge, + workspace_services=workspace_services, + ) + + @workspace.route("/workspace/") @authed_only -def view_workspace(service): - return render_template("workspace.html", iframe_name="workspace", service=service) +def view_workspace_service(service): + return render_template("workspace_service.html", iframe_name="workspace", service=service) @workspace.route("/workspace//", websocket=True) @workspace.route("/workspace//", websocket=True) diff --git a/dojo_plugin/scripts/load_dojo.py b/dojo_plugin/scripts/load_dojo.py new file mode 100644 index 000000000..0aec4491d --- /dev/null +++ b/dojo_plugin/scripts/load_dojo.py @@ -0,0 +1,54 @@ +import argparse +import CTFd.utils.user +import sys +import os + +from ..utils.dojo import dojo_create, generate_ssh_keypair +from ..models import Users, db + +# operate outside of a session +assert "unbound" in repr(CTFd.utils.user.session) +class MockSession: + def get(self, k, default=None): + if k == "id": + return 1 + return default + __getitem__ = get +CTFd.utils.user.session = MockSession() + +parser = argparse.ArgumentParser(description="Load a dojo into the DOJO.") +parser.add_argument( + '--user', default="1", + help="the dojo user who will own this dojo, either username or UID (default: UID 1)" +) +parser.add_argument('--private-key', help="private key of the deploy key for the dojo", default="") +parser.add_argument('--public-key', help="public key of the deploy key for the dojo", default="") +parser.add_argument('--official', help="mark the dojo as official", action="store_true") +parser.add_argument('location', type=str, help="the location to load. Can be a yml spec or a github repository URL.") +try: + args = parser.parse_args() +except SystemExit as e: + os._exit(e.args[0]) + +try: + user = Users.query.where(Users.id == int(args.user)).one() +except ValueError: + user = Users.query.where(Users.name == args.user).one() + +if os.path.isfile(args.location): + spec = open(args.location).read() + repository = "" +else: + spec = "" + repository = args.location + +assert bool(args.public_key) == bool(args.private_key), "Both the private and public key must be provided, or both must be excluded." +if args.public_key: + public_key, private_key = args.public_key, args.private_key +else: + public_key, private_key = generate_ssh_keypair() + +dojo = dojo_create(user, repository, public_key, private_key, spec) +if args.official: + dojo.official = True +db.session.commit() diff --git a/dojo_plugin/utils/__init__.py b/dojo_plugin/utils/__init__.py index adb977e58..6ddb328fe 100644 --- a/dojo_plugin/utils/__init__.py +++ b/dojo_plugin/utils/__init__.py @@ -23,6 +23,7 @@ from CTFd.utils.security.sanitize import sanitize_html from sqlalchemy import String, Integer from sqlalchemy.sql import or_ +from bleach.css_sanitizer import CSSSanitizer from ..config import WORKSPACE_NODES, MAC_HOSTNAME, MAC_USERNAME from ..models import Dojos, DojoMembers, DojoAdmins, DojoChallenges, WorkspaceTokens @@ -159,6 +160,29 @@ def render_markdown(s): clean_html = bleach.clean(raw_html, tags=markdown_tags, attributes=markdown_attrs) return Markup(clean_html) +def sanitize_survey(data): + allowed_tags = [ + "h1", "h2", "h3", "h4", "h5", "h6", + "b", "i", "strong", "em", "tt", + "p", "br", + "span", "div", "blockquote", "code", "pre", "hr", + "ul", "ol", "li", "dd", "dt", + "sub", "sup", + "style", "input", "label", "button" + ] + + allowed_attrs = { + "*": ["class", "style", "data-form-submit"], + "input": ["type", "name", "checked", "value", "placeholder", "readonly"], + "label": ["for"], + "button": ["type"], + } + + allowed_css = bleach.css_sanitizer.ALLOWED_CSS_PROPERTIES.union([ + "transition", "transform" + ]) + + return bleach.clean(data, tags=allowed_tags, attributes=allowed_attrs, css_sanitizer=CSSSanitizer(allowed_css_properties=allowed_css)) def unserialize_user_flag(user_flag, *, secret=None): if secret is None: diff --git a/dojo_plugin/utils/dojo.py b/dojo_plugin/utils/dojo.py index a93151904..9ff843a95 100644 --- a/dojo_plugin/utils/dojo.py +++ b/dojo_plugin/utils/dojo.py @@ -9,6 +9,8 @@ import inspect import pathlib import urllib.request +import base64 +import logging import yaml import requests @@ -21,7 +23,7 @@ from ..models import DojoAdmins, Dojos, DojoModules, DojoChallenges, DojoResources, DojoChallengeVisibilities, DojoResourceVisibilities, DojoModuleVisibilities from ..config import DOJOS_DIR -from ..utils import get_current_container +from ..utils import get_current_container, sanitize_survey DOJOS_TMP_DIR = DOJOS_DIR/"tmp" @@ -62,6 +64,7 @@ Optional("image"): IMAGE_REGEX, Optional("allow_privileged"): bool, + Optional("show_scoreboard"): bool, Optional("importable"): bool, Optional("import"): { @@ -70,24 +73,13 @@ Optional("auxiliary", default={}, ignore_extra_keys=True): dict, - Optional("survey"): Or( - { - "type": "multiplechoice", - "prompt": str, - Optional("probability"): float, - "options": [str], - }, - { - "type": "thumb", - "prompt": str, - Optional("probability"): float, - }, - { - "type": "freeform", - "prompt": str, - Optional("probability"): float, - }, - ), + Optional("survey"): { + Optional("probability"): float, + "prompt": str, + "data": str + }, + + Optional("survey-sources", default={}): str, Optional("modules", default=[]): [{ **ID_NAME_DESCRIPTION, @@ -95,6 +87,8 @@ Optional("image"): IMAGE_REGEX, Optional("allow_privileged"): bool, + Optional("show_challenges"): bool, + Optional("show_scoreboard"): bool, Optional("importable"): bool, Optional("import"): { @@ -102,67 +96,13 @@ "module": ID_REGEX, }, - Optional("survey"): Or( - { - "type": "multiplechoice", - "prompt": str, - Optional("probability"): float, - "options": [str], - }, - { - "type": "thumb", - "prompt": str, - Optional("probability"): float, - }, - { - "type": "freeform", - "prompt": str, - Optional("probability"): float, - }, - ), - - Optional("challenges", default=[]): [{ - **ID_NAME_DESCRIPTION, - **VISIBILITY, - - Optional("image"): IMAGE_REGEX, - Optional("allow_privileged"): bool, - Optional("importable"): bool, - Optional("progression_locked"): bool, - Optional("auxiliary", default={}, ignore_extra_keys=True): dict, - # Optional("path"): Regex(r"^[^\s\.\/][^\s\.]{,255}$"), - - Optional("import"): { - Optional("dojo"): UNIQUE_ID_REGEX, - Optional("module"): ID_REGEX, - "challenge": ID_REGEX, - }, - - Optional("transfer"): { - Optional("dojo"): UNIQUE_ID_REGEX, - Optional("module"): ID_REGEX, - "challenge": ID_REGEX, - }, + Optional("survey"): { + Optional("probability"): float, + "prompt": str, + "data": str + }, - Optional("survey"): Or( - { - "type": "multiplechoice", - "prompt": str, - Optional("probability"): float, - "options": [str], - }, - { - "type": "thumb", - "prompt": str, - Optional("probability"): float, - }, - { - "type": "freeform", - "prompt": str, - Optional("probability"): float, - }, - ) - }], + Optional("challenges", default=[]): [dict], Optional("resources", default=[]): [Or( { @@ -179,6 +119,38 @@ Optional("slides"): str, **VISIBILITY, }, + { + "type": "header", + "content": str, + **VISIBILITY, + }, + { + "type": "challenge", + "id": ID_REGEX, + "name": NAME_REGEX, + Optional("description"): str, + **VISIBILITY, + Optional("image"): IMAGE_REGEX, + Optional("allow_privileged"): bool, + Optional("importable"): bool, + Optional("progression_locked"): bool, + Optional("auxiliary"): dict, + Optional("import"): { + Optional("dojo"): UNIQUE_ID_REGEX, + Optional("module"): ID_REGEX, + "challenge": ID_REGEX, + }, + Optional("transfer"): { + Optional("dojo"): UNIQUE_ID_REGEX, + Optional("module"): ID_REGEX, + "challenge": ID_REGEX, + }, + Optional("survey"): { + Optional("probability"): float, + "prompt": str, + "data": str + }, + }, )], Optional("auxiliary", default={}, ignore_extra_keys=True): dict, @@ -211,7 +183,7 @@ def setdefault_name(entry): def setdefault_file(data, key, file_path): if file_path.exists(): - data.setdefault("description", file_path.read_text()) + data.setdefault(key, file_path.read_text()) def setdefault_subyaml(data, subyaml_path): @@ -252,17 +224,70 @@ def load_dojo_subyamls(data, dojo_dir): setdefault_file(module_data, "description", module_dir / "DESCRIPTION.md") setdefault_name(module_data) - for challenge_data in module_data.get("challenges", []): - if "id" not in challenge_data: - continue - - challenge_dir = module_dir / challenge_data["id"] - setdefault_subyaml(challenge_data, challenge_dir / "challenge.yml") - setdefault_file(challenge_data, "description", challenge_dir / "DESCRIPTION.md") - setdefault_name(challenge_data) + if "resources" not in module_data: + module_data["resources"] = [] + if module_data["resources"]: + module_data["resources"].insert(0, { + "type": "header", + "content": "Resources" + }) + + challenges = module_data.pop("challenges", []) + if challenges: + module_data["resources"].append({ + "type": "header", + "content": "Challenges" + }) + + for challenge_data in challenges: + if "import" in challenge_data and "id" not in challenge_data: + challenge_data["id"] = challenge_data["import"]["challenge"] + + if "id" not in challenge_data: + continue + + challenge_dir = module_dir / challenge_data["id"] + setdefault_subyaml(challenge_data, challenge_dir / "challenge.yml") + setdefault_file(challenge_data, "description", challenge_dir / "DESCRIPTION.md") + setdefault_name(challenge_data) + + challenge_data["type"] = "challenge" + + if "import" in challenge_data and "name" not in challenge_data: + challenge_data["name"] = challenge_data.get("id", "Imported Challenge").replace("-", " ").title() + + module_data["resources"].append(challenge_data) return data +def load_surveys(data, dojo_dir): + """ + Optional survey data can be stored in an arbitrary directory under dojo_dir + + This directory is specified by 'survey-sources' under the base yml file + + This function copies the html survey data into the survey.data attribute + """ + + survey_data = data.get("survey-sources", None) + if survey_data and type(survey_data) == str: + survey_dir = dojo_dir / survey_data + if "survey" in data and "src" in data["survey"]: + setdefault_file(data["survey"], "data", survey_dir / data["survey"]["src"]) + del data["survey"]["src"] + + for module_data in data.get("modules", []): + if "survey" in module_data and "src" in module_data["survey"]: + setdefault_file(module_data["survey"], "data", survey_dir / module_data["survey"]["src"]) + del module_data["survey"]["src"] + + for challenge_data in module_data.get("resources", []): + if challenge_data["type"] != "challenge": continue + if "survey" in challenge_data and "src" in challenge_data["survey"]: + setdefault_file(challenge_data["survey"], "data", survey_dir / challenge_data["survey"]["src"]) + del challenge_data["survey"]["src"] + + return data def dojo_initialize_files(data, dojo_dir): for dojo_file in data.get("files", []): @@ -292,6 +317,7 @@ def dojo_from_dir(dojo_dir, *, dojo=None): data_raw = yaml.safe_load(dojo_yml_path.read_text()) data = load_dojo_subyamls(data_raw, dojo_dir) + data = load_surveys(data, dojo_dir) dojo_initialize_files(data, dojo_dir) return dojo_from_spec(data, dojo_dir=dojo_dir, dojo=dojo) @@ -378,10 +404,30 @@ def shadow(attr, *datas, default=_missing, default_dict=None): return default_dict[attr] raise KeyError(f"Missing `{attr}` in `{datas}`") + def survey(*datas): + for data in reversed(datas): + if "survey" in data: + survey = dict(data["survey"]) + if not "data" in survey: + raise KeyError(f"Survey data not specified") + survey["data"] = sanitize_survey(survey["data"]) + return survey + return None + def import_ids(attrs, *datas): datas_import = [data.get("import", {}) for data in datas] return tuple(shadow(id, *datas_import) for id in attrs) + challenge_resources = [] + regular_resources = [] + for module_data in dojo_data.get("modules", []): + for resource_index, resource_data in enumerate(module_data.get("resources", [])): + if resource_data.get("type") == "challenge": + resource_data["unified_index"] = resource_index + challenge_resources.append((module_data, resource_data)) + else: + regular_resources.append((module_data, resource_data)) + dojo.modules = [ DojoModules( **{kwarg: module_data.get(kwarg) for kwarg in ["id", "name", "description"]}, @@ -396,24 +442,29 @@ def import_ids(attrs, *datas): ) if "import" not in challenge_data else None, progression_locked=challenge_data.get("progression_locked"), visibility=visibility(DojoChallengeVisibilities, dojo_data, module_data, challenge_data), - survey=shadow("survey", dojo_data, module_data, challenge_data, default=None), + survey=survey(dojo_data, module_data, challenge_data), default=(assert_import_one(DojoChallenges.from_id(*import_ids(["dojo", "module", "challenge"], dojo_data, module_data, challenge_data)), f"Import challenge `{'/'.join(import_ids(['dojo', 'module', 'challenge'], dojo_data, module_data, challenge_data))}` does not exist") if "import" in challenge_data else None), + unified_index=challenge_data.get("unified_index"), ) - for challenge_data in module_data["challenges"] - ] if "challenges" in module_data else None, + for challenge_data in [r for m, r in challenge_resources if m == module_data] + ], resources = [ DojoResources( **{kwarg: resource_data.get(kwarg) for kwarg in ["name", "type", "content", "video", "playlist", "slides"]}, visibility=visibility(DojoResourceVisibilities, dojo_data, module_data, resource_data), + resource_index=resource_index, ) - for resource_data in module_data["resources"] - ] if "resources" in module_data else None, + for resource_index, resource_data in enumerate(module_data.get("resources", [])) + if resource_data.get("type") != "challenge" + ], default=(assert_import_one(DojoModules.from_id(*import_ids(["dojo", "module"], dojo_data, module_data)), f"Import module `{'/'.join(import_ids(['dojo', 'module'], dojo_data, module_data))}` does not exist") if "import" in module_data else None), visibility=visibility(DojoModuleVisibilities, dojo_data, module_data), + show_challenges=shadow("show_challenges", dojo_data, module_data, default_dict=DojoModules.data_defaults), + show_scoreboard=shadow("show_scoreboard", dojo_data, module_data, default_dict=DojoModules.data_defaults), ) for module_data in dojo_data["modules"] ] if "modules" in dojo_data else [ diff --git a/dojo_plugin/utils/workspace.py b/dojo_plugin/utils/workspace.py index 48a6fb0ab..82367d816 100644 --- a/dojo_plugin/utils/workspace.py +++ b/dojo_plugin/utils/workspace.py @@ -3,7 +3,7 @@ from . import user_docker_client -on_demand_services = { "code", "desktop"} +on_demand_services = { "terminal", "code", "desktop"} def start_on_demand_service(user, service_name): if service_name not in on_demand_services: diff --git a/dojo_theme/static/css/custom.css b/dojo_theme/static/css/custom.css index f6cec2481..617fb8c26 100644 --- a/dojo_theme/static/css/custom.css +++ b/dojo_theme/static/css/custom.css @@ -227,6 +227,11 @@ p[data-hide="true"] { width: 2.5rem; } +.resource-icon { + width: 2.5rem; + color: #6c757d; +} + /* Styles for greying out the challenges button if it is disabled */ .accordion-item-header:has(.disabled) { opacity: 0.6 !important; @@ -699,75 +704,11 @@ code { border: 1px solid var(--brand-green); } - -.survey-prompt { - vertical-align: middle; - padding: 0px 15px; - font-weight: bold; -} - -.survey-thumb .fa { - cursor: pointer; - font-size: 36px; - vertical-align: middle; - padding: 0.5rem 0.6rem; -} - -.survey-thumb .fa:hover { - color: var(--brand-gold); - transition: all .1s ease-in-out; - transform: scale(1.05); -} - .survey { position:relative; padding:.75rem 0rem; } -.survey-multiplechoice { - display: inline; - text-align: left; -} - -.survey-multiplechoice div { - border: 1px var(--brand-gold); - background: none; - padding: 6px 15px; - cursor: pointer; - display: block; - user-select: none; -} - -.survey-multiplechoice div:hover { - transition: all .1s ease-in-out; - background-color: var(--brand-gold); -} - -.survey-freeresponse-row { - padding: 0px 30px; - padding-top: 10px; -} - -.survey-text-input { - border-color: #6c757d; - color: #856404; - box-shadow: none !important; -} - -.survey-text-input:focus { - border-width: 2px; - border-color: var(--brand-gold) !important; - color: #856404 !important; -} - -.survey-submit:hover { - background-color: var(--brand-gold); - color: #856404; - border-color: var(--brand-gold); -} - - - #closePopup { position: absolute; top: 10px; diff --git a/dojo_theme/static/font/SpaceGrotesk-VariableFont_wght.ttf b/dojo_theme/static/font/SpaceGrotesk-VariableFont_wght.ttf index e1329aa3b..2348ebf66 100644 Binary files a/dojo_theme/static/font/SpaceGrotesk-VariableFont_wght.ttf and b/dojo_theme/static/font/SpaceGrotesk-VariableFont_wght.ttf differ diff --git a/dojo_theme/static/js/dojo/challenges.js b/dojo_theme/static/js/dojo/challenges.js index 3e85449dc..479c2fe0c 100644 --- a/dojo_theme/static/js/dojo/challenges.js +++ b/dojo_theme/static/js/dojo/challenges.js @@ -4,6 +4,12 @@ function submitChallenge(event) { const challenge_id = parseInt(item.find('#challenge-id').val()) const submission = item.find('#challenge-input').val() + const flag_regex = /pwn.college{.*}/; + if (submission.match(flag_regex) == null) { + return; + } + item.find("#challenge-input").val(""); + item.find("#challenge-submit").addClass("disabled-button"); item.find("#challenge-submit").prop("disabled", true); @@ -106,11 +112,6 @@ function renderSubmissionResponse(response, item) { }).then(function (data) { if(data.type === "none") return if(Math.random() > data.probability) return - if(data.type === "thumb") { - survey_notification.addClass("text-center") - } else { - survey_notification.addClass("text-left") - } survey_notification.addClass( "alert-warning alert-dismissable" ); @@ -177,7 +178,7 @@ function startChallenge(event) { const item = $(event.currentTarget).closest(".accordion-item"); const module = item.find("#module").val() const challenge = item.find("#challenge").val() - const practice = event.currentTarget.id == "challenge-practice"; + const practice = event.currentTarget.id == "challenge-priv"; item.find("#challenge-start").addClass("disabled-button"); item.find("#challenge-start").prop("disabled", true); @@ -245,7 +246,7 @@ function startChallenge(event) { result_notification.removeClass(); if (result.success) { - var message = `Challenge successfully started! You can interact with it through a VSCode Workspace or a GUI Desktop Workspace.`; + var message = `Challenge successfully started!`; result_message.html(message); result_notification.addClass('alert alert-info alert-dismissable text-center'); @@ -269,6 +270,15 @@ function startChallenge(event) { item.find("#challenge-practice").removeClass("disabled-button"); item.find("#challenge-practice").prop("disabled", false); + $(".challenge-init").removeClass("challenge-hidden"); + $(".challenge-workspace").removeClass("workspace-fullscreen"); + $(".challenge-workspace").html(""); + if (result.success) { + item.find(".challenge-workspace").html(""); + item.find(".challenge-init").addClass("challenge-hidden"); + } + windowResizeCallback(""); + setTimeout(function() { item.find(".alert").slideUp(); item.find("#challenge-submit").removeClass("disabled-button"); @@ -282,53 +292,138 @@ function startChallenge(event) { }) } -function clickSurveyThumb(event) { - const clicked = $(event.currentTarget) - const item = $(event.currentTarget).closest(".accordion-item") - const survey_notification = item.find("#survey-notification") - if(clicked.hasClass("fa-thumbs-up")) { - surveySubmit("up", item) - } else { - surveySubmit("down", item) - } - survey_notification.slideUp() -} +async function buildSurvey(item) { + const form = item.find("form#survey-notification") + if(form.html() === "") return -function clickSurveyOption(event) { - const clicked = $(event.currentTarget) - const item = $(event.currentTarget).closest(".accordion-item") - const survey_notification = item.find("#survey-notification") - const index = clicked.attr("data-id") - surveySubmit(parseInt(index), item) - survey_notification.slideUp() -} + // fix styles + const challenge_id = item.find('#challenge-id').val() + for(const style of form.find("style")) { + let cssText = "" + for(const rule of style.sheet.cssRules) { + cssText += ".survey-id-" + challenge_id + " " + rule.cssText + " " + } + style.innerHTML = cssText + } -function clickSurveySubmit(event) { - const item = $(event.currentTarget).closest(".accordion-item") - const survey_notification = item.find("#survey-notification") - const response = item.find("#survey-freeresponse-input").val() - surveySubmit(response, item) - survey_notification.slideUp() + const customSubmits = item.find("[data-form-submit]") + customSubmits.each((_, element) => { + $(element).click(() => { + surveySubmit( + JSON.stringify({ + response: $(element).attr("data-form-submit") + }), + item + ) + form.slideUp() + }) + }) + // csrf fix + const formData = new FormData(form[0]) + form.submit(event => { + event.preventDefault() + surveySubmit(JSON.stringify(Object.fromEntries(formData)), item) + form.slideUp() + }) } function surveySubmit(data, item) { const challenge_name = item.find('#challenge').val() const module_name = item.find('#module').val() const dojo_name = init.dojo - return CTFd.fetch(`/pwncollege_api/v1/dojos/${dojo_name}/surveys/${module_name}/${challenge_name}`, { + return CTFd.fetch(`/pwncollege_api/v1/dojos/${dojo_name}/${module_name}/${challenge_name}/surveys`, { method: 'POST', credentials: 'same-origin', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - response: data - }) + body: data }) } +function markChallengeAsSolved(item) { + const unsolved_flag = item.find(".challenge-unsolved"); + if (unsolved_flag.hasClass("challenge-solved")) { + return; + } + + unsolved_flag.removeClass("challenge-unsolved"); + unsolved_flag.addClass("challenge-solved"); + + const total_solves = item.find(".total-solves"); + total_solves.text( + (parseInt(total_solves.text().trim().split(" ")[0]) + 1) + " solves" + ); + + const answer_input = item.find("#challenge-input"); + answer_input.val(""); + answer_input.removeClass("wrong"); + answer_input.addClass("correct"); + + const header = item.find('[id^="challenges-header-"]'); + const current_challenge_id = parseInt(header.attr('id').match(/(\d+)$/)[1]); + const next_challenge_button = $(`#challenges-header-button-${current_challenge_id + 1}`); + + unlockChallenge(next_challenge_button); + checkUserAwards() + .then(handleAwardPopup) + .catch(error => console.error("Award check failed:", error)); +} + +var scroll_pos_x; +var scroll_pox_y; + +function scrollDisable() { + scroll_pos_x = window.pageXOffset; + scroll_pox_y = window.pageYOffset; + document.body.classList.add("scroll-disabled"); +} + +function scrollRestore() { + document.body.classList.remove("scroll-disabled"); + window.pageXOffset = scroll_pos_x; + window.pageYOffset = scroll_pos_y; +} + +function contentExpand() { + $(".challenge-workspace").addClass("workspace-fullscreen"); + $(".challenge-iframe").addClass("challenge-iframe-fs"); + $(".navbar").addClass("fullscreen-hidden"); + $(".navbar-pulldown").addClass("fullscreen-hidden"); + $("#scoreboard-heading").addClass("fullscreen-hidden"); + $(".scoreboard-controls").addClass("fullscreen-hidden"); + $(".scoreboard").addClass("fullscreen-hidden"); + $(".alert").addClass("fullscreen-hidden"); + scrollDisable(); +} + +function contentContract() { + $(".challenge-workspace").removeClass("workspace-fullscreen"); + $(".challenge-iframe").removeClass("challenge-iframe-fs"); + $(".navbar").removeClass("fullscreen-hidden"); + $(".navbar-pulldown").removeClass("fullscreen-hidden"); + $("#scoreboard-heading").removeClass("fullscreen-hidden"); + $(".scoreboard-controls").removeClass("fullscreen-hidden"); + $(".scoreboard").removeClass("fullscreen-hidden"); + $(".alert").removeClass("fullscreen-hidden"); + scrollRestore(); +} + +function doFullscreen() { + if ($(".workspace-fullscreen")[0]) { + contentContract(); + } + else { + contentExpand(); + } +} + +function windowResizeCallback(event) { + $(".challenge-iframe").not(".challenge-iframe-fs").css("aspect-ratio", `${window.innerWidth} / ${window.innerHeight}`); +} + $(() => { $(".accordion-item").on("show.bs.collapse", function (event) { $(event.currentTarget).find("iframe").each(function (i, iframe) { @@ -340,6 +435,17 @@ $(() => { }); }); + const broadcast = new BroadcastChannel('broadcast'); + broadcast.onmessage = (event) => { + if (event.data.msg === 'challengeSolved') { + const challenge_id = event.data.challenge_id; + const item = $(`input#challenge-id[value='${challenge_id}']`).closest(".accordion-item"); + if (item.length) { + markChallengeAsSolved(item); + } + } + }; + $(".challenge-input").keyup(function (event) { if (event.keyCode == 13) { const submit = $(event.currentTarget).closest(".accordion-item").find("#challenge-submit"); @@ -347,14 +453,17 @@ $(() => { } }); - $(".accordion-item").find("#challenge-submit").click(submitChallenge); - $(".accordion-item").find("#challenge-start").click(startChallenge); - $(".accordion-item").find("#challenge-practice").click(startChallenge); - - $(".accordion-item").find("#survey-thumbs-up").click(clickSurveyThumb) - $(".accordion-item").find("#survey-thumbs-down").click(clickSurveyThumb) - $(".accordion-item").find(".survey-option").click(clickSurveyOption) + var submits = $(".accordion-item").find("#challenge-input"); + for (var i = 0; i < submits.length; i++) { + submits[i].oninput = submitChallenge; + } + $(".accordion-item").find("#challenge-start").click(startChallenge); + $(".challenge-init").find("#challenge-priv").click(startChallenge); - $(".accordion-item").find("#survey-submit").click(clickSurveySubmit) + window.addEventListener("resize", windowResizeCallback, true); + windowResizeCallback(""); + $(".accordion-item").each((_, item) => { + buildSurvey($(item)) + }) }); diff --git a/dojo_theme/static/js/dojo/navbar.js b/dojo_theme/static/js/dojo/navbar.js index f109dd59f..436a9c3fa 100644 --- a/dojo_theme/static/js/dojo/navbar.js +++ b/dojo_theme/static/js/dojo/navbar.js @@ -24,213 +24,7 @@ function get_and_set_iframe_url() { } }); } -async function fetch_current_module() { - const response = await fetch('/active-module/'); - const data = await response.json(); - if (data.c_current) { - $("#challengeMenuButton").removeClass("d-none"); - } - else { - $("#challengeMenuButton").addClass("d-none"); - } - return data -} -async function updateNavbarDropdown() { - const data = await fetch_current_module(); - - if (data.c_current) { - $("#dropdown-dojo").text(data.c_current.dojo_name).attr("href", `/${data.c_current.dojo_reference_id}/`); - $("#dropdown-module").text(data.c_current.module_name).attr("href", `/${data.c_current.dojo_reference_id}/${data.c_current.module_id}/`); - $("#dropdown-challenge").text(data.c_current.challenge_name); - $("#current #dojo").val(data.c_current.dojo_reference_id); - $("#current #module").val(data.c_current.module_id); - $("#current #challenge").val(data.c_current.challenge_reference_id); - $("#current #challenge-id").val(data.c_current.challenge_id); - $("#dropdown-description").html(data.c_current.description); - - if ("dojo_name" in data.c_previous) { - $("#previous").removeClass("invisible"); - $("#dropdown-prev-name").text(data.c_previous.challenge_name); - $("#previous #dojo").val(data.c_previous.dojo_reference_id); - $("#previous #module").val(data.c_previous.module_id); - $("#previous #challenge").val(data.c_previous.challenge_reference_id); - $("#previous #challenge-id").val(data.c_previous.challenge_id); - } else { - $("#previous").addClass("invisible"); - } - if ("dojo_name" in data.c_next) { - $("#next").removeClass("invisible"); - $("#dropdown-next-name").text(data.c_next.challenge_name); - $("#next #dojo").val(data.c_next.dojo_reference_id); - $("#next #module").val(data.c_next.module_id); - $("#next #challenge").val(data.c_next.challenge_reference_id); - $("#next #challenge-id").val(data.c_next.challenge_id); - } else { - $("#next").addClass("invisible"); - } - } -} - -function DropdownStartChallenge(event) { - event.preventDefault(); - const item = $(event.currentTarget).closest(".overflow-hidden"); - const module = item.find("#module").val() - const challenge = item.find("#challenge").val() - const dojo = item.find("#dojo").val() - const dropdown_controls = $("#dropdown-controls"); - dropdown_controls.find("button").prop("disabled", true); - - CTFd.fetch('/pwncollege_api/v1/docker', { - method: 'GET', - credentials: 'same-origin', - }).then(function (response) { - if (response.status === 403) { - // User is not logged in or CTF is paused. - window.location = - CTFd.config.urlRoot + - "/login?next=" + - CTFd.config.urlRoot + - window.location.pathname + - window.location.hash; - } - - return response.json(); - }).then(function (result) { - var params = { - "dojo": dojo, - "module": module, - "challenge": challenge, - "practice": result.practice, - }; - - var result_notification = dropdown_controls.find('#result-notification'); - var result_message = dropdown_controls.find('#result-message'); - result_notification.removeClass('alert-danger'); - result_notification.addClass('alert alert-warning alert-dismissable text-center'); - result_message.html("Loading."); - result_notification.slideDown(); - var dot_max = 5; - var dot_counter = 0; - setTimeout(function loadmsg() { - if (result_message.html().startsWith("Loading")) { - if (dot_counter < dot_max - 1){ - result_message.append("."); - dot_counter++; - } - else { - result_message.html("Loading."); - dot_counter = 0; - } - setTimeout(loadmsg, 500); - } - }, 500); - - CTFd.fetch('/pwncollege_api/v1/docker', { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify(params) - }).then(function (response) { - if (response.status === 403) { - // User is not logged in or CTF is paused. - window.location = - CTFd.config.urlRoot + - "/login?next=" + - CTFd.config.urlRoot + - window.location.pathname + - window.location.hash; - } - return response.json(); - }).then(async function (result) { - let result_notification = dropdown_controls.find('#result-notification'); - let result_message = dropdown_controls.find('#result-message'); - result_notification.removeClass(); - - if (result.success) { - let message = `Challenge successfully started! You can interact with it through a VSCode Workspace or a GUI Desktop.`; - result_message.html(message); - result_notification.addClass('alert alert-info alert-dismissable text-center'); - await updateNavbarDropdown(); - $(".challenge-active").removeClass("challenge-active"); - $(`.accordion-item input[value=${params.challenge}]`).closest(".accordion-item").find("h4.challenge-name").addClass("challenge-active"); - const broadcast_send = new BroadcastChannel('broadcast'); - broadcast_send.postMessage({ - time: new Date().getTime(), - msg: 'New challenge started' - }); - } - else { - let message = "Error:"; - message += "
"; - message += "" + result.error + ""; - message += "
"; - result_message.html(message); - result_notification.addClass('alert alert-warning alert-dismissable text-center'); - } - - result_notification.slideDown(); - - setTimeout(function() { - dropdown_controls.find("button").prop("disabled", false); - dropdown_controls.find(".alert").slideUp(); - item.find("#challenge-submit").removeClass("disabled-button"); - item.find("#challenge-submit").prop("disabled", false); - }, 10000); - }).catch(function (error) { - console.error(error); - let result_message = dropdown_controls.find('#result-message'); - result_message.html("Submission request failed: " + ((error || {}).message || error)); - result_notification.addClass('alert alert-warning alert-dismissable text-center'); - }) - event.stopPropagation(); - }) -} - -function submitFlag(event) { - event.preventDefault(); - const challenge_id = $("#current").find("#challenge-id").val(); - const submission = $("#dropdown-challenge-input").val(); - const dropdown_controls = $("#dropdown-controls"); - var body = { - 'challenge_id': challenge_id, - 'submission': submission, - }; - var result_notification = dropdown_controls.find('#result-notification'); - var result_message = dropdown_controls.find('#result-message'); - result_notification.removeClass(); - result_notification.addClass('alert alert-warning alert-dismissable text-center'); - result_message.html("Loading..."); - result_notification.slideDown(); - if (submission === "pwn.college{practice}") { - result_notification.removeClass(); - result_notification.addClass('alert alert-success alert-dismissable text-center'); - result_message.html('You have submitted the \"practice\" flag from launching the challenge in Practice mode! This flag is not valid for scoring. Run the challenge in non-practice mode by pressing Start above, then use your solution to get the \"real\" flag and submit it!'); - setTimeout(() => result_notification.slideUp(), 5000); - event.stopPropagation(); - return - } - - CTFd.api.post_challenge_attempt({}, body).then(function (resp) { - const result = resp.data; - if (result.status === 'correct') { - result_notification.removeClass(); - result_notification.addClass('alert alert-success alert-dismissable text-center'); - result_message.html('Flag submitted successfully!'); - $("#dropdown-challenge-input").val(""); - } else { - result_notification.removeClass(); - result_notification.addClass('alert alert-danger alert-dismissable text-center'); - result_message.html('Flag submission failed: '+ result.message); - } - setTimeout(() => result_notification.slideUp(), 5000); - }); - event.stopPropagation(); -} -updateNavbarDropdown(); $(() => { $("#show_description").click((event) =>{ $("#dropdown-description").toggle(); @@ -255,16 +49,6 @@ $(() => { $("main").removeClass("main-navbar-hidden"); $(".navbar-pulldown").removeClass("navbar-pulldown-shown"); }); - $("#previous").find("#challenge-start").click(DropdownStartChallenge); - $("#current").find("#challenge-start").click(DropdownStartChallenge); - $("#next").find("#challenge-start").click(DropdownStartChallenge); - $("#dropdown-challenge-submit").click(submitFlag); - $("#dropdown-challenge-input").keyup(function (event) { - if (event.keyCode === 13) { - $("#dropdown-challenge-submit").click(); - } - }); - $("#navbarDropdown").click(updateNavbarDropdown); }); @@ -295,7 +79,7 @@ $('#searchModal').on('hidden.bs.modal', function () { document.getElementById('searchResults').innerHTML = ''; clearNavigationState(); }); - + const input = document.getElementById("searchInput"); const resultsEl = document.getElementById("searchResults"); diff --git a/dojo_theme/static/js/dojo/util.js b/dojo_theme/static/js/dojo/util.js index cee5ee629..5acd81f5b 100644 --- a/dojo_theme/static/js/dojo/util.js +++ b/dojo_theme/static/js/dojo/util.js @@ -1,15 +1,18 @@ function copyToClipboard(event) { - event.preventDefault(); - const text = event.currentTarget.dataset.copy; - navigator.clipboard.writeText(text) - .then(() => { - const tooltip = event.currentTarget.querySelector("#tooltip"); - const original = tooltip.innerText; - tooltip.innerText = "Copied!"; - setTimeout(() => { - tooltip.innerText = original; - }, 1000); - }) + const input = document.getElementById('user-token-result'); + input.select(); + input.setSelectionRange(0, 99999); + document.execCommand("copy"); + + $(event.target).tooltip({ + title: "Copied!", + trigger: "manual" + }); + $(event.target).tooltip("show"); + + setTimeout(function() { + $(event.target).tooltip("hide"); + }, 1500); } document.addEventListener("DOMContentLoaded", function () { diff --git a/dojo_theme/static/js/dojo/workspace.dev.js b/dojo_theme/static/js/dojo/workspace.dev.js new file mode 120000 index 000000000..bb9428aeb --- /dev/null +++ b/dojo_theme/static/js/dojo/workspace.dev.js @@ -0,0 +1 @@ +workspace.js \ No newline at end of file diff --git a/dojo_theme/static/js/dojo/workspace.js b/dojo_theme/static/js/dojo/workspace.js new file mode 100644 index 000000000..3655d8628 --- /dev/null +++ b/dojo_theme/static/js/dojo/workspace.js @@ -0,0 +1,191 @@ + +function selectService(service) { + const content = document.getElementById("workspace-content"); + const url = new URL("/pwncollege_api/v1/workspace", window.location.origin); + url.searchParams.set("service", service); + fetch(url, { + method: "GET", + credentials: "same-origin" + }) + .then(response => response.json()) + .then(result => { + content.src = result["iframe_src"]; + }); +} + +function startChallenge(privileged) { + CTFd.fetch("/pwncollege_api/v1/docker", { + method: "GET", + credentials: 'same-origin' + }).then(function (response) { + if (response.status === 403) { + // User is not logged in or CTF is paused. + window.location = + CTFd.config.urlRoot + + "/login?next=" + + CTFd.config.urlRoot + + window.location.pathname + + window.location.hash; + } + return response.json(); + }).then(function (result) { + if (result.success == false) { + return; + } + + var params = { + "dojo": result.dojo, + "module": result.module, + "challenge": result.challenge, + "practice": privileged, + }; + + CTFd.fetch('/pwncollege_api/v1/docker', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(params) + }).then(function (response) { + return response.json; + }).then(function (result) { + if (result.success == false) { + return; + } + + selectService($("#workspace-select").val()); + + $(".btn-challenge-start") + .removeClass("disabled") + .removeClass("btn-disabled") + .prop("disabled", false); + }) + }); +} + +function challengeStartCallback(event) { + event.preventDefault(); + + $(".btn-challenge-start") + .addClass("disabled") + .addClass("btn-disabled") + .prop("disabled", true); + + if (document.getElementById("start-unprivileged").contains(event.target)) { + $(".option-active").removeClass("option-active"); + document.getElementById("start-unprivileged").classList.add("option-active"); + startChallenge(false); + } + else if (document.getElementById("start-privileged") != null && document.getElementById("start-privileged").contains(event.target)) { + $(".option-active").removeClass("option-active"); + document.getElementById("start-privileged").classList.add("option-active"); + startChallenge(true); + } + else { + console.log("Failed to start challenge."); + + $(".btn-challenge-start") + .removeClass("disabled") + .removeClass("btn-disabled") + .prop("disabled", false); + } +} + +function submitFlag(flag) { + flag_input = document.getElementById("flag-input"); + flag_input.value = ""; + flag_input.placeholder = "Submitting..."; + + var body = { + 'challenge_id': parseInt(document.getElementById("current-challenge-id").value), + 'submission': flag, + }; + var params = {}; + + CTFd.api.post_challenge_attempt(params, body) + .then(function (response) { + if (response.data.status == "incorrect") { + flag_input.placeholder = "Incorrect!"; + flag_input.classList.add("submit-incorrect"); + } + else if (response.data.status == "correct") { + flag_input.placeholder = "Correct!"; + flag_input.classList.add("submit-correct"); + } + else if (response.data.status == "already_solved") { + flag_input.placeholder = "Already Solved."; + flag_input.classList.add("submit-correct"); + } + else { + flag_input.placeholder = "Submission Failed."; + flag_input.classList.add("submit-warn"); + } + }); +} + +function hideNavbar() { + $(".navbar").addClass("navbar-hidden"); + $("main").addClass("main-navbar-hidden"); +} + +function showNavbar() { + $(".navbar").removeClass("navbar-hidden"); + $("main").removeClass("main-navbar-hidden"); +} + +function doFullscreen() { + if (document.getElementsByClassName("navbar")[0].classList.contains("navbar-hidden")) { + showNavbar(); + } + else { + hideNavbar(); + } +} + +$(() => { + if (new URLSearchParams(window.location.search).has("hide-navbar")) { + hideNavbar(); + } + $("footer").hide(); + + var previousWorkspace = localStorage.getItem("previousWorkspace"); + var workspaceSelect = document.getElementById("workspace-select"); + var option = workspaceSelect.options[0]; + if (previousWorkspace && workspaceSelect) { + for (var i = 0; i < workspaceSelect.options.length; i++) { + if (workspaceSelect.options[i].text === previousWorkspace) { + option = workspaceSelect.options[i]; + option.selected = true; + break; + } + } + } + selectService(option.value); + + $("#workspace-select").change((event) => { + event.preventDefault(); + localStorage.setItem("previousWorkspace", event.target.options[event.target.selectedIndex].text); + selectService(event.target.value); + }); + + $(".btn-challenge-start").click(challengeStartCallback); + + $("#flag-input").on("input", function(event) { + event.preventDefault(); + $(this).removeClass("submit-correct submit-incorrect submit-warn"); + $(this).attr("placeholder", "Flag"); + if ($(this).val().match(/pwn.college{.*}/)) { + submitFlag($(this).val()); + } + }); + + $("#fullscreen").click((event) => { + event.preventDefault(); + $("#fullscreen i").toggleClass("fa-compress fa-expand"); + // If the window is not an iframe, this will refer to its own do_fullscreen function. + // Otherwise it will call the do_fullscreen function of the window which we are iframed into. + window.parent.doFullscreen(); + }); +}); \ No newline at end of file diff --git a/dojo_theme/static/js/dojo/workspace.min.js b/dojo_theme/static/js/dojo/workspace.min.js new file mode 120000 index 000000000..bb9428aeb --- /dev/null +++ b/dojo_theme/static/js/dojo/workspace.min.js @@ -0,0 +1 @@ +workspace.js \ No newline at end of file diff --git a/dojo_theme/templates/components/navbar.html b/dojo_theme/templates/components/navbar.html index f6e8ec346..a55772204 100644 --- a/dojo_theme/templates/components/navbar.html +++ b/dojo_theme/templates/components/navbar.html @@ -17,8 +17,7 @@
+ {% if dojo.show_scoreboard %}

30-Day Scoreboard:

@@ -85,6 +86,8 @@

30-Day Scoreboard:

{{ scoreboard() }} {% endif %} + + {% endif %} {% endblock %} diff --git a/dojo_theme/templates/dojo_admin.html b/dojo_theme/templates/dojo_admin.html index b29e80fb7..c67912033 100644 --- a/dojo_theme/templates/dojo_admin.html +++ b/dojo_theme/templates/dojo_admin.html @@ -11,16 +11,15 @@

{{ dojo.name }}

- +

Share the dojo using this link!

+
+ +
+ +
+
@@ -112,4 +111,4 @@

{{ dojo.name }}

{% block scripts %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/dojo_theme/templates/error.html b/dojo_theme/templates/error.html new file mode 100644 index 000000000..52e62bce7 --- /dev/null +++ b/dojo_theme/templates/error.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} + +{% block stylesheets %} +{{ super() }} + +{% endblock %} + +{% block content %} +
+

{{ error | safe}}

+
+{% endblock %} diff --git a/dojo_theme/templates/macros/widgets.html b/dojo_theme/templates/macros/widgets.html index eea1683a2..d524de575 100644 --- a/dojo_theme/templates/macros/widgets.html +++ b/dojo_theme/templates/macros/widgets.html @@ -45,17 +45,17 @@
{%- endmacro %} -{% macro accordion_item(accordion_id, item_id, is_disabled) %} +{% macro accordion_item(accordion_id, item_id, is_disabled, challenge_index=None) %}

-

-
+
{{ caller(False) }}
diff --git a/dojo_theme/templates/module.html b/dojo_theme/templates/module.html index 16630216a..fd08e0585 100644 --- a/dojo_theme/templates/module.html +++ b/dojo_theme/templates/module.html @@ -1,6 +1,58 @@ {% extends "base.html" %} {% from "macros/widgets.html" import accordion_item %} +{% block stylesheets %} +{{ super() }} + + + +{% endblock %} + {% block content %}
@@ -23,179 +75,172 @@

{{ assessment.name }}
{% endif %} - {% if module.resources %} -

Lectures and Reading

+ {% if module.resources or module.challenges %} + {% if dojo.is_admin() and not module.show_challenges %} +

This module's challenges are hidden from view. You can see them as the dojo's administrator.

+ {% endif %} -
- {% for resource in module.resources %} - {% call(header) accordion_item("resources", loop.index) %} - {% if header %} -

{{ resource.name }}

+
+ {% set ns = namespace(challenge_count=0) %} + {% for item in module.unified_items %} + {% if item.item_type == 'resource' %} + {% set resource = item %} + {% if resource.type == 'header' %} +
+

{{ resource.content }}

{% else %} - {% if resource.type == "lecture" %} - {% if resource.video %} - {% set src = "https://www.youtube.com/embed/" + resource.video + "?" + ("list=" + resource.playlist + "&" if resource.playlist else "") + "rel=0" %} -
- -
- {% endif %} - {% if resource.slides %} - {% set src = "https://docs.google.com/presentation/d/" + resource.slides + "/embed" %} -
- -
- + {% call(header) accordion_item("module-content", loop.index) %} + {% if header %} + {% set resource_icon = "fa-video" if resource.type == "lecture" else "fa-file-alt" %} +

+ + {{ resource.name }} +

+ {% else %} + {% if resource.type == "lecture" %} + {% if resource.video %} + {% set src = "https://www.youtube.com/embed/" + resource.video + "?" + ("list=" + resource.playlist + "&" if resource.playlist else "") + "rel=0" %} +
+ +
+ {% endif %} + {% if resource.slides %} + {% set src = "https://docs.google.com/presentation/d/" + resource.slides + "/embed" %} +
+ +
+ + {% endif %} + + {% elif resource.type == "markdown" %} +
+ {{ resource.content | markdown }} +
+ {% endif %} {% endif %} + {% endcall %} + {% endif %} + {% elif item.item_type == 'challenge' and (module.show_challenges or dojo.is_admin()) %} + {% set challenge = item %} + {% set ns.challenge_count = ns.challenge_count + 1 %} + {% set solved = "challenge-solved" if challenge.challenge_id in user_solves else "challenge-unsolved" %} + {% set active = "challenge-active" if challenge.challenge_id == current_dojo_challenge.challenge_id else "" %} - {% elif resource.type == "markdown" %} -
- {{ resource.content | markdown }} -
+ {% set previous_challenge = None %} + {% set visible_challenges = [] %} + {% for prev_item in module.unified_items[:loop.index0] %} + {% if prev_item.item_type == 'challenge' and (module.show_challenges or dojo.is_admin()) %} + {% set _ = visible_challenges.append(prev_item) %} {% endif %} + {% endfor %} + {% if visible_challenges %} + {% set previous_challenge = visible_challenges[-1] %} {% endif %} - {% endcall %} - {% endfor %} -
-
- {% endif %} + {% set progression_locked = challenge.progression_locked and previous_challenge %} + {% set lock_challenge = progression_locked + and previous_challenge.challenge_id not in user_solves + and solved == "challenge-unsolved" + and not dojo.is_admin() %} + {% set hidden = not challenge.visible() %} + {% set icon = "fa-lock" if lock_challenge else "fa-flag" %} - {% if challenges %} -

Challenges

- -
- {% for challenge in challenges %} - - {% set solved = "challenge-solved" if challenge.challenge_id in user_solves else "challenge-unsolved" %} - {% set active = "challenge-active" if challenge.challenge_id == current_dojo_challenge.challenge_id else "" %} - {% set previous_challenge = challenges[loop.index0 - 1] %} - {% set progression_locked = challenge.progression_locked and not loop.first %} - {% set lock_challenge = progression_locked - and previous_challenge.challenge_id not in user_solves - and solved == "challenge-unsolved" - and not dojo.is_admin() %} - {% set hidden = not challenge.visible() %} - {% set icon = "fa-lock" if lock_challenge else "fa-flag" %} - - {% call(header) accordion_item("challenges", loop.index, lock_challenge) %} - {% if header %} -

- + {% call(header) accordion_item("module-content", loop.index, lock_challenge, challenge_index=ns.challenge_count) %} + {% if header %} +

+ + {% if lock_challenge %} +
+ {{ challenge.name or challenge.id }} + Solve {{ previous_challenge.name or previous_challenge.id }} to unlock this challenge + {{ challenge.name or challenge.id }} +
+ {% else %} + {{ challenge.name or challenge.id }} + {% endif %} + {% if dojo.is_admin() and (progression_locked or hidden) %} + + + {% if progression_locked %}progression locked{% endif %} + {% if progression_locked and hidden %} & {% endif %} + {% if hidden %}hidden{% endif %} + + — this challenge is accessible because you are this dojo's administrator + + {% endif %} +

+ + {% if challenge_container_counts.get(challenge.id, 0) > 0 %} + + {{ challenge_container_counts.get(challenge.id, 0) }} hacking, + + {% endif %} + + {{ total_solves.get(challenge.challenge_id, 0) }} solves + + + {% else %} +
{% if lock_challenge %} -
- {{ challenge.name or challenge.id }} - Solve {{ previous_challenge.name or previous_challenge.id }} to unlock this challenge - - {{ challenge.name or challenge.id }} -
+

This challenge is locked

{% else %} - {{ challenge.name or challenge.id }} - {% endif %} - {% if dojo.is_admin() and (progression_locked or hidden) %} - - - {% if progression_locked %}progression locked{% endif %} - {% if progression_locked and hidden %} & {% endif %} - {% if hidden %}hidden{% endif %} - - — this challenge is accessible because you are this dojo's administrator - + {{ challenge.description | markdown }} {% endif %} - - - {% if challenge_container_counts.get(challenge.id, 0) > 0 %} - - {{ challenge_container_counts.get(challenge.id, 0) }} hacking, - - {% endif %} - - {{ total_solves.get(challenge.challenge_id, 0) }} solves - - - {% else %} -
- {% if lock_challenge %} -

This challenge is locked

- {% else %} - {{ challenge.description | markdown }} - {% endif %} -
-
-
- -
- {% if challenge.allow_privileged %} -
-
- {% endif %} -
-
-
- - - - +
+
+ +
+ {% if challenge.allow_privileged %} +
+ +
+ {% endif %} +
+ + + + +
-
- +
+ {% if active %}{% endif %}
-
-
-
- -
-
-
-
-
+ {% endif %} + + {% set visible_challenges = [] %} + {% for item in module.unified_items %} + {% if item.item_type == 'challenge' %} + {% set _ = visible_challenges.append(item) %} + {% endif %} + {% endfor %} + + {% if visible_challenges and module.show_scoreboard %}

30-Day Scoreboard:

This scoreboard reflects solves for challenges in this module after the module launched in this dojo.

diff --git a/dojo_theme/templates/settings.html b/dojo_theme/templates/settings.html index a2ea5b961..6bc183fc5 100644 --- a/dojo_theme/templates/settings.html +++ b/dojo_theme/templates/settings.html @@ -129,7 +129,10 @@

User Settings

- + +

You can quickly generate an ssh key by running ssh-keygen -f key -N '' in a terminal on your (unix-friendly) host machine. + This will generate files key and key.pub, which are your private and public keys respectively. + Once you have linked your public ssh key to your account, you can connect to the dojo over ssh with ssh -i key hacker@pwn.college.

{% if discord_enabled %} diff --git a/dojo_theme/templates/workspace.html b/dojo_theme/templates/workspace.html index d6fe345e8..23ad7ee34 100644 --- a/dojo_theme/templates/workspace.html +++ b/dojo_theme/templates/workspace.html @@ -1,63 +1,139 @@ {% extends "base.html" %} + {% block stylesheets %} {{ super() }} + {% endblock %} + {% block content %} + - -

Loading...

+
+
+ +
+
+ + +
+
+ {% if challenge.allow_privileged %} + + {% endif %} + + +
+
{% endblock %} -{% block scripts %} -{{ super() }} - +{% block scripts %} + {% endblock %} \ No newline at end of file diff --git a/dojo_theme/templates/workspace_service.html b/dojo_theme/templates/workspace_service.html new file mode 100644 index 000000000..d6fe345e8 --- /dev/null +++ b/dojo_theme/templates/workspace_service.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block stylesheets %} +{{ super() }} + +{% endblock %} + +{% block content %} + + +

Loading...

+ +{% endblock %} + +{% block scripts %} +{{ super() }} + + +{% endblock %} \ No newline at end of file diff --git a/etc/docker/daemon-splunk.json b/etc/docker/daemon-splunk.json new file mode 100644 index 000000000..af9cc0fc5 --- /dev/null +++ b/etc/docker/daemon-splunk.json @@ -0,0 +1,22 @@ +{ + "data-root": "/data/docker", + "hosts": ["unix:///run/docker.sock"], + "builder": { + "Entitlements": { + "security-insecure": true + } + }, + "log-driver": "splunk", + "log-opts": { + "splunk-token": "11111111-1111-1111-1111-111111111111", + "splunk-url": "http://127.0.0.1:8088", + "splunk-insecureskipverify": "true", + "splunk-verify-connection": "false", + "splunk-gzip": "false", + "splunk-gzip-level": "0", + "splunk-format": "json", + "tag": "{{.Name}}/{{.ID}}", + "labels": "container_name,container_id,service", + "env": "DOJO_HOST,VIRTUAL_HOST,DOJO_ENV,WORKSPACE_NODE" + } +} \ No newline at end of file diff --git a/etc/docker/daemon.json b/etc/docker/daemon.json new file mode 100644 index 000000000..48c49b830 --- /dev/null +++ b/etc/docker/daemon.json @@ -0,0 +1,9 @@ +{ + "data-root": "/data/docker", + "hosts": ["unix:///run/docker.sock"], + "builder": { + "Entitlements": { + "security-insecure": true + } + } +} \ No newline at end of file diff --git a/splunk/README.md b/splunk/README.md new file mode 100644 index 000000000..ffa2fcc9a --- /dev/null +++ b/splunk/README.md @@ -0,0 +1,69 @@ +# Splunk Configuration for DOJO + +This configuration adds Splunk to the DOJO infrastructure to capture logs from all containers. + +## Setup + +1. The Splunk container is configured in `docker-compose.yml` with: + - Web interface on port 8001 (to avoid conflict with CTFd on port 8000) + - HEC (HTTP Event Collector) on port 8088 + - Management API on port 8089 + +2. When enabled, all containers send logs to Splunk via the Docker logging driver configured at the daemon level. + +3. Default credentials: + - Username: `admin` + - Password: `DojoSplunk2024!` + +4. HEC Token: `11111111-1111-1111-1111-111111111111` + +## Enabling Splunk + +1. Set `ENABLE_SPLUNK=true` in `/data/config.env` before starting the DOJO + +2. Start the DOJO normally: + ```bash + docker run --privileged -v /path/to/data:/data pwncollege/dojo + ``` + +3. The Splunk container will start automatically and Docker will be configured to send all logs to it + +## Testing + +Once Splunk is enabled and the DOJO is running: + +1. Access Splunk Web UI at `http://localhost:8001` + +2. Search for logs: + ``` + index=main source="docker" + ``` + +## Outer Container Logs + +To enable Splunk logging for the outer DOJO container: + +1. Set `ENABLE_SPLUNK=true` in `/data/config.env` before starting the DOJO + +2. Start (or restart) the DOJO container + +The Docker daemon will be automatically configured to send all container logs to Splunk. This configuration is applied during container initialization before Docker starts. + +## Troubleshooting + +1. Check Splunk is receiving data: + ```bash + curl -k -u admin:DojoSplunk2024! http://localhost:8089/services/data/inputs/http + ``` + +2. Test HEC endpoint: + ```bash + curl -k http://localhost:8088/services/collector/event \ + -H "Authorization: Splunk 11111111-1111-1111-1111-111111111111" \ + -d '{"event": "test event", "sourcetype": "manual"}' + ``` + +3. Check container logs are configured correctly: + ```bash + docker inspect | grep -A 10 LogConfig + ``` \ No newline at end of file diff --git a/splunk/default.yml b/splunk/default.yml new file mode 100644 index 000000000..d13caa62f --- /dev/null +++ b/splunk/default.yml @@ -0,0 +1,24 @@ +splunk: + accept_license: true + password: DojoSplunk2024! + hec: + enable: true + ssl: false + port: 8088 + token: "11111111-1111-1111-1111-111111111111" + conf: + - key: server + value: + license: /tmp/Splunk.License + - key: web + value: + settings: + enableSplunkWebSSL: false + - key: limits + value: + kv: + maxKVSize: 8192 + - key: server + value: + general: + serverName: dojo-splunk \ No newline at end of file diff --git a/splunk/test-splunk-integration.sh b/splunk/test-splunk-integration.sh new file mode 100755 index 000000000..88862f24c --- /dev/null +++ b/splunk/test-splunk-integration.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Test script to verify Splunk is receiving logs from all containers + +set -e + +SPLUNK_USER="admin" +SPLUNK_PASS="$1" +SPLUNK_HOST="127.0.0.1" +SPLUNK_PORT="8089" +HEC_PORT="8088" +HEC_TOKEN="11111111-1111-1111-1111-111111111111" + +echo "=== Splunk Integration Test ===" +echo "" + +# Check if Splunk is running +echo "[+] Checking if Splunk container is running..." +if docker ps | grep -q splunk; then + echo " ✓ Splunk container is running" +else + echo " ✗ Splunk container is not running" + echo " Run: docker-compose --profile main up -d splunk" + exit 1 +fi + +# Wait for Splunk to be ready +echo "[+] Waiting for Splunk to be ready..." +for i in {1..30}; do + if curl -s -k -u ${SPLUNK_USER}:${SPLUNK_PASS} https://${SPLUNK_HOST}:${SPLUNK_PORT}/services/server/info >/dev/null 2>&1; then + echo " ✓ Splunk is ready" + break + fi + if [ $i -eq 30 ]; then + echo " ✗ Splunk is not responding after 30 seconds" + exit 1 + fi + sleep 1 +done + +# Test HEC endpoint +echo "[+] Testing HTTP Event Collector (HEC)..." +response=$(curl -s -w "\n%{http_code}" -k http://${SPLUNK_HOST}:${HEC_PORT}/services/collector/event \ + -H "Authorization: Splunk ${HEC_TOKEN}" \ + -d '{"event": "test event from integration script", "sourcetype": "test"}') + +http_code=$(echo "$response" | tail -n1) +body=$(echo "$response" | head -n-1) + +if [ "$http_code" = "200" ]; then + echo " ✓ HEC is working (HTTP $http_code)" +else + echo " ✗ HEC test failed (HTTP $http_code)" + echo " Response: $body" +fi + +# Check logging configuration for containers +echo "[+] Checking container logging configuration..." +containers=$(docker ps --format "table {{.Names}}" | tail -n +2) +configured=0 +total=0 + +for container in $containers; do + total=$((total + 1)) + log_driver=$(docker inspect $container --format '{{.HostConfig.LogConfig.Type}}' 2>/dev/null || echo "unknown") + if [ "$log_driver" = "splunk" ]; then + configured=$((configured + 1)) + echo " ✓ $container is configured for Splunk logging" + else + echo " ✗ $container is using $log_driver driver (not Splunk)" + fi +done + +echo "" +echo "Summary: $configured/$total containers configured for Splunk logging" + +# Generate test logs +echo "" +echo "[+] Generating test logs from configured containers..." +for container in $containers; do + log_driver=$(docker inspect $container --format '{{.HostConfig.LogConfig.Type}}' 2>/dev/null || echo "unknown") + if [ "$log_driver" = "splunk" ]; then + docker exec $container sh -c "echo '[TEST] Splunk integration test log from $container at $(date)'" 2>/dev/null || true + fi +done + +echo "" +echo "[+] Test complete!" +echo "" +echo "To view logs in Splunk:" +echo " 1. Access Splunk Web UI at http://${SPLUNK_HOST}:8000" +echo " 2. Login with username: ${SPLUNK_USER} password: ${SPLUNK_PASS}" +echo " 3. Search for: index=main source=\"docker\"" +echo " 4. Or search for test logs: index=main \"[TEST]\"" diff --git a/test/conftest.py b/test/conftest.py index 774027577..3206f44e0 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -39,7 +39,10 @@ def guest_dojo_admin(): @pytest.fixture(scope="session") def example_dojo(admin_session): - rid = create_dojo("pwncollege/example-dojo", session=admin_session) + try: + rid = create_dojo("pwncollege/example-dojo", session=admin_session) + except AssertionError: + rid = "example" make_dojo_official(rid, admin_session) return rid @@ -116,10 +119,16 @@ def searchable_dojo(admin_session): make_dojo_official(rid, admin_session) return rid +@pytest.fixture +def hidden_challenges_dojo(admin_session, example_dojo): + rid = create_dojo_yml(open(TEST_DOJOS_LOCATION / "hidden_challenges.yml").read(), session=admin_session) + make_dojo_official(rid, admin_session) + return rid + @pytest.fixture(scope="session") -def progression_locked_dojo(admin_session): +def progression_locked_dojo(admin_session, example_dojo): return create_dojo_yml(open(TEST_DOJOS_LOCATION / "progression_locked_dojo.yml").read(), session=admin_session) @pytest.fixture(scope="session") def surveys_dojo(admin_session): - return create_dojo_yml(open(TEST_DOJOS_LOCATION / "surveys_dojo.yml").read(), session=admin_session) \ No newline at end of file + return create_dojo_yml(open(TEST_DOJOS_LOCATION / "surveys_dojo.yml").read(), session=admin_session) diff --git a/test/dojos/hidden_challenges.yml b/test/dojos/hidden_challenges.yml new file mode 100644 index 000000000..b29d528f1 --- /dev/null +++ b/test/dojos/hidden_challenges.yml @@ -0,0 +1,12 @@ +id: hide-challenges +name: Dojo With Hidden Challenges +description: This dojo hides its challenges. +modules: + - id: module + show_challenges: False + challenges: + - name: CHALLENGE + import: + dojo: example + module: hello + challenge: apple diff --git a/test/dojos/module_resources_dojo.yml b/test/dojos/module_resources_dojo.yml new file mode 100644 index 000000000..4813a26c1 --- /dev/null +++ b/test/dojos/module_resources_dojo.yml @@ -0,0 +1,46 @@ +id: module-resources-test +name: Module Resources Test +description: Test dojo for module resources + +modules: + - id: test + name: Test Module + description: Test module with mixed resources + resources: + - name: "Resource A" + type: lecture + video: hh4XAU6XYP0 + slides: 14ZJRIyf0HnoYO1N8GI5ygE-ZVgdhjJrvzkETFLz7NIo + - name: "Resource B" + type: markdown + content: | + Test content B with unique string: TESTB123 + - type: header + content: Advanced Section + - name: "Resource C" + type: lecture + video: hIK1Dfjxq4E + slides: 1NjoOj03eQsjnZWhm-A3IsxTVqeWUqFvR4wgfXnhsnu4 + playlist: PLHhKcdBlprMfIBVorNvOfY5UiHJmOk9nh + - name: "Challenge A" + id: test + type: challenge + import: + dojo: example + module: hello + challenge: apple + - name: "Resource D" + type: markdown + content: | + Test content D with unique string: TESTD456 + - name: "Resource E" + type: lecture + video: 1H1V1HkVt3k + slides: 1_xdrCm136NzcDl9bqSgAEQuigUHkNjkKmamhaej296Q + challenges: + - name: "Challenge B" + id: testb + import: + dojo: example + module: hello + challenge: banana diff --git a/test/dojos/surveys_dojo.yml b/test/dojos/surveys_dojo.yml index 557f63f4a..797f0c850 100644 --- a/test/dojos/surveys_dojo.yml +++ b/test/dojos/surveys_dojo.yml @@ -2,16 +2,12 @@ id: surveys-dojo type: public survey: prompt: Dojo-level prompt - type: multiplechoice - options: - - Option 1 - - Option 2 - - Option 3 + data:
dojo
modules: - id: surveys-module-1 survey: prompt: Module-level prompt - type: thumb + data:
module
challenges: - id: module-level import: @@ -25,7 +21,7 @@ modules: challenge: mars survey: prompt: Challenge-level prompt - type: freeform + data:
challenge
- id: surveys-module-2 challenges: - id: dojo-level diff --git a/test/local-tester.sh b/test/local-tester.sh index 69f3aaa87..fe5ab7115 100755 --- a/test/local-tester.sh +++ b/test/local-tester.sh @@ -1,34 +1,91 @@ -#!/bin/bash -ex +#!/bin/bash -exu cd $(dirname "${BASH_SOURCE[0]}")/.. +REPO_DIR=$(basename "$PWD") +DEFAULT_CONTAINER_NAME="local-${REPO_DIR}" + function usage { set +x - echo "Usage: $0 [-r DB_BACKUP ] [ -c DOJO_CONTAINER ] [ -D DOCKER_DIR ] [ -T ]" + echo "Usage: $0 [-r DB_BACKUP ] [ -c DOJO_CONTAINER ] [ -D DOCKER_DIR ] [ -W WORKSPACE_DIR ] [ -T ] [ -N ] [ -p ] [ -e ENV_VAR=value ] [ -b ] [ -M ] [ -g ]" echo "" echo " -r full path to db backup to restore" - echo " -c the name of the dojo container (default: dojo-test)" - echo " -D specify a directory for /data/docker (to avoid rebuilds)" + echo " -c the name of the dojo container (default: local-)" + echo " -D specify a directory for /data/docker to avoid rebuilds (default: ./cache/docker; specify as blank to disable)" + echo " -W specify a directory for /data/workspace to avoid rebuilds (default: ./cache/workspace; specify as blank to disable)" echo " -T don't run tests" + echo " -N don't (re)start the dojo" + echo " -P export ports (80->80, 443->443, 22->2222)" + echo " -e set environment variable (can be used multiple times)" + echo " -b build the Docker image locally (tag: same as container name)" + echo " -M run in multi-node mode (3 containers: 1 main + 2 workspace nodes)" + echo " -g use GitHub Actions group output formatting" exit } -WORKDIR=$(mktemp -d /tmp/dojo-test-XXXXXX) -VOLUME_ARGS=("-v" "$PWD:/opt/pwn.college" "-v" "$WORKDIR:/data:shared") +function cleanup_container { + local CONTAINER=$1 + docker kill "$CONTAINER" 2>/dev/null || echo "No $CONTAINER container to kill." + docker rm "$CONTAINER" 2>/dev/null || echo "No $CONTAINER container to remove." + while docker ps -a | grep "$CONTAINER$"; do sleep 1; done + + # freaking bad unmount + sleep 4 + mount | grep /tmp/local-data-${CONTAINER}-....../ | sed -e "s/.* on //" | sed -e "s/ .*//" | tac | while read ENTRY + do + sudo umount "$ENTRY" || echo "Failed ^" + done +} + +function fix_insane_routing { + local CONTAINER="$1" + read -a GW <<<$(ip route show default) + read -a NS <<<$(docker exec "$CONTAINER" cat /etc/resolv.conf | grep nameserver) + docker exec "$CONTAINER" ip route add "${GW[2]}" via 172.17.0.1 + [ "${GW[2]}" == "${NS[1]}" ] || docker exec "$CONTAINER" ip route add "${NS[1]}" via 172.17.0.1 +} + +function log_newgroup { + local title="$1" + if [ "$GITHUB_ACTIONS" == "yes" ]; then + echo "::group::$title" + else + echo "=== $title ===" + fi +} + +function log_endgroup { + if [ "$GITHUB_ACTIONS" == "yes" ]; then + echo "::endgroup::" + fi +} + ENV_ARGS=( ) DB_RESTORE="" -DOJO_CONTAINER=dojo-test +DOJO_CONTAINER="$DEFAULT_CONTAINER_NAME" TEST=yes -DOCKER_DIR="" -while getopts "r:c:he:TD:" OPT +DOCKER_DIR="./cache/docker" +WORKSPACE_DIR="./cache/workspace" +EXPORT_PORTS=no +BUILD_IMAGE=no +MULTINODE=no +GITHUB_ACTIONS=no +START=yes +while getopts "r:c:he:TD:W:PbMgN" OPT do case $OPT in r) DB_RESTORE="$OPTARG" ;; c) DOJO_CONTAINER="$OPTARG" ;; T) TEST=no ;; D) DOCKER_DIR="$OPTARG" ;; + W) WORKSPACE_DIR="$OPTARG" ;; e) ENV_ARGS+=("-e" "$OPTARG") ;; + p) EXPORT_PORTS=yes ;; + b) BUILD_IMAGE=yes ;; + M) MULTINODE=yes ;; + g) GITHUB_ACTIONS=yes ;; + N) START=no ;; h) usage ;; ?) OPTIND=$(($OPTIND-1)) @@ -39,43 +96,171 @@ done shift $((OPTIND-1)) export DOJO_CONTAINER -docker kill "$DOJO_CONTAINER" 2>/dev/null || echo "No $DOJO_CONTAINER container to kill." -docker rm "$DOJO_CONTAINER" 2>/dev/null || echo "No $DOJO_CONTAINER container to remove." -while docker ps -a | grep "$DOJO_CONTAINER"; do sleep 1; done -# freaking bad unmount -sleep 1 -mount | grep /tmp/dojo-test- | sed -e "s/.* on //" | sed -e "s/ .*//" | tac | while read ENTRY -do - sudo umount "$ENTRY" -done +if [ "$START" == "yes" ]; then + cleanup_container $DOJO_CONTAINER +fi + +if [ "$MULTINODE" == "yes" ]; then + cleanup_container $DOJO_CONTAINER-node1 + cleanup_container $DOJO_CONTAINER-node2 +fi + +WORKDIR=$(mktemp -d /tmp/local-data-${DOJO_CONTAINER}-XXXXXX) +if [ "$MULTINODE" == "yes" ]; then + WORKDIR_NODE1=$(mktemp -d /tmp/local-data-${DOJO_CONTAINER}-node1-XXXXXX) + WORKDIR_NODE2=$(mktemp -d /tmp/local-data-${DOJO_CONTAINER}-node2-XXXXXX) +fi + +MAIN_NODE_VOLUME_ARGS=("-v" "$PWD:/opt/pwn.college" "-v" "$WORKDIR:/data:shared") +[ -n "$WORKSPACE_DIR" ] && MAIN_NODE_VOLUME_ARGS+=( "-v" "$WORKSPACE_DIR:/data/workspace:shared" ) +if [ -n "$DOCKER_DIR" ]; then + MAIN_NODE_VOLUME_ARGS+=( "-v" "$DOCKER_DIR:/data/docker" ) + if [ "$START" == "yes" ]; then + sudo rm -rf $DOCKER_DIR/{containers,volumes} + fi +fi + +if [ "$MULTINODE" == "yes" ]; then + NODE1_VOLUME_ARGS=("-v" "$PWD:/opt/pwn.college" "-v" "$WORKDIR_NODE1:/data:shared") + NODE2_VOLUME_ARGS=("-v" "$PWD:/opt/pwn.college" "-v" "$WORKDIR_NODE2:/data:shared") + [ -n "$WORKSPACE_DIR" ] && NODE1_VOLUME_ARGS+=("-v" "$WORKSPACE_DIR:/data/workspace:shared") + [ -n "$WORKSPACE_DIR" ] && NODE2_VOLUME_ARGS+=("-v" "$WORKSPACE_DIR:/data/workspace:shared") + if [ -n "$DOCKER_DIR" ]; then + NODE1_VOLUME_ARGS+=("-v" "$DOCKER_DIR-node1:/data/docker") + NODE2_VOLUME_ARGS+=("-v" "$DOCKER_DIR-node2:/data/docker") + if [ "$START" == "yes" ]; then + sudo rm -rf $DOCKER_DIR-node1/{containers,volumes} + sudo rm -rf $DOCKER_DIR-node2/{containers,volumes} + fi + fi +fi + +IMAGE_NAME="pwncollege/dojo" +if [ "$BUILD_IMAGE" == "yes" ]; then + log_newgroup "Building Docker image with tag: $DOJO_CONTAINER" + docker build -t "$DOJO_CONTAINER" . || exit 1 + IMAGE_NAME="$DOJO_CONTAINER" + log_endgroup +fi -if [ -n "$DOCKER_DIR" ] -then - VOLUME_ARGS+=( "-v" "$DOCKER_DIR:/data/docker" ) - sudo rm -rf $DOCKER_DIR/{containers,volumes} +PORT_ARGS=() +if [ "$EXPORT_PORTS" == "yes" ]; then + PORT_ARGS+=("-p" "80:80" "-p" "443:443" "-p" "2222:22") +fi + +MULTINODE_ARGS=() +[ "$MULTINODE" == "yes" ] && MULTINODE_ARGS+=("-e" "WORKSPACE_NODE=0") + +log_newgroup "Starting main dojo container" +if [ "$START" == "yes" ]; then + docker run --rm --privileged -d \ + "${MAIN_NODE_VOLUME_ARGS[@]}" "${ENV_ARGS[@]}" "${PORT_ARGS[@]}" "${MULTINODE_ARGS[@]}" \ + --name "$DOJO_CONTAINER" "$IMAGE_NAME" \ + || exit 1 +fi +CONTAINER_IP=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$DOJO_CONTAINER") +if [ "$START" == "yes" ]; then + fix_insane_routing "$DOJO_CONTAINER" + docker exec "$DOJO_CONTAINER" dojo wait fi -docker run --rm --privileged -d "${VOLUME_ARGS[@]}" "${ENV_ARGS[@]}" -p 2222:22 -p 80:80 -p 443:443 --name "$DOJO_CONTAINER" pwncollege/dojo || exit 1 +docker exec "$DOJO_CONTAINER" docker pull pwncollege/challenge-simple +docker exec "$DOJO_CONTAINER" docker tag pwncollege/challenge-simple pwncollege/challenge-legacy +log_endgroup + +if [ "$START" == "yes" -a "$MULTINODE" == "yes" ]; then + log_newgroup "Setting up multi-node cluster" + docker exec "$DOJO_CONTAINER" dojo-node refresh + MAIN_KEY=$(docker exec "$DOJO_CONTAINER" cat /data/wireguard/publickey) + + docker run --rm --privileged -d \ + "${NODE1_VOLUME_ARGS[@]}" \ + "${ENV_ARGS[@]}" \ + -e WORKSPACE_NODE=1 \ + -e WORKSPACE_KEY="$MAIN_KEY" \ + -e DOJO_HOST="$CONTAINER_IP" \ + -e STORAGE_HOST="$CONTAINER_IP" \ + --name "$DOJO_CONTAINER-node1" \ + "$IMAGE_NAME" + fix_insane_routing "$DOJO_CONTAINER-node1" + + docker run --rm --privileged -d \ + "${NODE2_VOLUME_ARGS[@]}" \ + "${ENV_ARGS[@]}" \ + -e WORKSPACE_NODE=2 \ + -e WORKSPACE_KEY="$MAIN_KEY" \ + -e DOJO_HOST="$CONTAINER_IP" \ + -e STORAGE_HOST="$CONTAINER_IP" \ + --name "$DOJO_CONTAINER-node2" \ + "$IMAGE_NAME" + fix_insane_routing "$DOJO_CONTAINER-node2" + + # Wait for workspace containers and set up WireGuard + docker exec "$DOJO_CONTAINER-node1" dojo wait + docker exec "$DOJO_CONTAINER-node2" dojo wait + + docker exec "$DOJO_CONTAINER-node1" dojo-node refresh + docker exec "$DOJO_CONTAINER-node2" dojo-node refresh + + # Register workspace nodes with main node + NODE1_KEY=$(docker exec "$DOJO_CONTAINER-node1" cat /data/wireguard/publickey) + NODE2_KEY=$(docker exec "$DOJO_CONTAINER-node2" cat /data/wireguard/publickey) + + docker exec "$DOJO_CONTAINER" dojo-node add 1 "$NODE1_KEY" + docker exec "$DOJO_CONTAINER" dojo-node add 2 "$NODE2_KEY" -# fix the insane routing thing -read -a GW <<<$(ip route show default) -read -a NS <<<$(docker exec "$DOJO_CONTAINER" cat /etc/resolv.conf | grep nameserver) -docker exec "$DOJO_CONTAINER" ip route add "${GW[2]}" via 172.17.0.1 -docker exec "$DOJO_CONTAINER" ip route add "${NS[1]}" via 172.17.0.1 || echo "Failed to add nameserver route" + docker exec "$DOJO_CONTAINER-node1" docker pull pwncollege/challenge-simple + docker exec "$DOJO_CONTAINER-node1" docker tag pwncollege/challenge-simple pwncollege/challenge-legacy + docker exec "$DOJO_CONTAINER-node2" docker pull pwncollege/challenge-simple + docker exec "$DOJO_CONTAINER-node2" docker tag pwncollege/challenge-simple pwncollege/challenge-legacy + + # this is needed for the main node to understand that it's in multi-node mode + docker exec "$DOJO_CONTAINER" dojo up + docker exec "$DOJO_CONTAINER-node1" dojo up + docker exec "$DOJO_CONTAINER-node2" dojo up + + # Fix routing for user containers on workspace nodes + log_newgroup "Configuring multi-node networking" + + # Enable IP forwarding on all nodes + docker exec "$DOJO_CONTAINER" sysctl -w net.ipv4.ip_forward=1 + docker exec "$DOJO_CONTAINER-node1" sysctl -w net.ipv4.ip_forward=1 + docker exec "$DOJO_CONTAINER-node2" sysctl -w net.ipv4.ip_forward=1 + + # Fix routes on main node to match production (direct to wg0, not via specific IP) + docker exec "$DOJO_CONTAINER" bash -c "ip route del 10.16.0.0/12 2>/dev/null || true" + docker exec "$DOJO_CONTAINER" bash -c "ip route del 10.32.0.0/12 2>/dev/null || true" + docker exec "$DOJO_CONTAINER" ip route add 10.16.0.0/12 dev wg0 + docker exec "$DOJO_CONTAINER" ip route add 10.32.0.0/12 dev wg0 + + # Add the critical MASQUERADE rule from production for the entire 10.0.0.0/8 network + docker exec "$DOJO_CONTAINER" bash -c "iptables -t nat -C POSTROUTING -s 10.0.0.0/8 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s 10.0.0.0/8 -j MASQUERADE" + + # Wait a moment for routes to settle + sleep 10 + + log_endgroup +fi -docker exec "$DOJO_CONTAINER" dojo wait -if [ -n "$DB_RESTORE" ] -then +if [ -n "$DB_RESTORE" ]; then + log_newgroup "Restoring database backup" BASENAME=$(basename $DB_RESTORE) docker exec "$DOJO_CONTAINER" mkdir -p /data/backups/ [ -f "$DB_RESTORE" ] && docker cp "$DB_RESTORE" "$DOJO_CONTAINER":/data/backups/"$BASENAME" docker exec "$DOJO_CONTAINER" dojo restore "$BASENAME" + log_endgroup fi -until curl -Ls localhost.pwn.college | grep -q pwn; do sleep 1; done +log_newgroup "Waiting for dojo to be ready" +export DOJO_URL="http://${CONTAINER_IP}" +export DOJO_SSH_HOST="$CONTAINER_IP" +until curl -Ls "${DOJO_URL}" | grep -q pwn; do sleep 1; done +log_endgroup -docker exec "$DOJO_CONTAINER" docker pull pwncollege/challenge-simple -docker exec "$DOJO_CONTAINER" docker tag pwncollege/challenge-simple pwncollege/challenge-legacy - -[ "$TEST" == "yes" ] && MOZ_HEADLESS=1 pytest -v test/test_running.py test/test_welcome.py +if [ "$TEST" == "yes" ]; then + log_newgroup "Running tests" + export MOZ_HEADLESS=1 + pytest --order-dependencies -v test "$@" + log_endgroup +fi diff --git a/test/test_auth.py b/test/test_auth.py new file mode 100644 index 000000000..2516193f5 --- /dev/null +++ b/test/test_auth.py @@ -0,0 +1,23 @@ +import random +import string + +import pytest +import requests + +from utils import DOJO_URL, login + + +@pytest.mark.parametrize("endpoint", ["/", "/dojos", "/login", "/register"]) +def test_unauthenticated_return_200(endpoint): + response = requests.get(f"{DOJO_URL}{endpoint}") + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + + +def test_login(): + login("admin", "incorrect_password", success=False) + login("admin", "admin") + + +def test_register(): + random_id = "".join(random.choices(string.ascii_lowercase, k=16)) + login(random_id, random_id, register=True) \ No newline at end of file diff --git a/test/test_belts.py b/test/test_belts.py new file mode 100644 index 000000000..fe2ae012c --- /dev/null +++ b/test/test_belts.py @@ -0,0 +1,30 @@ +import pytest + +from utils import DOJO_URL, start_challenge, solve_challenge + + +@pytest.mark.dependency(depends=["test/test_dojos.py::test_dojo_completion"], scope="session") +def test_belts(belt_dojos, random_user): + user_name, session = random_user + for color,dojo in belt_dojos.items(): + start_challenge(dojo, "test", "test", session=session) + solve_challenge(dojo, "test", "test", session=session, user=user_name) + scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/_/0/1").json() + us = next(u for u in scoreboard["standings"] if u["name"] == user_name) + assert color in us["belt"] + + +@pytest.mark.dependency(depends=["test_belts"]) +def test_cumulative_belts(belt_dojos, random_user): + user_name, session = random_user + for color,dojo in reversed(belt_dojos.items()): + start_challenge(dojo, "test", "test", session=session) + solve_challenge(dojo, "test", "test", session=session, user=user_name) + scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/_/0/1").json() + us = next(u for u in scoreboard["standings"] if u["name"] == user_name) + if color == "orange": + # orange is last, so we should get all belts including blue + assert "blue" in us["belt"] + else: + # until orange, we should be stuck in white + assert "white" in us["belt"] diff --git a/test/test_challenges.py b/test/test_challenges.py new file mode 100644 index 000000000..ec48a06ea --- /dev/null +++ b/test/test_challenges.py @@ -0,0 +1,218 @@ +import subprocess +import pytest +import json +import re + +from utils import DOJO_URL, dojo_run, workspace_run, start_challenge, solve_challenge + +def check_mount(path, *, user, fstype=None, check_nosuid=True): + try: + result = workspace_run(f"findmnt -J {path}", user=user) + except subprocess.CalledProcessError as e: + assert False, f"'{path}' not mounted: {(e.stdout, e.stderr)}" + assert result, f"'{path}' not mounted: {(e.stdout, e.stderr)}" + + mount_info = json.loads(result.stdout) + assert len(mount_info.get("filesystems", [])) == 1, f"Expected exactly one filesystem, but got: {mount_info}" + + filesystem = mount_info["filesystems"][0] + assert filesystem["target"] == path, f"Expected '{path}' to be mounted at '{path}', but got: {filesystem}" + if fstype: + assert filesystem["fstype"] == fstype, f"Expected '{path}' to be mounted as '{fstype}', but got: {filesystem}" + if check_nosuid: + assert "nosuid" in filesystem["options"], f"Expected '{path}' to be mounted nosuid, but got: {filesystem}" + + +def db_sql(sql): + db_result = dojo_run("db", "-qAt", input=sql) + return db_result.stdout + + +def get_user_id(user_name): + return int(db_sql(f"SELECT id FROM users WHERE name = '{user_name}'")) + + + +@pytest.mark.dependency(depends=["test/test_dojos.py::test_create_dojo"], scope="session") +def test_start_challenge(admin_session): + start_challenge("example", "hello", "apple", session=admin_session) + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_active_module_endpoint(random_user): + _, session = random_user + start_challenge("example", "hello", "banana", session=session) + response = session.get(f"{DOJO_URL}/active-module") + challenges = { + "apple": { + "challenge_id": 1, + "challenge_name": "Apple", + "challenge_reference_id": "apple", + "dojo_name": "Example Dojo", + "dojo_reference_id": "example", + "module_id": "hello", + "module_name": "Hello", + "description": "

This is apple.

", + }, + "banana": { + "challenge_id": 2, + "challenge_name": "Banana", + "challenge_reference_id": "banana", + "dojo_name": "Example Dojo", + "dojo_reference_id": "example", + "module_id": "hello", + "module_name": "Hello", + "description": "

This is banana.

", + }, + "empty": {} + } + apple_description = challenges["apple"].pop("description") + challenges["apple"]["description"] = None + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["c_current"] == challenges["banana"], f"Expected challenge 'Banana'\n{challenges['banana']}\n, but got {response.json()['c_current']}" + assert response.json()["c_next"] == challenges["empty"], f"Expected empty {challenges['empty']} challenge, but got {response.json()['c_next']}" + assert response.json()["c_previous"] == challenges["apple"], f"Expected challenge 'Apple'\n{challenges['apple']}\n, but got {response.json()['c_previous']}" + challenges["apple"]["description"] = apple_description + + start_challenge("example", "hello", "apple", session=session) + response = session.get(f"{DOJO_URL}/active-module") + banana_description = challenges["banana"].pop("description") + challenges["banana"]["description"] = None + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["c_current"] == challenges["apple"], f"Expected challenge 'Apple'\n{challenges['apple']}\n, but got {response.json()['c_current']}" + assert response.json()["c_next"] == challenges["banana"], f"Expected challenge 'Banana'\n{challenges['banana']}\n, but got {response.json()['c_next']}" + assert response.json()["c_previous"] == challenges["empty"], f"Expected empty {challenges['empty']} challenge, but got {response.json()['c_previous']}" + challenges["banana"]["description"] = banana_description + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_progression_locked(progression_locked_dojo, random_user): + uid, session = random_user + assert session.get(f"{DOJO_URL}/dojo/{progression_locked_dojo}/join/").status_code == 200 + start_challenge(progression_locked_dojo, "progression-locked-module", "unlocked-challenge", session=session) + + with pytest.raises(AssertionError, match="Failed to start challenge: This challenge is locked"): + start_challenge(progression_locked_dojo, "progression-locked-module", "locked-challenge", session=session) + + solve_challenge(progression_locked_dojo, "progression-locked-module", "unlocked-challenge", session=session, user=uid) + start_challenge(progression_locked_dojo, "progression-locked-module", "locked-challenge", session=session) + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +@pytest.mark.parametrize("path", ["/flag", "/challenge/apple"]) +def test_workspace_path_exists(path): + try: + workspace_run(f"[ -f '{path}' ]", user="admin") + except subprocess.CalledProcessError: + assert False, f"Path does not exist: {path}" + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_workspace_flag_permission(): + try: + workspace_run("cat /flag", user="admin") + except subprocess.CalledProcessError as e: + assert "Permission denied" in e.stderr, f"Expected permission denied, but got: {(e.stdout, e.stderr)}" + else: + assert False, f"Expected permission denied, but got no error: {(e.stdout, e.stderr)}" + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_workspace_challenge(): + result = workspace_run("/challenge/apple", user="admin") + match = re.search("pwn.college{(\\S+)}", result.stdout) + assert match, f"Expected flag, but got: {result.stdout}" + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_workspace_home_mount(): + check_mount("/home/hacker", user="admin") + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_workspace_no_sudo(): + try: + s = workspace_run("sudo whoami", user="admin") + except subprocess.CalledProcessError: + pass + else: + assert False, f"Expected sudo to fail, but got no error: {(s.stdout, s.stderr)}" + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_workspace_practice_challenge(random_user): + user, session = random_user + start_challenge("example", "hello", "apple", practice=True, session=session) + try: + result = workspace_run("sudo whoami", user=user) + assert result.stdout.strip() == "root", f"Expected 'root', but got: ({result.stdout}, {result.stderr})" + except subprocess.CalledProcessError as e: + assert False, f"Expected sudo to succeed, but got: {(e.stdout, e.stderr)}" + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_workspace_home_persistent(random_user): + user, session = random_user + start_challenge("example", "hello", "apple", session=session) + workspace_run("touch /home/hacker/test", user=user) + start_challenge("example", "hello", "apple", session=session) + try: + workspace_run("[ -f '/home/hacker/test' ]", user=user) + except subprocess.CalledProcessError as e: + assert False, f"Expected file to exist, but got: {(e.stdout, e.stderr)}" + + +@pytest.mark.skip(reason="Disabling test temporarily until overlay issue is resolved") +@pytest.mark.dependency(depends=["test_workspace_home_persistent"]) +def test_workspace_as_user(admin_user, random_user): + admin_user, admin_session = admin_user + random_user, random_session = random_user + random_user_id = get_user_id(random_user) + + start_challenge("example", "hello", "apple", session=random_session) + workspace_run("touch /home/hacker/test", user=random_user) + + start_challenge("example", "hello", "apple", session=admin_session, as_user=random_user_id) + check_mount("/home/hacker", user=admin_user) + check_mount("/home/me", user=admin_user) + + try: + workspace_run("[ -f '/home/hacker/test' ]", user=admin_user) + except subprocess.CalledProcessError as e: + assert False, f"Expected existing file to exist, but got: {(e.stdout, e.stderr)}" + + workspace_run("touch /home/hacker/test2", user=random_user) + try: + workspace_run("[ -f '/home/hacker/test2' ]", user=admin_user) + except subprocess.CalledProcessError as e: + assert False, f"Expected new file to exist, but got: {(e.stdout, e.stderr)}" + + workspace_run("touch /home/hacker/test3", user=admin_user) + try: + workspace_run("[ ! -e '/home/hacker/test3' ]", user=random_user) + except subprocess.CalledProcessError as e: + assert False, f"Expected overlay file to not exist, but got: {(e.stdout, e.stderr)}" + + +@pytest.mark.dependency(depends=["test_start_challenge"]) +def test_reset_home_directory(random_user): + user, session = random_user + + # Create a file in the home directory + start_challenge("example", "hello", "apple", session=session) + workspace_run("touch /home/hacker/testfile", user=user) + + # Reset the home directory + response = session.post(f"{DOJO_URL}/pwncollege_api/v1/workspace/reset_home", json={}) + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["success"], f"Failed to reset home directory: {response.json()['error']}" + + try: + workspace_run("[ -f '/home/hacker/home-backup.tar.gz' ]", user=user) + except subprocess.CalledProcessError as e: + assert False, f"Expected zip file to exist, but got: {(e.stdout, e.stderr)}" + + try: + workspace_run("[ ! -f '/home/hacker/testfile' ]", user=user) + except subprocess.CalledProcessError as e: + assert False, f"Expected test file to be wiped, but got: {(e.stdout, e.stderr)}" diff --git a/test/test_dojos.py b/test/test_dojos.py new file mode 100644 index 000000000..d16519a89 --- /dev/null +++ b/test/test_dojos.py @@ -0,0 +1,206 @@ +import subprocess +import requests +import pytest +import random +import string + +from utils import TEST_DOJOS_LOCATION, DOJO_URL, dojo_run, create_dojo_yml, start_challenge, solve_challenge, workspace_run, login + + +def get_dojo_modules(dojo): + response = requests.get(f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/modules") + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + return response.json()["modules"] + + +def db_sql(sql): + db_result = dojo_run("db", "-qAt", input=sql) + return db_result.stdout + + +def get_user_id(user_name): + return int(db_sql(f"SELECT id FROM users WHERE name = '{user_name}'")) + + +@pytest.mark.dependency() +def test_create_dojo(example_dojo, admin_session): + assert admin_session.get(f"{DOJO_URL}/{example_dojo}/").status_code == 200 + assert admin_session.get(f"{DOJO_URL}/example/").status_code == 200 + + +@pytest.mark.dependency() +def test_get_dojo_modules(example_dojo): + modules = get_dojo_modules(example_dojo) + + hello_module = modules[0] + assert hello_module['id'] == "hello", f"Expected module id to be 'hello' but got {hello_module['id']}" + assert hello_module['name'] == "Hello", f"Expected module name to be 'Hello' but got {hello_module['name']}" + + world_module = modules[1] + assert world_module['id'] == "world", f"Expected module id to be 'world' but got {world_module['id']}" + assert world_module['name'] == "World", f"Expected module name to be 'World' but got {world_module['name']}" + + +@pytest.mark.dependency(depends=["test_create_dojo"]) +def test_delete_dojo(admin_session): + reference_id = create_dojo_yml("""id: delete-test""", session=admin_session) + assert admin_session.get(f"{DOJO_URL}/{reference_id}/").status_code == 200 + assert admin_session.post(f"{DOJO_URL}/dojo/{reference_id}/delete/", json={"dojo": reference_id}).status_code == 200 + assert admin_session.get(f"{DOJO_URL}/{reference_id}/").status_code == 404 + + +@pytest.mark.dependency(depends=["test_create_dojo"]) +def test_create_import_dojo(example_import_dojo, admin_session): + assert admin_session.get(f"{DOJO_URL}/{example_import_dojo}/").status_code == 200 + assert admin_session.get(f"{DOJO_URL}/example-import/").status_code == 200 + + +@pytest.mark.dependency(depends=["test_create_dojo"]) +def test_join_dojo(admin_session, guest_dojo_admin): + random_user_name, random_session = guest_dojo_admin + response = random_session.get(f"{DOJO_URL}/dojo/example/join/") + assert response.status_code == 200 + response = admin_session.get(f"{DOJO_URL}/dojo/example/admin/") + assert response.status_code == 200 + assert random_user_name in response.text and response.text.index("Members") < response.text.index(random_user_name) + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_promote_dojo_member(admin_session, guest_dojo_admin): + random_user_name, _ = guest_dojo_admin + random_user_id = get_user_id(random_user_name) + response = admin_session.post(f"{DOJO_URL}/pwncollege_api/v1/dojos/example/admins/promote", json={"user_id": random_user_id}) + assert response.status_code == 200 + response = admin_session.get(f"{DOJO_URL}/dojo/example/admin/") + assert random_user_name in response.text and response.text.index("Members") > response.text.index(random_user_name) + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_dojo_completion(simple_award_dojo, completionist_user): + user_name, session = completionist_user + dojo = simple_award_dojo + + response = session.get(f"{DOJO_URL}/dojo/{dojo}/join/") + assert response.status_code == 200 + from test_challenges import solve_challenge + for module, challenge in [ + ("hello", "apple"), ("hello", "banana"), + #("world", "earth"), ("world", "mars"), ("world", "venus") + ]: + start_challenge(dojo, module, challenge, session=session) + solve_challenge(dojo, module, challenge, session=session, user=user_name) + + # check for emoji + scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/_/0/1").json() + us = next(u for u in scoreboard["standings"] if u["name"] == user_name) + assert us["solves"] == 2 + assert len(us["badges"]) == 1 + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_no_practice(no_practice_challenge_dojo, no_practice_dojo, random_user): + _, session = random_user + for dojo in [ no_practice_challenge_dojo, no_practice_dojo ]: + response = session.get(f"{DOJO_URL}/dojo/{dojo}/join/") + assert response.status_code == 200 + response = session.post(f"{DOJO_URL}/pwncollege_api/v1/docker", json={ + "dojo": dojo, + "module": "test", + "challenge": "test", + "practice": True + }) + assert response.status_code == 200 + assert not response.json()["success"] + assert "practice" in response.json()["error"] + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_no_import(no_import_challenge_dojo, admin_session): + try: + create_dojo_yml(open(TEST_DOJOS_LOCATION / "forbidden_import.yml").read(), session=admin_session) + except AssertionError as e: + assert "Import disallowed" in str(e) + else: + raise AssertionError("forbidden-import dojo creation should have failed, but it succeeded") + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_prune_dojo_awards(simple_award_dojo, admin_session, completionist_user): + user_name, _ = completionist_user + db_sql(f"DELETE FROM solves WHERE id IN (SELECT id FROM solves WHERE user_id={get_user_id(user_name)} ORDER BY id DESC LIMIT 1)") + + response = admin_session.post(f"{DOJO_URL}/pwncollege_api/v1/dojos/{simple_award_dojo}/awards/prune", json={}) + assert response.status_code == 200 + + scoreboard = admin_session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{simple_award_dojo}/_/0/1").json() + us = next(u for u in scoreboard["standings"] if u["name"] == user_name) + assert us["solves"] == 1 + assert len(us["badges"]) == 0 + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_lfs(lfs_dojo, random_user): + uid, session = random_user + assert session.get(f"{DOJO_URL}/dojo/{lfs_dojo}/join/").status_code == 200 + start_challenge(lfs_dojo, "test", "test", session=session) + try: + workspace_run("[ -f '/challenge/dojo.txt' ]", user=uid) + except subprocess.CalledProcessError: + assert False, "LFS didn't create dojo.txt" + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_import_override(import_override_dojo, random_user): + uid, session = random_user + assert session.get(f"{DOJO_URL}/dojo/{import_override_dojo}/join/").status_code == 200 + start_challenge(import_override_dojo, "test", "test", session=session) + try: + workspace_run("[ -f '/challenge/boom' ]", user=uid) + workspace_run("[ ! -f '/challenge/apple' ]", user=uid) + except subprocess.CalledProcessError: + assert False, "dojo_initialize_files didn't create /challenge/boom" + + +@pytest.mark.dependency(depends=["test_join_dojo"]) +def test_challenge_transfer(transfer_src_dojo, transfer_dst_dojo, random_user): + user_name, session = random_user + assert session.get(f"{DOJO_URL}/dojo/{transfer_src_dojo}/join/").status_code == 200 + assert session.get(f"{DOJO_URL}/dojo/{transfer_dst_dojo}/join/").status_code == 200 + start_challenge(transfer_dst_dojo, "dst-module", "dst-challenge", session=session) + solve_challenge(transfer_dst_dojo, "dst-module", "dst-challenge", session=session, user=user_name) + scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{transfer_src_dojo}/_/0/1").json() + us = next(u for u in scoreboard["standings"] if u["name"] == user_name) + assert us["solves"] == 1 + + +@pytest.mark.dependency(depends=["test_create_dojo"]) +def test_hidden_challenges(admin_session, random_user, hidden_challenges_dojo): + assert "CHALLENGE" in admin_session.get(f"{DOJO_URL}/{hidden_challenges_dojo}/module/").text + assert random_user[1].get(f"{DOJO_URL}/{hidden_challenges_dojo}/module/").status_code == 200 + assert "CHALLENGE" not in random_user[1].get(f"{DOJO_URL}/{hidden_challenges_dojo}/module/").text + + +@pytest.mark.dependency(depends=["test/test_challenges.py::test_start_challenge"], scope="session") +def test_dojo_solves_api(example_dojo, random_user): + user_name, session = random_user + dojo = example_dojo + + random_id = "".join(random.choices(string.ascii_lowercase, k=16)) + other_session = login(random_id, random_id, register=True) + + start_challenge(dojo, "hello", "apple", session=session) + solve_challenge(dojo, "hello", "apple", session=session, user=user_name) + + response = session.get(f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/solves") + assert response.status_code == 200 + data = response.json() + assert data["success"] + assert len(data["solves"]) == 1 + assert data["solves"][0]["challenge_id"] == "apple" + + response = other_session.get(f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/solves", params={"username": user_name}) + assert response.status_code == 200 + data = response.json() + assert data["success"] + assert len(data["solves"]) == 1 + assert data["solves"][0]["challenge_id"] == "apple" diff --git a/test/test_module_resources.py b/test/test_module_resources.py new file mode 100644 index 000000000..7864f43fb --- /dev/null +++ b/test/test_module_resources.py @@ -0,0 +1,119 @@ +import pytest +import requests + +from utils import DOJO_URL, create_dojo_yml, TEST_DOJOS_LOCATION + + +@pytest.fixture(scope="session") +def module_resources_dojo(admin_session): + return create_dojo_yml(open(TEST_DOJOS_LOCATION / "module_resources_dojo.yml").read(), session=admin_session) + + +def test_module_resources(module_resources_dojo, admin_session, example_dojo): + dojo_id = module_resources_dojo + + response = admin_session.get(f"{DOJO_URL}/{dojo_id}/test/") + assert response.status_code == 200 + page_content = response.text + + assert "Resource A" in page_content + assert "Resource B" in page_content + assert "Resource C" in page_content + assert "Resource D" in page_content + assert "Resource E" in page_content + + assert "hh4XAU6XYP0" in page_content + assert "14ZJRIyf0HnoYO1N8GI5ygE-ZVgdhjJrvzkETFLz7NIo" in page_content + assert "TESTB123" in page_content + assert "hIK1Dfjxq4E" in page_content + assert "1NjoOj03eQsjnZWhm-A3IsxTVqeWUqFvR4wgfXnhsnu4" in page_content + assert "PLHhKcdBlprMfIBVorNvOfY5UiHJmOk9nh" in page_content + assert "TESTD456" in page_content + assert "1H1V1HkVt3k" in page_content + assert "1_xdrCm136NzcDl9bqSgAEQuigUHkNjkKmamhaej296Q" in page_content + + +def test_module_resources_order(module_resources_dojo, admin_session, example_dojo): + dojo_id = module_resources_dojo + + response = admin_session.get(f"{DOJO_URL}/{dojo_id}/test/") + assert response.status_code == 200 + page_content = response.text + + pos_a = page_content.find("Resource A") + pos_b = page_content.find("Resource B") + pos_c = page_content.find("Resource C") + pos_d = page_content.find("Resource D") + pos_e = page_content.find("Resource E") + + assert pos_a != -1 and pos_b != -1 and pos_c != -1 and pos_d != -1 and pos_e != -1 + assert pos_a < pos_b < pos_c < pos_d < pos_e + + +def test_module_resources_with_challenges(module_resources_dojo, admin_session, example_dojo): + dojo_id = module_resources_dojo + + response = admin_session.get(f"{DOJO_URL}/{dojo_id}/test/") + assert response.status_code == 200 + page_content = response.text + + assert "Challenge A" in page_content + assert "Challenge B" in page_content + + pos_resource_c = page_content.find("Resource C") + pos_challenge_a = page_content.find("Challenge A") + pos_resource_d = page_content.find("Resource D") + pos_challenge_b = page_content.find("Challenge B") + + assert pos_resource_c != -1 and pos_challenge_a != -1 and pos_resource_d != -1 and pos_challenge_b != -1 + + assert pos_resource_c < pos_challenge_a < pos_resource_d, f"Challenge A should be between Resource C ({pos_resource_c}) and Resource D ({pos_resource_d}), but found at {pos_challenge_a}" + + pos_resource_e = page_content.find("Resource E") + assert pos_resource_e < pos_challenge_b, f"Challenge B ({pos_challenge_b}) should come after Resource E ({pos_resource_e})" + + +def test_unified_ordering(module_resources_dojo, admin_session, example_dojo): + """Test that resources and challenges appear in YAML order""" + dojo_id = module_resources_dojo + + response = admin_session.get(f"{DOJO_URL}/{dojo_id}/test/") + assert response.status_code == 200 + page_content = response.text + + items = [ + "Resource A", + "Resource B", + "Advanced Section", + "Resource C", + "Challenge A", + "Resource D", + "Resource E", + "Challenge B" + ] + + positions = [page_content.find(item) for item in items] + + for i, pos in enumerate(positions): + assert pos != -1, f"{items[i]} not found in page" + + for i in range(len(positions) - 1): + assert positions[i] < positions[i+1], f"{items[i]} (at {positions[i]}) should appear before {items[i+1]} (at {positions[i+1]})" + + +def test_header_resources(module_resources_dojo, admin_session, example_dojo): + """Test that header resources render correctly""" + dojo_id = module_resources_dojo + + response = admin_session.get(f"{DOJO_URL}/{dojo_id}/test/") + assert response.status_code == 200 + page_content = response.text + + assert "
" in page_content + assert "

Advanced Section

" in page_content + + pos_b = page_content.find("Resource B") + pos_header = page_content.find("

Advanced Section

") + pos_c = page_content.find("Resource C") + + assert pos_b < pos_header < pos_c, f"Header should be between Resource B and C" diff --git a/test/test_running.py b/test/test_running.py deleted file mode 100644 index e593fb3c0..000000000 --- a/test/test_running.py +++ /dev/null @@ -1,561 +0,0 @@ -import json -import random -import re -import string -import subprocess - -import pytest -import requests - -from utils import TEST_DOJOS_LOCATION, DOJO_URL, login, dojo_run, workspace_run, create_dojo_yml - - -def get_dojo_modules(dojo): - response = requests.get(f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/modules") - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - return response.json()["modules"] - - -def start_challenge(dojo, module, challenge, practice=False, *, session, as_user=None): - start_challenge_json = dict(dojo=dojo, module=module, challenge=challenge, practice=practice) - if as_user: - start_challenge_json["as_user"] = as_user - response = session.post(f"{DOJO_URL}/pwncollege_api/v1/docker", json=start_challenge_json) - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["success"], f"Failed to start challenge: {response.json()['error']}" - - -def solve_challenge(dojo, module, challenge, *, session, flag=None, user=None): - flag = flag if flag is not None else workspace_run("cat /flag", user=user, root=True).stdout.strip() - response = session.post( - f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/{module}/{challenge}/solve", - json={"submission": flag} - ) - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["success"], "Expected to successfully submit flag" - -def get_challenge_survey(dojo, module, challenge, session): - response = session.get(f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/{module}/{challenge}/surveys") - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["success"], "Expected to recieve valid survey" - return response.json() - -def post_survey_response(dojo, module, challenge, survey_response, session): - response = session.post( - f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/{module}/{challenge}/surveys", - json={"response": survey_response} - ) - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["success"], "Expected to successfully submit survey" - -def db_sql(sql): - db_result = dojo_run("db", "-qAt", input=sql) - return db_result.stdout - - -def get_user_id(user_name): - return int(db_sql(f"SELECT id FROM users WHERE name = '{user_name}'")) - - -@pytest.mark.parametrize("endpoint", ["/", "/dojos", "/login", "/register"]) -def test_unauthenticated_return_200(endpoint): - response = requests.get(f"{DOJO_URL}{endpoint}") - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - - -def test_login(): - login("admin", "incorrect_password", success=False) - login("admin", "admin") - - -def test_register(): - random_id = "".join(random.choices(string.ascii_lowercase, k=16)) - login(random_id, random_id, register=True) - - -@pytest.mark.dependency() -def test_create_dojo(example_dojo, admin_session): - assert admin_session.get(f"{DOJO_URL}/{example_dojo}/").status_code == 200 - assert admin_session.get(f"{DOJO_URL}/example/").status_code == 200 - - -@pytest.mark.dependency() -def test_get_dojo_modules(example_dojo): - modules = get_dojo_modules(example_dojo) - - hello_module = modules[0] - assert hello_module['id'] == "hello", f"Expected module id to be 'hello' but got {hello_module['id']}" - assert hello_module['name'] == "Hello", f"Expected module name to be 'Hello' but got {hello_module['name']}" - - world_module = modules[1] - assert world_module['id'] == "world", f"Expected module id to be 'world' but got {world_module['id']}" - assert world_module['name'] == "World", f"Expected module name to be 'World' but got {world_module['name']}" - - -@pytest.mark.dependency(depends=["test_create_dojo"]) -def test_delete_dojo(admin_session): - reference_id = create_dojo_yml("""id: delete-test""", session=admin_session) - assert admin_session.get(f"{DOJO_URL}/{reference_id}/").status_code == 200 - assert admin_session.post(f"{DOJO_URL}/dojo/{reference_id}/delete/", json={"dojo": reference_id}).status_code == 200 - assert admin_session.get(f"{DOJO_URL}/{reference_id}/").status_code == 404 - - -@pytest.mark.dependency(depends=["test_create_dojo"]) -def test_create_import_dojo(example_import_dojo, admin_session): - assert admin_session.get(f"{DOJO_URL}/{example_import_dojo}/").status_code == 200 - assert admin_session.get(f"{DOJO_URL}/example-import/").status_code == 200 - - -@pytest.mark.dependency(depends=["test_create_dojo"]) -def test_start_challenge(admin_session): - start_challenge("example", "hello", "apple", session=admin_session) - - -@pytest.mark.dependency(depends=["test_create_dojo"]) -def test_join_dojo(admin_session, guest_dojo_admin): - random_user_name, random_session = guest_dojo_admin - response = random_session.get(f"{DOJO_URL}/dojo/example/join/") - assert response.status_code == 200 - response = admin_session.get(f"{DOJO_URL}/dojo/example/admin/") - assert response.status_code == 200 - assert random_user_name in response.text and response.text.index("Members") < response.text.index(random_user_name) - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_promote_dojo_member(admin_session, guest_dojo_admin): - random_user_name, _ = guest_dojo_admin - random_user_id = get_user_id(random_user_name) - response = admin_session.post(f"{DOJO_URL}/pwncollege_api/v1/dojos/example/admins/promote", json={"user_id": random_user_id}) - assert response.status_code == 200 - response = admin_session.get(f"{DOJO_URL}/dojo/example/admin/") - assert random_user_name in response.text and response.text.index("Members") > response.text.index(random_user_name) - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_dojo_completion(simple_award_dojo, completionist_user): - user_name, session = completionist_user - dojo = simple_award_dojo - - response = session.get(f"{DOJO_URL}/dojo/{dojo}/join/") - assert response.status_code == 200 - for module, challenge in [ - ("hello", "apple"), ("hello", "banana"), - #("world", "earth"), ("world", "mars"), ("world", "venus") - ]: - start_challenge(dojo, module, challenge, session=session) - solve_challenge(dojo, module, challenge, session=session, user=user_name) - - # check for emoji - scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/_/0/1").json() - us = next(u for u in scoreboard["standings"] if u["name"] == user_name) - assert us["solves"] == 2 - assert len(us["badges"]) == 1 - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_no_practice(no_practice_challenge_dojo, no_practice_dojo, random_user): - _, session = random_user - for dojo in [ no_practice_challenge_dojo, no_practice_dojo ]: - response = session.get(f"{DOJO_URL}/dojo/{dojo}/join/") - assert response.status_code == 200 - response = session.post(f"{DOJO_URL}/pwncollege_api/v1/docker", json={ - "dojo": dojo, - "module": "test", - "challenge": "test", - "practice": True - }) - assert response.status_code == 200 - assert not response.json()["success"] - assert "practice" in response.json()["error"] - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_lfs(lfs_dojo, random_user): - uid, session = random_user - assert session.get(f"{DOJO_URL}/dojo/{lfs_dojo}/join/").status_code == 200 - start_challenge(lfs_dojo, "test", "test", session=session) - try: - workspace_run("[ -f '/challenge/dojo.txt' ]", user=uid) - except subprocess.CalledProcessError: - assert False, "LFS didn't create dojo.txt" - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_import_override(import_override_dojo, random_user): - uid, session = random_user - assert session.get(f"{DOJO_URL}/dojo/{import_override_dojo}/join/").status_code == 200 - start_challenge(import_override_dojo, "test", "test", session=session) - try: - workspace_run("[ -f '/challenge/boom' ]", user=uid) - workspace_run("[ ! -f '/challenge/apple' ]", user=uid) - except subprocess.CalledProcessError: - assert False, "dojo_initialize_files didn't create /challenge/boom" - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_challenge_transfer(transfer_src_dojo, transfer_dst_dojo, random_user): - user_name, session = random_user - assert session.get(f"{DOJO_URL}/dojo/{transfer_src_dojo}/join/").status_code == 200 - assert session.get(f"{DOJO_URL}/dojo/{transfer_dst_dojo}/join/").status_code == 200 - start_challenge(transfer_dst_dojo, "dst-module", "dst-challenge", session=session) - solve_challenge(transfer_dst_dojo, "dst-module", "dst-challenge", session=session, user=user_name) - scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{transfer_src_dojo}/_/0/1").json() - us = next(u for u in scoreboard["standings"] if u["name"] == user_name) - assert us["solves"] == 1 - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_no_import(no_import_challenge_dojo, admin_session): - try: - create_dojo_yml(open(TEST_DOJOS_LOCATION / "forbidden_import.yml").read(), session=admin_session) - except AssertionError as e: - assert "Import disallowed" in str(e) - else: - raise AssertionError("forbidden-import dojo creation should have failed, but it succeeded") - - -@pytest.mark.dependency(depends=["test_join_dojo"]) -def test_prune_dojo_awards(simple_award_dojo, admin_session, completionist_user): - user_name, _ = completionist_user - db_sql(f"DELETE FROM solves WHERE id IN (SELECT id FROM solves WHERE user_id={get_user_id(user_name)} ORDER BY id DESC LIMIT 1)") - - response = admin_session.post(f"{DOJO_URL}/pwncollege_api/v1/dojos/{simple_award_dojo}/awards/prune", json={}) - assert response.status_code == 200 - - scoreboard = admin_session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{simple_award_dojo}/_/0/1").json() - us = next(u for u in scoreboard["standings"] if u["name"] == user_name) - assert us["solves"] == 1 - assert len(us["badges"]) == 0 - - -@pytest.mark.dependency(depends=["test_dojo_completion"]) -def test_belts(belt_dojos, random_user): - user_name, session = random_user - for color,dojo in belt_dojos.items(): - start_challenge(dojo, "test", "test", session=session) - solve_challenge(dojo, "test", "test", session=session, user=user_name) - scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/_/0/1").json() - us = next(u for u in scoreboard["standings"] if u["name"] == user_name) - assert color in us["belt"] - - -@pytest.mark.dependency(depends=["test_belts"]) -def test_cumulative_belts(belt_dojos, random_user): - user_name, session = random_user - for color,dojo in reversed(belt_dojos.items()): - start_challenge(dojo, "test", "test", session=session) - solve_challenge(dojo, "test", "test", session=session, user=user_name) - scoreboard = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/_/0/1").json() - us = next(u for u in scoreboard["standings"] if u["name"] == user_name) - if color == "orange": - # orange is last, so we should get all belts including blue - assert "blue" in us["belt"] - else: - # until orange, we should be stuck in white - assert "white" in us["belt"] - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -@pytest.mark.parametrize("path", ["/flag", "/challenge/apple"]) -def test_workspace_path_exists(path): - try: - workspace_run(f"[ -f '{path}' ]", user="admin") - except subprocess.CalledProcessError: - assert False, f"Path does not exist: {path}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_workspace_flag_permission(): - try: - workspace_run("cat /flag", user="admin") - except subprocess.CalledProcessError as e: - assert "Permission denied" in e.stderr, f"Expected permission denied, but got: {(e.stdout, e.stderr)}" - else: - assert False, f"Expected permission denied, but got no error: {(e.stdout, e.stderr)}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_workspace_challenge(): - result = workspace_run("/challenge/apple", user="admin") - match = re.search("pwn.college{(\\S+)}", result.stdout) - assert match, f"Expected flag, but got: {result.stdout}" - - -def check_mount(path, *, user, fstype=None, check_nosuid=True): - try: - result = workspace_run(f"findmnt -J {path}", user=user) - except subprocess.CalledProcessError as e: - assert False, f"'{path}' not mounted: {(e.stdout, e.stderr)}" - assert result, f"'{path}' not mounted: {(e.stdout, e.stderr)}" - - mount_info = json.loads(result.stdout) - assert len(mount_info.get("filesystems", [])) == 1, f"Expected exactly one filesystem, but got: {mount_info}" - - filesystem = mount_info["filesystems"][0] - assert filesystem["target"] == path, f"Expected '{path}' to be mounted at '{path}', but got: {filesystem}" - if fstype: - assert filesystem["fstype"] == fstype, f"Expected '{path}' to be mounted as '{fstype}', but got: {filesystem}" - if check_nosuid: - assert "nosuid" in filesystem["options"], f"Expected '{path}' to be mounted nosuid, but got: {filesystem}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_workspace_home_mount(): - check_mount("/home/hacker", user="admin") - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_workspace_no_sudo(): - try: - s = workspace_run("sudo whoami", user="admin") - except subprocess.CalledProcessError: - pass - else: - assert False, f"Expected sudo to fail, but got no error: {(s.stdout, s.stderr)}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_workspace_practice_challenge(random_user): - user, session = random_user - start_challenge("example", "hello", "apple", practice=True, session=session) - try: - result = workspace_run("sudo whoami", user=user) - assert result.stdout.strip() == "root", f"Expected 'root', but got: ({result.stdout}, {result.stderr})" - except subprocess.CalledProcessError as e: - assert False, f"Expected sudo to succeed, but got: {(e.stdout, e.stderr)}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_workspace_home_persistent(random_user): - user, session = random_user - start_challenge("example", "hello", "apple", session=session) - workspace_run("touch /home/hacker/test", user=user) - start_challenge("example", "hello", "apple", session=session) - try: - workspace_run("[ -f '/home/hacker/test' ]", user=user) - except subprocess.CalledProcessError as e: - assert False, f"Expected file to exist, but got: {(e.stdout, e.stderr)}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_active_module_endpoint(random_user): - user, session = random_user - start_challenge("example", "hello", "banana", session=session) - response = session.get(f"{DOJO_URL}/active-module") - challenges = { - "apple": { - "challenge_id": 1, - "challenge_name": "Apple", - "challenge_reference_id": "apple", - "dojo_name": "Example Dojo", - "dojo_reference_id": "example", - "module_id": "hello", - "module_name": "Hello", - "description": "

This is apple.

", - }, - "banana": { - "challenge_id": 2, - "challenge_name": "Banana", - "challenge_reference_id": "banana", - "dojo_name": "Example Dojo", - "dojo_reference_id": "example", - "module_id": "hello", - "module_name": "Hello", - "description": "

This is banana.

", - }, - "empty": {} - } - apple_description = challenges["apple"].pop("description") - challenges["apple"]["description"] = None - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["c_current"] == challenges["banana"], f"Expected challenge 'Banana'\n{challenges['banana']}\n, but got {response.json()['c_current']}" - assert response.json()["c_next"] == challenges["empty"], f"Expected empty {challenges['empty']} challenge, but got {response.json()['c_next']}" - assert response.json()["c_previous"] == challenges["apple"], f"Expected challenge 'Apple'\n{challenges['apple']}\n, but got {response.json()['c_previous']}" - challenges["apple"]["description"] = apple_description - - start_challenge("example", "hello", "apple", session=session) - response = session.get(f"{DOJO_URL}/active-module") - banana_description = challenges["banana"].pop("description") - challenges["banana"]["description"] = None - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["c_current"] == challenges["apple"], f"Expected challenge 'Apple'\n{challenges['apple']}\n, but got {response.json()['c_current']}" - assert response.json()["c_next"] == challenges["banana"], f"Expected challenge 'Banana'\n{challenges['banana']}\n, but got {response.json()['c_next']}" - assert response.json()["c_previous"] == challenges["empty"], f"Expected empty {challenges['empty']} challenge, but got {response.json()['c_previous']}" - challenges["banana"]["description"] = banana_description - - -def get_all_standings(session, dojo, module=None): - """ - Return a big list of all the standings, going through all the available pages. - """ - to_return = [] - - page_number = 1 - done = False - - if module is None: - module = "_" - - while not done: - response = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/{module}/0/{page_number}") - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - response = response.json() - - to_return.extend(response["standings"]) - - next_page = page_number + 1 - - if next_page in response["pages"]: - page_number += 1 - else: - done = True - - return to_return - - -@pytest.mark.dependency(depends=["test_workspace_challenge"]) -def test_scoreboard(random_user): - user, session = random_user - - dojo = "example" - module = "hello" - challenge = "apple" - - prior_standings = get_all_standings(session, dojo, module) - - start_challenge(dojo, module, challenge, session=session) - result = workspace_run("/challenge/apple", user=user) - flag = result.stdout.strip() - solve_challenge(dojo, module, challenge, session=session, flag=flag) - - new_standings = get_all_standings(session, dojo, module) - assert len(prior_standings) != len(new_standings), "Expected to have a new entry in the standings" - - found_me = False - for standing in new_standings: - if standing['name'] == user: - found_me = True - break - assert found_me, f"Unable to find new user {user} in new standings after solving a challenge" - - -@pytest.mark.skip(reason="Disabling test temporarily until overlay issue is resolved") -@pytest.mark.dependency(depends=["test_workspace_home_persistent"]) -def test_workspace_as_user(admin_user, random_user): - admin_user, admin_session = admin_user - random_user, random_session = random_user - random_user_id = get_user_id(random_user) - - start_challenge("example", "hello", "apple", session=random_session) - workspace_run("touch /home/hacker/test", user=random_user) - - start_challenge("example", "hello", "apple", session=admin_session, as_user=random_user_id) - check_mount("/home/hacker", user=admin_user) - check_mount("/home/me", user=admin_user) - - try: - workspace_run("[ -f '/home/hacker/test' ]", user=admin_user) - except subprocess.CalledProcessError as e: - assert False, f"Expected existing file to exist, but got: {(e.stdout, e.stderr)}" - - workspace_run("touch /home/hacker/test2", user=random_user) - try: - workspace_run("[ -f '/home/hacker/test2' ]", user=admin_user) - except subprocess.CalledProcessError as e: - assert False, f"Expected new file to exist, but got: {(e.stdout, e.stderr)}" - - workspace_run("touch /home/hacker/test3", user=admin_user) - try: - workspace_run("[ ! -e '/home/hacker/test3' ]", user=random_user) - except subprocess.CalledProcessError as e: - assert False, f"Expected overlay file to not exist, but got: {(e.stdout, e.stderr)}" - - -@pytest.mark.dependency(depends=["test_start_challenge"]) -def test_reset_home_directory(random_user): - user, session = random_user - - # Create a file in the home directory - start_challenge("example", "hello", "apple", session=session) - workspace_run("touch /home/hacker/testfile", user=user) - - # Reset the home directory - response = session.post(f"{DOJO_URL}/pwncollege_api/v1/workspace/reset_home", json={}) - assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" - assert response.json()["success"], f"Failed to reset home directory: {response.json()['error']}" - - try: - workspace_run("[ -f '/home/hacker/home-backup.tar.gz' ]", user=user) - except subprocess.CalledProcessError as e: - assert False, f"Expected zip file to exist, but got: {(e.stdout, e.stderr)}" - - try: - workspace_run("[ ! -f '/home/hacker/testfile' ]", user=user) - except subprocess.CalledProcessError as e: - assert False, f"Expected test file to be wiped, but got: {(e.stdout, e.stderr)}" - - -@pytest.mark.dependency() -def test_searchable_content(searchable_dojo, admin_session): - search_url = f"{DOJO_URL}/pwncollege_api/v1/search" - - cases = [ - # Matches in name only — verify name field - ("searchable", lambda r: any("searchable dojo" in d["name"].lower() for d in r["dojos"])), - ("hello", lambda r: any("hello module" in m["name"].lower() for m in r["modules"])), - ("Apple Challenge", lambda r: any("apple challenge" in c["name"].lower() for c in r["challenges"])), - - # Matches in description — verify `match` exists and contains the query - ("search test content", lambda r: any("search test content" in (d.get("match") or "").lower() for d in r["dojos"])), - ("search testing", lambda r: any("search testing" in (m.get("match") or "").lower() for m in r["modules"])), - ("about apples", lambda r: any("about apples" in (c.get("match") or "").lower() for c in r["challenges"])), - ] - - for query, validate in cases: - response = admin_session.get(search_url, params={"q": query}) - assert response.status_code == 200, f"Request failed for query: {query}" - data = response.json() - assert data["success"] - assert validate(data["results"]), f"No expected match found for query: {query}" - -def test_search_no_results(admin_session): - search_url = f"{DOJO_URL}/pwncollege_api/v1/search" - query = "qwertyuiopasdfgh" # something unlikely to match anything - - response = admin_session.get(search_url, params={"q": query}) - assert response.status_code == 200 - data = response.json() - - assert data["success"] - assert not data["results"]["dojos"], "Expected no dojo matches" - assert not data["results"]["modules"], "Expected no module matches" - assert not data["results"]["challenges"], "Expected no challenge matches" - -def test_progression_locked(progression_locked_dojo, random_user): - uid, session = random_user - assert session.get(f"{DOJO_URL}/dojo/{progression_locked_dojo}/join/").status_code == 200 - start_challenge(progression_locked_dojo, "progression-locked-module", "unlocked-challenge", session=session) - - with pytest.raises(AssertionError, match="Failed to start challenge: This challenge is locked"): - start_challenge(progression_locked_dojo, "progression-locked-module", "locked-challenge", session=session) - - solve_challenge(progression_locked_dojo, "progression-locked-module", "unlocked-challenge", session=session, user=uid) - start_challenge(progression_locked_dojo, "progression-locked-module", "locked-challenge", session=session) - -def test_surveys(surveys_dojo, random_user): - uid, session = random_user - assert session.get(f"{DOJO_URL}/dojo/{surveys_dojo}/join/").status_code == 200 - - challenge_level_survey = get_challenge_survey(surveys_dojo, "surveys-module-1", "challenge-level", session=session) - module_level_survey = get_challenge_survey(surveys_dojo, "surveys-module-1", "module-level", session=session) - dojo_level_survey = get_challenge_survey(surveys_dojo, "surveys-module-2", "dojo-level", session=session) - - assert challenge_level_survey["prompt"] == "Challenge-level prompt", "Challenge-level survey is wrong/missing" - assert module_level_survey["prompt"] == "Module-level prompt", "Module-level survey is wrong/missing" - assert dojo_level_survey["prompt"] == "Dojo-level prompt", "Dojo-level survey is wrong/missing" - - assert len(dojo_level_survey["options"]) == 3, "Survey options are wrong/missing" - - post_survey_response(surveys_dojo, "surveys-module-1", "challenge-level", "Test response", session=session) - post_survey_response(surveys_dojo, "surveys-module-1", "module-level", "up", session=session) - post_survey_response(surveys_dojo, "surveys-module-2", "dojo-level", 1, session=session) \ No newline at end of file diff --git a/test/test_scoreboard.py b/test/test_scoreboard.py new file mode 100644 index 000000000..bf8653904 --- /dev/null +++ b/test/test_scoreboard.py @@ -0,0 +1,58 @@ +import pytest + +from utils import DOJO_URL, workspace_run, start_challenge, solve_challenge + + +def get_all_standings(session, dojo, module=None): + """ + Return a big list of all the standings, going through all the available pages. + """ + to_return = [] + + page_number = 1 + done = False + + if module is None: + module = "_" + + while not done: + response = session.get(f"{DOJO_URL}/pwncollege_api/v1/scoreboard/{dojo}/{module}/0/{page_number}") + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + response = response.json() + + to_return.extend(response["standings"]) + + next_page = page_number + 1 + + if next_page in response["pages"]: + page_number += 1 + else: + done = True + + return to_return + + +@pytest.mark.dependency(depends=["test/test_challenges.py::test_workspace_challenge"], scope="session") +def test_scoreboard(random_user): + user, session = random_user + + dojo = "example" + module = "hello" + challenge = "apple" + + prior_standings = get_all_standings(session, dojo, module) + + start_challenge(dojo, module, challenge, session=session) + result = workspace_run("/challenge/apple", user=user) + flag = result.stdout.strip() + solve_challenge(dojo, module, challenge, session=session, flag=flag) + + new_standings = get_all_standings(session, dojo, module) + assert len(prior_standings) != len(new_standings), "Expected to have a new entry in the standings" + + found_me = False + for standing in new_standings: + if standing['name'] == user: + found_me = True + break + assert found_me, f"Unable to find new user {user} in new standings after solving a challenge" diff --git a/test/test_search.py b/test/test_search.py new file mode 100644 index 000000000..ecd291d7e --- /dev/null +++ b/test/test_search.py @@ -0,0 +1,41 @@ +import pytest + +from utils import DOJO_URL + + +@pytest.mark.dependency() +def test_searchable_content(searchable_dojo, admin_session): + search_url = f"{DOJO_URL}/pwncollege_api/v1/search" + + cases = [ + # Matches in name only — verify name field + ("searchable", lambda r: any("searchable dojo" in d["name"].lower() for d in r["dojos"])), + ("hello", lambda r: any("hello module" in m["name"].lower() for m in r["modules"])), + ("Apple Challenge", lambda r: any("apple challenge" in c["name"].lower() for c in r["challenges"])), + + # Matches in description — verify `match` exists and contains the query + ("search test content", lambda r: any("search test content" in (d.get("match") or "").lower() for d in r["dojos"])), + ("search testing", lambda r: any("search testing" in (m.get("match") or "").lower() for m in r["modules"])), + ("about apples", lambda r: any("about apples" in (c.get("match") or "").lower() for c in r["challenges"])), + ] + + for query, validate in cases: + response = admin_session.get(search_url, params={"q": query}) + assert response.status_code == 200, f"Request failed for query: {query}" + data = response.json() + assert data["success"] + assert validate(data["results"]), f"No expected match found for query: {query}" + + +def test_search_no_results(admin_session): + search_url = f"{DOJO_URL}/pwncollege_api/v1/search" + query = "qwertyuiopasdfgh" # something unlikely to match anything + + response = admin_session.get(search_url, params={"q": query}) + assert response.status_code == 200 + data = response.json() + + assert data["success"] + assert not data["results"]["dojos"], "Expected no dojo matches" + assert not data["results"]["modules"], "Expected no module matches" + assert not data["results"]["challenges"], "Expected no challenge matches" \ No newline at end of file diff --git a/test/test_ssh.py b/test/test_ssh.py new file mode 100644 index 000000000..e2ef8845f --- /dev/null +++ b/test/test_ssh.py @@ -0,0 +1,218 @@ +import pytest +import subprocess +import time +import tempfile +import os + +from utils import DOJO_URL, login, dojo_run, workspace_run, start_challenge + + +def add_ssh_key(session, ssh_key): + response = session.post( + f"{DOJO_URL}/pwncollege_api/v1/ssh_key", + json={"ssh_key": ssh_key} + ) + return response + + +def delete_ssh_key(session, ssh_key): + key_parts = ssh_key.split() + normalized_key = f"{key_parts[0]} {key_parts[1]}" + response = session.delete( + f"{DOJO_URL}/pwncollege_api/v1/ssh_key", + json={"ssh_key": normalized_key} + ) + return response + + +def verify_ssh_access(private_key_file, should_work=True): + result = ssh_command(private_key_file, "whoami") + if should_work: + assert result.returncode == 0 + assert "hacker" in result.stdout + else: + assert result.returncode != 0 + return result + + + + +@pytest.fixture +def temp_ssh_keys(): + with tempfile.TemporaryDirectory() as tmpdir: + keys = {} + + rsa_key_path = os.path.join(tmpdir, 'test_rsa') + subprocess.run([ + 'ssh-keygen', '-t', 'rsa', '-b', '2048', '-f', rsa_key_path, '-N', '' + ], check=True, capture_output=True) + + with open(f'{rsa_key_path}.pub', 'r') as f: + rsa_public = f.read().strip() + + keys['rsa'] = {'private_file': rsa_key_path, 'public': rsa_public} + + ed25519_key_path = os.path.join(tmpdir, 'test_ed25519') + subprocess.run([ + 'ssh-keygen', '-t', 'ed25519', '-f', ed25519_key_path, '-N', '' + ], check=True, capture_output=True) + + with open(f'{ed25519_key_path}.pub', 'r') as f: + ed25519_public = f.read().strip() + + keys['ed25519'] = {'private_file': ed25519_key_path, 'public': ed25519_public} + + yield keys + +def ssh_command(private_key_file, command="echo 'SSH test successful'"): + ssh_host = os.getenv('DOJO_SSH_HOST', 'localhost') + ssh_port = int(os.getenv('DOJO_SSH_PORT', '22')) + + ssh_cmd = [ + 'ssh', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'PasswordAuthentication=no', + '-o', 'ConnectTimeout=10', + '-i', private_key_file, + '-p', str(ssh_port), + f'hacker@{ssh_host}', + command + ] + + result = subprocess.run( + ssh_cmd, + capture_output=True, + text=True, + timeout=30 + ) + return result + +def test_add_single_ssh_key(random_user, temp_ssh_keys, example_dojo): + user_id, session = random_user + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + assert response.json()["success"] is True + + start_challenge("example", "hello", "apple", session=session, wait=5) + verify_ssh_access(temp_ssh_keys['rsa']['private_file']) + +def test_add_multiple_ssh_keys(random_user, temp_ssh_keys, example_dojo): + user_id, session = random_user + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + + response = add_ssh_key(session, temp_ssh_keys['ed25519']['public']) + assert response.status_code == 200 + + start_challenge("example", "hello", "apple", session=session, wait=5) + + verify_ssh_access(temp_ssh_keys['rsa']['private_file']) + verify_ssh_access(temp_ssh_keys['ed25519']['private_file']) + +def test_delete_ssh_key(random_user, temp_ssh_keys, example_dojo): + user_id, session = random_user + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + + start_challenge("example", "hello", "apple", session=session, wait=5) + verify_ssh_access(temp_ssh_keys['rsa']['private_file']) + + response = delete_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + assert response.json()["success"] is True + + time.sleep(2) + verify_ssh_access(temp_ssh_keys['rsa']['private_file'], should_work=False) + +def test_change_ssh_key(random_user, temp_ssh_keys, example_dojo): + user_id, session = random_user + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + + start_challenge("example", "hello", "apple", session=session, wait=5) + verify_ssh_access(temp_ssh_keys['rsa']['private_file']) + + response = delete_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + + response = add_ssh_key(session, temp_ssh_keys['ed25519']['public']) + assert response.status_code == 200 + + time.sleep(2) + + verify_ssh_access(temp_ssh_keys['rsa']['private_file'], should_work=False) + verify_ssh_access(temp_ssh_keys['ed25519']['private_file']) + +def test_add_invalid_ssh_key(random_user): + user_id, session = random_user + + invalid_keys = [ + "not a valid ssh key", + "ssh-rsa", + "ssh-rsa AAAAB3NzaC1yc2EA", + "ssh-dss AAAAB3NzaC1kc3MA", + "ssh-rsa AAAAB3NzaC1yc2EA!!!", + ] + + for invalid_key in invalid_keys: + response = add_ssh_key(session, invalid_key) + assert response.status_code == 400 + assert response.json().get("success") is False + assert "Invalid SSH Key" in response.json().get("error", "") + +def test_add_duplicate_ssh_key(random_user, temp_ssh_keys): + user_id, session = random_user + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 400 + assert "already in use" in response.json().get("error", "") + +def test_delete_nonexistent_ssh_key(random_user, temp_ssh_keys): + user_id, session = random_user + + response = delete_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 400 + assert "does not exist" in response.json().get("error", "") + +def test_ssh_command_execution(random_user, temp_ssh_keys, example_dojo): + user_id, session = random_user + + response = add_ssh_key(session, temp_ssh_keys['rsa']['public']) + assert response.status_code == 200 + + start_challenge("example", "hello", "apple", session=session, wait=5) + + commands = [ + ("whoami", "hacker"), + ("pwd", "/home/hacker"), + ("id -un", "hacker"), + ("ls /", "bin"), + ] + + for cmd, expected in commands: + result = ssh_command(temp_ssh_keys['rsa']['private_file'], cmd) + if result.returncode != 0: + print(f"Command '{cmd}' failed with code {result.returncode}") + print(f"stdout: {result.stdout}") + print(f"stderr: {result.stderr}") + assert result.returncode == 0 + if expected not in result.stdout: + print(f"Expected '{expected}' not found in output of '{cmd}'") + print(f"stdout: {repr(result.stdout)}") + assert expected in result.stdout + +def test_ssh_key_with_comment(random_user, temp_ssh_keys): + user_id, session = random_user + + key_with_comment = f"{temp_ssh_keys['rsa']['public']} test@example.com" + + response = add_ssh_key(session, key_with_comment) + assert response.status_code == 200 diff --git a/test/test_surveys.py b/test/test_surveys.py new file mode 100644 index 000000000..839391f87 --- /dev/null +++ b/test/test_surveys.py @@ -0,0 +1,40 @@ +import pytest + +from utils import DOJO_URL + + +def get_challenge_survey(dojo, module, challenge, session): + response = session.get(f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/{module}/{challenge}/surveys") + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["success"], "Expected to recieve valid survey" + return response.json() + + +def post_survey_response(dojo, module, challenge, survey_response, session): + response = session.post( + f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/{module}/{challenge}/surveys", + json={"response": survey_response} + ) + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["success"], "Expected to successfully submit survey" + + +def test_surveys(surveys_dojo, random_user): + uid, session = random_user + assert session.get(f"{DOJO_URL}/dojo/{surveys_dojo}/join/").status_code == 200 + + challenge_level_survey = get_challenge_survey(surveys_dojo, "surveys-module-1", "challenge-level", session=session) + module_level_survey = get_challenge_survey(surveys_dojo, "surveys-module-1", "module-level", session=session) + dojo_level_survey = get_challenge_survey(surveys_dojo, "surveys-module-2", "dojo-level", session=session) + + assert challenge_level_survey["prompt"] == "Challenge-level prompt", "Challenge-level survey prompt is wrong/missing" + assert module_level_survey["prompt"] == "Module-level prompt", "Module-level survey prompt is wrong/missing" + assert dojo_level_survey["prompt"] == "Dojo-level prompt", "Dojo-level survey prompt is wrong/missing" + + assert challenge_level_survey["data"] == "
challenge
", "Challenge-level survey data is wrong/missing" + assert module_level_survey["data"] == "
module
", "Module-level survey data is wrong/missing" + assert dojo_level_survey["data"] == "
dojo
", "Dojo-level survey data is wrong/missing" + + post_survey_response(surveys_dojo, "surveys-module-1", "challenge-level", "Test response", session=session) + post_survey_response(surveys_dojo, "surveys-module-1", "module-level", "up", session=session) + post_survey_response(surveys_dojo, "surveys-module-2", "dojo-level", 1, session=session) \ No newline at end of file diff --git a/test/test_welcome.py b/test/test_welcome.py index de771224a..577be5bda 100644 --- a/test/test_welcome.py +++ b/test/test_welcome.py @@ -54,7 +54,6 @@ def wait_for_selector(selector): browser.close() browser.switch_to.window(module_window) - @contextlib.contextmanager def desktop_terminal(browser, user_id): module_window = browser.current_window_handle @@ -73,6 +72,30 @@ def desktop_terminal(browser, user_id): browser.switch_to.window(module_window) +@contextlib.contextmanager +def ttyd_terminal(browser): + module_window = browser.current_window_handle + + browser.switch_to.new_window("tab") + browser.get(f"{DOJO_URL}/workspace/terminal") + + wait = WebDriverWait(browser, 30) + workspace_iframe = wait.until(EC.presence_of_element_located((By.ID, "workspace_iframe"))) + browser.switch_to.frame(workspace_iframe) + + # Wait for ttyd to be ready and find the terminal input + time.sleep(3) + # ttyd uses body as the input element + body = browser.find_element("tag name", "body") + body.click() # Focus the terminal + time.sleep(1) + + yield body + + browser.close() + browser.switch_to.window(module_window) + + # Expands the accordion entry of the challenge def challenge_expand(browser, idx): browser.refresh() @@ -80,22 +103,48 @@ def challenge_expand(browser, idx): time.sleep(0.5) -def challenge_start(browser, idx, practice=False): - challenge_expand(browser, idx) +def challenge_start(browser, idx, practice=False, first=True): + if first: + challenge_expand(browser, idx) + body = browser.find_element("id", f"challenges-body-{idx}") - body.find_element("id", "challenge-practice" if practice else "challenge-start").click() - while "started" not in body.find_element("id", "result-message").text: - time.sleep(0.5) + restore = browser.current_window_handle + + if first: + body.find_element("id", "challenge-start").click() + while "started" not in body.find_element("id", "result-message").text: + time.sleep(0.5) + time.sleep(1) + + browser.switch_to.frame(body.find_element("id", "workspace-iframe")) + + if practice: + browser.find_element("id", "start-privileged").click() + while "disabled" in browser.find_element("id", "start-privileged").get_attribute("class"): + time.sleep(0.5) + elif not first: + browser.find_element("id", "start-unprivileged").click() + while "disabled" in browser.find_element("id", "start-unprivileged").get_attribute("class"): + time.sleep(0.5) + time.sleep(1) + browser.switch_to.window(restore) + def challenge_submit(browser, idx, flag): - challenge_expand(browser, idx) body = browser.find_element("id", f"challenges-body-{idx}") - body.find_element("id", "challenge-input").send_keys(flag) - body.find_element("id", "challenge-submit").click() - while "Correct" not in body.find_element("id", "result-message").text: + restore = browser.current_window_handle + + browser.switch_to.frame(body.find_element("id", "workspace-iframe")) + browser.find_element("id", "flag-input").send_keys(flag) + + counter = 0 + while not "orrect" in browser.find_element("id", "flag-input").get_attribute("placeholder") and counter < 20: time.sleep(0.5) + counter = counter + 1 + assert counter != 20 + browser.switch_to.window(restore) # Gets the accordion entry index def challenge_idx(browser, name): @@ -132,21 +181,34 @@ def test_welcome_vscode(random_user_browser, welcome_dojo): browser.close() -def test_welcome_practice(random_user_browser, welcome_dojo): +def test_welcome_ttyd(random_user_browser, welcome_dojo): + random_id, _, browser = random_user_browser + browser.get(f"{DOJO_URL}/welcome/welcome") + idx = challenge_idx(browser, "The Flag File") + + challenge_start(browser, idx) + with ttyd_terminal(browser) as terminal: + terminal.send_keys("/challenge/solve; cat /flag | tee /tmp/out\n") + time.sleep(5) + flag = workspace_run("tail -n1 /tmp/out", user=random_id).stdout.split()[-1] + challenge_submit(browser, idx, flag) + browser.close() + + +def skip_test_welcome_practice(random_user_browser, welcome_dojo): random_id, _, browser = random_user_browser browser.get(f"{DOJO_URL}/welcome/welcome") idx = challenge_idx(browser, "Using Practice Mode") challenge_start(browser, idx, practice=True) - with vscode_terminal(browser) as vs: - vs.send_keys("sudo chmod 644 /challenge/secret\n") - vs.send_keys("cp /challenge/secret /home/hacker/\n") + with desktop_terminal(browser, random_id) as vs: + vs.send_keys("sudo cat /challenge/secret >/home/hacker/secret 2>&1\n") time.sleep(1) - challenge_start(browser, idx, practice=False) - with vscode_terminal(browser) as vs: + challenge_start(browser, idx, practice=False, first=False) + with desktop_terminal(browser, random_id) as vs: vs.send_keys("/challenge/solve < secret | tee /tmp/out\n") - time.sleep(5) - flag = workspace_run("tail -n1 /tmp/out", user=random_id).stdout.split()[-1] + time.sleep(2) + flag = workspace_run("tail -n1 /tmp/out 2>&1", user=random_id).stdout.split()[-1] challenge_submit(browser, idx, flag) browser.close() diff --git a/test/utils.py b/test/utils.py index 4bfe56bd6..dd644ae19 100644 --- a/test/utils.py +++ b/test/utils.py @@ -73,3 +73,25 @@ def workspace_run(cmd, *, user, root=False, **kwargs): args += [ "-s" ] args += [ user ] return dojo_run(*args, input=cmd, check=True, **kwargs) + + +def start_challenge(dojo, module, challenge, practice=False, *, session, as_user=None, wait=0): + start_challenge_json = dict(dojo=dojo, module=module, challenge=challenge, practice=practice) + if as_user: + start_challenge_json["as_user"] = as_user + response = session.post(f"{DOJO_URL}/pwncollege_api/v1/docker", json=start_challenge_json) + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["success"], f"Failed to start challenge: {response.json()['error']}" + + if wait > 0: + time.sleep(wait) + + +def solve_challenge(dojo, module, challenge, *, session, flag=None, user=None): + flag = flag if flag is not None else workspace_run("cat /flag", user=user, root=True).stdout.strip() + response = session.post( + f"{DOJO_URL}/pwncollege_api/v1/dojos/{dojo}/{module}/{challenge}/solve", + json={"submission": flag} + ) + assert response.status_code == 200, f"Expected status code 200, but got {response.status_code}" + assert response.json()["success"], "Expected to successfully submit flag" diff --git a/workspace/Dockerfile b/workspace/Dockerfile index f19b14aba..b5734e73c 100644 --- a/workspace/Dockerfile +++ b/workspace/Dockerfile @@ -1,7 +1,7 @@ FROM alpine RUN apk add --no-cache nix - +RUN apk add --no-cache git RUN cat >> /etc/nix/nix.conf </dev/null; do sleep 0.1; done + until ${pkgs.curl}/bin/curl -fs localhost:8080 >/dev/null; do sleep 0.1; done ''; in pkgs.stdenv.mkDerivation { diff --git a/workspace/services/desktop.nix b/workspace/services/desktop.nix index e2cce53c5..4d66406d1 100644 --- a/workspace/services/desktop.nix +++ b/workspace/services/desktop.nix @@ -49,7 +49,7 @@ let --listen 6080 until [ -e /tmp/.X11-unix/X0 ]; do sleep 0.1; done - until ${pkgs.curl}/bin/curl -s localhost:6080 >/dev/null; do sleep 0.1; done + until ${pkgs.curl}/bin/curl -fs localhost:6080 >/dev/null; do sleep 0.1; done # By default, xfce4-session invokes dbus-launch without `--config-file`, and it fails to find /etc/dbus-1/session.conf; so we manually specify the config file here. ${service}/bin/dojo-service start desktop-service/xfce4-session \ diff --git a/workspace/services/desktop/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml b/workspace/services/desktop/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml new file mode 100644 index 000000000..2535d8bde --- /dev/null +++ b/workspace/services/desktop/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/workspace/services/terminal.nix b/workspace/services/terminal.nix new file mode 100644 index 000000000..9de40905c --- /dev/null +++ b/workspace/services/terminal.nix @@ -0,0 +1,44 @@ +{ pkgs }: + +let + service = import ./service.nix { inherit pkgs; }; + + serviceScript = pkgs.writeScript "dojo-terminal" '' + #!${pkgs.bash}/bin/bash + + until [ -f /run/dojo/var/ready ]; do sleep 0.1; done + + export TERM=xterm-256color + + ${service}/bin/dojo-service start terminal-service/ttyd \ + ${pkgs.ttyd}/bin/ttyd \ + --port 7681 \ + --interface 0.0.0.0 \ + --writable \ + -t disableLeaveAlert=true \ + $SHELL --login + + until ${pkgs.curl}/bin/curl -fs localhost:7681 >/dev/null; do sleep 0.1; done + ''; + +in pkgs.stdenv.mkDerivation { + name = "terminal-service"; + buildInputs = with pkgs; [ + ttyd + bashInteractive + curl + ]; + dontUnpack = true; + + installPhase = '' + runHook preInstall + + mkdir -p $out/bin + cp ${serviceScript} $out/bin/dojo-terminal + chmod +x $out/bin/dojo-terminal + ln -s ${pkgs.ttyd}/bin/ttyd $out/bin/ttyd + ln -s ${pkgs.ttyd}/bin/ttyd $out/bin/terminal + + runHook postInstall + ''; +}