diff --git a/LICENSE b/LICENSE index ddfc653..17cf2d9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2022 Andy Smith +Copyright (c) 2024 Phan Nhat Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index fdd952f..16a0bba 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,191 @@ # CTFd Docker Containers Plugin -This CTFd plugin allows you to run ephemeral Docker containers for specific challenges. Teams can request a container to use as needed, and its lifecycle will be managed by the plugin. +
+

CTFd Docker Containers Plugin

+

+ A plugin to create containerized challenges for your CTF contest. +

+
+ +## Table of Contents +1. [Getting Started](#getting-started) + - [Prerequisites](#prerequisites) + - [Installation](#installation) +2. [Usage](#usage) + - [Using Local Docker Daemon](#using-local-docker-daemon) + - [Using Remote Docker via SSH](#using-remote-docker-via-ssh) +3. [Demo](#demo) +4. [Roadmap](#roadmap) +5. [License](#license) +6. [Contact](#contact) + +--- + +## Getting Started + +This section provides instructions for setting up the project locally. + +### Prerequisites + +To use this plugin, you should have: +- Experience hosting CTFd with Docker +- Basic knowledge of Docker +- SSH access to remote servers (if using remote Docker) + +### Installation + +1. **Clone this repository:** + ```bash + git clone https://github.com/phannhat17/CTFd-Docker-Plugin.git + ``` +2. **Rename the folder:** + ```bash + mv CTFd-Docker-Plugin containers + ``` +3. **Move the folder to the CTFd plugins directory:** + ```bash + mv containers /path/to/CTFd/plugins/ + ``` + +[Back to top](#ctfd-docker-containers-plugin) + +--- ## Usage -Place this plugin in your CTFd/plugins directory. The name of the directory MUST be "containers" (so if you cloned this repo, rename "CTFd-Docker-Plugin" to "containers"). +### Using Local Docker Daemon + +#### Case A: **CTFd Running Directly on Host:** + - Go to the plugin settings page: `/containers/settings` + - Fill in all fields except the `Base URL`. + + ![Settings Example](./image-readme/1.png) + +#### Case B: **CTFd Running via Docker:** + - Map the Docker socket into the CTFd container by modify the `docker-compose.yml` file: + ```bash + services: + ctfd: + ... + volumes: + - /var/run/docker.sock:/var/run/docker.sock + ... + ``` + - Restart CTFd + - Go to the plugin settings page: `/containers/settings` + - Fill in all fields except the `Base URL`. + +### Using Remote Docker via SSH + +For remote Docker, the CTFd host must have SSH access to the remote server. + +#### Prerequisites: +- **SSH access** from the CTFd host to the Docker server +- The remote server's fingerprint should be in the `known_hosts` file +- SSH key files (`id_rsa`) and an SSH config file should be available + +#### Case A: **CTFd Running via Docker** + +1. **Prepare SSH Config:** + ```bash + mkdir ssh_config + cp ~/.ssh/id_rsa ~/.ssh/known_hosts ~/.ssh/config ssh_config/ + ``` + +2. **Mount SSH Config into the CTFd container:** + ```yaml + services: + ctfd: + ... + volumes: + - ./ssh_config:/root/.ssh:ro + ... + ``` + +3. **Restart CTFd:** + ```bash + docker-compose down + docker-compose up -d + ``` + +#### Case B: **CTFd Running Directly on Host** + +1. **Ensure SSH Access:** + - Test the connection: + ```bash + ssh user@remote-server + ``` + +2. **Configure Docker Base URL:** + - In the CTFd plugin settings page (`/containers/settings`), set: + ``` + Base URL: ssh://user@remote-server + ``` + +3. **Restart CTFd:** + ```bash + sudo systemctl restart ctfd + ``` + +[Back to top](#ctfd-docker-containers-plugin) + +--- + +## Demo + +### Admin Dashboard +- Manage running containers +- Filter by challenge or player + +![Manage Containers](./image-readme/manage.png) + +### Challenge View + +**Web Access** | **TCP Access** +:-------------:|:-------------: +![Web](./image-readme/http.png) | ![TCP](./image-readme/tcp.png) + +### Live Demo + +![Live Demo](./image-readme/demo.gif) + +[Back to top](#ctfd-docker-containers-plugin) + +--- + +## Roadmap + +- [x] Support for user mode +- [x] Admin dashboard with team/user filtering +- [x] Compatibility with the core-beta theme +- [x] Monitor share flag +- [ ] Monitor detail on share flag +- [ ] Prevent container creation on solved challenge + +For more features and known issues, check the [open issues](https://github.com/phannhat17/CTFd-Docker-Plugin/issues). + +[Back to top](#ctfd-docker-containers-plugin) + +--- + +## License + +Distributed under the MIT License. See `LICENSE.txt` for details. + +> This plugin is an upgrade of [andyjsmith's plugin](https://github.com/andyjsmith/CTFd-Docker-Plugin) with additional features. + +If there are licensing concerns, please reach out via email (contact below). -To configure the plugin, go to the admin page, click the dropdown in the navbar for plugins, and go to the Containers page. Then you can click the settings button to configure the connection. You will need to specify some values, including the connection string to use. This can either be the local Unix socket, or an SSH connection. If using SSH, make sure the CTFd host can successfully SSH into the Docker target (i.e. set up public key pairs). The other options are described on the page. After saving, the plugin will try to connect to the Docker daemon and the status should show as an error message or as a green symbol. +[Back to top](#ctfd-docker-containers-plugin) -To create challenges, use the container challenge type and configure the options. It is set up with dynamic scoring, so if you want regular scoring, set the maximum and minimum to the same value and the decay to zero. +--- -If you need to specify advanced options like the volumes, read the [Docker SDK for Python documentation](https://docker-py.readthedocs.io/en/stable/containers.html) for the syntax, since most options are passed directly to the SDK. +## Contact -When a user clicks on a container challenge, a button labeled "Get Connection Info" appears. Clicking it shows the information below with a random port assignment. +**Phan Nhat** +- **Discord:** ftpotato +- **Email:** contact@phannhat.id.vn +- **Project Link:** [CTFd Docker Plugin](https://github.com/phannhat17/CTFd-Docker-Plugin) -![Challenge dialog](dialog.png) +[Back to top](#ctfd-docker-containers-plugin) -A note, we used hidden teams as non-school teams in PCTF 2022 so if you want them to count for decreasing the dynamic challenge points, you need to remove the `Model.hidden == False,` line from the `calculate_value` function in `__init__.py`. diff --git a/__init__.py b/__init__.py index 3c90152..4b84595 100644 --- a/__init__.py +++ b/__init__.py @@ -7,32 +7,25 @@ from flask import Blueprint, request, Flask, render_template, url_for, redirect, flash -from CTFd.models import db, Solves +from CTFd.models import db, Solves, Teams, Users from CTFd.plugins import register_plugin_assets_directory from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge -from CTFd.utils.decorators import authed_only, admins_only, during_ctf_time_only, ratelimit, require_verified_emails -from CTFd.utils.user import get_current_user from CTFd.utils.modes import get_model - -from .models import ContainerChallengeModel, ContainerInfoModel, ContainerSettingsModel +from .models import ContainerChallengeModel, ContainerInfoModel, ContainerSettingsModel, ContainerFlagModel, ContainerCheatLog from .container_manager import ContainerManager, ContainerException +from .admin_routes import admin_bp, set_container_manager as set_admin_manager +from .user_routes import containers_bp, set_container_manager as set_user_manager +from .helpers import * +from CTFd.utils.user import get_current_user +settings = json.load(open(get_settings_path())) class ContainerChallenge(BaseChallenge): - id = "container" # Unique identifier used to register challenges - name = "container" # Name of a challenge type - templates = { # Handlebars templates used for each aspect of challenge editing & viewing - "create": "/plugins/containers/assets/create.html", - "update": "/plugins/containers/assets/update.html", - "view": "/plugins/containers/assets/view.html", - } - scripts = { # Scripts that are loaded when a template is loaded - "create": "/plugins/containers/assets/create.js", - "update": "/plugins/containers/assets/update.js", - "view": "/plugins/containers/assets/view.js", - } - # Route at which files are accessible. This must be registered using register_plugin_assets_directory() - route = "/plugins/containers/assets/" + id = settings["plugin-info"]["id"] + name = settings["plugin-info"]["name"] + templates = settings["plugin-info"]["templates"] + scripts = settings["plugin-info"]["scripts"] + route = settings["plugin-info"]["base_path"] challenge_model = ContainerChallengeModel @@ -51,6 +44,7 @@ def read(cls, challenge): "image": challenge.image, "port": challenge.port, "command": challenge.command, + "connection_type": challenge.connection_type, "initial": challenge.initial, "decay": challenge.decay, "minimum": challenge.minimum, @@ -92,8 +86,8 @@ def calculate_value(cls, challenge): # It is important that this calculation takes into account floats. # Hence this file uses from __future__ import division value = ( - ((challenge.minimum - challenge.initial) / (challenge.decay ** 2)) - * (solve_count ** 2) + ((challenge.minimum - challenge.initial) / (challenge.decay**2)) + * (solve_count**2) ) + challenge.initial value = math.ceil(value) @@ -128,410 +122,89 @@ def update(cls, challenge, request): def solve(cls, user, team, challenge, request): super().solve(user, team, challenge, request) - ContainerChallenge.calculate_value(challenge) - - -def settings_to_dict(settings): - return { - setting.key: setting.value for setting in settings - } - - -def load(app: Flask): - app.db.create_all() - CHALLENGE_CLASSES["container"] = ContainerChallenge - register_plugin_assets_directory( - app, base_path="/plugins/containers/assets/" - ) - - container_settings = settings_to_dict(ContainerSettingsModel.query.all()) - container_manager = ContainerManager(container_settings, app) + cls.calculate_value(challenge) - containers_bp = Blueprint( - 'containers', __name__, template_folder='templates', static_folder='assets', url_prefix='/containers') + @classmethod + def attempt(cls, challenge, request): + # 1) Gather user/team & submitted_flag + try: + user, x_id, submitted_flag = get_xid_and_flag() + except ValueError as e: + return False, str(e) - @containers_bp.app_template_filter("format_time") - def format_time_filter(unix_seconds): - # return time.ctime(unix_seconds) - return datetime.datetime.fromtimestamp(unix_seconds, tz=datetime.datetime.now( - datetime.timezone.utc).astimezone().tzinfo).isoformat() + # 2) Get running container + container_info = None + try: + container_info = get_active_container(challenge.id, x_id) + except ValueError as e: + return False, str(e) - def kill_container(container_id): - container: ContainerInfoModel = ContainerInfoModel.query.filter_by( - container_id=container_id).first() + # 3) Check if container is actually running + from . import container_manager + if not container_manager or not container_manager.is_container_running(container_info.container_id): + return False, "Your container is not running; you cannot submit yet." + # Validate the flag belongs to the user/team try: - container_manager.kill_container(container_id) - except ContainerException: - return {"error": "Docker is not initialized. Please check your settings."} - - db.session.delete(container) + container_flag = get_container_flag(submitted_flag, user, container_manager, container_info, challenge) + except ValueError as e: + return False, str(e) # Return incorrect flag message if not cheating + # 6) Mark used & kill container => success + container_flag.used = True db.session.commit() - return {"success": "Container killed"} - def renew_container(chal_id, team_id): - # Get the requested challenge - challenge = ContainerChallenge.challenge_model.query.filter_by( - id=chal_id).first() + # **If the challenge is static, delete both flag and container records** + if challenge.flag_mode == "static": + db.session.delete(container_flag) + db.session.commit() + + # **If the challenge is random, keep the flag but delete only the container info** + if challenge.flag_mode == "random": + db.session.query(ContainerFlagModel).filter_by(container_id=container_info.container_id).update({"container_id": None}) + db.session.commit() - # Make sure the challenge exists and is a container challenge - if challenge is None: - return {"error": "Challenge not found"}, 400 + # Remove container info record + container = ContainerInfoModel.query.filter_by(container_id=container_info.container_id).first() + if container: + db.session.delete(container) + db.session.commit() - running_containers = ContainerInfoModel.query.filter_by( - challenge_id=challenge.id, team_id=team_id) - running_container = running_containers.first() + # Kill the container + container_manager.kill_container(container_info.container_id) - if running_container is None: - return {"error": "Container not found, try resetting the container."} + return True, "Correct" - try: - running_container.expires = int( - time.time() + container_manager.expiration_seconds) - db.session.commit() - except ContainerException: - return {"error": "Database error occurred, please try again."} - - return {"success": "Container renewed", "expires": running_container.expires} - - def create_container(chal_id, team_id): - # Get the requested challenge - challenge = ContainerChallenge.challenge_model.query.filter_by( - id=chal_id).first() - - # Make sure the challenge exists and is a container challenge - if challenge is None: - return {"error": "Challenge not found"}, 400 - - # Check for any existing containers for the team - running_containers = ContainerInfoModel.query.filter_by( - challenge_id=challenge.id, team_id=team_id) - running_container = running_containers.first() - - # If a container is already running for the team, return it - if running_container: - # Check if Docker says the container is still running before returning it - try: - if container_manager.is_container_running( - running_container.container_id): - return json.dumps({ - "status": "already_running", - "hostname": container_manager.settings.get("docker_hostname", ""), - "port": running_container.port, - "expires": running_container.expires - }) - else: - # Container is not running, it must have died or been killed, - # remove it from the database and create a new one - running_containers.delete() - db.session.commit() - except ContainerException as err: - return {"error": str(err)}, 500 - - # TODO: Should insert before creating container, then update. That would avoid a TOCTOU issue - - # Run a new Docker container - try: - created_container = container_manager.create_container( - challenge.image, challenge.port, challenge.command, challenge.volumes) - except ContainerException as err: - return {"error": str(err)} - - # Fetch the random port Docker assigned - port = container_manager.get_container_port(created_container.id) - - # Port may be blank if the container failed to start - if port is None: - return json.dumps({ - "status": "error", - "error": "Could not get port" - }) - - expires = int(time.time() + container_manager.expiration_seconds) - - # Insert the new container into the database - new_container = ContainerInfoModel( - container_id=created_container.id, - challenge_id=challenge.id, - team_id=team_id, - port=port, - timestamp=int(time.time()), - expires=expires - ) - db.session.add(new_container) - db.session.commit() +container_manager = None # Global - return json.dumps({ - "status": "created", - "hostname": container_manager.settings.get("docker_hostname", ""), - "port": port, - "expires": expires - }) - - @containers_bp.route('/api/request', methods=['POST']) - @authed_only - @during_ctf_time_only - @require_verified_emails - @ratelimit(method="POST", limit=6, interval=60) - def route_request_container(): - user = get_current_user() - - # Validate the request - if request.json is None: - return {"error": "Invalid request"}, 400 - - if request.json.get("chal_id", None) is None: - return {"error": "No chal_id specified"}, 400 - - if user is None: - return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 +def load(app: Flask): + # Ensure database is initialized + app.db.create_all() - try: - return create_container(request.json.get("chal_id"), user.team.id) - except ContainerException as err: - return {"error": str(err)}, 500 - - @containers_bp.route('/api/renew', methods=['POST']) - @authed_only - @during_ctf_time_only - @require_verified_emails - @ratelimit(method="POST", limit=6, interval=60) - def route_renew_container(): - user = get_current_user() - - # Validate the request - if request.json is None: - return {"error": "Invalid request"}, 400 - - if request.json.get("chal_id", None) is None: - return {"error": "No chal_id specified"}, 400 - - if user is None: - return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 + # Register the challenge type + CHALLENGE_CLASSES["container"] = ContainerChallenge - try: - return renew_container(request.json.get("chal_id"), user.team.id) - except ContainerException as err: - return {"error": str(err)}, 500 - - @containers_bp.route('/api/reset', methods=['POST']) - @authed_only - @during_ctf_time_only - @require_verified_emails - @ratelimit(method="POST", limit=6, interval=60) - def route_restart_container(): - user = get_current_user() - - # Validate the request - if request.json is None: - return {"error": "Invalid request"}, 400 - - if request.json.get("chal_id", None) is None: - return {"error": "No chal_id specified"}, 400 - - if user is None: - return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 - - running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by( - challenge_id=request.json.get("chal_id"), team_id=user.team.id).first() - - if running_container: - kill_container(running_container.container_id) - - return create_container(request.json.get("chal_id"), user.team.id) - - @containers_bp.route('/api/stop', methods=['POST']) - @authed_only - @during_ctf_time_only - @require_verified_emails - @ratelimit(method="POST", limit=10, interval=60) - def route_stop_container(): - user = get_current_user() - - # Validate the request - if request.json is None: - return {"error": "Invalid request"}, 400 - - if request.json.get("chal_id", None) is None: - return {"error": "No chal_id specified"}, 400 - - if user is None: - return {"error": "User not found"}, 400 - if user.team is None: - return {"error": "User not a member of a team"}, 400 - - running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by( - challenge_id=request.json.get("chal_id"), team_id=user.team.id).first() - - if running_container: - return kill_container(running_container.container_id) - - return {"error": "No container found"}, 400 - - @containers_bp.route('/api/kill', methods=['POST']) - @admins_only - def route_kill_container(): - if request.json is None: - return {"error": "Invalid request"}, 400 - - if request.json.get("container_id", None) is None: - return {"error": "No container_id specified"}, 400 - - return kill_container(request.json.get("container_id")) - - @containers_bp.route('/api/purge', methods=['POST']) - @admins_only - def route_purge_containers(): - containers: "list[ContainerInfoModel]" = ContainerInfoModel.query.all() - for container in containers: - try: - kill_container(container.container_id) - except ContainerException: - pass - return {"success": "Purged all containers"}, 200 - - @containers_bp.route('/api/images', methods=['GET']) - @admins_only - def route_get_images(): - try: - images = container_manager.get_images() - except ContainerException as err: - return {"error": str(err)} - - return {"images": images} - - @containers_bp.route('/api/settings/update', methods=['POST']) - @admins_only - def route_update_settings(): - if request.form.get("docker_base_url") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("docker_hostname") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("container_expiration") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("container_maxmemory") is None: - return {"error": "Invalid request"}, 400 - - if request.form.get("container_maxcpu") is None: - return {"error": "Invalid request"}, 400 - - docker_base_url = ContainerSettingsModel.query.filter_by( - key="docker_base_url").first() - - docker_hostname = ContainerSettingsModel.query.filter_by( - key="docker_hostname").first() - - container_expiration = ContainerSettingsModel.query.filter_by( - key="container_expiration").first() - - container_maxmemory = ContainerSettingsModel.query.filter_by( - key="container_maxmemory").first() - - container_maxcpu = ContainerSettingsModel.query.filter_by( - key="container_maxcpu").first() - - # Create or update - if docker_base_url is None: - # Create - docker_base_url = ContainerSettingsModel( - key="docker_base_url", value=request.form.get("docker_base_url")) - db.session.add(docker_base_url) - else: - # Update - docker_base_url.value = request.form.get("docker_base_url") - - # Create or update - if docker_hostname is None: - # Create - docker_hostname = ContainerSettingsModel( - key="docker_hostname", value=request.form.get("docker_hostname")) - db.session.add(docker_hostname) - else: - # Update - docker_hostname.value = request.form.get("docker_hostname") - - # Create or update - if container_expiration is None: - # Create - container_expiration = ContainerSettingsModel( - key="container_expiration", value=request.form.get("container_expiration")) - db.session.add(container_expiration) - else: - # Update - container_expiration.value = request.form.get( - "container_expiration") - - # Create or update - if container_maxmemory is None: - # Create - container_maxmemory = ContainerSettingsModel( - key="container_maxmemory", value=request.form.get("container_maxmemory")) - db.session.add(container_maxmemory) - else: - # Update - container_maxmemory.value = request.form.get("container_maxmemory") - - # Create or update - if container_maxcpu is None: - # Create - container_maxcpu = ContainerSettingsModel( - key="container_maxcpu", value=request.form.get("container_maxcpu")) - db.session.add(container_maxcpu) - else: - # Update - container_maxcpu.value = request.form.get("container_maxcpu") + register_plugin_assets_directory( + app, base_path=settings["plugin-info"]["base_path"] + ) - db.session.commit() + global container_manager + container_settings = settings_to_dict(ContainerSettingsModel.query.all()) + container_manager = ContainerManager(container_settings, app) - container_manager.settings = settings_to_dict( - ContainerSettingsModel.query.all()) + base_bp = Blueprint( + "containers", + __name__, + template_folder=settings["blueprint"]["template_folder"], + static_folder=settings["blueprint"]["static_folder"] + ) - if container_manager.settings.get("docker_base_url") is not None: - try: - container_manager.initialize_connection( - container_manager.settings, app) - except ContainerException as err: - flash(str(err), "error") - return redirect(url_for(".route_containers_settings")) + set_admin_manager(container_manager) + set_user_manager(container_manager) - return redirect(url_for(".route_containers_dashboard")) + # Register the blueprints + app.register_blueprint(admin_bp) # Admin APIs + app.register_blueprint(containers_bp) # User APIs - @containers_bp.route('/dashboard', methods=['GET']) - @admins_only - def route_containers_dashboard(): - running_containers = ContainerInfoModel.query.order_by( - ContainerInfoModel.timestamp.desc()).all() - connected = False - try: - connected = container_manager.is_connected() - except ContainerException: - pass - - for i, container in enumerate(running_containers): - try: - running_containers[i].is_running = container_manager.is_container_running( - container.container_id) - except ContainerException: - running_containers[i].is_running = False - - return render_template('container_dashboard.html', containers=running_containers, connected=connected) - - @containers_bp.route('/settings', methods=['GET']) - @admins_only - def route_containers_settings(): - running_containers = ContainerInfoModel.query.order_by( - ContainerInfoModel.timestamp.desc()).all() - return render_template('container_settings.html', settings=container_manager.settings) - - app.register_blueprint(containers_bp) + app.register_blueprint(base_bp) diff --git a/admin_routes.py b/admin_routes.py new file mode 100644 index 0000000..6912eb3 --- /dev/null +++ b/admin_routes.py @@ -0,0 +1,242 @@ +import json +from flask import Blueprint, request, jsonify, render_template, url_for, redirect, Flask, flash +from CTFd.models import db +from .models import ContainerChallengeModel, ContainerInfoModel, ContainerSettingsModel, ContainerCheatLog +from .container_manager import ContainerManager, ContainerException +from CTFd.utils.decorators import admins_only +from .helpers import * + +admin_bp = Blueprint("container_admin", __name__, url_prefix="/containers/admin") + +container_manager = None + +def set_container_manager(manager): + global container_manager + container_manager = manager + +# Admin dashboard +@admin_bp.route("/dashboard", methods=["GET"]) +@admins_only +def route_containers_dashboard(): + connected = False + try: + connected = container_manager.is_connected() + except ContainerException: + pass + + running_containers = ContainerInfoModel.query.order_by( + ContainerInfoModel.timestamp.desc() + ).all() + + for i, container in enumerate(running_containers): + try: + running_containers[i].is_running = container_manager.is_container_running( + container.container_id + ) + except ContainerException: + running_containers[i].is_running = False + + return render_template( + "container_dashboard.html", + containers=running_containers, + connected=connected, + ) + +@admin_bp.route("/settings", methods=["GET"]) +@admins_only +def route_containers_settings(): + connected = False + try: + connected = container_manager.is_connected() + except ContainerException: + pass + + return render_template( + "container_settings.html", + settings=container_manager.settings, + connected=connected, + ) + +@admin_bp.route("/cheat", methods=["GET"]) +@admins_only +def route_containers_cheat(): + connected = False + try: + connected = container_manager.is_connected() + except ContainerException: + pass + + cheat_logs = ContainerCheatLog.query.order_by(ContainerCheatLog.timestamp.desc()).all() + + return render_template( + "container_cheat.html", + connected=connected, + cheat_logs=cheat_logs + ) + +# Admin API +@admin_bp.route("/api/settings", methods=["POST"]) +@admins_only +def route_update_settings(): + + required_fields = [ + "docker_base_url", + "docker_hostname", + "container_expiration", + "container_maxmemory", + "container_maxcpu", + "max_containers", + ] + + # Validate required fields + for field in required_fields: + if request.form.get(field) is None: + return {"error": f"{field} is required."}, 400 + + # Update settings dynamically + for key in required_fields: + value = request.form.get(key) + setting = ContainerSettingsModel.query.filter_by(key=key).first() + + if not setting: + setting = ContainerSettingsModel(key=key, value=value) + db.session.add(setting) + else: + setting.value = value + + db.session.commit() + + # Refresh container manager settings + container_manager.settings = settings_to_dict( + ContainerSettingsModel.query.all() + ) + + if container_manager.settings.get("docker_base_url") is not None: + try: + container_manager.initialize_connection(container_manager.settings, Flask) + except ContainerException as err: + flash(str(err), "error") + return redirect(url_for(".route_containers_settings")) + + return redirect(url_for(".route_containers_dashboard")) + +@admin_bp.route("/api/kill", methods=["POST"]) +@admins_only +def route_admin_kill_container(): + try: + validate_request(request.json, ["container_id"]) + return kill_container(container_manager, request.json.get("container_id")) + except ValueError as err: + return {"error": str(err)}, 400 + +@admin_bp.route("/api/purge", methods=["POST"]) +@admins_only +def route_purge_containers(): + """Bulk delete multiple containers""" + try: + validate_request(request.json, ["container_ids"]) + container_ids = request.json.get("container_ids", []) + if not container_ids: + return {"error": "No containers selected"}, 400 + + deleted_count = 0 + for container_id in container_ids: + container = ContainerInfoModel.query.filter_by(container_id=container_id).first() + if container: + try: + container_manager.kill_container(container_id) + db.session.delete(container) + deleted_count += 1 + except ContainerException: + continue + + db.session.commit() + return {"success": f"Deleted {deleted_count} container(s)"} + except ValueError as err: + return {"error": str(err)}, 400 + +@admin_bp.route("/api/images", methods=["GET"]) +@admins_only +def route_get_images(): + try: + images = container_manager.get_images() + except ContainerException as err: + return {"error": str(err)} + + return {"images": images} + +@admin_bp.route("/api/running_containers", methods=["GET"]) +@admins_only +def route_get_running_containers(): + running_containers = ContainerInfoModel.query.order_by( + ContainerInfoModel.timestamp.desc() + ).all() + + connected = False + try: + connected = container_manager.is_connected() + except ContainerException: + pass + + # Create lists to store unique teams and challenges + unique_teams = set() + unique_challenges = set() + + for i, container in enumerate(running_containers): + try: + running_containers[i].is_running = ( + container_manager.is_container_running(container.container_id) + ) + except ContainerException: + running_containers[i].is_running = False + + # Add team and challenge to the unique sets + if is_team_mode() is True: + unique_teams.add(f"{container.team.name} [{container.team_id}]") + else: + unique_teams.add(f"{container.user.name} [{container.user_id}]") + unique_challenges.add( + f"{container.challenge.name} [{container.challenge_id}]" + ) + + # Convert unique sets to lists + unique_teams_list = list(unique_teams) + unique_challenges_list = list(unique_challenges) + + # Create a list of dictionaries containing running_containers data + running_containers_data = [] + for container in running_containers: + if is_team_mode() is True: + container_data = { + "container_id": container.container_id, + "image": container.challenge.image, + "challenge": f"{container.challenge.name} [{container.challenge_id}]", + "team": f"{container.team.name} [{container.team_id}]", + "port": container.port, + "created": container.timestamp, + "expires": container.expires, + "is_running": container.is_running, + } + else: + container_data = { + "container_id": container.container_id, + "image": container.challenge.image, + "challenge": f"{container.challenge.name} [{container.challenge_id}]", + "user": f"{container.user.name} [{container.user_id}]", + "port": container.port, + "created": container.timestamp, + "expires": container.expires, + "is_running": container.is_running, + } + running_containers_data.append(container_data) + + # Create a JSON response containing running_containers_data, unique teams, and unique challenges + response_data = { + "containers": running_containers_data, + "connected": connected, + "teams": unique_teams_list, + "challenges": unique_challenges_list, + } + + # Return the JSON response + return jsonify(response_data) diff --git a/assets/create.html b/assets/create.html index 8893172..d14e35d 100644 --- a/assets/create.html +++ b/assets/create.html @@ -7,6 +7,15 @@ {% endblock %} {% block value %} +
+ + +