├── .gitignore ├── LICENSE ├── README.md ├── __init__.py ├── assets ├── create.html ├── create.js ├── update.html ├── update.js ├── view.html └── view.js ├── config.json ├── container_manager.py ├── dialog.png ├── models.py ├── requirements.txt └── templates ├── container_dashboard.html └── container_settings.html /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Andy Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CTFd Docker Containers Plugin 2 | 3 | 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. 4 | 5 | ## Usage 6 | 7 | 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"). 8 | 9 | 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. 10 | 11 | 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. 12 | 13 | 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. 14 | 15 | 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. 16 | 17 | ![Challenge dialog](dialog.png) 18 | 19 | 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`. 20 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import division 2 | 3 | import time 4 | import json 5 | import datetime 6 | import math 7 | 8 | from flask import Blueprint, request, Flask, render_template, url_for, redirect, flash 9 | 10 | from CTFd.models import db, Solves 11 | from CTFd.plugins import register_plugin_assets_directory 12 | from CTFd.plugins.challenges import CHALLENGE_CLASSES, BaseChallenge 13 | from CTFd.utils.decorators import authed_only, admins_only, during_ctf_time_only, ratelimit, require_verified_emails 14 | from CTFd.utils.user import get_current_user 15 | from CTFd.utils.modes import get_model 16 | 17 | from .models import ContainerChallengeModel, ContainerInfoModel, ContainerSettingsModel 18 | from .container_manager import ContainerManager, ContainerException 19 | 20 | 21 | class ContainerChallenge(BaseChallenge): 22 | id = "container" # Unique identifier used to register challenges 23 | name = "container" # Name of a challenge type 24 | templates = { # Handlebars templates used for each aspect of challenge editing & viewing 25 | "create": "/plugins/containers/assets/create.html", 26 | "update": "/plugins/containers/assets/update.html", 27 | "view": "/plugins/containers/assets/view.html", 28 | } 29 | scripts = { # Scripts that are loaded when a template is loaded 30 | "create": "/plugins/containers/assets/create.js", 31 | "update": "/plugins/containers/assets/update.js", 32 | "view": "/plugins/containers/assets/view.js", 33 | } 34 | # Route at which files are accessible. This must be registered using register_plugin_assets_directory() 35 | route = "/plugins/containers/assets/" 36 | 37 | challenge_model = ContainerChallengeModel 38 | 39 | @classmethod 40 | def read(cls, challenge): 41 | """ 42 | This method is in used to access the data of a challenge in a format processable by the front end. 43 | 44 | :param challenge: 45 | :return: Challenge object, data dictionary to be returned to the user 46 | """ 47 | data = { 48 | "id": challenge.id, 49 | "name": challenge.name, 50 | "value": challenge.value, 51 | "image": challenge.image, 52 | "port": challenge.port, 53 | "command": challenge.command, 54 | "initial": challenge.initial, 55 | "decay": challenge.decay, 56 | "minimum": challenge.minimum, 57 | "description": challenge.description, 58 | "connection_info": challenge.connection_info, 59 | "category": challenge.category, 60 | "state": challenge.state, 61 | "max_attempts": challenge.max_attempts, 62 | "type": challenge.type, 63 | "type_data": { 64 | "id": cls.id, 65 | "name": cls.name, 66 | "templates": cls.templates, 67 | "scripts": cls.scripts, 68 | }, 69 | } 70 | return data 71 | 72 | @classmethod 73 | def calculate_value(cls, challenge): 74 | Model = get_model() 75 | 76 | solve_count = ( 77 | Solves.query.join(Model, Solves.account_id == Model.id) 78 | .filter( 79 | Solves.challenge_id == challenge.id, 80 | Model.hidden == False, 81 | Model.banned == False, 82 | ) 83 | .count() 84 | ) 85 | 86 | # If the solve count is 0 we shouldn't manipulate the solve count to 87 | # let the math update back to normal 88 | if solve_count != 0: 89 | # We subtract -1 to allow the first solver to get max point value 90 | solve_count -= 1 91 | 92 | # It is important that this calculation takes into account floats. 93 | # Hence this file uses from __future__ import division 94 | value = ( 95 | ((challenge.minimum - challenge.initial) / (challenge.decay ** 2)) 96 | * (solve_count ** 2) 97 | ) + challenge.initial 98 | 99 | value = math.ceil(value) 100 | 101 | if value < challenge.minimum: 102 | value = challenge.minimum 103 | 104 | challenge.value = value 105 | db.session.commit() 106 | return challenge 107 | 108 | @classmethod 109 | def update(cls, challenge, request): 110 | """ 111 | This method is used to update the information associated with a challenge. This should be kept strictly to the 112 | Challenges table and any child tables. 113 | :param challenge: 114 | :param request: 115 | :return: 116 | """ 117 | data = request.form or request.get_json() 118 | 119 | for attr, value in data.items(): 120 | # We need to set these to floats so that the next operations don't operate on strings 121 | if attr in ("initial", "minimum", "decay"): 122 | value = float(value) 123 | setattr(challenge, attr, value) 124 | 125 | return ContainerChallenge.calculate_value(challenge) 126 | 127 | @classmethod 128 | def solve(cls, user, team, challenge, request): 129 | super().solve(user, team, challenge, request) 130 | 131 | ContainerChallenge.calculate_value(challenge) 132 | 133 | 134 | def settings_to_dict(settings): 135 | return { 136 | setting.key: setting.value for setting in settings 137 | } 138 | 139 | 140 | def load(app: Flask): 141 | app.db.create_all() 142 | CHALLENGE_CLASSES["container"] = ContainerChallenge 143 | register_plugin_assets_directory( 144 | app, base_path="/plugins/containers/assets/" 145 | ) 146 | 147 | container_settings = settings_to_dict(ContainerSettingsModel.query.all()) 148 | container_manager = ContainerManager(container_settings, app) 149 | 150 | containers_bp = Blueprint( 151 | 'containers', __name__, template_folder='templates', static_folder='assets', url_prefix='/containers') 152 | 153 | @containers_bp.app_template_filter("format_time") 154 | def format_time_filter(unix_seconds): 155 | # return time.ctime(unix_seconds) 156 | return datetime.datetime.fromtimestamp(unix_seconds, tz=datetime.datetime.now( 157 | datetime.timezone.utc).astimezone().tzinfo).isoformat() 158 | 159 | def kill_container(container_id): 160 | container: ContainerInfoModel = ContainerInfoModel.query.filter_by( 161 | container_id=container_id).first() 162 | 163 | try: 164 | container_manager.kill_container(container_id) 165 | except ContainerException: 166 | return {"error": "Docker is not initialized. Please check your settings."} 167 | 168 | db.session.delete(container) 169 | 170 | db.session.commit() 171 | return {"success": "Container killed"} 172 | 173 | def renew_container(chal_id, team_id): 174 | # Get the requested challenge 175 | challenge = ContainerChallenge.challenge_model.query.filter_by( 176 | id=chal_id).first() 177 | 178 | # Make sure the challenge exists and is a container challenge 179 | if challenge is None: 180 | return {"error": "Challenge not found"}, 400 181 | 182 | running_containers = ContainerInfoModel.query.filter_by( 183 | challenge_id=challenge.id, team_id=team_id) 184 | running_container = running_containers.first() 185 | 186 | if running_container is None: 187 | return {"error": "Container not found, try resetting the container."} 188 | 189 | try: 190 | running_container.expires = int( 191 | time.time() + container_manager.expiration_seconds) 192 | db.session.commit() 193 | except ContainerException: 194 | return {"error": "Database error occurred, please try again."} 195 | 196 | return {"success": "Container renewed", "expires": running_container.expires} 197 | 198 | def create_container(chal_id, team_id): 199 | # Get the requested challenge 200 | challenge = ContainerChallenge.challenge_model.query.filter_by( 201 | id=chal_id).first() 202 | 203 | # Make sure the challenge exists and is a container challenge 204 | if challenge is None: 205 | return {"error": "Challenge not found"}, 400 206 | 207 | # Check for any existing containers for the team 208 | running_containers = ContainerInfoModel.query.filter_by( 209 | challenge_id=challenge.id, team_id=team_id) 210 | running_container = running_containers.first() 211 | 212 | # If a container is already running for the team, return it 213 | if running_container: 214 | # Check if Docker says the container is still running before returning it 215 | try: 216 | if container_manager.is_container_running( 217 | running_container.container_id): 218 | return json.dumps({ 219 | "status": "already_running", 220 | "hostname": container_manager.settings.get("docker_hostname", ""), 221 | "port": running_container.port, 222 | "expires": running_container.expires 223 | }) 224 | else: 225 | # Container is not running, it must have died or been killed, 226 | # remove it from the database and create a new one 227 | running_containers.delete() 228 | db.session.commit() 229 | except ContainerException as err: 230 | return {"error": str(err)}, 500 231 | 232 | # TODO: Should insert before creating container, then update. That would avoid a TOCTOU issue 233 | 234 | # Run a new Docker container 235 | try: 236 | created_container = container_manager.create_container( 237 | challenge.image, challenge.port, challenge.command, challenge.volumes) 238 | except ContainerException as err: 239 | return {"error": str(err)} 240 | 241 | # Fetch the random port Docker assigned 242 | port = container_manager.get_container_port(created_container.id) 243 | 244 | # Port may be blank if the container failed to start 245 | if port is None: 246 | return json.dumps({ 247 | "status": "error", 248 | "error": "Could not get port" 249 | }) 250 | 251 | expires = int(time.time() + container_manager.expiration_seconds) 252 | 253 | # Insert the new container into the database 254 | new_container = ContainerInfoModel( 255 | container_id=created_container.id, 256 | challenge_id=challenge.id, 257 | team_id=team_id, 258 | port=port, 259 | timestamp=int(time.time()), 260 | expires=expires 261 | ) 262 | db.session.add(new_container) 263 | db.session.commit() 264 | 265 | return json.dumps({ 266 | "status": "created", 267 | "hostname": container_manager.settings.get("docker_hostname", ""), 268 | "port": port, 269 | "expires": expires 270 | }) 271 | 272 | @containers_bp.route('/api/request', methods=['POST']) 273 | @authed_only 274 | @during_ctf_time_only 275 | @require_verified_emails 276 | @ratelimit(method="POST", limit=6, interval=60) 277 | def route_request_container(): 278 | user = get_current_user() 279 | 280 | # Validate the request 281 | if request.json is None: 282 | return {"error": "Invalid request"}, 400 283 | 284 | if request.json.get("chal_id", None) is None: 285 | return {"error": "No chal_id specified"}, 400 286 | 287 | if user is None: 288 | return {"error": "User not found"}, 400 289 | if user.team is None: 290 | return {"error": "User not a member of a team"}, 400 291 | 292 | try: 293 | return create_container(request.json.get("chal_id"), user.team.id) 294 | except ContainerException as err: 295 | return {"error": str(err)}, 500 296 | 297 | @containers_bp.route('/api/renew', methods=['POST']) 298 | @authed_only 299 | @during_ctf_time_only 300 | @require_verified_emails 301 | @ratelimit(method="POST", limit=6, interval=60) 302 | def route_renew_container(): 303 | user = get_current_user() 304 | 305 | # Validate the request 306 | if request.json is None: 307 | return {"error": "Invalid request"}, 400 308 | 309 | if request.json.get("chal_id", None) is None: 310 | return {"error": "No chal_id specified"}, 400 311 | 312 | if user is None: 313 | return {"error": "User not found"}, 400 314 | if user.team is None: 315 | return {"error": "User not a member of a team"}, 400 316 | 317 | try: 318 | return renew_container(request.json.get("chal_id"), user.team.id) 319 | except ContainerException as err: 320 | return {"error": str(err)}, 500 321 | 322 | @containers_bp.route('/api/reset', methods=['POST']) 323 | @authed_only 324 | @during_ctf_time_only 325 | @require_verified_emails 326 | @ratelimit(method="POST", limit=6, interval=60) 327 | def route_restart_container(): 328 | user = get_current_user() 329 | 330 | # Validate the request 331 | if request.json is None: 332 | return {"error": "Invalid request"}, 400 333 | 334 | if request.json.get("chal_id", None) is None: 335 | return {"error": "No chal_id specified"}, 400 336 | 337 | if user is None: 338 | return {"error": "User not found"}, 400 339 | if user.team is None: 340 | return {"error": "User not a member of a team"}, 400 341 | 342 | running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by( 343 | challenge_id=request.json.get("chal_id"), team_id=user.team.id).first() 344 | 345 | if running_container: 346 | kill_container(running_container.container_id) 347 | 348 | return create_container(request.json.get("chal_id"), user.team.id) 349 | 350 | @containers_bp.route('/api/stop', methods=['POST']) 351 | @authed_only 352 | @during_ctf_time_only 353 | @require_verified_emails 354 | @ratelimit(method="POST", limit=10, interval=60) 355 | def route_stop_container(): 356 | user = get_current_user() 357 | 358 | # Validate the request 359 | if request.json is None: 360 | return {"error": "Invalid request"}, 400 361 | 362 | if request.json.get("chal_id", None) is None: 363 | return {"error": "No chal_id specified"}, 400 364 | 365 | if user is None: 366 | return {"error": "User not found"}, 400 367 | if user.team is None: 368 | return {"error": "User not a member of a team"}, 400 369 | 370 | running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by( 371 | challenge_id=request.json.get("chal_id"), team_id=user.team.id).first() 372 | 373 | if running_container: 374 | return kill_container(running_container.container_id) 375 | 376 | return {"error": "No container found"}, 400 377 | 378 | @containers_bp.route('/api/kill', methods=['POST']) 379 | @admins_only 380 | def route_kill_container(): 381 | if request.json is None: 382 | return {"error": "Invalid request"}, 400 383 | 384 | if request.json.get("container_id", None) is None: 385 | return {"error": "No container_id specified"}, 400 386 | 387 | return kill_container(request.json.get("container_id")) 388 | 389 | @containers_bp.route('/api/purge', methods=['POST']) 390 | @admins_only 391 | def route_purge_containers(): 392 | containers: "list[ContainerInfoModel]" = ContainerInfoModel.query.all() 393 | for container in containers: 394 | try: 395 | kill_container(container.container_id) 396 | except ContainerException: 397 | pass 398 | return {"success": "Purged all containers"}, 200 399 | 400 | @containers_bp.route('/api/images', methods=['GET']) 401 | @admins_only 402 | def route_get_images(): 403 | try: 404 | images = container_manager.get_images() 405 | except ContainerException as err: 406 | return {"error": str(err)} 407 | 408 | return {"images": images} 409 | 410 | @containers_bp.route('/api/settings/update', methods=['POST']) 411 | @admins_only 412 | def route_update_settings(): 413 | if request.form.get("docker_base_url") is None: 414 | return {"error": "Invalid request"}, 400 415 | 416 | if request.form.get("docker_hostname") is None: 417 | return {"error": "Invalid request"}, 400 418 | 419 | if request.form.get("container_expiration") is None: 420 | return {"error": "Invalid request"}, 400 421 | 422 | if request.form.get("container_maxmemory") is None: 423 | return {"error": "Invalid request"}, 400 424 | 425 | if request.form.get("container_maxcpu") is None: 426 | return {"error": "Invalid request"}, 400 427 | 428 | docker_base_url = ContainerSettingsModel.query.filter_by( 429 | key="docker_base_url").first() 430 | 431 | docker_hostname = ContainerSettingsModel.query.filter_by( 432 | key="docker_hostname").first() 433 | 434 | container_expiration = ContainerSettingsModel.query.filter_by( 435 | key="container_expiration").first() 436 | 437 | container_maxmemory = ContainerSettingsModel.query.filter_by( 438 | key="container_maxmemory").first() 439 | 440 | container_maxcpu = ContainerSettingsModel.query.filter_by( 441 | key="container_maxcpu").first() 442 | 443 | # Create or update 444 | if docker_base_url is None: 445 | # Create 446 | docker_base_url = ContainerSettingsModel( 447 | key="docker_base_url", value=request.form.get("docker_base_url")) 448 | db.session.add(docker_base_url) 449 | else: 450 | # Update 451 | docker_base_url.value = request.form.get("docker_base_url") 452 | 453 | # Create or update 454 | if docker_hostname is None: 455 | # Create 456 | docker_hostname = ContainerSettingsModel( 457 | key="docker_hostname", value=request.form.get("docker_hostname")) 458 | db.session.add(docker_hostname) 459 | else: 460 | # Update 461 | docker_hostname.value = request.form.get("docker_hostname") 462 | 463 | # Create or update 464 | if container_expiration is None: 465 | # Create 466 | container_expiration = ContainerSettingsModel( 467 | key="container_expiration", value=request.form.get("container_expiration")) 468 | db.session.add(container_expiration) 469 | else: 470 | # Update 471 | container_expiration.value = request.form.get( 472 | "container_expiration") 473 | 474 | # Create or update 475 | if container_maxmemory is None: 476 | # Create 477 | container_maxmemory = ContainerSettingsModel( 478 | key="container_maxmemory", value=request.form.get("container_maxmemory")) 479 | db.session.add(container_maxmemory) 480 | else: 481 | # Update 482 | container_maxmemory.value = request.form.get("container_maxmemory") 483 | 484 | # Create or update 485 | if container_maxcpu is None: 486 | # Create 487 | container_maxcpu = ContainerSettingsModel( 488 | key="container_maxcpu", value=request.form.get("container_maxcpu")) 489 | db.session.add(container_maxcpu) 490 | else: 491 | # Update 492 | container_maxcpu.value = request.form.get("container_maxcpu") 493 | 494 | db.session.commit() 495 | 496 | container_manager.settings = settings_to_dict( 497 | ContainerSettingsModel.query.all()) 498 | 499 | if container_manager.settings.get("docker_base_url") is not None: 500 | try: 501 | container_manager.initialize_connection( 502 | container_manager.settings, app) 503 | except ContainerException as err: 504 | flash(str(err), "error") 505 | return redirect(url_for(".route_containers_settings")) 506 | 507 | return redirect(url_for(".route_containers_dashboard")) 508 | 509 | @containers_bp.route('/dashboard', methods=['GET']) 510 | @admins_only 511 | def route_containers_dashboard(): 512 | running_containers = ContainerInfoModel.query.order_by( 513 | ContainerInfoModel.timestamp.desc()).all() 514 | 515 | connected = False 516 | try: 517 | connected = container_manager.is_connected() 518 | except ContainerException: 519 | pass 520 | 521 | for i, container in enumerate(running_containers): 522 | try: 523 | running_containers[i].is_running = container_manager.is_container_running( 524 | container.container_id) 525 | except ContainerException: 526 | running_containers[i].is_running = False 527 | 528 | return render_template('container_dashboard.html', containers=running_containers, connected=connected) 529 | 530 | @containers_bp.route('/settings', methods=['GET']) 531 | @admins_only 532 | def route_containers_settings(): 533 | running_containers = ContainerInfoModel.query.order_by( 534 | ContainerInfoModel.timestamp.desc()).all() 535 | return render_template('container_settings.html', settings=container_manager.settings) 536 | 537 | app.register_blueprint(containers_bp) 538 | -------------------------------------------------------------------------------- /assets/create.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/challenges/create.html" %} 2 | 3 | {% block header %} 4 | 7 | {% endblock %} 8 | 9 | {% block value %} 10 |
11 | 16 | 17 | 18 |
19 | 20 |
21 | 26 | 27 |
28 | 29 |
30 | 35 | 36 |
37 | 38 |
39 | 45 | 49 |
50 | 51 |
52 | 58 | 59 |
60 | 61 |
62 | 68 | 69 |
70 | 71 |
72 | 79 | 80 |
81 | {% endblock %} 82 | 83 | {% block type %} 84 | 85 | {% endblock %} -------------------------------------------------------------------------------- /assets/create.js: -------------------------------------------------------------------------------- 1 | CTFd.plugin.run((_CTFd) => { 2 | const $ = _CTFd.lib.$; 3 | const md = _CTFd.lib.markdown(); 4 | }); 5 | 6 | var containerImage = document.getElementById("container-image"); 7 | var containerImageDefault = document.getElementById("container-image-default"); 8 | var path = "/containers/api/images"; 9 | 10 | var xhr = new XMLHttpRequest(); 11 | xhr.open("GET", path, true); 12 | xhr.setRequestHeader("Accept", "application/json"); 13 | xhr.setRequestHeader("CSRF-Token", init.csrfNonce); 14 | xhr.send(); 15 | xhr.onload = function () { 16 | var data = JSON.parse(this.responseText); 17 | if (data.error != undefined) { 18 | // Error 19 | containerImageDefault.innerHTML = data.error; 20 | } else { 21 | // Success 22 | for (var i = 0; i < data.images.length; i++) { 23 | var opt = document.createElement("option"); 24 | opt.value = data.images[i]; 25 | opt.innerHTML = data.images[i]; 26 | containerImage.appendChild(opt); 27 | } 28 | containerImageDefault.innerHTML = "Choose an image..."; 29 | containerImage.removeAttribute("disabled"); 30 | } 31 | console.log(data); 32 | }; 33 | -------------------------------------------------------------------------------- /assets/update.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/challenges/update.html" %} 2 | 3 | {% block value %} 4 |
5 | 10 | 11 |
12 | 13 |
14 | 19 | 20 |
21 | 22 |
23 | 28 | 29 |
30 | 31 |
32 | 37 | 38 |
39 | 40 |
41 | 44 | 50 | 54 |
55 | 56 |
57 | 63 | 64 |
65 | 66 |
67 | 73 | 74 |
75 | 76 |
77 | 84 | 85 |
86 | {% endblock %} -------------------------------------------------------------------------------- /assets/update.js: -------------------------------------------------------------------------------- 1 | var containerImage = document.getElementById("container-image"); 2 | var containerImageDefault = document.getElementById("container-image-default"); 3 | var path = "/containers/api/images"; 4 | 5 | var xhr = new XMLHttpRequest(); 6 | xhr.open("GET", path, true); 7 | xhr.setRequestHeader("Accept", "application/json"); 8 | xhr.setRequestHeader("CSRF-Token", init.csrfNonce); 9 | xhr.send(); 10 | xhr.onload = function () { 11 | var data = JSON.parse(this.responseText); 12 | if (data.error != undefined) { 13 | // Error 14 | containerImageDefault.innerHTML = data.error; 15 | } else { 16 | // Success 17 | for (var i = 0; i < data.images.length; i++) { 18 | var opt = document.createElement("option"); 19 | opt.value = data.images[i]; 20 | opt.innerHTML = data.images[i]; 21 | containerImage.appendChild(opt); 22 | } 23 | containerImageDefault.innerHTML = "Choose an image..."; 24 | containerImage.removeAttribute("disabled"); 25 | containerImage.value = container_image_selected; 26 | } 27 | console.log(data); 28 | }; 29 | -------------------------------------------------------------------------------- /assets/view.html: -------------------------------------------------------------------------------- 1 | {% extends "challenge.html" %} 2 | 3 | {% block connection_info %} 4 |
5 |
6 | 9 | 21 | 25 | {% endblock %} -------------------------------------------------------------------------------- /assets/view.js: -------------------------------------------------------------------------------- 1 | CTFd._internal.challenge.data = undefined; 2 | 3 | CTFd._internal.challenge.renderer = CTFd.lib.markdown(); 4 | 5 | CTFd._internal.challenge.preRender = function () {}; 6 | 7 | CTFd._internal.challenge.render = function (markdown) { 8 | return CTFd._internal.challenge.renderer.render(markdown); 9 | }; 10 | 11 | CTFd._internal.challenge.postRender = function () {}; 12 | 13 | CTFd._internal.challenge.submit = function (preview) { 14 | var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val()); 15 | var submission = CTFd.lib.$("#challenge-input").val(); 16 | 17 | var body = { 18 | challenge_id: challenge_id, 19 | submission: submission, 20 | }; 21 | var params = {}; 22 | if (preview) { 23 | params["preview"] = true; 24 | } 25 | 26 | return CTFd.api 27 | .post_challenge_attempt(params, body) 28 | .then(function (response) { 29 | if (response.status === 429) { 30 | // User was ratelimited but process response 31 | return response; 32 | } 33 | if (response.status === 403) { 34 | // User is not logged in or CTF is paused. 35 | return response; 36 | } 37 | return response; 38 | }); 39 | }; 40 | 41 | function mergeQueryParams(parameters, queryParameters) { 42 | if (parameters.$queryParameters) { 43 | Object.keys(parameters.$queryParameters).forEach(function ( 44 | parameterName 45 | ) { 46 | var parameter = parameters.$queryParameters[parameterName]; 47 | queryParameters[parameterName] = parameter; 48 | }); 49 | } 50 | 51 | return queryParameters; 52 | } 53 | 54 | function container_request(challenge_id) { 55 | var path = "/containers/api/request"; 56 | var requestButton = document.getElementById("container-request-btn"); 57 | var requestResult = document.getElementById("container-request-result"); 58 | var connectionInfo = document.getElementById("container-connection-info"); 59 | var containerExpires = document.getElementById("container-expires"); 60 | var containerExpiresTime = document.getElementById( 61 | "container-expires-time" 62 | ); 63 | var requestError = document.getElementById("container-request-error"); 64 | 65 | requestButton.setAttribute("disabled", "disabled"); 66 | 67 | var xhr = new XMLHttpRequest(); 68 | xhr.open("POST", path, true); 69 | xhr.setRequestHeader("Content-Type", "application/json"); 70 | xhr.setRequestHeader("Accept", "application/json"); 71 | xhr.setRequestHeader("CSRF-Token", init.csrfNonce); 72 | xhr.send(JSON.stringify({ chal_id: challenge_id })); 73 | xhr.onload = function () { 74 | var data = JSON.parse(this.responseText); 75 | if (data.error !== undefined) { 76 | // Container error 77 | requestError.style.display = ""; 78 | requestError.firstElementChild.innerHTML = data.error; 79 | requestButton.removeAttribute("disabled"); 80 | } else if (data.message !== undefined) { 81 | // CTFd error 82 | requestError.style.display = ""; 83 | requestError.firstElementChild.innerHTML = data.message; 84 | requestButton.removeAttribute("disabled"); 85 | } else { 86 | // Success 87 | requestError.style.display = "none"; 88 | requestError.firstElementChild.innerHTML = ""; 89 | requestButton.parentNode.removeChild(requestButton); 90 | connectionInfo.innerHTML = data.hostname + ":" + data.port; 91 | containerExpires.innerHTML = Math.ceil( 92 | (new Date(data.expires * 1000) - new Date()) / 1000 / 60 93 | ); 94 | containerExpiresTime.innerHTML = new Date( 95 | data.expires * 1000 96 | ).toLocaleTimeString(); 97 | requestResult.style.display = ""; 98 | } 99 | console.log(data); 100 | }; 101 | } 102 | 103 | function container_reset(challenge_id) { 104 | var path = "/containers/api/reset"; 105 | var resetButton = document.getElementById("container-reset-btn"); 106 | var requestResult = document.getElementById("container-request-result"); 107 | var containerExpires = document.getElementById("container-expires"); 108 | var containerExpiresTime = document.getElementById( 109 | "container-expires-time" 110 | ); 111 | var connectionInfo = document.getElementById("container-connection-info"); 112 | var requestError = document.getElementById("container-request-error"); 113 | 114 | resetButton.setAttribute("disabled", "disabled"); 115 | 116 | var xhr = new XMLHttpRequest(); 117 | xhr.open("POST", path, true); 118 | xhr.setRequestHeader("Content-Type", "application/json"); 119 | xhr.setRequestHeader("Accept", "application/json"); 120 | xhr.setRequestHeader("CSRF-Token", init.csrfNonce); 121 | xhr.send(JSON.stringify({ chal_id: challenge_id })); 122 | xhr.onload = function () { 123 | var data = JSON.parse(this.responseText); 124 | if (data.error !== undefined) { 125 | // Container rrror 126 | requestError.style.display = ""; 127 | requestError.firstElementChild.innerHTML = data.error; 128 | resetButton.removeAttribute("disabled"); 129 | } else if (data.message !== undefined) { 130 | // CTFd error 131 | requestError.style.display = ""; 132 | requestError.firstElementChild.innerHTML = data.message; 133 | resetButton.removeAttribute("disabled"); 134 | } else { 135 | // Success 136 | requestError.style.display = "none"; 137 | connectionInfo.innerHTML = data.hostname + ":" + data.port; 138 | containerExpires.innerHTML = Math.ceil( 139 | (new Date(data.expires * 1000) - new Date()) / 1000 / 60 140 | ); 141 | containerExpiresTime.innerHTML = new Date( 142 | data.expires * 1000 143 | ).toLocaleTimeString(); 144 | requestResult.style.display = ""; 145 | resetButton.removeAttribute("disabled"); 146 | } 147 | console.log(data); 148 | }; 149 | } 150 | 151 | function container_renew(challenge_id) { 152 | var path = "/containers/api/renew"; 153 | var renewButton = document.getElementById("container-renew-btn"); 154 | var requestResult = document.getElementById("container-request-result"); 155 | var containerExpires = document.getElementById("container-expires"); 156 | var containerExpiresTime = document.getElementById( 157 | "container-expires-time" 158 | ); 159 | var requestError = document.getElementById("container-request-error"); 160 | 161 | renewButton.setAttribute("disabled", "disabled"); 162 | 163 | var xhr = new XMLHttpRequest(); 164 | xhr.open("POST", path, true); 165 | xhr.setRequestHeader("Content-Type", "application/json"); 166 | xhr.setRequestHeader("Accept", "application/json"); 167 | xhr.setRequestHeader("CSRF-Token", init.csrfNonce); 168 | xhr.send(JSON.stringify({ chal_id: challenge_id })); 169 | xhr.onload = function () { 170 | var data = JSON.parse(this.responseText); 171 | if (data.error !== undefined) { 172 | // Container rrror 173 | requestError.style.display = ""; 174 | requestError.firstElementChild.innerHTML = data.error; 175 | renewButton.removeAttribute("disabled"); 176 | } else if (data.message !== undefined) { 177 | // CTFd error 178 | requestError.style.display = ""; 179 | requestError.firstElementChild.innerHTML = data.message; 180 | renewButton.removeAttribute("disabled"); 181 | } else { 182 | // Success 183 | requestError.style.display = "none"; 184 | requestResult.style.display = ""; 185 | containerExpires.innerHTML = Math.ceil( 186 | (new Date(data.expires * 1000) - new Date()) / 1000 / 60 187 | ); 188 | containerExpiresTime.innerHTML = new Date( 189 | data.expires * 1000 190 | ).toLocaleTimeString(); 191 | renewButton.removeAttribute("disabled"); 192 | } 193 | console.log(data); 194 | }; 195 | } 196 | 197 | function container_stop(challenge_id) { 198 | var path = "/containers/api/stop"; 199 | var stopButton = document.getElementById("container-stop-btn"); 200 | var requestResult = document.getElementById("container-request-result"); 201 | var connectionInfo = document.getElementById("container-connection-info"); 202 | var requestError = document.getElementById("container-request-error"); 203 | 204 | stopButton.setAttribute("disabled", "disabled"); 205 | 206 | var xhr = new XMLHttpRequest(); 207 | xhr.open("POST", path, true); 208 | xhr.setRequestHeader("Content-Type", "application/json"); 209 | xhr.setRequestHeader("Accept", "application/json"); 210 | xhr.setRequestHeader("CSRF-Token", init.csrfNonce); 211 | xhr.send(JSON.stringify({ chal_id: challenge_id })); 212 | xhr.onload = function () { 213 | var data = JSON.parse(this.responseText); 214 | if (data.error !== undefined) { 215 | // Container rrror 216 | requestError.style.display = ""; 217 | requestError.firstElementChild.innerHTML = data.error; 218 | stopButton.removeAttribute("disabled"); 219 | } else if (data.message !== undefined) { 220 | // CTFd error 221 | requestError.style.display = ""; 222 | requestError.firstElementChild.innerHTML = data.message; 223 | stopButton.removeAttribute("disabled"); 224 | } else { 225 | // Success 226 | requestError.style.display = "none"; 227 | requestResult.innerHTML = 228 | "Container stopped. Reopen this challenge to start another."; 229 | } 230 | console.log(data); 231 | }; 232 | } 233 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Containers", 3 | "route": "/containers/dashboard" 4 | } 5 | -------------------------------------------------------------------------------- /container_manager.py: -------------------------------------------------------------------------------- 1 | import atexit 2 | import time 3 | import json 4 | 5 | from flask import Flask 6 | from apscheduler.schedulers.background import BackgroundScheduler 7 | from apscheduler.schedulers import SchedulerNotRunningError 8 | import docker 9 | import paramiko.ssh_exception 10 | import requests 11 | 12 | from CTFd.models import db 13 | from .models import ContainerInfoModel 14 | 15 | 16 | class ContainerException(Exception): 17 | def __init__(self, *args: object) -> None: 18 | super().__init__(*args) 19 | if args: 20 | self.message = args[0] 21 | else: 22 | self.message = None 23 | 24 | def __str__(self) -> str: 25 | if self.message: 26 | return self.message 27 | else: 28 | return "Unknown Container Exception" 29 | 30 | 31 | class ContainerManager: 32 | def __init__(self, settings, app): 33 | self.settings = settings 34 | self.client = None 35 | self.app = app 36 | if settings.get("docker_base_url") is None or settings.get("docker_base_url") == "": 37 | return 38 | 39 | # Connect to the docker daemon 40 | try: 41 | self.initialize_connection(settings, app) 42 | except ContainerException: 43 | print("Docker could not initialize or connect.") 44 | return 45 | 46 | def initialize_connection(self, settings, app) -> None: 47 | self.settings = settings 48 | self.app = app 49 | 50 | # Remove any leftover expiration schedulers 51 | try: 52 | self.expiration_scheduler.shutdown() 53 | except (SchedulerNotRunningError, AttributeError): 54 | # Scheduler was never running 55 | pass 56 | 57 | if settings.get("docker_base_url") is None: 58 | self.client = None 59 | return 60 | 61 | try: 62 | self.client = docker.DockerClient( 63 | base_url=settings.get("docker_base_url")) 64 | except (docker.errors.DockerException) as e: 65 | self.client = None 66 | raise ContainerException("CTFd could not connect to Docker") 67 | except TimeoutError as e: 68 | self.client = None 69 | raise ContainerException( 70 | "CTFd timed out when connecting to Docker") 71 | except paramiko.ssh_exception.NoValidConnectionsError as e: 72 | self.client = None 73 | raise ContainerException( 74 | "CTFd timed out when connecting to Docker: " + str(e)) 75 | except paramiko.ssh_exception.AuthenticationException as e: 76 | self.client = None 77 | raise ContainerException( 78 | "CTFd had an authentication error when connecting to Docker: " + str(e)) 79 | 80 | # Set up expiration scheduler 81 | try: 82 | self.expiration_seconds = int( 83 | settings.get("container_expiration", 0)) * 60 84 | except (ValueError, AttributeError): 85 | self.expiration_seconds = 0 86 | 87 | EXPIRATION_CHECK_INTERVAL = 5 88 | 89 | if self.expiration_seconds > 0: 90 | self.expiration_scheduler = BackgroundScheduler() 91 | self.expiration_scheduler.add_job( 92 | func=self.kill_expired_containers, args=(app,), trigger="interval", seconds=EXPIRATION_CHECK_INTERVAL) 93 | self.expiration_scheduler.start() 94 | 95 | # Shut down the scheduler when exiting the app 96 | atexit.register(lambda: self.expiration_scheduler.shutdown()) 97 | 98 | # TODO: Fix this cause it doesn't work 99 | def run_command(func): 100 | def wrapper_run_command(self, *args, **kwargs): 101 | if self.client is None: 102 | try: 103 | self.__init__(self.settings, self.app) 104 | except: 105 | raise ContainerException("Docker is not connected") 106 | try: 107 | if self.client is None: 108 | raise ContainerException("Docker is not connected") 109 | if self.client.ping(): 110 | return func(self, *args, **kwargs) 111 | except (paramiko.ssh_exception.SSHException, ConnectionError, requests.exceptions.ConnectionError) as e: 112 | # Try to reconnect before failing 113 | try: 114 | self.__init__(self.settings, self.app) 115 | except: 116 | pass 117 | raise ContainerException( 118 | "Docker connection was lost. Please try your request again later.") 119 | return wrapper_run_command 120 | 121 | @run_command 122 | def kill_expired_containers(self, app: Flask): 123 | with app.app_context(): 124 | containers: "list[ContainerInfoModel]" = ContainerInfoModel.query.all() 125 | 126 | for container in containers: 127 | delta_seconds = container.expires - int(time.time()) 128 | if delta_seconds < 0: 129 | try: 130 | self.kill_container(container.container_id) 131 | except ContainerException: 132 | print( 133 | "[Container Expiry Job] Docker is not initialized. Please check your settings.") 134 | 135 | db.session.delete(container) 136 | db.session.commit() 137 | 138 | @run_command 139 | def is_container_running(self, container_id: str) -> bool: 140 | container = self.client.containers.list(filters={"id": container_id}) 141 | if len(container) == 0: 142 | return False 143 | return container[0].status == "running" 144 | 145 | @run_command 146 | def create_container(self, image: str, port: int, command: str, volumes: str): 147 | kwargs = {} 148 | 149 | # Set the memory and CPU limits for the container 150 | if self.settings.get("container_maxmemory"): 151 | try: 152 | mem_limit = int(self.settings.get("container_maxmemory")) 153 | if mem_limit > 0: 154 | kwargs["mem_limit"] = f"{mem_limit}m" 155 | except ValueError: 156 | ContainerException( 157 | "Configured container memory limit must be an integer") 158 | if self.settings.get("container_maxcpu"): 159 | try: 160 | cpu_period = float(self.settings.get("container_maxcpu")) 161 | if cpu_period > 0: 162 | kwargs["cpu_quota"] = int(cpu_period * 100000) 163 | kwargs["cpu_period"] = 100000 164 | except ValueError: 165 | ContainerException( 166 | "Configured container CPU limit must be a number") 167 | 168 | if volumes is not None and volumes != "": 169 | print("Volumes:", volumes) 170 | try: 171 | volumes_dict = json.loads(volumes) 172 | kwargs["volumes"] = volumes_dict 173 | except json.decoder.JSONDecodeError: 174 | raise ContainerException("Volumes JSON string is invalid") 175 | 176 | try: 177 | return self.client.containers.run( 178 | image, 179 | ports={str(port): None}, 180 | command=command, 181 | detach=True, 182 | auto_remove=True, 183 | **kwargs 184 | ) 185 | except docker.errors.ImageNotFound: 186 | raise ContainerException("Docker image not found") 187 | 188 | @run_command 189 | def get_container_port(self, container_id: str) -> "str|None": 190 | try: 191 | for port in list(self.client.containers.get(container_id).ports.values()): 192 | if port is not None: 193 | return port[0]["HostPort"] 194 | except (KeyError, IndexError): 195 | return None 196 | 197 | @run_command 198 | def get_images(self) -> "list[str]|None": 199 | try: 200 | images = self.client.images.list() 201 | except (KeyError, IndexError): 202 | return [] 203 | 204 | images_list = [] 205 | for image in images: 206 | if len(image.tags) > 0: 207 | images_list.append(image.tags[0]) 208 | 209 | images_list.sort() 210 | return images_list 211 | 212 | @run_command 213 | def kill_container(self, container_id: str): 214 | try: 215 | self.client.containers.get(container_id).kill() 216 | except docker.errors.NotFound: 217 | pass 218 | 219 | def is_connected(self) -> bool: 220 | try: 221 | self.client.ping() 222 | except: 223 | return False 224 | return True 225 | -------------------------------------------------------------------------------- /dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/andyjsmith/CTFd-Docker-Plugin/6d261577e4af5d7efdf941350e1a117259353277/dialog.png -------------------------------------------------------------------------------- /models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.sql import func 2 | from sqlalchemy.orm import relationship 3 | 4 | from CTFd.models import db 5 | from CTFd.models import Challenges 6 | 7 | 8 | class ContainerChallengeModel(Challenges): 9 | __mapper_args__ = {"polymorphic_identity": "container"} 10 | id = db.Column( 11 | db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE"), primary_key=True 12 | ) 13 | image = db.Column(db.Text) 14 | port = db.Column(db.Integer) 15 | command = db.Column(db.Text, default="") 16 | volumes = db.Column(db.Text, default="") 17 | 18 | # Dynamic challenge properties 19 | initial = db.Column(db.Integer, default=0) 20 | minimum = db.Column(db.Integer, default=0) 21 | decay = db.Column(db.Integer, default=0) 22 | 23 | def __init__(self, *args, **kwargs): 24 | super(ContainerChallengeModel, self).__init__(**kwargs) 25 | self.value = kwargs["initial"] 26 | 27 | 28 | class ContainerInfoModel(db.Model): 29 | __mapper_args__ = {"polymorphic_identity": "container_info"} 30 | container_id = db.Column(db.String(512), primary_key=True) 31 | challenge_id = db.Column( 32 | db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE") 33 | ) 34 | team_id = db.Column( 35 | db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE") 36 | ) 37 | port = db.Column(db.Integer) 38 | timestamp = db.Column(db.Integer) 39 | expires = db.Column(db.Integer) 40 | team = relationship("Teams", foreign_keys=[team_id]) 41 | challenge = relationship(ContainerChallengeModel, 42 | foreign_keys=[challenge_id]) 43 | 44 | 45 | class ContainerSettingsModel(db.Model): 46 | __mapper_args__ = {"polymorphic_identity": "container_settings"} 47 | key = db.Column(db.String(512), primary_key=True) 48 | value = db.Column(db.Text) 49 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | docker 2 | paramiko 3 | apscheduler -------------------------------------------------------------------------------- /templates/container_dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | 4 | {% block content %} 5 | 6 | 16 | 17 |
18 |
19 |

Containers

20 |
21 |
22 |
23 | {% with messages = get_flashed_messages() %} 24 | {% if messages %} 25 | {% for message in messages %} 26 | 29 | {% endfor %} 30 | {% endif %} 31 | {% endwith %} 32 | 33 | 35 | Settings 37 | 38 | {% if connected %} 39 | Docker Connected 40 | {% else %} 41 | Docker Not Connected 42 | {% endif %} 43 | 44 | 45 | 46 | 47 | 49 | 51 | 53 | 55 | 57 | 59 | 61 | 63 | 65 | 66 | 67 | {% if containers %} 68 | {% for c in containers %} 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | {% if c.is_running %} 78 | 79 | {% else %} 80 | 81 | {% endif %} 82 | 84 | 85 | {% endfor %} 86 | {% endif %} 87 | 88 |
Container ID 48 | Image 50 | Challenge 52 | Team 54 | Port 56 | Created 58 | Expires 60 | Running 62 | Kill 64 |
{{ c.container_id[:12] }}{{ c.challenge.image }}{{ c.challenge.name }} [{{ c.challenge_id }}]{{ c.team.name }} [{{ c.team_id }}]{{ c.port }}{{ c.timestamp|format_time }}{{ c.expires|format_time }}YesNo
89 |
90 | 91 | {% endblock %} 92 | 93 | {% block scripts %} 94 | 141 | {% endblock %} -------------------------------------------------------------------------------- /templates/container_settings.html: -------------------------------------------------------------------------------- 1 | {% extends 'admin/base.html' %} 2 | {% block content %} 3 |
4 |
5 |

Docker Config

6 |
7 |
8 |
9 | {% with messages = get_flashed_messages() %} 10 | {% if messages %} 11 | {% for message in messages %} 12 | 15 | {% endfor %} 16 | {% endif %} 17 | {% endwith %} 18 |
19 |
20 |
22 |
23 | 26 | 29 |
30 |
31 | 34 | 36 |
37 |
38 | 41 | 43 |
44 |
45 | 48 | 50 |
51 |
52 | 55 | 57 |
58 |
59 | 62 | Cancel 63 |
64 |
65 | 66 | 67 |
68 |

Instructions

69 |

70 | The Base URL should be the local socket address of the Docker daemon, i.e. 71 | unix://var/run/docker.sock, or it can be a remote SSH address, e.g. 72 | ssh://root@example.com. In either case, sudo will not be executed. For a local socket, the user 73 | CTFd is running as should have permissions for Docker; for SSH connections, the SSH user in the Base URL should 74 | be root or have Docker permissions. 75 |

76 |
77 | {% endblock content %} 78 | {% block scripts %} 79 | 84 | {% endblock scripts %} --------------------------------------------------------------------------------