├── .gitignore
├── LICENSE
├── README.md
├── __init__.py
├── assets
├── create.html
├── create.js
├── update.html
├── update.js
├── view.html
└── view.js
├── config.json
├── container_manager.py
├── image-readme
├── 1.png
├── 2.png
├── 3.png
├── 4.png
├── demo.gif
├── http.png
├── main.png
├── manage.png
└── tcp.png
├── models.py
├── settings.json
└── 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) 2024 Phan Nhat
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 |
2 |
3 |
10 |
11 |
12 |
13 |
14 |
21 |
22 |
23 |
24 |
', methods=['GET'])
371 | @authed_only
372 | @during_ctf_time_only
373 | @require_verified_emails
374 | @ratelimit(method="GET", limit=settings["requests"]["limit"], interval=settings["requests"]["limit"])
375 | def get_connect_type(challenge_id):
376 | try:
377 | return connect_type(challenge_id)
378 | except ContainerException as err:
379 | return {"error": str(err)}, 500
380 |
381 | @containers_bp.route('/api/view_info', methods=['POST'])
382 | @authed_only
383 | @during_ctf_time_only
384 | @require_verified_emails
385 | @ratelimit(method="POST", limit=settings["requests"]["limit"], interval=settings["requests"]["limit"])
386 | def route_view_info():
387 | user = get_current_user()
388 |
389 | # Validate the request
390 | if request.json is None:
391 | return {"error": "Invalid request"}, 400
392 |
393 | if request.json.get("chal_id", None) is None:
394 | return {"error": "No chal_id specified"}, 400
395 |
396 | if user is None:
397 | return {"error": "User not found"}, 400
398 | if user.team is None and is_team_mode() is True:
399 | return {"error": "User not a member of a team"}, 400
400 |
401 | try:
402 | if is_team_mode() is True:
403 | return view_container_info(request.json.get("chal_id"), user.team.id, True)
404 | elif is_team_mode() is False:
405 | return view_container_info(request.json.get("chal_id"), user.id, False)
406 | except ContainerException as err:
407 | return {"error": str(err)}, 500
408 |
409 | @containers_bp.route('/api/request', methods=['POST'])
410 | @authed_only
411 | @during_ctf_time_only
412 | @require_verified_emails
413 | @ratelimit(method="POST", limit=settings["requests"]["limit"], interval=settings["requests"]["limit"])
414 | def route_request_container():
415 | user = get_current_user()
416 |
417 | # Validate the request
418 | if request.json is None:
419 | return {"error": "Invalid request"}, 400
420 |
421 | if request.json.get("chal_id", None) is None:
422 | return {"error": "No chal_id specified"}, 400
423 |
424 | if user is None:
425 | return {"error": "User not found"}, 400
426 | if user.team is None and is_team_mode() is True:
427 | return {"error": "User not a member of a team"}, 400
428 |
429 | try:
430 | if is_team_mode() is True:
431 | return create_container(request.json.get("chal_id"), user.team.id, user.id,True)
432 | elif is_team_mode() is False:
433 | return create_container(request.json.get("chal_id"), user.id, user.id, False)
434 | except ContainerException as err:
435 | return {"error": str(err)}, 500
436 |
437 | @containers_bp.route('/api/renew', methods=['POST'])
438 | @authed_only
439 | @during_ctf_time_only
440 | @require_verified_emails
441 | @ratelimit(method="POST", limit=settings["requests"]["limit"], interval=settings["requests"]["limit"])
442 | def route_renew_container():
443 | user = get_current_user()
444 |
445 | # Validate the request
446 | if request.json is None:
447 | return {"error": "Invalid request"}, 400
448 |
449 | if request.json.get("chal_id", None) is None:
450 | return {"error": "No chal_id specified"}, 400
451 |
452 | if user is None:
453 | return {"error": "User not found"}, 400
454 | if user.team is None and is_team_mode() is True:
455 | return {"error": "User not a member of a team"}, 400
456 |
457 | try:
458 | if is_team_mode() is True:
459 | return renew_container(request.json.get("chal_id"), user.team.id, True)
460 | elif is_team_mode() is False:
461 | return renew_container(request.json.get("chal_id"), user.id, False)
462 | except ContainerException as err:
463 | return {"error": str(err)}, 500
464 |
465 | user = get_current_user()
466 |
467 | # Validate the request
468 | if request.json is None:
469 | return {"error": "Invalid request"}, 400
470 |
471 | if request.json.get("chal_id", None) is None:
472 | return {"error": "No chal_id specified"}, 400
473 |
474 | if user is None:
475 | return {"error": "User not found"}, 400
476 | if user.team is None and is_team_mode() is True:
477 | return {"error": "User not a member of a team"}, 400
478 |
479 | if is_team_mode() is True:
480 | running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by(
481 | challenge_id=request.json.get("chal_id"), team_id=user.team.id).first()
482 |
483 | if running_container:
484 | kill_container(running_container.container_id)
485 |
486 | return create_container(request.json.get("chal_id"), user.team.id, user.id, True)
487 | elif is_team_mode() is False:
488 | running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by(
489 | challenge_id=request.json.get("chal_id"), team_id=user.id).first()
490 |
491 | if running_container:
492 | kill_container(running_container.container_id)
493 |
494 | return create_container(request.json.get("chal_id"), user.id, None, False)
495 |
496 | @containers_bp.route('/api/stop', methods=['POST'])
497 | @authed_only
498 | @during_ctf_time_only
499 | @require_verified_emails
500 | @ratelimit(method="POST", limit=settings["requests"]["limit"], interval=settings["requests"]["limit"])
501 | def route_stop_container():
502 | user = get_current_user()
503 |
504 | # Validate the request
505 | if request.json is None:
506 | return {"error": "Invalid request"}, 400
507 |
508 | if request.json.get("chal_id", None) is None:
509 | return {"error": "No chal_id specified"}, 400
510 |
511 | if user is None:
512 | return {"error": "User not found"}, 400
513 | if user.team is None and is_team_mode() is True:
514 | return {"error": "User not a member of a team"}, 400
515 |
516 | if is_team_mode() is True:
517 | running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by(
518 | challenge_id=request.json.get("chal_id"), team_id=user.team.id).first()
519 |
520 | if running_container:
521 | return kill_container(running_container.container_id)
522 |
523 | return {"error": "No container found"}, 400
524 | elif is_team_mode() is False:
525 | running_container: ContainerInfoModel = ContainerInfoModel.query.filter_by(
526 | challenge_id=request.json.get("chal_id"), user_id=user.id).first()
527 |
528 | if running_container:
529 | return kill_container(running_container.container_id)
530 |
531 | return {"error": "No container found"}, 400
532 |
533 |
534 | @containers_bp.route('/api/kill', methods=['POST'])
535 | @admins_only
536 | def route_kill_container():
537 | if request.json is None:
538 | return {"error": "Invalid request"}, 400
539 |
540 | if request.json.get("container_id", None) is None:
541 | return {"error": "No container_id specified"}, 400
542 |
543 | return kill_container(request.json.get("container_id"))
544 |
545 | @containers_bp.route('/api/purge', methods=['POST'])
546 | @admins_only
547 | def route_purge_containers():
548 | containers: "list[ContainerInfoModel]" = ContainerInfoModel.query.all()
549 | for container in containers:
550 | try:
551 | kill_container(container.container_id)
552 | except ContainerException:
553 | pass
554 | return {"success": "Purged all containers"}, 200
555 |
556 | @containers_bp.route('/api/images', methods=['GET'])
557 | @admins_only
558 | def route_get_images():
559 | try:
560 | images = container_manager.get_images()
561 | except ContainerException as err:
562 | return {"error": str(err)}
563 |
564 | return {"images": images}
565 |
566 | @containers_bp.route('/api/settings/update', methods=['POST'])
567 | @admins_only
568 | def route_update_settings():
569 | if request.form.get("docker_base_url") is None:
570 | return {"error": "Invalid request"}, 400
571 |
572 | if request.form.get("docker_hostname") is None:
573 | return {"error": "Invalid request"}, 400
574 |
575 | if request.form.get("container_expiration") is None:
576 | return {"error": "Invalid request"}, 400
577 |
578 | if request.form.get("container_maxmemory") is None:
579 | return {"error": "Invalid request"}, 400
580 |
581 | if request.form.get("container_maxcpu") is None:
582 | return {"error": "Invalid request"}, 400
583 |
584 | docker_base_url = ContainerSettingsModel.query.filter_by(
585 | key="docker_base_url").first()
586 |
587 | docker_hostname = ContainerSettingsModel.query.filter_by(
588 | key="docker_hostname").first()
589 |
590 | container_expiration = ContainerSettingsModel.query.filter_by(
591 | key="container_expiration").first()
592 |
593 | container_maxmemory = ContainerSettingsModel.query.filter_by(
594 | key="container_maxmemory").first()
595 |
596 | container_maxcpu = ContainerSettingsModel.query.filter_by(
597 | key="container_maxcpu").first()
598 |
599 | # Create or update
600 | if docker_base_url is None:
601 | # Create
602 | docker_base_url = ContainerSettingsModel(
603 | key="docker_base_url", value=request.form.get("docker_base_url"))
604 | db.session.add(docker_base_url)
605 | else:
606 | # Update
607 | docker_base_url.value = request.form.get("docker_base_url")
608 |
609 | # Create or update
610 | if docker_hostname is None:
611 | # Create
612 | docker_hostname = ContainerSettingsModel(
613 | key="docker_hostname", value=request.form.get("docker_hostname"))
614 | db.session.add(docker_hostname)
615 | else:
616 | # Update
617 | docker_hostname.value = request.form.get("docker_hostname")
618 |
619 | # Create or update
620 | if container_expiration is None:
621 | # Create
622 | container_expiration = ContainerSettingsModel(
623 | key="container_expiration", value=request.form.get("container_expiration"))
624 | db.session.add(container_expiration)
625 | else:
626 | # Update
627 | container_expiration.value = request.form.get(
628 | "container_expiration")
629 |
630 | # Create or update
631 | if container_maxmemory is None:
632 | # Create
633 | container_maxmemory = ContainerSettingsModel(
634 | key="container_maxmemory", value=request.form.get("container_maxmemory"))
635 | db.session.add(container_maxmemory)
636 | else:
637 | # Update
638 | container_maxmemory.value = request.form.get("container_maxmemory")
639 |
640 | # Create or update
641 | if container_maxcpu is None:
642 | # Create
643 | container_maxcpu = ContainerSettingsModel(
644 | key="container_maxcpu", value=request.form.get("container_maxcpu"))
645 | db.session.add(container_maxcpu)
646 | else:
647 | # Update
648 | container_maxcpu.value = request.form.get("container_maxcpu")
649 |
650 | db.session.commit()
651 |
652 | container_manager.settings = settings_to_dict(
653 | ContainerSettingsModel.query.all())
654 |
655 | if container_manager.settings.get("docker_base_url") is not None:
656 | try:
657 | container_manager.initialize_connection(
658 | container_manager.settings, app)
659 | except ContainerException as err:
660 | flash(str(err), "error")
661 | return redirect(url_for(".route_containers_settings"))
662 |
663 | return redirect(url_for(".route_containers_dashboard"))
664 |
665 | @containers_bp.route('/dashboard', methods=['GET'])
666 | @admins_only
667 | def route_containers_dashboard():
668 | running_containers = ContainerInfoModel.query.order_by(
669 | ContainerInfoModel.timestamp.desc()).all()
670 |
671 | connected = False
672 | try:
673 | connected = container_manager.is_connected()
674 | except ContainerException:
675 | pass
676 |
677 | for i, container in enumerate(running_containers):
678 | try:
679 | running_containers[i].is_running = container_manager.is_container_running(
680 | container.container_id)
681 | except ContainerException:
682 | running_containers[i].is_running = False
683 |
684 | return render_template('container_dashboard.html', containers=running_containers, connected=connected)
685 |
686 | @containers_bp.route('/api/running_containers', methods=['GET'])
687 | @admins_only
688 | def route_get_running_containers():
689 | running_containers = ContainerInfoModel.query.order_by(
690 | ContainerInfoModel.timestamp.desc()).all()
691 |
692 | connected = False
693 | try:
694 | connected = container_manager.is_connected()
695 | except ContainerException:
696 | pass
697 |
698 | # Create lists to store unique teams and challenges
699 | unique_teams = set()
700 | unique_challenges = set()
701 |
702 | for i, container in enumerate(running_containers):
703 | try:
704 | running_containers[i].is_running = container_manager.is_container_running(
705 | container.container_id)
706 | except ContainerException:
707 | running_containers[i].is_running = False
708 |
709 | # Add team and challenge to the unique sets
710 | if is_team_mode() is True:
711 | unique_teams.add(f"{container.team.name} [{container.team_id}]")
712 | else:
713 | unique_teams.add(f"{container.user.name} [{container.user_id}]")
714 | unique_challenges.add(f"{container.challenge.name} [{container.challenge_id}]")
715 |
716 | # Convert unique sets to lists
717 | unique_teams_list = list(unique_teams)
718 | unique_challenges_list = list(unique_challenges)
719 |
720 | # Create a list of dictionaries containing running_containers data
721 | running_containers_data = []
722 | for container in running_containers:
723 | if is_team_mode() is True:
724 | container_data = {
725 | "container_id": container.container_id,
726 | "image": container.challenge.image,
727 | "challenge": f"{container.challenge.name} [{container.challenge_id}]",
728 | "team": f"{container.team.name} [{container.team_id}]",
729 | "user": f"{container.user.name} [{container.user_id}]",
730 | "port": container.port,
731 | "created": container.timestamp,
732 | "expires": container.expires,
733 | "is_running": container.is_running
734 | }
735 | else:
736 | container_data = {
737 | "container_id": container.container_id,
738 | "image": container.challenge.image,
739 | "challenge": f"{container.challenge.name} [{container.challenge_id}]",
740 | "user": f"{container.user.name} [{container.user_id}]",
741 | "port": container.port,
742 | "created": container.timestamp,
743 | "expires": container.expires,
744 | "is_running": container.is_running
745 | }
746 | running_containers_data.append(container_data)
747 |
748 | # Create a JSON response containing running_containers_data, unique teams, and unique challenges
749 | response_data = {
750 | "containers": running_containers_data,
751 | "connected": connected,
752 | "teams": unique_teams_list,
753 | "challenges": unique_challenges_list
754 | }
755 |
756 | # Return the JSON response
757 | return json.dumps(response_data)
758 |
759 |
760 | @containers_bp.route('/settings', methods=['GET'])
761 | @admins_only
762 | def route_containers_settings():
763 | running_containers = ContainerInfoModel.query.order_by(
764 | ContainerInfoModel.timestamp.desc()).all()
765 | return render_template('container_settings.html', settings=container_manager.settings)
766 |
767 | app.register_blueprint(containers_bp)
768 |
--------------------------------------------------------------------------------
/assets/create.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/challenges/create.html" %}
2 |
3 | {% block header %}
4 |
5 | Container challenges dynamically spin up a Docker container for each team to run the challenge on.
6 |
7 | {% endblock %}
8 |
9 | {% block value %}
10 |
11 |
12 | Connection Info
13 |
14 | Use this to specify a link, hostname, or connection instructions for your challenge.
15 |
16 |
17 |
19 |
20 |
21 | Initial Value
22 |
23 | This is how many points the challenge is worth initially.
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | Decay Limit
32 |
33 | The amount of solves before the challenge reaches its minimum value
34 |
35 |
36 |
37 |
38 |
39 |
40 | Minimum Value
41 |
42 | This is the lowest that the challenge can be worth
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | Image
51 |
52 | Name of the Docker image to spin up
53 |
54 |
55 |
57 | Loading...
58 |
59 |
60 |
61 |
62 |
63 | Connect type
64 |
65 | Connect via web, tcp or ssh
66 |
67 |
68 |
70 | Choose a value...
71 | Web
72 | TCP
73 | SSH
74 |
75 |
76 |
77 |
78 |
79 | Port
80 |
81 | Internal container port that should be exposed (external port will be automatically assigned)
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 | Command
90 |
91 | Command to run in the Docker container
92 |
93 |
94 |
95 |
96 |
97 |
107 | {% endblock %}
108 |
109 | {% block type %}
110 |
111 | {% 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 | fetch(path, {
11 | method: "GET",
12 | headers: {
13 | "Accept": "application/json",
14 | "CSRF-Token": init.csrfNonce
15 | }
16 | })
17 | .then(response => {
18 | if (!response.ok) {
19 | // Handle error response
20 | return Promise.reject("Error fetching data");
21 | }
22 | return response.json();
23 | })
24 | .then(data => {
25 | if (data.error != undefined) {
26 | // Error
27 | containerImageDefault.innerHTML = data.error;
28 | } else {
29 | // Success
30 | for (var i = 0; i < data.images.length; i++) {
31 | var opt = document.createElement("option");
32 | opt.value = data.images[i];
33 | opt.innerHTML = data.images[i];
34 | containerImage.appendChild(opt);
35 | }
36 | containerImageDefault.innerHTML = "Choose an image...";
37 | containerImage.removeAttribute("disabled");
38 | }
39 | console.log(data);
40 | })
41 | .catch(error => {
42 | // Handle fetch error
43 | console.error(error);
44 | });
45 |
--------------------------------------------------------------------------------
/assets/update.html:
--------------------------------------------------------------------------------
1 | {% extends "admin/challenges/update.html" %}
2 |
3 | {% block connection_info %}
4 |
5 |
6 | Connection Info
7 |
8 | Use this to specify a link, hostname, or connection instructions for your challenge.
9 |
10 |
11 |
13 |
14 | {% endblock %}
15 |
16 | {% block value %}
17 |
18 |
19 | Current Value
20 |
21 | This is how many points the challenge is worth right now.
22 |
23 |
24 |
25 |
26 |
27 |
28 | Initial Value
29 |
30 | This is how many points the challenge was worth initially.
31 |
32 |
33 |
34 |
35 |
36 |
37 | Decay Limit
38 |
39 | The amount of solves before the challenge reaches its minimum value
40 |
41 |
42 |
43 |
44 |
45 |
46 | Minimum Value
47 |
48 | This is the lowest that the challenge can be worth
49 |
50 |
51 |
52 |
53 |
54 |
55 |
58 |
59 | Image
60 |
61 | Name of the Docker image to spin up
62 |
63 |
64 |
66 | Loading...
67 |
68 |
69 |
70 |
71 |
72 | Connect type
73 |
74 | Connect via web, tcp or ssh
75 |
76 |
77 |
79 | Choose a value...
80 | Web
81 | TCP
82 | SSH
83 |
84 |
85 |
86 | {% if challenge.ctype == "ssh" %}
87 |
88 |
89 | SSH Username
90 |
91 | Username that will be used to connect to the SSH machine [Only shown if connect type is SSH]
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 | SSH Password
100 |
101 | Password that will be used to connect to the SSH machine [Only shown if connect type is SSH]
102 |
103 |
104 |
105 |
106 | {% endif %}
107 |
108 |
109 |
110 | Port
111 |
112 | Internal container port that should be exposed (external port will be automatically assigned)
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | Command
121 |
122 | Command to run in the Docker container
123 |
124 |
125 |
126 |
127 |
128 |
138 | {% 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 | fetch(path, {
6 | method: "GET",
7 | headers: {
8 | "Accept": "application/json",
9 | "CSRF-Token": init.csrfNonce
10 | }
11 | })
12 | .then(response => response.json())
13 | .then(data => {
14 | if (data.error !== undefined) {
15 | // Error
16 | containerImageDefault.innerHTML = data.error;
17 | } else {
18 | // Success
19 | for (var i = 0; i < data.images.length; i++) {
20 | var opt = document.createElement("option");
21 | opt.value = data.images[i];
22 | opt.innerHTML = data.images[i];
23 | containerImage.appendChild(opt);
24 | }
25 | containerImageDefault.innerHTML = "Choose an image...";
26 | containerImage.removeAttribute("disabled");
27 | containerImage.value = container_image_selected;
28 | }
29 | })
30 | .catch(error => {
31 | console.error("Fetch error:", error);
32 | });
33 |
34 | var currentURL = window.location.href;
35 | var match = currentURL.match(/\/challenges\/(\d+)/);
36 |
37 | if (match && match[1]) {
38 | var challenge_id = parseInt(match[1]);
39 |
40 | var connectType = document.getElementById("connect-type");
41 | var connectTypeDefault = document.getElementById("connect-type-default");
42 |
43 | var connectTypeEndpoint = "/containers/api/get_connect_type/" + challenge_id;
44 |
45 | fetch(connectTypeEndpoint, {
46 | method: "GET",
47 | headers: {
48 | "Accept": "application/json",
49 | "CSRF-Token": init.csrfNonce
50 | }
51 | })
52 | .then(response => response.json())
53 | .then(connectTypeData => {
54 | if (connectTypeData.error !== undefined) {
55 | console.error("Error:", connectTypeData.error);
56 | } else {
57 | var connectTypeValue = connectTypeData.connect;
58 | connectTypeDefault.innerHTML = "Choose...";
59 | connectType.removeAttribute("disabled");
60 | connectType.value = connectTypeValue;
61 | }
62 | })
63 | .catch(error => {
64 | console.error("Fetch error:", error);
65 | });
66 | } else {
67 | console.error("Challenge ID not found in the URL.");
68 | }
69 |
--------------------------------------------------------------------------------
/assets/view.html:
--------------------------------------------------------------------------------
1 | {% extends "challenge.html" %}
2 |
3 | {% block connection_info %}
4 |
5 |
6 |
7 |
8 |
9 | Initiate Suffering
10 |
11 |
12 | Prolong Suffering
13 |
14 |
15 | End Suffering
16 |
17 |
18 |
19 | {% endblock %}
--------------------------------------------------------------------------------
/assets/view.js:
--------------------------------------------------------------------------------
1 | CTFd._internal.challenge.data = undefined;
2 |
3 | CTFd._internal.challenge.renderer = null;
4 |
5 | CTFd._internal.challenge.preRender = function () {};
6 |
7 | CTFd._internal.challenge.render = null;
8 |
9 | CTFd._internal.challenge.postRender = function () {};
10 |
11 | CTFd._internal.challenge.submit = function (preview) {
12 | var challenge_id = parseInt(CTFd.lib.$("#challenge-id").val());
13 | var submission = CTFd.lib.$("#challenge-input").val();
14 |
15 | let alert = resetAlert();
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 resetAlert() {
55 | let alert = document.getElementById("deployment-info");
56 | alert.innerHTML = "";
57 | alert.classList.remove("alert-danger");
58 | return alert;
59 | }
60 |
61 | function toggleChallengeCreate() {
62 | let btn = document.getElementById("create-chal");
63 | btn.classList.toggle('d-none');
64 | }
65 |
66 | function toggleChallengeUpdate() {
67 | let btn = document.getElementById("extend-chal");
68 | btn.classList.toggle('d-none');
69 |
70 | btn = document.getElementById("terminate-chal");
71 | btn.classList.toggle('d-none');
72 | }
73 |
74 | function calculateExpiry(date) {
75 | // Get the difference in minutes
76 | let difference = Math.ceil(
77 | (new Date(date * 1000) - new Date()) / 1000 / 60
78 | );;
79 | return difference;
80 | }
81 |
82 | function createChallengeLinkElement(data, parent) {
83 |
84 | var expires = document.createElement('span');
85 | expires.textContent = "Suffering ends in " + calculateExpiry(new Date(data.expires)) + " minutes.";
86 |
87 | parent.append(expires);
88 | parent.append(document.createElement('br'));
89 |
90 | if (data.connect == "tcp") {
91 | let codeElement = document.createElement('code');
92 | codeElement.textContent = 'nc ' + data.hostname + " " + data.port;
93 | parent.append(codeElement);
94 | } else if(data.connect == "ssh") {
95 | let codeElement = document.createElement('code');
96 | // In case you have to get the password from other sources
97 | if(data.ssh_password == null) {
98 | codeElement.textContent = 'ssh -o StrictHostKeyChecking=no ' + data.ssh_username + '@' + data.hostname + " -p" + data.port;
99 | } else {
100 | codeElement.textContent = 'sshpass -p' + data.ssh_password + " ssh -o StrictHostKeyChecking=no " + data.ssh_username + '@' + data.hostname + " -p" + data.port;
101 | }
102 | parent.append(codeElement);
103 | } else {
104 | let link = document.createElement('a');
105 | link.href = 'http://' + data.hostname + ":" + data.port;
106 | link.textContent = 'http://' + data.hostname + ":" + data.port;
107 | link.target = '_blank'
108 | parent.append(link);
109 | }
110 | }
111 |
112 | function view_container_info(challenge_id) {
113 | resetAlert();
114 | var path = "/containers/api/view_info";
115 |
116 | let alert = document.getElementById("deployment-info");
117 | fetch(path, {
118 | method: "POST",
119 | headers: {
120 | "Content-Type": "application/json",
121 | "Accept": "application/json",
122 | "CSRF-Token": init.csrfNonce
123 | },
124 | body: JSON.stringify({ chal_id: challenge_id })
125 | })
126 | .then(response => response.json())
127 | .then(data => {
128 | if (data.status == "Suffering hasn't begun") {
129 | alert.append(data.status);
130 | toggleChallengeCreate();
131 | } else if (data.status == "already_running") {
132 | // Success
133 | createChallengeLinkElement(data, alert);
134 | toggleChallengeUpdate();
135 | } else {
136 | resetAlert();
137 | alert.append(data.message);
138 | alert.classList.toggle('alert-danger');
139 | toggleChallengeUpdate();
140 | }
141 | })
142 | .catch(error => {
143 | console.error("Fetch error:", error);
144 | });
145 | }
146 |
147 | function container_request(challenge_id) {
148 | var path = "/containers/api/request";
149 | let alert = resetAlert();
150 |
151 | fetch(path, {
152 | method: "POST",
153 | headers: {
154 | "Content-Type": "application/json",
155 | "Accept": "application/json",
156 | "CSRF-Token": init.csrfNonce
157 | },
158 | body: JSON.stringify({ chal_id: challenge_id })
159 | })
160 | .then(response => response.json())
161 | .then(data => {
162 | if (data.error !== undefined) {
163 | // Container error
164 | alert.append(data.error);
165 | alert.classList.toggle('alert-danger');
166 | toggleChallengeCreate();
167 | } else if (data.message !== undefined) {
168 | // CTFd error
169 | alert.append(data.message);
170 | alert.classList.toggle('alert-danger');
171 | toggleChallengeCreate();
172 | } else {
173 | // Success
174 | createChallengeLinkElement(data, alert);
175 | toggleChallengeUpdate();
176 | toggleChallengeCreate();
177 | }
178 | })
179 | .catch(error => {
180 | console.error("Fetch error:", error);
181 | });
182 | }
183 |
184 | function container_renew(challenge_id) {
185 | var path = "/containers/api/renew";
186 | let alert = resetAlert();
187 |
188 | fetch(path, {
189 | method: "POST",
190 | headers: {
191 | "Content-Type": "application/json",
192 | "Accept": "application/json",
193 | "CSRF-Token": init.csrfNonce
194 | },
195 | body: JSON.stringify({ chal_id: challenge_id })
196 | })
197 | .then(response => response.json())
198 | .then(data => {
199 | if (data.error !== undefined) {
200 | // Container error
201 | alert.append(data.error);
202 | alert.classList.toggle('alert-danger');
203 | toggleChallengeCreate();
204 | } else if (data.message !== undefined) {
205 | // CTFd error
206 | alert.append(data.message);
207 | alert.classList.toggle('alert-danger');
208 | toggleChallengeCreate();
209 | } else {
210 | // Success
211 | createChallengeLinkElement(data, alert);
212 | }
213 | })
214 | .catch(error => {
215 | console.error("Fetch error:", error);
216 | });
217 | }
218 |
219 | function container_stop(challenge_id) {
220 | var path = "/containers/api/stop";
221 | let alert = resetAlert();
222 |
223 | fetch(path, {
224 | method: "POST",
225 | headers: {
226 | "Content-Type": "application/json",
227 | "Accept": "application/json",
228 | "CSRF-Token": init.csrfNonce
229 | },
230 | body: JSON.stringify({ chal_id: challenge_id })
231 | })
232 | .then(response => response.json())
233 | .then(data => {
234 | if (data.error !== undefined) {
235 | // Container error
236 | alert.append(data.error);
237 | alert.classList.toggle('alert-danger');
238 | toggleChallengeCreate();
239 | } else if (data.message !== undefined) {
240 | // CTFd error
241 | alert.append(data.message);
242 | alert.classList.toggle('alert-danger');
243 | toggleChallengeCreate();
244 | } else {
245 | // Success
246 | alert.append("You have suffered enough.");
247 | toggleChallengeCreate();
248 | toggleChallengeUpdate();
249 | }
250 | })
251 | .catch(error => {
252 | console.error("Fetch error:", error);
253 | });
254 | }
255 |
256 |
--------------------------------------------------------------------------------
/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 | import socket
12 | import random
13 |
14 | from CTFd.models import db
15 | from .models import ContainerInfoModel
16 |
17 | """ To those who will just copy instead of forking, atleast give credits to the author and change your commit messages ;) """
18 | class ContainerException(Exception):
19 | def __init__(self, *args: object) -> None:
20 | super().__init__(*args)
21 | if args:
22 | self.message = args[0]
23 | else:
24 | self.message = None
25 |
26 | def __str__(self) -> str:
27 | if self.message:
28 | return self.message
29 | else:
30 | return "Unknown Container Exception"
31 |
32 | class ContainerManager:
33 | def __init__(self, settings, app):
34 | self.settings = settings
35 | self.client = None
36 | self.app = app
37 | if settings.get("docker_base_url") is None or settings.get("docker_base_url") == "":
38 | return
39 |
40 | # Connect to the docker daemon
41 | try:
42 | self.initialize_connection(settings, app)
43 | except ContainerException:
44 | print("Docker could not initialize or connect.")
45 | return
46 |
47 | def __check_port__(self, port: int) -> bool:
48 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
49 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
50 | s.settimeout(1)
51 | try:
52 | s.bind(("0.0.0.0", port))
53 | s.close()
54 | return True
55 | except Exception as e:
56 | print(f"Error when fetching port: {e}")
57 | return False
58 |
59 | def initialize_connection(self, settings, app) -> None:
60 | self.settings = settings
61 | self.app = app
62 |
63 | # Remove any leftover expiration schedulers
64 | try:
65 | self.expiration_scheduler.shutdown()
66 | except (SchedulerNotRunningError, AttributeError):
67 | # Scheduler was never running
68 | pass
69 |
70 | if settings.get("docker_base_url") is None:
71 | self.client = None
72 | return
73 |
74 | try:
75 | self.client = docker.DockerClient(
76 | base_url=settings.get("docker_base_url"))
77 | except (docker.errors.DockerException) as e:
78 | self.client = None
79 | print(f"Error: {e}")
80 | raise ContainerException("CTFd could not connect to Docker")
81 | except TimeoutError as e:
82 | self.client = None
83 | raise ContainerException(
84 | "CTFd timed out when connecting to Docker")
85 | except paramiko.ssh_exception.NoValidConnectionsError as e:
86 | self.client = None
87 | raise ContainerException(
88 | "CTFd timed out when connecting to Docker: " + str(e))
89 | except paramiko.ssh_exception.AuthenticationException as e:
90 | self.client = None
91 | raise ContainerException(
92 | "CTFd had an authentication error when connecting to Docker: " + str(e))
93 |
94 | # Set up expiration scheduler
95 | try:
96 | self.expiration_seconds = int(
97 | settings.get("container_expiration", 0)) * 60
98 | except (ValueError, AttributeError):
99 | self.expiration_seconds = 0
100 |
101 | EXPIRATION_CHECK_INTERVAL = 5
102 |
103 | if self.expiration_seconds > 0:
104 | self.expiration_scheduler = BackgroundScheduler()
105 | self.expiration_scheduler.add_job(
106 | func=self.kill_expired_containers, args=(app,), trigger="interval", seconds=EXPIRATION_CHECK_INTERVAL)
107 | self.expiration_scheduler.start()
108 |
109 | # Shut down the scheduler when exiting the app
110 | atexit.register(lambda: self.expiration_scheduler.shutdown())
111 |
112 | # TODO: Fix this cause it doesn't work
113 | def run_command(func):
114 | def wrapper_run_command(self, *args, **kwargs):
115 | if self.client is None:
116 | try:
117 | self.__init__(self.settings, self.app)
118 | except:
119 | raise ContainerException("Docker is not connected")
120 | try:
121 | if self.client is None:
122 | raise ContainerException("Docker is not connected")
123 | if self.client.ping():
124 | return func(self, *args, **kwargs)
125 | except (paramiko.ssh_exception.SSHException, ConnectionError, requests.exceptions.ConnectionError) as e:
126 | # Try to reconnect before failing
127 | try:
128 | self.__init__(self.settings, self.app)
129 | except:
130 | pass
131 | raise ContainerException(
132 | "Docker connection was lost. Please try your request again later.")
133 | return wrapper_run_command
134 |
135 | @run_command
136 | def kill_expired_containers(self, app: Flask):
137 | with app.app_context():
138 | containers: "list[ContainerInfoModel]" = ContainerInfoModel.query.all()
139 |
140 | for container in containers:
141 | delta_seconds = container.expires - int(time.time())
142 | if delta_seconds < 0:
143 | try:
144 | self.kill_container(container.container_id)
145 | except ContainerException:
146 | print(
147 | "[Container Expiry Job] Docker is not initialized. Please check your settings.")
148 |
149 | db.session.delete(container)
150 | db.session.commit()
151 |
152 | @run_command
153 | def is_container_running(self, container_id: str) -> bool:
154 | container = self.client.containers.list(filters={"id": container_id})
155 | if len(container) == 0:
156 | return False
157 | return container[0].status == "running"
158 |
159 | @run_command
160 | def create_container(self, chal_id: str, team_id: str, user_id: str, image: str, port: int, command: str, volumes: str):
161 | kwargs = {}
162 |
163 | # Set the memory and CPU limits for the container
164 | if self.settings.get("container_maxmemory"):
165 | try:
166 | mem_limit = int(self.settings.get("container_maxmemory"))
167 | if mem_limit > 0:
168 | kwargs["mem_limit"] = f"{mem_limit}m"
169 | except ValueError:
170 | ContainerException(
171 | "Configured container memory limit must be an integer")
172 | if self.settings.get("container_maxcpu"):
173 | try:
174 | cpu_period = float(self.settings.get("container_maxcpu"))
175 | if cpu_period > 0:
176 | kwargs["cpu_quota"] = int(cpu_period * 100000)
177 | kwargs["cpu_period"] = 100000
178 | except ValueError:
179 | ContainerException(
180 | "Configured container CPU limit must be a number")
181 |
182 | if volumes is not None and volumes != "":
183 | print("Volumes:", volumes)
184 | try:
185 | volumes_dict = json.loads(volumes)
186 | kwargs["volumes"] = volumes_dict
187 | except json.decoder.JSONDecodeError:
188 | raise ContainerException("Volumes JSON string is invalid")
189 |
190 | external_port = random.randint(port, 65535)
191 | while not self.__check_port__(external_port):
192 | external_port = random.randint(port, 65535)
193 |
194 | print(f"Using {external_port} as the external port for challenge {chal_id} for team {team_id} spawned by {user_id}")
195 | try:
196 | return self.client.containers.run(
197 | image,
198 | ports={str(port): str(external_port)},
199 | command=command,
200 | detach=True,
201 | auto_remove=True,
202 | environment={"CHALLENGE_ID": chal_id, "TEAM_ID": team_id, "USER_ID": user_id},
203 | **kwargs
204 | )
205 | except docker.errors.ImageNotFound:
206 | raise ContainerException("Docker image not found")
207 |
208 | @run_command
209 | def get_container_port(self, container_id: str) -> "str|None":
210 | try:
211 | for port in list(self.client.containers.get(container_id).ports.values()):
212 | if port is not None:
213 | return port[0]["HostPort"]
214 | except (KeyError, IndexError):
215 | return None
216 |
217 | @run_command
218 | def get_images(self) -> "list[str]|None":
219 | try:
220 | images = self.client.images.list()
221 | except (KeyError, IndexError):
222 | return []
223 |
224 | images_list = []
225 | for image in images:
226 | if len(image.tags) > 0:
227 | images_list.append(image.tags[0])
228 |
229 | images_list.sort()
230 | return images_list
231 |
232 | @run_command
233 | def kill_container(self, container_id: str):
234 | try:
235 | self.client.containers.get(container_id).kill()
236 | except docker.errors.NotFound:
237 | pass
238 |
239 | def is_connected(self) -> bool:
240 | try:
241 | self.client.ping()
242 | except:
243 | return False
244 | return True
245 |
--------------------------------------------------------------------------------
/image-readme/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/1.png
--------------------------------------------------------------------------------
/image-readme/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/2.png
--------------------------------------------------------------------------------
/image-readme/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/3.png
--------------------------------------------------------------------------------
/image-readme/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/4.png
--------------------------------------------------------------------------------
/image-readme/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/demo.gif
--------------------------------------------------------------------------------
/image-readme/http.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/http.png
--------------------------------------------------------------------------------
/image-readme/main.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/main.png
--------------------------------------------------------------------------------
/image-readme/manage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/manage.png
--------------------------------------------------------------------------------
/image-readme/tcp.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/TheFlash2k/containers/b8cf27c5d8224e8e987fde7391c9ab9c48e02b7c/image-readme/tcp.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 | ctype = db.Column(db.Text, default="tcp")
18 |
19 | ssh_username = db.Column(db.Text, nullable=True)
20 | ssh_password = db.Column(db.Text, nullable=True)
21 |
22 | # Dynamic challenge properties
23 | initial = db.Column(db.Integer, default=0)
24 | minimum = db.Column(db.Integer, default=0)
25 | decay = db.Column(db.Integer, default=0)
26 |
27 | def __init__(self, *args, **kwargs):
28 | super(ContainerChallengeModel, self).__init__(**kwargs)
29 | self.value = kwargs["initial"]
30 |
31 |
32 | class ContainerInfoModel(db.Model):
33 | __mapper_args__ = {"polymorphic_identity": "container_info"}
34 | container_id = db.Column(db.String(512), primary_key=True)
35 | challenge_id = db.Column(
36 | db.Integer, db.ForeignKey("challenges.id", ondelete="CASCADE")
37 | )
38 | team_id = db.Column(
39 | db.Integer, db.ForeignKey("teams.id", ondelete="CASCADE")
40 | )
41 | user_id = db.Column(
42 | db.Integer, db.ForeignKey("users.id", ondelete="CASCADE")
43 | )
44 | port = db.Column(db.Integer)
45 | ssh_username = db.Column(db.Text, nullable=True)
46 | ssh_password = db.Column(db.Text, nullable=True)
47 | timestamp = db.Column(db.Integer)
48 | expires = db.Column(db.Integer)
49 | team = relationship("Teams", foreign_keys=[team_id])
50 | user = relationship("Users", foreign_keys=[user_id])
51 | challenge = relationship(ContainerChallengeModel,
52 | foreign_keys=[challenge_id])
53 |
54 | class ContainerSettingsModel(db.Model):
55 | __mapper_args__ = {"polymorphic_identity": "container_settings"}
56 | key = db.Column(db.String(512), primary_key=True)
57 | value = db.Column(db.Text)
58 |
--------------------------------------------------------------------------------
/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "modes" : {
3 | "USERS_MODE": "users",
4 | "TEAMS_MODE": "teams"
5 | },
6 |
7 | "plugin-info" : {
8 | "id": "container",
9 | "name": "container",
10 | "templates" : {
11 | "create": "/plugins/containers/assets/create.html",
12 | "update": "/plugins/containers/assets/update.html",
13 | "view": "/plugins/containers/assets/view.html"
14 | },
15 | "scripts": {
16 | "create": "/plugins/containers/assets/create.js",
17 | "update": "/plugins/containers/assets/update.js",
18 | "view": "/plugins/containers/assets/view.js"
19 | },
20 | "route" : "/plugins/containers/assets/"
21 | },
22 |
23 | "requests" : {
24 | "limit": 500,
25 | "interval": 10
26 | },
27 |
28 | "vars" : {
29 | "MAX_CONTAINERS_ALLOWED": 4
30 | }
31 | }
--------------------------------------------------------------------------------
/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 |
27 | {{ message }}
28 |
29 | {% endfor %}
30 | {% endif %}
31 | {% endwith %}
32 |
33 |
Purge All
34 | Containers
35 |
Settings
37 |
38 | {% if connected %}
39 |
Docker Connected
40 | {% else %}
41 |
Docker Not Connected
42 | {% endif %}
43 |
44 |
45 |
Filter
46 |
47 |
48 |
49 |
50 | All Teams/Users
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | All Challenges
59 |
60 |
61 |
62 |
63 |
64 | Apply Filters
65 |
66 |
67 |
68 |
69 |
70 | {% if containers %}
71 | {% for c in containers %}
72 |
73 |
74 | Container ID
75 |
76 | Image
77 |
78 | Challenge
79 |
80 | {% if c.team == None %}
81 | User
82 | {% else %}
83 | User
84 | Team
85 | {% endif %}
86 | Port
87 |
88 | Created
89 |
90 | Expires
91 |
92 | Running
93 |
94 | Kill
95 |
96 |
97 |
98 |
99 |
100 | {{ c.container_id[:12] }}
101 | {{ c.challenge.image }}
102 | {{ c.challenge.name }} [{{ c.challenge_id }}]
103 | {% if c.team == None %}
104 | {{ c.user.name }} [{{ c.user_id }}]
105 | {% else %}
106 | {{ c.user.name }} [{{ c.user_id }}]
107 | {{ c.team.name }} [{{ c.team_id }}]
108 | {% endif %}
109 | {{ c.port }}
110 | {{ c.timestamp|format_time }}
111 | {{ c.expires|format_time }}
112 | {% if c.is_running %}
113 | Yes
114 | {% else %}
115 | No
116 | {% endif %}
117 |
119 |
120 |
121 | {% endfor %}
122 | {% endif %}
123 |
124 |
125 |
126 | {% endblock %}
127 |
128 | {% block scripts %}
129 |
271 | {% 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 |
13 | {{ message }}
14 |
15 | {% endfor %}
16 | {% endif %}
17 | {% endwith %}
18 |
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 %}
--------------------------------------------------------------------------------