├── .devcontainer.json ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── bandit.ini ├── conf ├── checker │ ├── checkermaster.env │ └── ctf-checkermaster@.service ├── controller │ ├── controller.env │ └── ctf-controller.service ├── submission │ ├── ctf-submission@.service │ └── submission.env ├── vpnstatus │ ├── ctf-vpnstatus.env │ └── ctf-vpnstatus.service └── web │ └── prod_settings.py ├── debian ├── .gitignore ├── changelog ├── clean ├── compat ├── control ├── copyright ├── install ├── postinst ├── rules └── source │ └── format ├── docs ├── architecture.md ├── checkers │ ├── go-library.md │ ├── index.md │ └── python-library.md ├── index.md ├── installation.md ├── observability.md └── submission.md ├── examples ├── checker │ ├── example_checker.env │ ├── example_checker.py │ ├── example_checker_go │ │ ├── go.mod │ │ ├── go.sum │ │ └── main.go │ ├── example_service.py │ └── sudoers.d │ │ └── ctf-checker ├── vpnstatus │ └── sudoers.d │ │ └── ctf-vpnstatus └── web │ ├── nginx.conf │ └── uwsgi.ini ├── go └── checkerlib │ ├── go.mod │ ├── go.sum │ ├── ipc.go │ ├── lib.go │ ├── logger.go │ └── utils.go ├── mkdocs.yml ├── scripts ├── checker │ ├── ctf-checkermaster │ └── ctf-logviewer ├── controller │ └── ctf-controller ├── submission │ └── ctf-submission └── vpnstatus │ └── ctf-vpnstatus ├── setup.py ├── src ├── ctf_gameserver │ ├── __init__.py │ ├── checker │ │ ├── __init__.py │ │ ├── database.py │ │ ├── master.py │ │ ├── metrics.py │ │ └── supervisor.py │ ├── checkerlib │ │ ├── __init__.py │ │ └── lib.py │ ├── controller │ │ ├── __init__.py │ │ ├── controller.py │ │ ├── database.py │ │ └── scoring.py │ ├── lib │ │ ├── __init__.py │ │ ├── args.py │ │ ├── checkresult.py │ │ ├── daemon.py │ │ ├── database.py │ │ ├── date_time.py │ │ ├── exceptions.py │ │ ├── flag.py │ │ ├── metrics.py │ │ └── test_util.py │ ├── submission │ │ ├── __init__.py │ │ ├── database.py │ │ └── submission.py │ ├── vpnstatus │ │ ├── __init__.py │ │ ├── database.py │ │ └── status.py │ └── web │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── base_settings.py │ │ ├── context_processors.py │ │ ├── dev_settings.py │ │ ├── flatpages │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── forms.py │ │ ├── models.py │ │ ├── templates │ │ │ └── flatpage.html │ │ └── views.py │ │ ├── forms.py │ │ ├── middleware.py │ │ ├── registration │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── admin_inline.py │ │ ├── fields.py │ │ ├── forms.py │ │ ├── models.py │ │ ├── templates │ │ │ ├── confirmation_mail.txt │ │ │ ├── edit_team.html │ │ │ ├── mail_teams.html │ │ │ ├── register.html │ │ │ ├── team_downloads.html │ │ │ └── team_list.html │ │ ├── util.py │ │ └── views.py │ │ ├── scoring │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── calculations.py │ │ ├── decorators.py │ │ ├── forms.py │ │ ├── models.py │ │ ├── templates │ │ │ ├── competition_nav.html │ │ │ ├── missing_checks.html │ │ │ ├── scoreboard.html │ │ │ ├── service_history.html │ │ │ └── service_status.html │ │ ├── templatetags │ │ │ ├── __init__.py │ │ │ └── status_css_class.py │ │ └── views.py │ │ ├── static │ │ ├── missing_checks.js │ │ ├── progress_spinner.gif │ │ ├── robots.txt │ │ ├── scoreboard.js │ │ ├── service_history.js │ │ ├── service_status.js │ │ ├── service_util.js │ │ └── style.css │ │ ├── templates │ │ ├── 400.html │ │ ├── 403.html │ │ ├── 404.html │ │ ├── 500.html │ │ ├── base-common.html │ │ ├── base-wide.html │ │ ├── base.html │ │ ├── login.html │ │ ├── password_change.html │ │ ├── password_reset.html │ │ ├── password_reset_complete.html │ │ ├── password_reset_confirm.html │ │ ├── password_reset_done.html │ │ ├── password_reset_mail.txt │ │ └── password_reset_subject.txt │ │ ├── templatetags │ │ ├── __init__.py │ │ └── templatetags │ │ │ ├── __init__.py │ │ │ ├── dict_access.py │ │ │ └── form_as_div.py │ │ ├── urls.py │ │ ├── util.py │ │ ├── vpnstatus │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── models.py │ │ ├── templates │ │ │ └── status_history.html │ │ └── views.py │ │ └── wsgi.py ├── dev_manage.py └── pylintrc ├── tests ├── checker │ ├── fixtures │ │ ├── integration.json │ │ └── master.json │ ├── integration_basic_checkerscript.py │ ├── integration_down_checkerscript.py │ ├── integration_exception_checkerscript.py │ ├── integration_multi_checkerscript.py │ ├── integration_state_checkerscript.py │ ├── integration_sudo_checkerscript.py │ ├── integration_unfinished_checkerscript.py │ ├── test_integration.py │ ├── test_master.py │ └── test_metrics.py ├── checkerlib │ └── test_local.py ├── controller │ ├── fixtures │ │ ├── main_loop.json │ │ └── scoring.json.xz │ ├── scoring_reference.csv │ ├── test_main_loop.py │ ├── test_scoring.py │ └── test_sleep_seconds.py ├── lib │ ├── test_args.py │ ├── test_date_time.py │ └── test_flag.py ├── submission │ ├── fixtures │ │ └── server.json │ └── test_server.py └── vpnstatus │ ├── fixtures │ └── status.json │ └── test_status.py └── tox.ini /.devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "python:3.11-alpine", 3 | "updateContentCommand": "apk --no-cache add git curl build-base jpeg-dev zlib-dev iputils-ping", 4 | "postCreateCommand": "pip3 install --editable .[dev] && make dev", 5 | "customizations": { 6 | "vscode": { 7 | "extensions": ["ms-python.python"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | lint: 9 | name: Lint soure code 10 | runs-on: ubuntu-latest 11 | container: python:3.11-bookworm 12 | steps: 13 | - uses: actions/checkout@v4 14 | - run: pip install -e .[dev] 15 | - run: make lint 16 | 17 | # Test with Tox, a recent Python version and libraries from PyPI 18 | test_tox: 19 | name: Test with Tox 20 | runs-on: ubuntu-latest 21 | container: python:3.11-bookworm 22 | permissions: 23 | # Required for "EnricoMi/publish-unit-test-result-action" 24 | checks: write 25 | steps: 26 | - uses: actions/checkout@v4 27 | - name: Setup dependencies 28 | run: | 29 | pip install tox 30 | # Make sure we have our dependencies, which are not required for Tox but for `make build` 31 | pip install -e . 32 | # Ping is required for VPNStatusTest 33 | apt-get --yes update 34 | apt-get --yes install iputils-ping 35 | - run: make build 36 | - run: tox -e py311 -- --junitxml=.tox/py311/log/results.xml 37 | - name: Publish unit test results 38 | uses: EnricoMi/publish-unit-test-result-action@v2 39 | if: always() 40 | with: 41 | files: .tox/py*/log/results.xml 42 | comment_mode: "off" 43 | - name: Archive unit test results 44 | uses: actions/upload-artifact@v4 45 | if: always() 46 | with: 47 | name: tox-test-results 48 | path: .tox/py*/log/results.xml 49 | if-no-files-found: error 50 | - name: Archive code coverage results 51 | uses: actions/upload-artifact@v4 52 | if: always() 53 | with: 54 | name: tox-code-coverage-report 55 | path: .tox/py*/log/htmlcov 56 | if-no-files-found: error 57 | 58 | build_deb_package: 59 | name: Build Debian package 60 | runs-on: ubuntu-latest 61 | container: debian:bookworm 62 | steps: 63 | - uses: actions/checkout@v4 64 | - run: apt-get --yes update 65 | - run: apt-get --yes install --no-install-recommends devscripts dpkg-dev equivs 66 | # Add `--yes` to mk-build-deps' default options for apt-get 67 | - run: mk-build-deps --install --tool 'apt-get --yes -o Debug::pkgProblemResolver=yes --no-install-recommends' debian/control 68 | - run: dpkg-buildpackage --unsigned-changes --unsigned-buildinfo 69 | - run: mv ../ctf-gameserver_*.deb . 70 | - name: Store Debian package 71 | uses: actions/upload-artifact@v4 72 | with: 73 | name: deb-package 74 | path: ctf-gameserver_*.deb 75 | if-no-files-found: error 76 | 77 | # Test with Python and libraries from Debian Stable sources 78 | test_debian: 79 | name: Test with Debian 80 | runs-on: ubuntu-latest 81 | container: debian:bookworm 82 | needs: build_deb_package 83 | steps: 84 | - uses: actions/checkout@v4 85 | - uses: actions/download-artifact@v4 86 | with: 87 | name: deb-package 88 | - run: apt-get --yes update 89 | # Install our package in order to install its dependencies 90 | - run: apt-get --yes install --no-install-recommends ./ctf-gameserver_*.deb 91 | - run: apt-get --yes install make curl unzip python3-pytest python3-pytest-cov 92 | - run: make build 93 | - run: pytest-3 --junitxml=results.xml --cov=src --cov-report=term --cov-report=html tests 94 | - name: Archive unit test results 95 | uses: actions/upload-artifact@v4 96 | if: always() 97 | with: 98 | name: debian-test-results 99 | path: results.xml 100 | if-no-files-found: error 101 | - name: Archive code coverage results 102 | uses: actions/upload-artifact@v4 103 | if: always() 104 | with: 105 | name: debian-code-coverage-report 106 | path: htmlcov 107 | if-no-files-found: error 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | 3 | # Development venv 4 | /.venv/ 5 | 6 | # Python packaging 7 | /build/ 8 | /dist/ 9 | /src/ctf_gameserver.egg-info/ 10 | 11 | # Debian packaging 12 | /.pybuild/ 13 | /debian/ctf-gameserver/ 14 | /debian/files 15 | 16 | # Python testing 17 | /.tox/ 18 | /.coverage 19 | 20 | # Go examples 21 | /examples/checker/example_checker_go/example_checker 22 | 23 | # Web component 24 | /src/ctf_gameserver/web/dev-db.sqlite3 25 | /src/ctf_gameserver/web/team_downloads/ 26 | /src/ctf_gameserver/web/uploads/ 27 | /src/ctf_gameserver/web/static/ext/ 28 | /src/ctf_gameserver/web/registration/countries.csv 29 | # We don't actually migrate anything and generate migrations files dynamically 30 | /src/ctf_gameserver/web/*/migrations/__init__.py 31 | /src/ctf_gameserver/web/*/migrations/0001_initial.py 32 | 33 | # MkDocs HTML build 34 | /docs_site/ 35 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) FAUST -- FAU Security Team 2 | Copyright (c) Christoph Egger 3 | Copyright (c) Felix Dreissig 4 | Copyright (c) Simon Ruderich 5 | 6 | Permission to use, copy, modify, and/or distribute this software for any 7 | purpose with or without fee is hereby granted, provided that the above 8 | copyright notice and this permission notice appear in all copies. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 11 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 12 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 13 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 14 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 15 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 16 | PERFORMANCE OF THIS SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SOURCE_DIR ?= src 2 | WEB_DIR ?= $(SOURCE_DIR)/ctf_gameserver/web 3 | EXT_DIR ?= $(WEB_DIR)/static/ext 4 | DEV_MANAGE ?= src/dev_manage.py 5 | TESTS_DIR ?= tests 6 | 7 | .PHONY: dev build ext migrations run_web test lint run_docs clean 8 | .INTERMEDIATE: bootstrap.zip 9 | 10 | dev: $(WEB_DIR)/dev-db.sqlite3 ext 11 | build: ext migrations 12 | ext: $(EXT_DIR)/jquery.min.js $(EXT_DIR)/bootstrap $(WEB_DIR)/registration/countries.csv 13 | 14 | 15 | migrations: $(WEB_DIR)/registration/countries.csv 16 | $(DEV_MANAGE) makemigrations templatetags registration scoring flatpages vpnstatus 17 | 18 | $(WEB_DIR)/dev-db.sqlite3: migrations $(WEB_DIR)/registration/countries.csv 19 | $(DEV_MANAGE) migrate 20 | DJANGO_SUPERUSER_PASSWORD=password $(DEV_MANAGE) createsuperuser --no-input --username admin --email 'admin@example.org' 21 | 22 | $(EXT_DIR)/jquery.min.js: 23 | mkdir -p $(EXT_DIR) 24 | curl https://code.jquery.com/jquery-1.11.3.min.js -o $@ 25 | 26 | bootstrap.zip: 27 | curl -L https://github.com/twbs/bootstrap/releases/download/v3.3.5/bootstrap-3.3.5-dist.zip -o $@ 28 | 29 | $(EXT_DIR)/bootstrap: bootstrap.zip 30 | mkdir -p $(EXT_DIR) 31 | unzip -n $< -d $(EXT_DIR) 32 | mv -v $(EXT_DIR)/bootstrap-3.3.5-dist $(EXT_DIR)/bootstrap 33 | 34 | $(WEB_DIR)/registration/countries.csv: 35 | # Official download link from http://data.okfn.org/data/core/country-list, under Public Domain 36 | curl https://raw.githubusercontent.com/datasets/country-list/master/data.csv -o $@ 37 | 38 | 39 | run_web: 40 | $(DEV_MANAGE) runserver 41 | 42 | test: 43 | pytest --cov $(SOURCE_DIR) $(TESTS_DIR) 44 | 45 | lint: 46 | # Run Pylint, pycodestyle and Bandit to check the code for potential errors, style guideline violations 47 | # and security issues 48 | pylint --rcfile $(SOURCE_DIR)/pylintrc $(SOURCE_DIR) $(TESTS_DIR) 49 | pycodestyle $(SOURCE_DIR) $(TESTS_DIR) 50 | bandit --ini bandit.ini -r $(SOURCE_DIR) 51 | 52 | run_docs: 53 | mkdocs serve 54 | 55 | docs_site: mkdocs.yml $(wildcard docs/* docs/*/*) 56 | mkdocs build --strict 57 | 58 | 59 | clean: 60 | rm -rf src/ctf_gameserver/web/*/migrations 61 | rm -f src/ctf_gameserver/web/dev-db.sqlite3 src/ctf_gameserver/web/registration/countries.csv 62 | rm -rf src/ctf_gameserver/web/static/ext 63 | rm -rf build dist src/ctf_gameserver.egg-info 64 | rm -rf docs_site 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CTF Gameserver 2 | ============== 3 | 4 | This is a Gameserver for [attack-defense (IT security) CTFs](https://ctftime.org/ctf-wtf/). It is used for 5 | hosting [FAUST CTF](https://www.faustctf.net), but designed to be re-usable for other competitions. It is 6 | scalable to large online CTFs, battle-tested in many editions of FAUST CTF, and customizable for other 7 | competitions. 8 | 9 | For documentation on architecture, installation, etc., head to [ctf-gameserver.org](https://ctf-gameserver.org/). 10 | 11 | What's Included 12 | --------------- 13 | The Gameserver consists of multiple components: 14 | 15 | * Web: A [Django](https://www.djangoproject.com/)-based web application for team registration, scoreboards, 16 | and simple hosting of informational pages. It also contains the model files, which define the database 17 | structure. 18 | * Controller: Coordinates the progress of the competition, e.g. the current tick and flags to be placed. 19 | * Checker: Place and retrieve flags and test the service status on all teams' Vulnboxes. The Checker Master 20 | launches Checker Scripts, which are individual to each service. 21 | * Checkerlib: Libraries to assist in developing Checker Scripts. Currently, Python and Go are supported. 22 | * Submission: Server to submit captured flags to. 23 | * VPN Status: Optional helper that collects statistics about network connectivity to teams. 24 | 25 | Related Projects 26 | ---------------- 27 | There are several alternatives out there, although none of them could really convince us when we started the 28 | project in 2015. Your mileage may vary. 29 | 30 | * [ictf-framework](https://github.com/shellphish/ictf-framework) from the team behind iCTF, one of the most 31 | well-known attack-defense CTFs. In addition to a gameserver, it includes utilities for VM creation and 32 | network setup. We had trouble to get it running and documentation is generally rather scarce. 33 | * [HackerDom checksystem](https://github.com/HackerDom/checksystem) is the Gameserver powering RuCTF. The 34 | first impression wasn't too bad, but it didn't look quite feature-complete to us. However, we didn't really 35 | grasp the Perl code, so we might have overlooked something. 36 | * [saarctf-gameserver](https://github.com/MarkusBauer/saarctf-gameserver) from our friends at saarsec is 37 | younger than our Gameserver. It contains a nice scoreboard and infrastructure for VPN/network setup. 38 | * [EnoEngine](https://github.com/enowars/EnoEngine) by our other friends at ENOFLAG is also younger than 39 | our solution. 40 | * [CTFd](https://ctfd.io/) is the de-facto standard for [jeopardy-based CTFs](https://ctftime.org/ctf-wtf/). 41 | It is, however, not suitable for an attack-defense CTF. 42 | 43 | Another factor for the creation of our own system was that we didn't want to build a large CTF on top of a 44 | system which we don't entirely understand. 45 | 46 | Development 47 | ----------- 48 | For a local development environment, set up a [Python venv](https://docs.python.org/3/library/venv.html) or 49 | use our [dev container](https://code.visualstudio.com/docs/devcontainers/containers) from 50 | `.devcontainer.json`. 51 | 52 | Then, run `make dev`. Tests can be executed through `make test` and a development instance of the Web 53 | component can be launched with `make run_web`. 54 | 55 | We always aim to keep our Python dependencies compatible with the versions packaged in Debian stable. 56 | Debian-based distributions are our primary target, but the Python code should generally be 57 | platform-independent. 58 | 59 | Security 60 | -------- 61 | Should you encounter any security vulnerabilities in the Gameserver, please report them to us privately. 62 | Use GitHub vulnerability reporting or contact Felix Dreissig or Simon Ruderich directly. 63 | 64 | Copyright 65 | --------- 66 | The Gameserver was initially created by Christoph Egger and Felix Dreissig. It is currently maintained by 67 | Felix Dreissig and Simon Ruderich with contributions from others. 68 | 69 | It is released under the ISC License. 70 | -------------------------------------------------------------------------------- /bandit.ini: -------------------------------------------------------------------------------- 1 | [bandit] 2 | # Disable warnings about (non-critical) "subprocess" usage as well as those about `mark_safe()` -- we use it 3 | # quite some times and (hopefully) know what we're doing 4 | skips: B404,B603,B703,B308 5 | -------------------------------------------------------------------------------- /conf/checker/checkermaster.env: -------------------------------------------------------------------------------- 1 | CTF_DBNAME="DUMMY" 2 | CTF_DBUSER="DUMMY" 3 | 4 | CTF_SUDOUSER="ctf-checkerrunner" 5 | CTF_IPPATTERN="0.0.%s.2" 6 | CTF_FLAGSECRET="RFVNTVlTRUNSRVQ=" 7 | -------------------------------------------------------------------------------- /conf/checker/ctf-checkermaster@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CTF Gameserver Checker Master 3 | After=postgresql.service 4 | 5 | [Service] 6 | Type=notify 7 | User=ctf-checkermaster 8 | EnvironmentFile=/etc/ctf-gameserver/checkermaster.env 9 | EnvironmentFile=-/etc/ctf-gameserver/checker/%i.env 10 | ExecStart=/usr/bin/ctf-checkermaster 11 | # Allow waiting for Checker Scripts to finish 12 | TimeoutStopSec=90 13 | Restart=on-failure 14 | RestartSec=5 15 | SyslogIdentifier=ctf-checkermaster@%I 16 | 17 | # Security options, cannot use any which imply `NoNewPrivileges` because Checker Scripts can get executed 18 | # using sudo 19 | PrivateTmp=yes 20 | ProtectControlGroups=yes 21 | ProtectHome=yes 22 | ProtectSystem=strict 23 | 24 | [Install] 25 | WantedBy=multi-user.target 26 | -------------------------------------------------------------------------------- /conf/controller/controller.env: -------------------------------------------------------------------------------- 1 | CTF_DBNAME="DUMMY" 2 | CTF_DBUSER="DUMMY" 3 | -------------------------------------------------------------------------------- /conf/controller/ctf-controller.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CTF Gameserver Controller 3 | After=postgresql.service 4 | 5 | [Service] 6 | Type=notify 7 | DynamicUser=yes 8 | # Python breaks without HOME environment variable and with `DynamicUser` 9 | Environment=HOME=/tmp 10 | EnvironmentFile=/etc/ctf-gameserver/controller.env 11 | ExecStart=/usr/bin/ctf-controller 12 | Restart=on-failure 13 | RestartSec=5 14 | 15 | # Security options 16 | CapabilityBoundingSet= 17 | LockPersonality=yes 18 | MemoryDenyWriteExecute=yes 19 | NoNewPrivileges=yes 20 | PrivateDevices=yes 21 | PrivateTmp=yes 22 | PrivateUsers=yes 23 | ProtectControlGroups=yes 24 | ProtectHome=yes 25 | ProtectKernelModules=yes 26 | ProtectKernelTunables=yes 27 | ProtectSystem=strict 28 | RestrictNamespaces=yes 29 | RestrictRealtime=yes 30 | SystemCallArchitectures=native 31 | 32 | [Install] 33 | WantedBy=multi-user.target 34 | -------------------------------------------------------------------------------- /conf/submission/ctf-submission@.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CTF Flag-Submission Service 3 | After=postgresql.service 4 | 5 | [Service] 6 | Type=notify 7 | DynamicUser=yes 8 | # Python breaks without HOME environment variable and with `DynamicUser` 9 | Environment=HOME=/tmp 10 | EnvironmentFile=/etc/ctf-gameserver/submission.env 11 | EnvironmentFile=-/etc/ctf-gameserver/submission-%i.env 12 | ExecStart=/usr/bin/ctf-submission 13 | Restart=on-failure 14 | RestartSec=5 15 | 16 | # Security options 17 | CapabilityBoundingSet= 18 | LockPersonality=yes 19 | MemoryDenyWriteExecute=yes 20 | NoNewPrivileges=yes 21 | PrivateDevices=yes 22 | PrivateTmp=yes 23 | PrivateUsers=yes 24 | ProtectControlGroups=yes 25 | ProtectHome=yes 26 | ProtectKernelModules=yes 27 | ProtectKernelTunables=yes 28 | ProtectSystem=strict 29 | RestrictNamespaces=yes 30 | RestrictRealtime=yes 31 | SystemCallArchitectures=native 32 | 33 | [Install] 34 | WantedBy=multi-user.target 35 | -------------------------------------------------------------------------------- /conf/submission/submission.env: -------------------------------------------------------------------------------- 1 | CTF_DBNAME="DUMMY" 2 | CTF_DBUSER="DUMMY" 3 | 4 | CTF_FLAGSECRET="RFVNTVlTRUNSRVQ=" 5 | CTF_TEAMREGEX="^0\.0\.(\d+)\.\d+$" 6 | -------------------------------------------------------------------------------- /conf/vpnstatus/ctf-vpnstatus.env: -------------------------------------------------------------------------------- 1 | CTF_DBNAME="DUMMY" 2 | CTF_DBUSER="DUMMY" 3 | 4 | CTF_WIREGUARD_IFPATTERN="wg%d" 5 | 6 | CTF_GATEWAY_IPPATTERN="0.0.%s.1" 7 | CTF_DEMO_IPPATTERN="0.0.%s.3" 8 | CTF_DEMO_SERVICEPORT="80" 9 | CTF_VULNBOX_IPPATTERN="0.0.%s.2" 10 | CTF_VULNBOX_SERVICEPORT="80" 11 | -------------------------------------------------------------------------------- /conf/vpnstatus/ctf-vpnstatus.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=CTF Gameserver Controller 3 | After=postgresql.service 4 | 5 | [Service] 6 | Type=notify 7 | User=ctf-vpnstatus 8 | EnvironmentFile=/etc/ctf-gameserver/vpnstatus.env 9 | ExecStart=/usr/bin/ctf-vpnstatus 10 | Restart=on-failure 11 | RestartSec=5 12 | 13 | # Security options, cannot use any which imply `NoNewPrivileges` because checks can get executed using sudo 14 | PrivateTmp=yes 15 | ProtectControlGroups=yes 16 | ProtectHome=yes 17 | ProtectSystem=strict 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /conf/web/prod_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django and project specific settings for usage in production. 3 | Edit this file and adjust the options to your own requirements! You may also set additional options from 4 | https://docs.djangoproject.com/en/1.8/ref/settings/. 5 | """ 6 | 7 | # pylint: disable=wildcard-import, unused-wildcard-import 8 | from ctf_gameserver.web.base_settings import * 9 | 10 | 11 | # Content Security Policy header in the format `directive: [values]`, see e.g 12 | # http://www.html5rocks.com/en/tutorials/security/content-security-policy/ for an explanation 13 | # The initially selected directives should cover most sensitive cases, but still allow YouTube embeds, 14 | # webfonts etc. 15 | CSP_POLICIES = { 16 | 'base-uri': ["'self'"], 17 | 'connect-src': ["'self'"], 18 | 'form-action': ["'self'"], 19 | 'object-src': ["'none'"], 20 | 'script-src': ["'self'"], 21 | 'style-src': ["'self'"] 22 | } 23 | 24 | # Set to True if your site is available exclusively through HTTPS and not via plaintext HTTP 25 | HTTPS = False 26 | 27 | 28 | # Your database settings 29 | # See https://docs.djangoproject.com/en/1.8/ref/settings/#databases 30 | DATABASES = { 31 | 'default': { 32 | 'ENGINE': 'django.db.backends.postgresql', 33 | 'HOST': '', 34 | 'PORT': '', 35 | 'NAME': '', 36 | 'USER': '', 37 | 'PASSWORD': '', 38 | 'CONN_MAX_AGE': 60 39 | } 40 | } 41 | 42 | # Your cache configuration 43 | # See https://docs.djangoproject.com/en/1.8/topics/cache/#setting-up-the-cache 44 | CACHES = { 45 | 'default': { 46 | 'BACKEND': 'django.core.cache.backends.memcached.PyLibMCCache', 47 | 'LOCATION': '', 48 | 'TIMEOUT': 60 49 | } 50 | } 51 | 52 | # Settings for the SMTP server that will be used to send email messages 53 | # See https://docs.djangoproject.com/en/1.8/ref/settings/#email-host and other options 54 | EMAIL_HOST = '' 55 | EMAIL_PORT = 25 56 | EMAIL_HOST_USER = '' 57 | EMAIL_HOST_PASSWORD = '' 58 | # See https://docs.djangoproject.com/en/1.8/ref/settings/#email-use-tls 59 | EMAIL_USE_TLS = False 60 | EMAIL_USE_SSL = False 61 | 62 | # Sender address for messages sent by the gameserver 63 | DEFAULT_FROM_EMAIL = '' 64 | 65 | # Filesystem path where user-uploaded files are stored 66 | # This directory must be served by the web server under the path defined by MEDIA_URL in 'base_settings.py' 67 | # ("/uploads" by default) 68 | MEDIA_ROOT = '' 69 | 70 | # Base filesystem path where files for per-team downloads are stored, optional without per-team downloads 71 | # A hierarchy with a directory per team (called by net number) is expected below this path 72 | # For example, file "vpn.conf" for the team with net number 42 must be in: 73 | # /42/vpn.conf 74 | # This directory must *not* be served by a web server 75 | TEAM_DOWNLOADS_ROOT = '' 76 | 77 | # The backend used to store user sessions 78 | SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' 79 | 80 | # A long, random string, which you are supposed to keep secret 81 | SECRET_KEY = '' 82 | 83 | # Insert all hostnames your site is available under 84 | # See https://docs.djangoproject.com/en/1.8/ref/settings/#allowed-hosts 85 | ALLOWED_HOSTS = [] 86 | 87 | # The name of the time zone (i.e. something like "Europe/Berlin") in which dates should be displayed 88 | # See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones for a list of valid options 89 | TIME_ZONE = '' 90 | 91 | # First day of the week: 0 means Sunday, 1 means Monday and so on 92 | FIRST_DAY_OF_WEEK = 1 93 | 94 | # When using Graylog for checker logging, base URL for generating links to log searches 95 | # Probably either "http://:/search" or "http://:/streams//search" 96 | #GRAYLOG_SEARCH_URL = 'http://localhost:9000/search' 97 | 98 | 99 | # You should not have to edit anything below this line 100 | 101 | # Set up logging to syslog 102 | LOGGING = { 103 | 'version': 1, 104 | 'disable_existing_loggers': False, 105 | 'handlers': { 106 | 'syslog': { 107 | 'class': 'logging.handlers.SysLogHandler', 108 | 'address': '/dev/log' 109 | } 110 | }, 111 | 'loggers': { 112 | 'django': { 113 | 'handlers': ['syslog'], 114 | 'level': 'WARNING' 115 | } 116 | } 117 | } 118 | 119 | DEBUG = False 120 | 121 | if HTTPS: 122 | CSRF_COOKIE_SECURE = True 123 | SESSION_COOKIE_SECURE = True 124 | -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | *debhelper* 2 | *.substvars -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | ctf-gameserver (1.0) unstable; urgency=medium 2 | 3 | * Initial release. 4 | 5 | -- Christoph Egger Sat, 23 Mar 2019 14:34:12 +0100 6 | -------------------------------------------------------------------------------- /debian/clean: -------------------------------------------------------------------------------- 1 | src/CTF_Gameserver.egg-info/* 2 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: ctf-gameserver 2 | Section: education 3 | Priority: optional 4 | Maintainer: Christoph Egger 5 | Build-Depends: 6 | debhelper (>= 10), 7 | dh-python, 8 | curl, 9 | python3, 10 | python3-all, 11 | python3-django, 12 | python3-markdown, 13 | python3-pil, 14 | python3-setuptools, 15 | python3-tz, 16 | unzip 17 | Standards-Version: 4.3.0 18 | Homepage: http://ctf-gameserver.org 19 | 20 | Package: ctf-gameserver 21 | Architecture: all 22 | Section: python 23 | Depends: 24 | ${misc:Depends}, 25 | ${python3:Depends}, 26 | iputils-ping, 27 | # ctf-gameserver brings its own JQuery, but python3-django requires Debian's JQUery (for the admin interface) 28 | # and it's only a "Recommend" there 29 | libjs-jquery, 30 | procps, 31 | python3-configargparse, 32 | python3-django, 33 | python3-markdown, 34 | python3-pil, 35 | python3-prometheus-client, 36 | python3-psycopg2, 37 | python3-tz, 38 | python3-requests, 39 | python3-systemd 40 | Recommends: 41 | python3-graypy, 42 | sudo, 43 | systemd, 44 | uwsgi | libapache2-mod-wsgi | httpd-wsgi 45 | Description: FAUST CTF Gameserver 46 | Gameserver implementation for Attack/Defense Capture the Flag (CTF) 47 | competitions. Used by and originally developed for FAUST CTF. 48 | . 49 | This package contains all components required to set up a Gameserver 50 | instance. 51 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Files: * 2 | Copyright: 3 | 2015-2016, Christoph Egger 4 | 2015-2019, Felix Dreissig 5 | 2015-2019, FAUST -- FAU Security Team 6 | License: ISC 7 | 8 | License: ISC 9 | Permission to use, copy, modify, and/or distribute this software for any 10 | purpose with or without fee is hereby granted, provided that the above 11 | copyright notice and this permission notice appear in all copies. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 14 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 15 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 16 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 17 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 18 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 19 | PERFORMANCE OF THIS SOFTWARE. 20 | -------------------------------------------------------------------------------- /debian/install: -------------------------------------------------------------------------------- 1 | conf/checker/checkermaster.env etc/ctf-gameserver 2 | conf/checker/ctf-checkermaster@.service lib/systemd/system 3 | 4 | conf/controller/controller.env etc/ctf-gameserver 5 | conf/controller/ctf-controller.service lib/systemd/system 6 | 7 | conf/submission/submission.env etc/ctf-gameserver 8 | conf/submission/ctf-submission@.service lib/systemd/system 9 | 10 | conf/vpnstatus/ctf-vpnstatus.env etc/ctf-gameserver 11 | conf/vpnstatus/ctf-vpnstatus.service lib/systemd/system 12 | 13 | conf/web/prod_settings.py etc/ctf-gameserver/web 14 | -------------------------------------------------------------------------------- /debian/postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | if ! getent passwd ctf-checkermaster >/dev/null; then 4 | adduser --system --group --home /var/lib/ctf-checkermaster --gecos 'CTF Gameserver Checker Master user,,,' ctf-checkermaster 5 | fi 6 | if ! getent passwd ctf-checkerrunner >/dev/null; then 7 | adduser --system --group --home /var/lib/ctf-checkerrunner --gecos 'CTF Gameserver Checker Script user,,,' ctf-checkerrunner 8 | fi 9 | if ! getent passwd ctf-vpnstatus >/dev/null; then 10 | adduser --system --group --home /var/lib/ctf-vpnstatus --gecos 'CTF Gameserver VPN Status Checker user,,,' ctf-vpnstatus 11 | fi 12 | 13 | # No dh-systemd because we don't want to enable/start any services 14 | if test -x /bin/systemctl; then 15 | systemctl daemon-reload 16 | fi 17 | 18 | #DEBHELPER# 19 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | %: 4 | dh $@ --with python3 --buildsystem=pybuild 5 | 6 | override_dh_auto_clean: 7 | make clean 8 | dh_auto_clean 9 | 10 | override_dh_auto_build: 11 | make build 12 | dh_auto_build 13 | # Skip tests, we run them separately in CI 14 | override_dh_auto_test: 15 | : 16 | 17 | override_dh_fixperms: 18 | dh_fixperms 19 | # These may contain passwords 20 | chmod 0600 debian/ctf-gameserver/etc/ctf-gameserver/*.env 21 | 22 | # If dh-systemd is installed, it tries to enable the services even without being enabled above 23 | override_dh_systemd_enable: 24 | : 25 | override_dh_systemd_start: 26 | : 27 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (native) 2 | -------------------------------------------------------------------------------- /docs/checkers/go-library.md: -------------------------------------------------------------------------------- 1 | Checker Script Go Library 2 | ========================= 3 | 4 | The **Checker Script Go Library** provides facilities to write [Checker Scripts](index.md) in Go. 5 | 6 | It takes care of: 7 | 8 | * Communication with the Checker Master 9 | * Starting check steps 10 | * Command line argument handling 11 | * Configuring logging to send messages to the Master 12 | * Setup of default timeouts for "net/http" 13 | * Handling of common connection errors and converting them to a DOWN result 14 | 15 | This means that you do **not** have to handle timeout errors and can just let the library take care of them. 16 | 17 | Installation 18 | ------------ 19 | The library uses Go modules and can be imported via "github.com/fausecteam/ctf-gameserver/go/checkerlib" 20 | 21 | API 22 | --- 23 | To create a Checker Script, implement the `checkerlib.Checker` interface with the following methods: 24 | 25 | * `PlaceFlag(ip string, team int, tick int) (checkerlib.Result, error)`: Called once per Script execution to 26 | place a flag for the current tick. Use `checkerlib.GetFlag(tick, nil)` to get the flag and (optionally) 27 | `SetFlagID(data string)` to store the flag ID. 28 | * `CheckService(ip string, team int) (Result, error)`: Called once per Script execution to determine general 29 | service health. 30 | * `CheckFlag(ip string, team int, tick int) (checkerlib.Result, error)`: Determine if the flag for the given 31 | tick can be retrieved. Use `checkerlib.GetFlag(tick, nil)` to get the flag to check for. Called multiple 32 | times per Script execution, for the current and preceding ticks. 33 | 34 | In your `main()`, call `checkerlib.RunCheck()` with your implementation as argument. The library will take 35 | care of calling your methods, merging the results, and submitting them to the Checker Master. 36 | 37 | ### Persistent State 38 | * `StoreState(key string, data interface{})`: Store data persistently across runs (serialized as JSON). 39 | Data is stored per service and team with the given key as an additional identifier. 40 | * `LoadState(key string, data interface{}) bool`: Retrieve data stored through `StoreState()` (deserialized into 41 | `data`). Returns `true` if any state was found. 42 | 43 | ### Helper functions 44 | * `Dial(network, address string) (net.Conn, error)`: Calls `net.DialTimeout()` with an appropriate timeout. 45 | 46 | ### Constants 47 | * `Timeout`: Default timeout used when connecting to services. In case you cannot use the standard functions 48 | of "net/http" (those using `DefaultClient`/`DefaultTransport`) or `checkerlib.Dial()`. 49 | * Check results, [see general docs](index.md#check-results) for their semantics: 50 | * `ResultOk` 51 | * `ResultDown` 52 | * `ResultFaulty` 53 | * `ResultFlagNotFound` 54 | 55 | ### Minimal Example 56 | ```go 57 | package main 58 | 59 | import ( 60 | "github.com/fausecteam/ctf-gameserver/go/checkerlib" 61 | ) 62 | 63 | func main() { 64 | checkerlib.RunCheck(checker{}) 65 | } 66 | 67 | type checker struct{} 68 | 69 | func (c checker) PlaceFlag(ip string, team int, tick int) (checkerlib.Result, error) { 70 | return checkerlib.ResultOk, nil 71 | } 72 | 73 | func (c checker) CheckService(ip string, team int) (checkerlib.Result, error) { 74 | return checkerlib.ResultOk, nil 75 | } 76 | 77 | func (c checker) CheckFlag(ip string, team int, tick int) (checkerlib.Result, error) { 78 | return checkerlib.ResultOk, nil 79 | } 80 | ``` 81 | 82 | For a complete, but still simple, Checker Script see `examples/checker/example_checker_go` in the [CTF 83 | Gameserver repository](https://github.com/fausecteam/ctf-gameserver). 84 | 85 | Local Execution 86 | --------------- 87 | When running your Checker Script locally, just pass your service IP, the tick to check (starting from 0), 88 | and a dummy team ID as command line arguments: 89 | 90 | ```sh 91 | go build && ./checkerscript ::1 10 0 92 | ``` 93 | 94 | The library will print messages to stderr and generate dummy flags when launched without a Checker Master. 95 | State stored will be persisted in a file called `_state.json` in the current directory in that case. 96 | -------------------------------------------------------------------------------- /docs/checkers/python-library.md: -------------------------------------------------------------------------------- 1 | Checker Script Python Library 2 | ============================= 3 | 4 | The **Checker Script Python Library** provides facilities to write [Checker Scripts](index.md) in 5 | Python 3. 6 | 7 | It takes care of: 8 | 9 | * Communication with the Checker Master 10 | * Starting check steps 11 | * Command line argument handling 12 | * Configuring logging to send messages to the Master 13 | * Setup of default timeouts for Python sockets, [urllib3](https://urllib3.readthedocs.io), and 14 | [Requests](https://requests.readthedocs.io) 15 | * Handling of common connection exceptions and converting them to a DOWN result 16 | 17 | This means that you do **not** have to catch timeout exceptions and can just let the library take care of 18 | them. 19 | 20 | Installation 21 | ------------ 22 | To use the library, you must have the `ctf_gameserver.checkerlib` package available to your Python 23 | installation. That package is self-contained and does not require any external dependencies. 24 | 25 | One option to do that would be to clone the [CTF Gameserver 26 | repository](https://github.com/fausecteam/ctf-gameserver) and create a symlink called `ctf_gameserver` to 27 | `src/ctf_gameserver`. 28 | 29 | Another option would be to install CTF Gameserver (preferably to a virtualenv) by running `pip install .` 30 | in the repository directory. 31 | 32 | API 33 | --- 34 | To create a Checker Script, create a subclass of `checkerlib.BaseChecker` implementing the following methods: 35 | 36 | * `place_flag(self, tick: int) -> checkerlib.CheckResult`: Called once per Script execution to place a flag 37 | for the current tick. Use `checkerlib.get_flag(tick)` to get the flag. 38 | * `check_service(self) -> checkerlib.CheckResult`: Called once per Script execution to determine general 39 | service health. 40 | * `check_flag(self, tick: int) -> checkerlib.CheckResult`: Determine if the flag for the given tick can be 41 | retrieved. Use `checkerlib.get_flag(tick)` to get the flag to check for. Called multiple times per Script 42 | execution, for the current and preceding ticks. 43 | 44 | In your `__main__` code, call `checkerlib.run_check()` with your class as argument. The library will take 45 | care of calling your methods, merging the results, and submitting them to the Checker Master. 46 | 47 | ### Functions 48 | * `get_flag(tick: int) -> str`: Get the flag for the given tick (for the checked team). 49 | * `set_flagid(data: str) -> None`: Store the Flag ID for the current tick. 50 | * `store_state(key: str, data: Any) -> None`: Store arbitrary Python data persistently across runs. 51 | * `load_state(key: str) -> Any`: Retrieve data stored through `store_state()`. 52 | * `run_check(checker_cls: Type[BaseChecker]) -> None`: Start the check. 53 | 54 | ### Classes 55 | * The `checkerlib.BaseChecker` class provides the following attributes: 56 | * `self.ip`: IP address of the checked team (may be IPv4 or IPv6, depending on your CTF) 57 | * `self.team`: (Net) number of the checked team 58 | * `checkerlib.CheckResult` provides the following constants to express check results, [see general 59 | docs](index.md#check-results) for their semantics: 60 | * `CheckResult.OK` 61 | * `CheckResult.DOWN` 62 | * `CheckResult.FAULTY` 63 | * `CheckResult.FLAG_NOT_FOUND` 64 | 65 | ### Minimal Example 66 | ```py 67 | #!/usr/bin/env python3 68 | 69 | from ctf_gameserver import checkerlib 70 | 71 | class MinimalChecker(checkerlib.BaseChecker): 72 | def place_flag(self, tick): 73 | return checkerlib.CheckResult.OK 74 | 75 | def check_service(self): 76 | return checkerlib.CheckResult.OK 77 | 78 | def check_flag(self, tick): 79 | return checkerlib.CheckResult.OK 80 | 81 | if __name__ == '__main__': 82 | checkerlib.run_check(MinimalChecker) 83 | ``` 84 | 85 | For a complete, but still simple, Checker Script see `examples/checker/example_checker.py` in the 86 | [CTF Gameserver repository](https://github.com/fausecteam/ctf-gameserver). 87 | 88 | Local Execution 89 | --------------- 90 | When running your Checker Script locally, just pass your service IP, the tick to check (starting from 0), 91 | and a dummy team ID as command line arguments: 92 | 93 | ```sh 94 | ./checkerscript.py ::1 10 0 95 | ``` 96 | 97 | The library will print messages to stdout and generate dummy flags when launched without a Checker Master. 98 | State stored will be persisted in a file called `_state.json` in the current directory in that case. 99 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | CTF Gameserver 2 | ============== 3 | 4 | FAUST's CTF Gameserver is a gameserver for [attack-defense (IT security) CTFs](https://ctftime.org/ctf-wtf/). 5 | It is used for hosting [FAUST CTF](https://www.faustctf.net), but designed to be re-usable for other 6 | competitions. It is scalable to large online CTFs, battle-tested in many editions of FAUST CTF, and 7 | customizable for other competitions. 8 | 9 | Components 10 | ---------- 11 | The Gameserver consists of multiple components. They may be deployed independently of each other as their 12 | only means of communication is a shared database. 13 | 14 | * Web: A [Django](https://www.djangoproject.com/)-based web application for team registration, scoreboards, 15 | and simple hosting of informational pages. It also contains the model files, which define the database 16 | structure. 17 | * Controller: Coordinates the progress of the competition, e.g. the current tick and flags to be placed. 18 | * Checker: Place and retrieve flags and test the service status on all teams' Vulnboxes. The Checker Master 19 | launches Checker Scripts, which are individual to each service. 20 | * Checkerlib: Libraries to assist in developing Checker Scripts. Currently, Python and Go are supported. 21 | * Submission: Server to submit captured flags to. 22 | * VPN Status: Optional helper that collects statistics about network connectivity to teams. 23 | 24 | Environment 25 | ----------- 26 | CTF Gameserver does **not** include facilities for network infrastructure, VPN setup, and Vulnbox creation. 27 | 28 | ### Requirements 29 | * Server(s) based on [Debian](https://www.debian.org/) or derivatives 30 | * [PostgreSQL](https://www.postgresql.org/) database 31 | * Web server and WSGI application server for the Web component 32 | 33 | ### Network 34 | It expects a network, completely local or VPN-based, with the following properties: 35 | 36 | * Teams need to be able to reach each other. 37 | * Checkers have to reach the teams. 38 | * Teams should not be able to distinguish between Checker and team traffic, i.e. at least applying a 39 | masquerading NAT. 40 | * Teams have to reach the Submission server. The Submission server needs to see real source addresses 41 | (without NAT). 42 | * All Gameserver components need to reach the database. Teams should not be able to talk to the database. 43 | * Both IPv4 and IPv6 are supported. It must be possible to map the teams' network ranges to their 44 | [team (net) number](architecture.md#team-numbers) based on string patterns. For example, use an addressing 45 | scheme like `10.66..0/24`. 46 | * One exception is displaying the latest handshake on the VPN Status History page, which is currently only 47 | implemented for [WireGuard](https://www.wireguard.com/). 48 | 49 | Further Reading 50 | --------------- 51 | Some links that contain interesting information for hosting your own CTF: 52 | 53 | * A member of the team behind the *Pls, I Want In* CTF wrote about their infrastructure 54 | [here](https://dev.jameslowther.com/Projects/Pls,-I-Want-In---2024). They used CTF Gameserver and a 55 | scalable, highly available setup hosted on AWS with Terraform. 56 | * The FAUST CTF infrastructure team gave [a talk on preventing traffic 57 | fingerprinting](https://www.haproxy.com/user-spotlight-series/preventing-traffic-fingerprinting-in-capture-the-flag-competitions) with iptables and HAProxy 58 | at HAProxyConf 2022. 59 | -------------------------------------------------------------------------------- /docs/observability.md: -------------------------------------------------------------------------------- 1 | Observability 2 | ============= 3 | 4 | This page describes **how to monitor the Gameserver with dashboards, metrics, and logs**. 5 | 6 | Built-In Dashboards 7 | ------------------- 8 | CTF Gameserver's Web component includes several dashboards for service authors to observe the behavior of 9 | their Checker Scripts: 10 | 11 | * **Service History** shows the check results for all teams over multiple ticks. It provides a nice overview 12 | of the Checker Script's behavior at large. 13 | * **Missing Checks** lists checks with the "Not checked" status. These are particularly interesting because 14 | they point at crashes or timeouts. 15 | 16 | Access to all of these requires a user account with "Staff" status. If configured, links to the corresponding 17 | logs in Graylog are automatically generated (setting `GRAYLOG_SEARCH_URL`). 18 | 19 | Users with "Staff" status can also view the **VPN Status History** dashboard of any selected team. 20 | 21 | Logging 22 | ------- 23 | All components write logs to stdout, from where they are usually picked up by systemd. You can view them 24 | through the regular journald facilities (`journalctl`). 25 | 26 | The only exception to this are Checker Script logs, which can be very verbose and should be accessible to 27 | their individual authors. 28 | 29 | ### Checkers 30 | You must explicitly configure Checker Script logs to be sent to either journald or Graylog. 31 | 32 | [Graylog (Open)](https://graylog.org/products/source-available/) is the recommended option, especially for 33 | larger competitions. It allows logs to be accessed through a web interface and filtered by service, team, 34 | tick, etc. When `GRAYLOG_SEARCH_URL` is configured for the Web component, the built-in dashboards 35 | automatically generate links to the respective logs. 36 | 37 | After installing Graylog, create a new "GELF UDP" input through the web interface with a large enough 38 | `recv_buffer_size` (we use 2 MB, i.e. 2097152 bytes). The parameters of this input then get used in the 39 | `CTF_GELF_SERVER` option. 40 | 41 | With journald-based Checker logging, you can filter log entries like this: 42 | 43 | journalctl -u ctf-checkermaster@service.service SYSLOG_IDENTIFIER=checker_service-team023-tick042 44 | 45 | Additionally, the `ctf-logviewer` script is available. It is designed to be used as an SSH `ForceCommand` to 46 | give service authors access to logs for a specific service. 47 | 48 | Metrics 49 | ------- 50 | All components except Web can expose metrics in [Prometheus](https://prometheus.io/) format. Prometheus 51 | enables both alerting and dashboarding with [Grafana](https://grafana.com/grafana/). 52 | 53 | To enable metrics, configure `CTF_METRICS_LISTEN` (the Ansible roles do that by default). For the available 54 | metrics and their description, manually request the metrics via HTTP. 55 | -------------------------------------------------------------------------------- /docs/submission.md: -------------------------------------------------------------------------------- 1 | Flag Submission 2 | =============== 3 | 4 | In order to score points for captured flags, the flags are submitted over a simple TCP-based plaintext 5 | protocol. That protocol was agreed upon by the organizers of several A/D CTFs in [this GitHub 6 | discussion](https://github.com/enowars/specification/issues/14). 7 | 8 | The following documentation describes the generic, agreed-upon protocol. CTF Gameserver itself uses a more 9 | restricted flag format, it will for example never generate non-ASCII flags. For details on how CTF Gameserver 10 | creates flags, see [flag architecture](architecture.md#flags). 11 | 12 | Definitions 13 | ----------- 14 | * **Whitespace** consists of one or more space (ASCII `0x20`) and/or tab (ASCII `0x09`) characters. 15 | * **Newline** is a single `\n` (ASCII `0x0a`) character. 16 | * **Flags** are sequences of arbitrary characters, except whitespace and newlines. 17 | 18 | Protocol 19 | -------- 20 | The client connects to the server on a TCP port specified by the respective CTF. The server MAY send a 21 | welcome banner, consisting of anything except two subsequent newlines. The server MUST indicate that the 22 | welcome sequence has finished by sending two subsequent newlines (`\n\n`). 23 | 24 | If a general error with the connection or its configuration renders the server inoperable, it MAY send an 25 | arbitrary error message and close the connection before sending the welcome sequence. The error message MUST 26 | NOT contain two subsequent newlines. 27 | 28 | To submit a flag, the client MUST send the flag followed by a single newline. 29 | The server's response MUST consist of: 30 | 31 | 1. A repetition of the submitted flag 32 | 2. Whitespace 33 | 3. One of the response codes defined below 34 | 4. Optionally: Whitespace, followed by a custom message consisting of any characters except newlines 35 | 5. Newline 36 | 37 | During a single connection, the client MAY submit an arbitrary number of flags. When the client is finished, 38 | it MUST close the TCP connection. The server MAY close the connection on inactivity for a certain amount of 39 | time. 40 | 41 | The client MAY send flags without waiting for the welcome sequence or responses to previously submitted 42 | flags. The server MAY send the responses in an arbitrary order; the connection between flags and responses 43 | can be derived from the flag repetition in the response. 44 | 45 | Response Codes 46 | -------------- 47 | * `OK`: The flag was valid, has been accepted by the server, and will be considered for scoring. 48 | * `DUP`: The flag was already submitted before (by the same team). 49 | * `OWN`: The flag belongs to (i.e. is supposed to be protected by) the submitting team. 50 | * `OLD`: The flag has expired and cannot be submitted anymore. 51 | * `INV`: The flag is not valid. 52 | * `ERR`: The server encountered an internal error. It MAY close the TCP connection. Submission may be retried 53 | at a later point. 54 | 55 | The server MUST implement `OK`, `INV`, and `ERR`. Other response codes are optional. The client MUST be able 56 | to handle all specified response codes. For extensibility, the client SHOULD be able to handle any response 57 | codes consisting of uppercase ASCII letters. 58 | 59 | Example 60 | ------- 61 | "C:" and "S:" indicate lines sent by the client and server, respectively. Each line includes the terminating 62 | newline. 63 | 64 | ``` 65 | S: Welcome to Example CTF flag submission! 🌈 66 | S: Please submit one flag per line. 67 | S: 68 | C: FLAG{4578616d706c65} 69 | S: FLAG{4578616d706c65} OK 70 | C: 🏴‍☠️ 71 | C: FLAG{🤔🧙‍♂️👻💩🎉} 72 | S: FLAG{🤔🧙‍♂️👻💩🎉} DUP You already submitted this flag 73 | S: 🏴‍☠️ INV Bad flag format 74 | ``` 75 | -------------------------------------------------------------------------------- /examples/checker/example_checker.env: -------------------------------------------------------------------------------- 1 | CTF_SERVICE="example_slug" 2 | CTF_CHECKERSCRIPT="/path/to/example_checker.py" 3 | CTF_CHECKERCOUNT="1" 4 | CTF_INTERVAL="10" 5 | -------------------------------------------------------------------------------- /examples/checker/example_checker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import logging 4 | import socket 5 | 6 | from ctf_gameserver import checkerlib 7 | 8 | 9 | class ExampleChecker(checkerlib.BaseChecker): 10 | 11 | def place_flag(self, tick): 12 | conn = connect(self.ip) 13 | flag = checkerlib.get_flag(tick) 14 | conn.sendall('SET {} {}\n'.format(tick, flag).encode('utf-8')) 15 | logging.info('Sent SET command: Flag %s', flag) 16 | 17 | try: 18 | resp = recv_line(conn) 19 | logging.info('Received response to SET command: %s', repr(resp)) 20 | except UnicodeDecodeError: 21 | logging.warning('Received non-UTF-8 data: %s', repr(resp)) 22 | return checkerlib.CheckResult.FAULTY 23 | if resp != 'OK': 24 | logging.warning('Received wrong response to SET command') 25 | return checkerlib.CheckResult.FAULTY 26 | 27 | conn.close() 28 | return checkerlib.CheckResult.OK 29 | 30 | def check_service(self): 31 | conn = connect(self.ip) 32 | conn.sendall(b'XXX\n') 33 | logging.info('Sent dummy command') 34 | 35 | try: 36 | recv_line(conn) 37 | logging.info('Received response to dummy command') 38 | except UnicodeDecodeError: 39 | logging.warning('Received non-UTF-8 data') 40 | return checkerlib.CheckResult.FAULTY 41 | 42 | conn.close() 43 | return checkerlib.CheckResult.OK 44 | 45 | def check_flag(self, tick): 46 | flag = checkerlib.get_flag(tick) 47 | 48 | conn = connect(self.ip) 49 | conn.sendall('GET {}\n'.format(tick).encode('utf-8')) 50 | logging.info('Sent GET command') 51 | 52 | try: 53 | resp = recv_line(conn) 54 | logging.info('Received response to GET command: %s', repr(resp)) 55 | except UnicodeDecodeError: 56 | logging.warning('Received non-UTF-8 data: %s', repr(resp)) 57 | return checkerlib.CheckResult.FAULTY 58 | if resp != flag: 59 | logging.warning('Received wrong response to GET command') 60 | return checkerlib.CheckResult.FLAG_NOT_FOUND 61 | 62 | conn.close() 63 | return checkerlib.CheckResult.OK 64 | 65 | 66 | def connect(ip): 67 | 68 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 69 | sock.connect((ip, 9999)) 70 | return sock 71 | 72 | 73 | def recv_line(conn): 74 | 75 | received = b'' 76 | while not received.endswith(b'\n'): 77 | new = conn.recv(1024) 78 | if len(new) == 0: 79 | if not received.endswith(b'\n'): 80 | raise EOFError('Unexpected EOF') 81 | break 82 | received += new 83 | return received.decode('utf-8').rstrip() 84 | 85 | 86 | if __name__ == '__main__': 87 | 88 | checkerlib.run_check(ExampleChecker) 89 | -------------------------------------------------------------------------------- /examples/checker/example_checker_go/go.mod: -------------------------------------------------------------------------------- 1 | module example_checker 2 | 3 | go 1.19 4 | 5 | require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20230218080707-d1dfb3000ddf 6 | 7 | require ( 8 | golang.org/x/crypto v0.6.0 // indirect 9 | golang.org/x/sys v0.5.0 // indirect 10 | ) 11 | -------------------------------------------------------------------------------- /examples/checker/example_checker_go/go.sum: -------------------------------------------------------------------------------- 1 | github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20230218080707-d1dfb3000ddf h1:F5ijjN6Wg9DVtJ/hKVcKoOnNgsaHtsqc2Adqk/NSTUE= 2 | github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20230218080707-d1dfb3000ddf/go.mod h1:Hb+eKFbWT/6ELb4Qokkr5Pw6jaihL13lfCaWPsgTdBg= 3 | golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= 4 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 5 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 6 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 7 | -------------------------------------------------------------------------------- /examples/checker/example_checker_go/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net" 9 | 10 | "github.com/fausecteam/ctf-gameserver/go/checkerlib" 11 | ) 12 | 13 | func main() { 14 | checkerlib.RunCheck(checker{}) 15 | } 16 | 17 | type checker struct{} 18 | 19 | func (c checker) PlaceFlag(ip string, team int, tick int) (checkerlib.Result, error) { 20 | conn, err := connect(ip) 21 | if err != nil { 22 | return -1, err 23 | } 24 | defer conn.Close() 25 | 26 | flag := checkerlib.GetFlag(tick) 27 | 28 | _, err = fmt.Fprintf(conn, "SET %d %s\n", tick, flag) 29 | if err != nil { 30 | return -1, err 31 | } 32 | log.Printf("Sent SET command: %d %s", tick, flag) 33 | 34 | line, err := readLine(conn) 35 | if err != nil { 36 | return -1, err 37 | } 38 | log.Printf("Received response to SET command: %q", line) 39 | 40 | if line != "OK" { 41 | log.Print("Received wrong response to SET command") 42 | return checkerlib.ResultFaulty, nil 43 | } 44 | 45 | return checkerlib.ResultOk, nil 46 | } 47 | 48 | func (c checker) CheckService(ip string, team int) (checkerlib.Result, error) { 49 | conn, err := connect(ip) 50 | if err != nil { 51 | return -1, err 52 | } 53 | defer conn.Close() 54 | 55 | _, err = fmt.Fprint(conn, "XXX\n") 56 | if err != nil { 57 | return -1, err 58 | } 59 | log.Print("Sent dummy command") 60 | 61 | line, err := readLine(conn) 62 | if err != nil { 63 | return -1, err 64 | } 65 | log.Printf("Received response to SET command: %q", line) 66 | 67 | return checkerlib.ResultOk, nil 68 | } 69 | 70 | func (c checker) CheckFlag(ip string, team int, tick int) (checkerlib.Result, error) { 71 | conn, err := connect(ip) 72 | if err != nil { 73 | return -1, err 74 | } 75 | defer conn.Close() 76 | 77 | _, err = fmt.Fprintf(conn, "GET %d\n", tick) 78 | if err != nil { 79 | return -1, err 80 | } 81 | log.Printf("Sent GET command: %d", tick) 82 | 83 | line, err := readLine(conn) 84 | if err != nil { 85 | return -1, err 86 | } 87 | log.Printf("Received response to GET command: %q", line) 88 | 89 | flag := checkerlib.GetFlag(tick) 90 | if line != flag { 91 | log.Print("Received wrong response to GET command") 92 | return checkerlib.ResultFlagNotFound, nil 93 | } 94 | 95 | return checkerlib.ResultOk, nil 96 | } 97 | 98 | // Helper functions 99 | 100 | func connect(ip string) (net.Conn, error) { 101 | return checkerlib.Dial("tcp", net.JoinHostPort(ip, "9999")) 102 | } 103 | 104 | func readLine(r io.Reader) (string, error) { 105 | s := bufio.NewScanner(r) 106 | if !s.Scan() { 107 | err := s.Err() 108 | if err == nil { 109 | err = io.EOF 110 | } 111 | return "", nil 112 | } 113 | return s.Text(), nil 114 | } 115 | -------------------------------------------------------------------------------- /examples/checker/example_service.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import socketserver 4 | 5 | 6 | _STORE = {} 7 | 8 | 9 | class RequestHandler(socketserver.BaseRequestHandler): 10 | 11 | def handle(self): 12 | client_data = self._recv_line() 13 | print('Received:', client_data) 14 | 15 | try: 16 | operation, client_data = client_data.split(' ', 1) 17 | except ValueError: 18 | response = 'INVALID' 19 | else: 20 | if operation == 'GET': 21 | key = client_data 22 | try: 23 | response = _STORE[key] 24 | except KeyError: 25 | response = 'NODATA' 26 | elif operation == 'SET': 27 | try: 28 | key, value = client_data.split(' ', 1) 29 | except ValueError: 30 | response = 'INVALID' 31 | else: 32 | _STORE[key] = value 33 | response = 'OK' 34 | else: 35 | response = 'INVALID' 36 | 37 | print('Response:', response) 38 | self.request.sendall(response.encode('utf-8') + b'\n') 39 | 40 | def _recv_line(self): 41 | received = b'' 42 | while not received.endswith(b'\n'): 43 | new = self.request.recv(1024) 44 | if len(new) == 0: 45 | if not received.endswith(b'\n'): 46 | raise EOFError('Unexpected EOF') 47 | break 48 | received += new 49 | return received.decode('utf-8').rstrip() 50 | 51 | 52 | if __name__ == "__main__": 53 | 54 | with socketserver.TCPServer(('localhost', 9999), RequestHandler) as server: 55 | server.serve_forever() 56 | -------------------------------------------------------------------------------- /examples/checker/sudoers.d/ctf-checker: -------------------------------------------------------------------------------- 1 | # Allow user "ctf-checkermaster" to use the `--close-from` argument when running sudo 2 | Defaults:ctf-checkermaster closefrom_override 3 | 4 | # Allow user "ctf-checkermaster" to run any command as user "ctf-checkerrunner" without being prompted for a 5 | # password 6 | ctf-checkermaster ALL = (ctf-checkerrunner) NOPASSWD: ALL 7 | -------------------------------------------------------------------------------- /examples/vpnstatus/sudoers.d/ctf-vpnstatus: -------------------------------------------------------------------------------- 1 | # Allow user "ctf-vpnstatus" to display WireGuard status without being prompted for a password 2 | ctf-vpnstatus ALL = (root) NOPASSWD: /usr/bin/wg show all latest-handshakes 3 | -------------------------------------------------------------------------------- /examples/web/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | location / { 6 | include uwsgi_params; 7 | uwsgi_pass unix:/run/uwsgi/app/ctf-gameserver/socket; 8 | } 9 | 10 | location /static/ { 11 | alias /usr/lib/python3/dist-packages/ctf_gameserver/web/static/; 12 | } 13 | location /static/admin/ { 14 | alias /usr/lib/python3/dist-packages/django/contrib/admin/static/admin/; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /examples/web/uwsgi.ini: -------------------------------------------------------------------------------- 1 | [uwsgi] 2 | uid = www-data 3 | gid = www-data 4 | processes = 1 5 | threads = 4 6 | module = django.core.wsgi:get_wsgi_application() 7 | plugins = python3 8 | python-path=/etc/ctf-gameserver/web 9 | env = DJANGO_SETTINGS_MODULE=prod_settings 10 | -------------------------------------------------------------------------------- /go/checkerlib/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fausecteam/ctf-gameserver/go/checkerlib 2 | 3 | go 1.19 4 | 5 | require golang.org/x/crypto v0.6.0 6 | 7 | require golang.org/x/sys v0.5.0 // indirect 8 | -------------------------------------------------------------------------------- /go/checkerlib/go.sum: -------------------------------------------------------------------------------- 1 | golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= 2 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 3 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 4 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 5 | -------------------------------------------------------------------------------- /go/checkerlib/ipc.go: -------------------------------------------------------------------------------- 1 | // Communication with checkermaster 2 | 3 | package checkerlib 4 | 5 | import ( 6 | "bufio" 7 | "bytes" 8 | "encoding/json" 9 | "io" 10 | "sync" 11 | ) 12 | 13 | type ipcData struct { 14 | sync.Mutex 15 | 16 | in *bufio.Scanner 17 | out io.Writer 18 | } 19 | 20 | func (i *ipcData) Send(action string, param interface{}) { 21 | ipc.Lock() 22 | defer ipc.Unlock() 23 | 24 | i.send(action, param) 25 | } 26 | func (i *ipcData) SendRecv(action string, param interface{}) interface{} { 27 | ipc.Lock() 28 | defer ipc.Unlock() 29 | 30 | i.send(action, param) 31 | return i.recv() 32 | } 33 | 34 | func (i *ipcData) send(action string, param interface{}) { 35 | data := struct { 36 | Action string `json:"action"` 37 | Param interface{} `json:"param"` 38 | }{ 39 | action, 40 | param, 41 | } 42 | 43 | x, err := json.Marshal(data) 44 | if err != nil { 45 | panic(err) 46 | } 47 | // Make sure that our JSON consists of just a single line as required 48 | // by IPC protocol 49 | x = append(bytes.Replace(x, []byte{'\n'}, nil, -1), '\n') 50 | 51 | _, err = i.out.Write(x) 52 | if err != nil { 53 | panic(err) 54 | } 55 | } 56 | 57 | func (i *ipcData) recv() interface{} { 58 | if !i.in.Scan() { 59 | panic(i.in.Err()) 60 | } 61 | var x struct { 62 | Response interface{} `json:"response"` 63 | } 64 | err := json.Unmarshal(i.in.Bytes(), &x) 65 | if err != nil { 66 | panic(err) 67 | } 68 | return x.Response 69 | } 70 | -------------------------------------------------------------------------------- /go/checkerlib/logger.go: -------------------------------------------------------------------------------- 1 | package checkerlib 2 | 3 | import ( 4 | "bytes" 5 | ) 6 | 7 | type logger struct{} 8 | 9 | // "log" calls Write() once per log call 10 | func (l logger) Write(p []byte) (int, error) { 11 | x := struct { 12 | Message string `json:"message"` 13 | //`json:"levelno"` 14 | //`json:"pathname"` 15 | //`json:"lineno"` 16 | //`json:"funcName"` 17 | }{ 18 | string(bytes.TrimSuffix(p, []byte{'\n'})), 19 | } 20 | ipc.Send("LOG", x) 21 | return len(p), nil 22 | } 23 | -------------------------------------------------------------------------------- /go/checkerlib/utils.go: -------------------------------------------------------------------------------- 1 | package checkerlib 2 | 3 | import ( 4 | "net" 5 | ) 6 | 7 | // Dial calls net.DialTimeout with an appropriate timeout. 8 | func Dial(network, address string) (net.Conn, error) { 9 | return net.DialTimeout(network, address, Timeout) 10 | } 11 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: CTF Gameserver 2 | site_description: Documentation for FAUST CTF Gameserver 3 | site_author: FAUST -- FAU Security Team 4 | site_url: https://ctf-gameserver.org 5 | repo_url: https://github.com/fausecteam/ctf-gameserver 6 | # Disable "Edit in GitHub" 7 | edit_uri: '' 8 | 9 | theme: 10 | name: mkdocs 11 | nav_style: light 12 | # This would load third-party JavaScript, better disable it 13 | highlightjs: False 14 | 15 | nav: 16 | - Home: index.md 17 | - Architecture: architecture.md 18 | - Installation: installation.md 19 | - Checkers: 20 | - General: checkers/index.md 21 | - checkers/python-library.md 22 | - checkers/go-library.md 23 | - Submission: submission.md 24 | - Observability: observability.md 25 | 26 | # Use copyright field to add links to the footer 27 | copyright: 'Privacy | Legal' 28 | 29 | site_dir: docs_site 30 | markdown_extensions: 31 | - toc: 32 | permalink: True 33 | - admonition 34 | -------------------------------------------------------------------------------- /scripts/checker/ctf-checkermaster: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from ctf_gameserver.checker import main 6 | 7 | 8 | if __name__ == '__main__': 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /scripts/checker/ctf-logviewer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from os import execlp, environ 4 | from sys import argv, exit, stderr 5 | import subprocess 6 | import re 7 | import shlex 8 | 9 | if len(argv) > 1: 10 | allowed_services = argv[1].split(",") 11 | else: 12 | allowed_services = ["*"] 13 | 14 | if not 'SSH_ORIGINAL_COMMAND' in environ: 15 | print("No service selected", file=stderr) 16 | print("You are authorized to access %s" % allowed_services, 17 | file=stderr) 18 | exit(1) 19 | 20 | origcmd = environ['SSH_ORIGINAL_COMMAND'] 21 | origargs = shlex.split(origcmd) 22 | 23 | service = origargs[0] 24 | unit = "ctf-checkermaster@%s.service" % service 25 | 26 | units = subprocess.check_output(["journalctl", "-F_SYSTEMD_UNIT"]).decode().split("\n") 27 | if unit not in units: 28 | print("No logs for service '%s' does not exist!" % service, file=stderr) 29 | exit(1) 30 | 31 | if service not in allowed_services and "*" not in allowed_services: 32 | print("You are not authorized to access logs for service %s" % service, 33 | file=stderr) 34 | exit(1) 35 | 36 | if len(origargs) == 1: 37 | execlp("journalctl", "journalctl", "--no-pager", "-f", "-n", "200", "-u", unit) 38 | else: 39 | matchobj = re.match("(?:team(?P[0-9]+))?-?(?:tick(?P[0-9]+))?", origargs[1]) 40 | groups = [(key, value) for key, value in matchobj.groupdict().items() if value is not None] 41 | if matchobj is not None and groups: 42 | filters = ["=".join(kv) for kv in groups] 43 | execlp("journalctl", "journalctl", "--no-pager", "-u", unit, *filters) 44 | else: 45 | print("Expected 'team([0-9]+)-tick([0-9]+)' but got '%s'" % origargs[1], 46 | file=stderr) 47 | -------------------------------------------------------------------------------- /scripts/controller/ctf-controller: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from ctf_gameserver.controller import main 6 | 7 | 8 | if __name__ == '__main__': 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /scripts/submission/ctf-submission: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from ctf_gameserver.submission import main 6 | 7 | 8 | if __name__ == '__main__': 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /scripts/vpnstatus/ctf-vpnstatus: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import sys 4 | 5 | from ctf_gameserver.vpnstatus import main 6 | 7 | 8 | if __name__ == '__main__': 9 | sys.exit(main()) 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from setuptools import setup, find_packages 6 | 7 | 8 | setup( 9 | name = 'ctf_gameserver', 10 | description = 'FAUST CTF Gameserver', 11 | url = 'http://ctf-gameserver.org', 12 | version = '1.0', 13 | author = 'Christoph Egger, Felix Dreissig', 14 | author_email = 'christoph.egger@fau.de, f30@f30.me', 15 | license = 'ISC', 16 | 17 | install_requires = [ 18 | 'ConfigArgParse', 19 | 'Django == 3.2.*', 20 | 'Markdown', 21 | 'Pillow', 22 | 'prometheus_client', 23 | 'pytz', 24 | 'requests', 25 | ], 26 | extras_require = { 27 | 'dev': [ 28 | 'bandit', 29 | 'mkdocs', 30 | 'psycopg2-binary', 31 | 'pycodestyle', 32 | 'pylint', 33 | 'pytest', 34 | 'pytest-cov', 35 | 'tox' 36 | ], 37 | 'prod': [ 38 | 'psycopg2', 39 | 'systemd' 40 | ] 41 | }, 42 | 43 | package_dir = {'': 'src'}, 44 | packages = find_packages('src'), 45 | scripts = [ 46 | 'scripts/checker/ctf-checkermaster', 47 | 'scripts/checker/ctf-logviewer', 48 | 'scripts/controller/ctf-controller', 49 | 'scripts/submission/ctf-submission', 50 | 'scripts/vpnstatus/ctf-vpnstatus' 51 | ], 52 | package_data = { 53 | 'ctf_gameserver.web': [ 54 | '*/templates/*.html', 55 | '*/templates/*.txt', 56 | 'templates/*.html', 57 | 'templates/*.txt', 58 | 'static/robots.txt', 59 | 'static/*.css', 60 | 'static/*.gif', 61 | 'static/*.js', 62 | 'static/ext/jquery.min.js', 63 | 'static/ext/bootstrap/css/*', 64 | 'static/ext/bootstrap/fonts/*', 65 | 'static/ext/bootstrap/js/*' 66 | ], 67 | 'ctf_gameserver.web.registration': [ 68 | 'countries.csv' 69 | ] 70 | } 71 | ) 72 | -------------------------------------------------------------------------------- /src/ctf_gameserver/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/checker/__init__.py: -------------------------------------------------------------------------------- 1 | from .master import main 2 | -------------------------------------------------------------------------------- /src/ctf_gameserver/checkerlib/__init__.py: -------------------------------------------------------------------------------- 1 | from .lib import BaseChecker, CheckResult, get_flag, set_flagid, load_state, run_check, store_state 2 | -------------------------------------------------------------------------------- /src/ctf_gameserver/controller/__init__.py: -------------------------------------------------------------------------------- 1 | from .controller import main 2 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/lib/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/args.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import urllib.parse 3 | 4 | import configargparse 5 | 6 | 7 | def get_arg_parser_with_db(description): 8 | """ 9 | Returns an ArgumentParser pre-initalized with common arguments for configuring logging and the main 10 | database connection. It also supports reading arguments from environment variables. 11 | """ 12 | 13 | parser = configargparse.ArgumentParser(description=description, auto_env_var_prefix='ctf_') 14 | 15 | parser.add_argument('--loglevel', default='WARNING', type=str, 16 | choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], help='Log level') 17 | 18 | db_group = parser.add_argument_group('database', 'Gameserver database') 19 | db_group.add_argument('--dbhost', type=str, help='Hostname of the database. If unspecified, the ' 20 | 'default Unix socket will be used.') 21 | db_group.add_argument('--dbname', type=str, required=True, help='Name of the used database') 22 | db_group.add_argument('--dbuser', type=str, required=True, help='User name for database access') 23 | db_group.add_argument('--dbpassword', type=str, help='Password for database access if needed') 24 | 25 | return parser 26 | 27 | 28 | def parse_host_port(text): 29 | 30 | """ 31 | Parses a host and port specification from a string in the format `:`. 32 | 33 | Returns: 34 | The parsing result as a tuple of (host, port, family). `family` is a constant from Python's socket 35 | interface representing an address family, e.g. `socket.AF_INET`. 36 | """ 37 | 38 | # Use pseudo URL for splitting, see https://stackoverflow.com/a/53172593 39 | url_parts = urllib.parse.urlsplit('//' + text) 40 | if url_parts.hostname is None or url_parts.port is None: 41 | raise ValueError('Invalid host or port') 42 | 43 | try: 44 | addrinfo = socket.getaddrinfo(url_parts.hostname, url_parts.port) 45 | except socket.gaierror as e: 46 | raise ValueError('Could not determine address family') from e 47 | 48 | return (url_parts.hostname, url_parts.port, addrinfo[0][0]) 49 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/checkresult.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | 4 | STATUS_TIMEOUT = 5 5 | 6 | 7 | class CheckResult(enum.Enum): 8 | """ 9 | Maps possible Checker results to their integer values. 10 | These integers map directly to the database! (See also: "web/scoring/models.py") 11 | """ 12 | 13 | OK = 0 14 | DOWN = 1 # Error in the network connection, e.g. a timeout or connection abort 15 | FAULTY = 2 # Service is available, but not behaving as expected 16 | FLAG_NOT_FOUND = 3 17 | RECOVERING = 4 18 | # TIMEOUT (5) is only used internally and not exposed here, especially not to Checker Scripts 19 | 20 | def __str__(self): 21 | return self.name 22 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/daemon.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def notify(*args, **kwargs): 5 | 6 | try: 7 | import systemd.daemon # pylint: disable=import-outside-toplevel 8 | systemd.daemon.notify(*args, **kwargs) 9 | except ImportError: 10 | logging.info('Ignoring daemon notification due to missing systemd module') 11 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/database.py: -------------------------------------------------------------------------------- 1 | from contextlib import contextmanager 2 | import sqlite3 3 | 4 | 5 | @contextmanager 6 | def transaction_cursor(db_conn, always_rollback=False): 7 | """ 8 | Context Manager providing a cursor within a database transaction for any PEP 249-compliant database 9 | connection (with support for transactions). The transaction will be committed after leaving the context 10 | and rolled back when an exception occurs in the context. 11 | Context Managers for the database are not specified by PEP 249 and implemented by some libraries (e.g. 12 | psycopg2) in ways incompatible to each other. 13 | 14 | Args: 15 | db_conn: A PEP 249-compliant database connection. 16 | always_rollback: Do never commit transactions, but always roll them back. Useful for testing the 17 | required grants (at least with some databases). 18 | """ 19 | 20 | # A transaction BEGINs implicitly when the previous one has been finalized 21 | cursor = db_conn.cursor() 22 | 23 | if isinstance(cursor, sqlite3.Cursor): 24 | if sqlite3.threadsafety < 2: 25 | raise Exception('SQLite must be built with thread safety') 26 | 27 | cursor = _SQLite3Cursor(cursor) 28 | 29 | try: 30 | yield cursor 31 | except: # noqa 32 | db_conn.rollback() 33 | raise 34 | 35 | if always_rollback: 36 | db_conn.rollback() 37 | else: 38 | db_conn.commit() 39 | 40 | 41 | class _SQLite3Cursor: 42 | """ 43 | Wrapper for sqlite3.Cursor, which translates Psycopg2-style parameter format strings and SQL features 44 | to constructs understood by SQLite. 45 | This is quite hacky, but it should only ever be used in tests, as we don't support SQLite in production. 46 | """ 47 | 48 | def __init__(self, orig_cursor): 49 | self._orig_cursor = orig_cursor 50 | 51 | def __getattribute__(self, name): 52 | # Prevent endless recursion 53 | if name == '_orig_cursor': 54 | return object.__getattribute__(self, name) 55 | 56 | if name == 'execute': 57 | def sqlite3_execute(_, operation, *args, **kwargs): 58 | operation = _translate_operation(operation) 59 | return self._orig_cursor.execute(operation, *args, **kwargs) 60 | 61 | # Turn function into bound method (to be called on an instance) 62 | # pylint: disable=no-value-for-parameter 63 | sqlite3_execute_bound = sqlite3_execute.__get__(self, _SQLite3Cursor) 64 | return sqlite3_execute_bound 65 | 66 | if name == 'executemany': 67 | def sqlite3_executemany(_, operation, *args, **kwargs): 68 | operation = _translate_operation(operation) 69 | return self._orig_cursor.executemany(operation, *args, **kwargs) 70 | 71 | # pylint: disable=no-value-for-parameter 72 | sqlite3_executemany_bound = sqlite3_executemany.__get__(self, _SQLite3Cursor) 73 | return sqlite3_executemany_bound 74 | 75 | return self._orig_cursor.__getattribute__(name) 76 | 77 | 78 | def _translate_operation(operation): 79 | """ 80 | Translates Psycopg2 features to their SQLite counterparts on a best-effort base. 81 | """ 82 | 83 | # Apart from being a best effort, this also changes the semantics, but SQLite just doesn't support 84 | # "LOCK TABLE" 85 | if operation.startswith('LOCK TABLE'): 86 | return '' 87 | 88 | # The placeholder is always "%s" in Psycopg2, "even if a different placeholder (such as a %d for 89 | # integers or %f for floats) may look more appropriate" 90 | operation = operation.replace('%s', '?') 91 | operation = operation.replace('NOW()', "DATETIME('now')") 92 | 93 | return operation 94 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/date_time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | 4 | def ensure_utc_aware(datetime_or_time): 5 | """ 6 | Ensures that a datetime or time object is timezone-aware. 7 | For naive objects, a new object is returned with its timezone set to UTC. Already timezone-aware objects 8 | are returned as-is, without any timezone change or conversion. 9 | """ 10 | 11 | if datetime_or_time is None: 12 | return None 13 | 14 | # This is how timezone-awareness is officially defined 15 | if datetime_or_time.tzinfo is not None: 16 | if isinstance(datetime_or_time, datetime.datetime): 17 | if datetime_or_time.tzinfo.utcoffset(datetime_or_time) is not None: 18 | return datetime_or_time 19 | elif isinstance(datetime_or_time, datetime.time): 20 | if datetime_or_time.tzinfo.utcoffset(None) is not None: 21 | return datetime_or_time 22 | else: 23 | raise TypeError('ensure_utc_aware() can only handle datetime and time objects') 24 | 25 | return datetime_or_time.replace(tzinfo=datetime.timezone.utc) 26 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/exceptions.py: -------------------------------------------------------------------------------- 1 | class DBDataError(ValueError): 2 | """ 3 | A piece of information is missing from the database or in an unexpected state. 4 | """ 5 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/flag.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | import datetime 4 | import hashlib 5 | from hmac import compare_digest 6 | import struct 7 | 8 | # Length of the MAC (in bytes) 9 | MAC_LEN = 10 10 | # expiration_timestamp + flag.id + protecting_team.net_no 11 | DATA_LEN = 8 + 4 + 2 12 | # Static string with which flags get XOR-ed to make them look more random (just for the looks) 13 | XOR_STRING = b'CTF-GAMESERVER' 14 | 15 | 16 | def generate(expiration_time, flag_id, team_net_no, secret, prefix='FLAG_'): 17 | """ 18 | Generates a flag for the given arguments, i.e. the MAC-protected string that gets placed in services and 19 | captured by teams. This is deterministic and should always return the same result for the same arguments. 20 | 21 | Args: 22 | expiration_time: Datetime object (preferably timezone-aware) at which the flag expires 23 | flag_id: ID (primary key) of the flag's associated database entry 24 | team_net_no: Net number of the team protecting this flag 25 | secret: Secret used for the MAC 26 | prefix: String to prepend to the generated flag 27 | """ 28 | 29 | if flag_id < 0 or flag_id > 2**32 - 1: 30 | raise ValueError('Flag ID must fit in unsigned 32 bits') 31 | if team_net_no < 0 or team_net_no > 2**16 - 1: 32 | raise ValueError('Team net number must fit in unsigned 16 bits') 33 | 34 | protected_data = struct.pack('! Q I H', int(expiration_time.timestamp()), flag_id, team_net_no) 35 | protected_data = bytes([c ^ d for c, d in zip(protected_data, XOR_STRING)]) 36 | mac = _gen_mac(secret, protected_data) 37 | 38 | return prefix + base64.b64encode(protected_data + mac).decode('ascii') 39 | 40 | 41 | def verify(flag, secret, prefix='FLAG_'): 42 | """ 43 | Verifies flag validity and returns data from the flag. 44 | Will raise an appropriate exception if verification fails. 45 | 46 | Args: 47 | flag: MAC-protected flag string 48 | secret: Secret used for the MAC 49 | prefix: String to prepend to the generated flag 50 | 51 | Returns: 52 | Data from the flag as a tuple of (flag_id, team_net_no) 53 | """ 54 | 55 | if not flag.startswith(prefix): 56 | raise InvalidFlagFormat() 57 | 58 | try: 59 | raw_flag = base64.b64decode(flag[len(prefix):]) 60 | except (ValueError, binascii.Error): 61 | raise InvalidFlagFormat() from None 62 | 63 | try: 64 | protected_data, flag_mac = raw_flag[:DATA_LEN], raw_flag[DATA_LEN:] 65 | except IndexError: 66 | raise InvalidFlagFormat() from None 67 | 68 | mac = _gen_mac(secret, protected_data) 69 | if not compare_digest(mac, flag_mac): 70 | raise InvalidFlagMAC() 71 | 72 | protected_data = bytes([c ^ d for c, d in zip(protected_data, XOR_STRING)]) 73 | expiration_timestamp, flag_id, team_net_no = struct.unpack('! Q I H', protected_data) 74 | expiration_time = datetime.datetime.fromtimestamp(expiration_timestamp, datetime.timezone.utc) 75 | if expiration_time < _now(): 76 | raise FlagExpired(expiration_time) 77 | 78 | return (flag_id, team_net_no) 79 | 80 | 81 | def _gen_mac(secret, protected_data): 82 | 83 | # Keccak does not need an HMAC construction, the secret can simply be prepended 84 | sha3 = hashlib.sha3_256() 85 | sha3.update(secret) 86 | sha3.update(protected_data) 87 | return sha3.digest()[:MAC_LEN] 88 | 89 | 90 | def _now(): 91 | """ 92 | Wrapper around datetime.datetime.now() to enable mocking in test cases. 93 | """ 94 | 95 | return datetime.datetime.now(datetime.timezone.utc) 96 | 97 | 98 | class FlagVerificationError(Exception): 99 | """ 100 | Base class for all Flag Exceptions. 101 | """ 102 | 103 | 104 | class InvalidFlagFormat(FlagVerificationError): 105 | """ 106 | Flag does not match the expected format. 107 | """ 108 | 109 | 110 | class InvalidFlagMAC(FlagVerificationError): 111 | """ 112 | MAC does not match with configured secret. 113 | """ 114 | 115 | 116 | class FlagExpired(FlagVerificationError): 117 | """ 118 | Flag is already expired. 119 | """ 120 | 121 | def __init__(self, expiration_time): 122 | super().__init__(f'Flag expired since {expiration_time}') 123 | self.expiration_time = expiration_time 124 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/metrics.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from wsgiref import simple_server 3 | 4 | import prometheus_client 5 | 6 | 7 | def start_metrics_server(host, port, family, registry=prometheus_client.REGISTRY): 8 | """ 9 | Custom variant of prometheus_client.start_wsgi_server() with support for specifying the address family to 10 | listen on. 11 | """ 12 | 13 | class FamilyServer(simple_server.WSGIServer): 14 | address_family = family 15 | 16 | app = prometheus_client.make_wsgi_app(registry) 17 | http_server = simple_server.make_server(host, port, app, FamilyServer, handler_class=SilentHandler) 18 | thread = threading.Thread(target=http_server.serve_forever) 19 | thread.daemon = True 20 | thread.start() 21 | 22 | 23 | class SilentHandler(simple_server.WSGIRequestHandler): 24 | 25 | def log_message(self, _, *args): # pylint: disable=arguments-differ 26 | """ 27 | Doesn't log anything. 28 | """ 29 | -------------------------------------------------------------------------------- /src/ctf_gameserver/lib/test_util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities for writing unit tests for CTF Gameserver code. 3 | """ 4 | 5 | import os 6 | 7 | import django 8 | from django.db import connection 9 | from django.test.testcases import TransactionTestCase 10 | from django.test.utils import setup_databases, teardown_databases 11 | 12 | 13 | class DatabaseTestCase(TransactionTestCase): 14 | """ 15 | Base class for test cases which use the Django facilities to provide a temporary test database and 16 | (database) fixture loading, but test code which does not belong to the web component. 17 | """ 18 | 19 | @classmethod 20 | def setUpClass(cls): 21 | """ 22 | Sets up a temporary test database for the whole test case. 23 | For regular Django tests, this is usually done by Django's test runner. 24 | """ 25 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ctf_gameserver.web.dev_settings') 26 | django.setup() 27 | 28 | # `interactive=False` causes the test database to be destroyed without asking if it already exists 29 | cls._old_db_conf = setup_databases(verbosity=1, interactive=False) 30 | 31 | super().setUpClass() 32 | 33 | # Get a fresh raw DB connection with as little of Django's pre-configuration as possible 34 | cls.connection = connection.get_new_connection(connection.get_connection_params()) 35 | # Ensure SQLite's default isolaton level (without autocommit) is being used 36 | cls.connection.isolation_level = '' 37 | 38 | @classmethod 39 | def tearDownClass(cls): 40 | super().tearDownClass() 41 | teardown_databases(cls._old_db_conf, verbosity=1) 42 | 43 | @property 44 | def connection(self): 45 | return self.__class__.connection 46 | -------------------------------------------------------------------------------- /src/ctf_gameserver/submission/__init__.py: -------------------------------------------------------------------------------- 1 | from .submission import main 2 | -------------------------------------------------------------------------------- /src/ctf_gameserver/submission/database.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | 3 | # See https://github.com/PyCQA/pylint/issues/2948 for Pylint behavior 4 | from psycopg2.errors import UniqueViolation # pylint: disable=no-name-in-module 5 | 6 | from ctf_gameserver.lib.database import transaction_cursor 7 | from ctf_gameserver.lib.date_time import ensure_utc_aware 8 | from ctf_gameserver.lib.exceptions import DBDataError 9 | 10 | 11 | def get_static_info(db_conn): 12 | """ 13 | Returns the competition's name and the flag prefix, as configured in the database. 14 | """ 15 | 16 | with transaction_cursor(db_conn) as cursor: 17 | cursor.execute('SELECT competition_name, flag_prefix FROM scoring_gamecontrol') 18 | result = cursor.fetchone() 19 | 20 | if result is None: 21 | raise DBDataError('Game control information has not been configured') 22 | 23 | return result 24 | 25 | 26 | def get_dynamic_info(db_conn): 27 | """ 28 | Returns the competition's start and end time, as stored in the database. 29 | """ 30 | 31 | with transaction_cursor(db_conn) as cursor: 32 | cursor.execute('SELECT start, "end" FROM scoring_gamecontrol') 33 | result = cursor.fetchone() 34 | 35 | if result is None: 36 | raise DBDataError('Game control information has not been configured') 37 | 38 | return (ensure_utc_aware(result[0]), ensure_utc_aware(result[1])) 39 | 40 | 41 | def team_is_nop(db_conn, team_net_no): 42 | """ 43 | Returns whether the team with the given net number is marked as NOP team. 44 | """ 45 | 46 | with transaction_cursor(db_conn) as cursor: 47 | cursor.execute('SELECT nop_team FROM registration_team WHERE net_number = %s', (team_net_no,)) 48 | result = cursor.fetchone() 49 | 50 | if result is None: 51 | return False 52 | 53 | return result[0] 54 | 55 | 56 | def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False, fake_team_id=None, 57 | fake_tick=None): 58 | """ 59 | Stores a capture of the given flag by the given team in the database. 60 | """ 61 | 62 | with transaction_cursor(db_conn, prohibit_changes) as cursor: 63 | cursor.execute('SELECT user_id FROM registration_team WHERE net_number = %s', 64 | (capturing_team_net_no,)) 65 | result = cursor.fetchone() 66 | if fake_team_id is not None: 67 | result = (fake_team_id,) 68 | if result is None: 69 | raise TeamNotExisting() 70 | capturing_team_id = result[0] 71 | 72 | cursor.execute('SELECT current_tick FROM scoring_gamecontrol') 73 | result = cursor.fetchone() 74 | if fake_tick is not None: 75 | result = (fake_tick,) 76 | tick = result[0] 77 | 78 | try: 79 | cursor.execute('INSERT INTO scoring_capture (flag_id, capturing_team_id, timestamp, tick)' 80 | ' VALUES (%s, %s, NOW(), %s)', (flag_id, capturing_team_id, tick)) 81 | except (UniqueViolation, sqlite3.IntegrityError): 82 | raise DuplicateCapture() from None 83 | 84 | 85 | class TeamNotExisting(DBDataError): 86 | """ 87 | Indicates that a Team for the given parameters could not be found in the database. 88 | """ 89 | 90 | 91 | class DuplicateCapture(DBDataError): 92 | """ 93 | Indicates that a Flag has already been captured by a Team before. 94 | """ 95 | -------------------------------------------------------------------------------- /src/ctf_gameserver/vpnstatus/__init__.py: -------------------------------------------------------------------------------- 1 | from .status import main 2 | -------------------------------------------------------------------------------- /src/ctf_gameserver/vpnstatus/database.py: -------------------------------------------------------------------------------- 1 | from ctf_gameserver.lib.database import transaction_cursor 2 | 3 | 4 | def get_active_teams(db_conn): 5 | """ 6 | Returns active teams as tuples of (user) ID and net number. 7 | """ 8 | 9 | with transaction_cursor(db_conn) as cursor: 10 | cursor.execute('SELECT auth_user.id, team.net_number FROM auth_user, registration_team team' 11 | ' WHERE auth_user.id = team.user_id AND auth_user.is_active') 12 | result = cursor.fetchall() 13 | 14 | return result 15 | 16 | 17 | def add_results(db_conn, results_dict, prohibit_changes=False): 18 | """ 19 | Stores all check results for all teams in the database. Expects the results as a nested dict with team 20 | IDs as outer keys and kinds of checks as inner keys. 21 | """ 22 | 23 | with transaction_cursor(db_conn, prohibit_changes) as cursor: 24 | rows = [] 25 | for team_id, team_results in results_dict.items(): 26 | rows.append((team_id, team_results['wireguard_handshake_time'], 27 | team_results['gateway_ping_rtt_ms'], team_results['demo_ping_rtt_ms'], 28 | team_results['demo_service_ok'], team_results['vulnbox_ping_rtt_ms'], 29 | team_results['vulnbox_service_ok'])) 30 | 31 | cursor.executemany('INSERT INTO vpnstatus_vpnstatuscheck (team_id, wireguard_handshake_time,' 32 | ' gateway_ping_rtt_ms, demo_ping_rtt_ms, demo_service_ok, vulnbox_ping_rtt_ms,' 33 | ' vulnbox_service_ok, timestamp)' 34 | 'VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())', rows) 35 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.functional import classproperty 3 | from django.utils.translation import gettext_lazy as _ 4 | from django.contrib.auth.models import User 5 | from django.contrib.auth.admin import UserAdmin 6 | 7 | from .registration.models import Team 8 | from .registration.admin_inline import InlineTeamAdmin 9 | from .scoring.models import GameControl 10 | from .util import format_lazy 11 | 12 | 13 | class CTFAdminSite(admin.AdminSite): 14 | """ 15 | Custom variant of the AdminSite which replaces the default headers and titles. 16 | """ 17 | 18 | index_title = _('Administration home') 19 | 20 | # Declare this lazily through a classproperty in order to avoid a circular dependency when creating 21 | # migrations 22 | @classproperty 23 | def site_header(cls): # pylint: disable=no-self-argument 24 | return format_lazy(_('{competition_name} administration'), 25 | competition_name=GameControl.get_instance().competition_name) 26 | 27 | @classproperty 28 | def site_title(cls): # pylint: disable=no-self-argument 29 | return cls.site_header 30 | 31 | 32 | admin_site = CTFAdminSite() # pylint: disable=invalid-name 33 | 34 | 35 | @admin.register(User, site=admin_site) 36 | class CTFUserAdmin(UserAdmin): 37 | """ 38 | Custom variant of UserAdmin which adjusts the displayed, filterable and editable fields and adds an 39 | InlineModelAdmin for the associated team. 40 | """ 41 | 42 | def __init__(self, *args, **kwargs): 43 | super().__init__(*args, **kwargs) 44 | 45 | # Add email field to user creation form 46 | for fieldset in self.add_fieldsets: 47 | if fieldset[0] is None: 48 | fieldset[1]['fields'] += ('email',) 49 | 50 | class TeamListFilter(admin.SimpleListFilter): 51 | """ 52 | Admin list filter which allows filtering of user lists by whether they are associated with a Team. 53 | """ 54 | title = _('associated team') 55 | parameter_name = 'has_team' 56 | 57 | def lookups(self, request, model_admin): 58 | return ( 59 | ('1', _('Yes')), 60 | ('0', _('No')) 61 | ) 62 | 63 | def queryset(self, request, queryset): 64 | if self.value() == '1': 65 | return queryset.filter(team__isnull=False) 66 | elif self.value() == '0': 67 | return queryset.filter(team__isnull=True) 68 | else: 69 | return queryset 70 | 71 | @admin.display(ordering='team__net_number', description='Net Number') 72 | def team_net_number(self, user): 73 | try: 74 | return user.team.net_number 75 | except Team.DoesNotExist: 76 | return None 77 | 78 | list_display = ('username', 'is_active', 'is_staff', 'is_superuser', 'team_net_number', 'date_joined') 79 | list_filter = ('is_active', 'is_staff', 'is_superuser', TeamListFilter, 'date_joined') 80 | search_fields = ('username', 'email', 'team__net_number', 'team__informal_email', 'team__affiliation', 81 | 'team__country') 82 | 83 | fieldsets = ( 84 | (None, {'fields': ('username', 'password', 'email')}), 85 | (_('Permissions'), {'fields': ('is_active', 'is_staff', 'is_superuser')}), 86 | (_('Important dates'), {'fields': ('last_login', 'date_joined')}), 87 | ) 88 | inlines = [InlineTeamAdmin] 89 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/base_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common Django settings for the web part of 'ctf-gameserver'. 3 | You should not have to edit this file for out-of-the-box usage, but of course it's customizable just as the 4 | rest of the code. 5 | """ 6 | 7 | import os 8 | 9 | from django.urls import reverse_lazy 10 | from django.contrib.messages import constants as messages 11 | 12 | # This file's directory, to conveniently build absolute paths using `os.path.join(BASE_DIR, )` 13 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 14 | 15 | 16 | HOME_URL = reverse_lazy('home_flatpage') 17 | THUMBNAIL_SIZE = (100, 100) 18 | 19 | 20 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' 21 | 22 | INSTALLED_APPS = ( 23 | 'django.contrib.contenttypes', 24 | 'django.contrib.sessions', 25 | 'django.contrib.auth', 26 | 'django.contrib.messages', 27 | 'django.contrib.admin', 28 | 'django.contrib.staticfiles', 29 | 'ctf_gameserver.web.templatetags', 30 | 'ctf_gameserver.web.registration', 31 | 'ctf_gameserver.web.scoring', 32 | 'ctf_gameserver.web.flatpages', 33 | 'ctf_gameserver.web.vpnstatus' 34 | ) 35 | 36 | # Ordering of the middlewares is important, see 37 | # https://docs.djangoproject.com/en/1.11/ref/middleware/#middleware-ordering 38 | MIDDLEWARE = [ 39 | 'django.middleware.security.SecurityMiddleware', 40 | 'django.contrib.sessions.middleware.SessionMiddleware', 41 | 'django.middleware.common.CommonMiddleware', 42 | 'django.middleware.csrf.CsrfViewMiddleware', 43 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 44 | 'django.contrib.messages.middleware.MessageMiddleware', 45 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 46 | 'ctf_gameserver.web.middleware.csp_middleware' 47 | ] 48 | 49 | TEMPLATES = [{ 50 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 51 | 'DIRS': [os.path.join(BASE_DIR, 'templates')], 52 | 'APP_DIRS': True, 53 | 'OPTIONS': { 54 | 'context_processors': [ 55 | 'django.contrib.auth.context_processors.auth', 56 | 'django.contrib.messages.context_processors.messages', 57 | 'django.template.context_processors.request', 58 | 'django.template.context_processors.i18n', 59 | 'django.template.context_processors.static', 60 | 'django.template.context_processors.media', 61 | 'ctf_gameserver.web.context_processors.game_control', 62 | 'ctf_gameserver.web.context_processors.flatpage_nav' 63 | ] 64 | } 65 | }] 66 | 67 | STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] 68 | 69 | ROOT_URLCONF = 'ctf_gameserver.web.urls' 70 | WSGI_APPLICATION = 'ctf_gameserver.web.wsgi.application' 71 | 72 | STATIC_URL = '/static/' 73 | MEDIA_URL = '/uploads/' 74 | LOGIN_URL = '/auth/login/' 75 | LOGOUT_URL = '/auth/logout/' 76 | LOGIN_REDIRECT_URL = HOME_URL 77 | 78 | # Make message level tags match the CSS classes from Bootstrap 79 | MESSAGE_TAGS = { 80 | messages.ERROR: 'alert-danger', 81 | messages.WARNING: 'alert-warning', 82 | messages.SUCCESS: 'alert-success', 83 | messages.INFO: 'alert-info', 84 | messages.DEBUG: 'ialert-info' 85 | } 86 | 87 | # We're prepared for translations, but don't provide them out-of-the-box; most internationalization features 88 | # can therefore be disabled 89 | USE_I18N = False 90 | USE_TZ = True 91 | LANGUAGE_CODE = 'en-us' 92 | 93 | TIME_FORMAT = 'H:i' 94 | MONTH_DAY_FORMAT = 'j F' 95 | DATE_FORMAT = MONTH_DAY_FORMAT + ' Y' 96 | SHORT_DATE_FORMAT = 'Y-m-d' 97 | DATETIME_FORMAT = DATE_FORMAT + ' ' + TIME_FORMAT 98 | SHORT_DATETIME_FORMAT = SHORT_DATE_FORMAT + ' ' + TIME_FORMAT 99 | 100 | PASSWORD_RESET_TIMEOUT = 86400 101 | CSRF_COOKIE_HTTPONLY = True 102 | SECURE_CONTENT_TYPE_NOSNIFF = True 103 | SECURE_BROWSER_XSS_FILTER = True 104 | X_FRAME_OPTIONS = 'DENY' 105 | CSRF_COOKIE_SAMESITE = 'Lax' 106 | SESSION_COOKIE_SAMESITE = 'Lax' 107 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/context_processors.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | from .scoring import models as scoring_models 4 | from .flatpages import models as flatpages_models 5 | 6 | 7 | def game_control(_): 8 | """ 9 | Context processor which adds information from the Game Control table to the context. 10 | """ 11 | 12 | control_instance = scoring_models.GameControl.get_instance() 13 | 14 | return { 15 | 'competition_name': control_instance.competition_name, 16 | 'registration_open': control_instance.registration_open, 17 | 'services_public': control_instance.are_services_public() 18 | } 19 | 20 | 21 | def flatpage_nav(_): 22 | """ 23 | Context processor which adds data required for the main navigation of flatpages to the context. 24 | """ 25 | 26 | categories = flatpages_models.Category.objects.all() 27 | pages = flatpages_models.Flatpage.objects_without_category.all() 28 | 29 | return {'all_categories': categories, 'pages_without_category': pages, 'HOME_URL': settings.HOME_URL} 30 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/dev_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django and project specific settings for usage during development. 3 | Everything should be ready-to-go for a common development environment, but you may of course tweak some 4 | options. 5 | """ 6 | 7 | # pylint: disable=wildcard-import, unused-wildcard-import 8 | from .base_settings import * 9 | 10 | 11 | CSP_POLICIES = { 12 | # The debug error page uses inline JavaScript and CSS 13 | 'script-src': ["'self'", "'unsafe-inline'"], 14 | 'style-src': ["'self'", "'unsafe-inline'"], 15 | 'object-src': ["'self'"], 16 | 'connect-src': ["'self'"] 17 | } 18 | 19 | 20 | DATABASES = { 21 | 'default': { 22 | 'ENGINE': 'django.db.backends.sqlite3', 23 | 'NAME': os.path.join(BASE_DIR, 'dev-db.sqlite3'), 24 | } 25 | } 26 | 27 | CACHES = { 28 | 'default': { 29 | 'BACKEND': 'django.core.cache.backends.dummy.DummyCache' 30 | } 31 | } 32 | 33 | EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 34 | DEFAULT_FROM_EMAIL = 'ctf-gameserver.web@localhost' 35 | 36 | MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') 37 | TEAM_DOWNLOADS_ROOT = os.path.join(BASE_DIR, 'team_downloads') 38 | 39 | SESSION_ENGINE = 'django.contrib.sessions.backends.db' 40 | 41 | SECRET_KEY = 'OnlySuitableForDevelopment' # nosec 42 | 43 | TIME_ZONE = 'UTC' 44 | FIRST_DAY_OF_WEEK = 1 45 | 46 | 47 | DEBUG = True 48 | INTERNAL_IPS = ['127.0.0.1'] 49 | 50 | GRAYLOG_SEARCH_URL = 'http://localhost:9000/search' 51 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/flatpages/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/flatpages/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/flatpages/admin.py: -------------------------------------------------------------------------------- 1 | from django.utils.translation import gettext_lazy as _ 2 | from django.contrib import admin 3 | 4 | from ctf_gameserver.web.admin import admin_site 5 | from . import models, forms 6 | 7 | 8 | @admin.register(models.Category, site=admin_site) 9 | class CategoryAdmin(admin.ModelAdmin): 10 | """ 11 | Admin object for the flatpage Categories. 12 | """ 13 | 14 | list_display = ('title', 'ordering') 15 | list_editable = ('ordering',) 16 | search_fields = ('title',) 17 | 18 | form = forms.CategoryAdminForm 19 | 20 | 21 | @admin.register(models.Flatpage, site=admin_site) 22 | class FlatpageAdmin(admin.ModelAdmin): 23 | """ 24 | Admin object for Flatpage objects from the custom flatpages implementation. 25 | """ 26 | 27 | list_display = ('title', 'category', 'ordering') 28 | list_editable = ('category', 'ordering') 29 | list_filter = ('category',) 30 | search_fields = ('title', 'content') 31 | 32 | form = forms.FlatpageAdminForm 33 | fieldsets = ( 34 | (None, {'fields': ('title', 'content')}), 35 | (_('Menu hierarchy'), {'fields': ('category', 'ordering')}) 36 | ) 37 | view_on_site = True 38 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/flatpages/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.text import slugify 3 | from django.utils.safestring import mark_safe 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from . import models 7 | 8 | 9 | class CategoryAdminForm(forms.ModelForm): 10 | """ 11 | Form for Category objects, designed primarily to be used in CategoryAdmin. 12 | """ 13 | 14 | class Meta: 15 | model = models.Category 16 | exclude = ('slug',) 17 | 18 | def save(self, commit=True): 19 | category = super().save(commit=False) 20 | slug = slugify(category.title) 21 | 22 | raw_slug = slug 23 | counter = 1 24 | 25 | # Titles are just as unique as slugs, but slugify() is not bijective 26 | while models.Category.objects.filter(slug=slug).exclude(pk=category.pk).exists(): 27 | slug = '{}-{:d}'.format(raw_slug, counter) 28 | counter += 1 29 | 30 | category.slug = slug 31 | 32 | if commit: 33 | category.save() 34 | 35 | return category 36 | 37 | 38 | class FlatpageAdminForm(forms.ModelForm): 39 | """ 40 | Form for Flatpage objects, designed primarily to be used in FlatpageAdmin. 41 | """ 42 | 43 | class Meta: 44 | model = models.Flatpage 45 | exclude = ('slug',) 46 | help_texts = { 47 | 'title': _('Leave empty for the home page.'), 48 | # pylint: disable=no-member 49 | 'content': mark_safe(_('{markdown} or raw HTML are allowed.').format( 50 | markdown='' 51 | 'Markdown' 52 | )) 53 | } 54 | 55 | def clean(self): 56 | cleaned_data = super().clean() 57 | 58 | if cleaned_data['category'] is not None and not cleaned_data['title']: 59 | raise forms.ValidationError(_('The home page must not have a category, every other page has to ' 60 | 'have a title')) 61 | 62 | return cleaned_data 63 | 64 | def save(self, commit=True): 65 | page = super().save(commit=False) 66 | slug = slugify(page.title) 67 | 68 | raw_slug = slug 69 | counter = 1 70 | 71 | while models.Flatpage.objects.filter(category=page.category, slug=slug).exclude(pk=page.pk).exists(): 72 | slug = '{}-{:d}'.format(raw_slug, counter) 73 | counter += 1 74 | 75 | page.slug = slug 76 | 77 | if commit: 78 | page.save() 79 | 80 | return page 81 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/flatpages/models.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | from django.db import models 3 | from django.utils.safestring import mark_safe 4 | from markdown import markdown 5 | 6 | 7 | class Category(models.Model): 8 | """ 9 | (Menu) hierarchy level for Flatpages. 10 | """ 11 | 12 | title = models.CharField(max_length=100) 13 | ordering = models.PositiveSmallIntegerField(default=10) 14 | slug = models.SlugField(max_length=100, unique=True) 15 | 16 | class Meta: 17 | ordering = ('ordering', 'title') 18 | 19 | def __str__(self): # pylint: disable=invalid-str-returned 20 | return self.title 21 | 22 | 23 | class Flatpage(models.Model): 24 | """ 25 | Data model for pages with static content ("About" pages, rules etc.). 26 | This custom implementation is quite similar to Django's flat pages, but supports Markdown and 27 | organization of the pages into Categories. As django.contrib.flatpages adds a dependency to the sites 28 | framework, it turned out easier to re-implement the base functionality instead of extending it. 29 | """ 30 | 31 | # Title may be blank for the home page 32 | title = models.CharField(max_length=100, blank=True) 33 | content = models.TextField() 34 | category = models.ForeignKey(Category, null=True, blank=True, on_delete=models.PROTECT) 35 | ordering = models.PositiveSmallIntegerField(default=10) 36 | slug = models.SlugField(max_length=100) 37 | 38 | class Meta: 39 | # Slug is usually (automatically) generated from the title, add constraints for both because of 40 | # https://code.djangoproject.com/ticket/13091 41 | unique_together = ( 42 | ('category', 'title'), 43 | ('category', 'slug') 44 | ) 45 | index_together = ('category', 'slug') 46 | ordering = ('category', 'ordering', 'title') 47 | 48 | class ObjectsWithoutCategoryManager(models.Manager): 49 | def get_queryset(self): 50 | return super().get_queryset().filter(category=None).exclude(title='') 51 | 52 | # The first Manager in a class is used as default 53 | objects = models.Manager() 54 | # QuerySet that only returns Flatpages without a category, but not the home page 55 | objects_without_category = ObjectsWithoutCategoryManager() 56 | 57 | def __str__(self): # pylint: disable=invalid-str-returned 58 | return self.title 59 | 60 | def clean(self): 61 | """ 62 | Performs additional validation to ensure the unique constraint for category and title also applies 63 | when category is NULL. Django's constraint validation skips this case, and the actual constraint's 64 | behavior is database-specific. 65 | """ 66 | if self.category is None and type(self)._default_manager.filter( 67 | category = self.category, 68 | title = self.title 69 | ).exclude(pk=self.pk).exists(): 70 | raise self.unique_error_message(self.__class__, ('category', 'title')) 71 | 72 | def get_absolute_url(self): 73 | # pylint: disable=no-member 74 | if self.is_home_page(): 75 | return reverse('home_flatpage') 76 | elif self.category is None: 77 | return reverse('no_category_flatpage', kwargs={'slug': self.slug}) 78 | else: 79 | return reverse('category_flatpage', kwargs={'category': self.category.slug, 'slug': self.slug}) 80 | 81 | @property 82 | def siblings(self): 83 | """ 84 | Access siblings of this page, i.e. pages in the same category. For convenience, this includes this 85 | page itself. 86 | """ 87 | return type(self)._default_manager.filter(category=self.category) 88 | 89 | def has_siblings(self): 90 | """ 91 | Indicates whether the page has any siblings. This does not include the page itself, so it is False 92 | when `len(self.siblings) == 1`. 93 | """ 94 | return self.siblings.exclude(pk=self.pk).exists() 95 | 96 | def is_home_page(self): 97 | """ 98 | Indicates whether the page is the home page. 99 | """ 100 | return not self.title and self.category is None 101 | 102 | def render_content(self): 103 | """ 104 | Returns the page's content as rendered HTML. 105 | """ 106 | return mark_safe(markdown(self.content)) 107 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/flatpages/templates/flatpage.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 17 | 18 |
19 | {% if sidebar_links %} 20 | 29 | 30 |
31 | {% else %} 32 |
33 | {% endif %} 34 | {{ page.render_content }} 35 |
36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/flatpages/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, get_object_or_404 2 | 3 | from .models import Flatpage 4 | 5 | 6 | def flatpage(request, category=None, slug=''): 7 | if category is None: 8 | page = get_object_or_404(Flatpage, category=None, slug=slug) 9 | else: 10 | page = get_object_or_404(Flatpage, category__slug=category, slug=slug) 11 | 12 | # Hide sidebar links for pages without category 13 | if page.category is not None and page.has_siblings(): 14 | sidebar_links = page.siblings 15 | else: 16 | sidebar_links = [] 17 | 18 | return render(request, 'flatpage.html', {'page': page, 'sidebar_links': sidebar_links}) 19 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm 4 | 5 | from .scoring.models import GameControl 6 | 7 | 8 | class TeamAuthenticationForm(AuthenticationForm): 9 | """ 10 | Custom variant of the login form that replaces "Username" with "Team name". 11 | """ 12 | 13 | username = forms.CharField(max_length=254, label=_('Team name')) 14 | 15 | 16 | class FormalPasswordResetForm(PasswordResetForm): 17 | """ 18 | Custom variant of the password reset form that replaces "Email" with "Formal email", adds a help text 19 | and adds the CTF's title to the email rendering context. 20 | """ 21 | 22 | email = forms.EmailField(max_length=254, label=_('Formal email'), help_text='The address you stated ' 23 | 'as authorative for sensitive requests.') 24 | 25 | def send_mail(self, subject_template_name, email_template_name, context, from_email, to_email, 26 | html_email_template_name=None): 27 | context['competition_name'] = GameControl.get_instance().competition_name 28 | 29 | return super().send_mail(subject_template_name, email_template_name, context, from_email, to_email, 30 | html_email_template_name) 31 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | 3 | 4 | def csp_middleware(get_response): 5 | """ 6 | Middleware which adds a 'Content Security Policy' header according to the 'CSP_POLICIES' setting to every 7 | HTTP response. 8 | """ 9 | 10 | def middleware(request): 11 | response = get_response(request) 12 | 13 | if settings.CSP_POLICIES: 14 | policies = [] 15 | 16 | for directive, values in settings.CSP_POLICIES.items(): 17 | policies.append(directive + ' ' + ' '.join(values)) 18 | 19 | response['Content-Security-Policy'] = '; '.join(policies) 20 | 21 | return response 22 | 23 | return middleware 24 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/registration/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from ctf_gameserver.web.admin import admin_site 4 | from .models import TeamDownload 5 | 6 | 7 | @admin.register(TeamDownload, site=admin_site) 8 | class FlagAdmin(admin.ModelAdmin): 9 | 10 | search_fields = ('filename', 'description') 11 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/admin_inline.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .models import Team 5 | from .forms import AdminTeamForm 6 | 7 | 8 | class InlineTeamAdmin(admin.StackedInline): 9 | """ 10 | InlineModelAdmin for Team objects. Primarily designed to be used within a UserAdmin. 11 | """ 12 | 13 | model = Team 14 | form = AdminTeamForm 15 | 16 | # Abuse the plural title as headline, since more than one team will never be edited using this inline 17 | verbose_name_plural = _('Associated team') 18 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/fields.py: -------------------------------------------------------------------------------- 1 | import os 2 | import warnings 3 | from urllib.parse import urljoin 4 | from io import BytesIO 5 | 6 | from django.db.models.fields.files import ImageField, ImageFieldFile 7 | from django.forms.widgets import ClearableFileInput 8 | from django.conf import settings 9 | from django.utils.encoding import filepath_to_uri 10 | from django.utils.html import conditional_escape, format_html 11 | from PIL import Image 12 | 13 | # Force an error when image decompression takes too much memory 14 | Image.MAX_IMAGE_PIXELS = 2048*2048 15 | warnings.simplefilter('error', Image.DecompressionBombWarning) 16 | 17 | 18 | class ThumbnailImageFieldFile(ImageFieldFile): 19 | """ 20 | Custom variant of ImageFieldFile which automatically generates a thumbnail when saving an image and 21 | stores the serialized PIL image instead of the raw input data to disk. 22 | """ 23 | 24 | def save(self, name, content, *args, **kwargs): # pylint: disable=arguments-differ 25 | # Can't use `content.image` because of https://code.djangoproject.com/ticket/30252 26 | image = Image.open(content) 27 | 28 | # We store a newly serialized version of the image, to (hopefully) prevent attacks where users 29 | # upload a valid image file that might also be interpreted as HTML/JS due to content sniffing. 30 | # If we didn't convert everything to PNG, we'd also have to take care to only allow file 31 | # extensions for which the web server sends a image/* mime type. 32 | data = BytesIO() 33 | image.save(data, 'PNG') 34 | data.seek(0) 35 | super().save(name, data, *args, **kwargs) 36 | 37 | thumbnail_data = BytesIO() 38 | thumbnail = image.copy() 39 | thumbnail.thumbnail(settings.THUMBNAIL_SIZE) 40 | thumbnail.save(thumbnail_data, 'PNG') 41 | thumbnail_data.seek(0) 42 | self.storage.save(self.get_thumbnail_path(), thumbnail_data) 43 | 44 | # Keep property of the parent method 45 | save.alters_data = True 46 | 47 | def delete(self, *args, **kwargs): # pylint: disable=arguments-differ 48 | super().delete(*args, **kwargs) 49 | 50 | # This shouldn't fail if the thumbnail doesn't exist 51 | self.storage.delete(self.get_thumbnail_path()) 52 | 53 | delete.alters_data = True 54 | 55 | def get_thumbnail_path(self): 56 | """ 57 | Returns the path of the image's thumbnail version relative to the storage root (i.e. its "name" in 58 | storage system terms). 59 | Thumbnails have the same name as their original images stored in a 'thumbnails' directory alongside 60 | the original images. 61 | """ 62 | path, filename = os.path.split(self.name) 63 | return os.path.join(path, 'thumbnails', filename) 64 | 65 | def get_thumbnail_url(self): 66 | """ 67 | Returns the (absolute) URL for the image's thumbnail version. 68 | """ 69 | return urljoin(settings.MEDIA_URL, filepath_to_uri(self.get_thumbnail_path())) 70 | 71 | 72 | class ThumbnailImageField(ImageField): 73 | """ 74 | Custom variant of ImageField which automatically resizes and re-serializes an uploaded image. 75 | """ 76 | 77 | attr_class = ThumbnailImageFieldFile 78 | 79 | 80 | class ClearableThumbnailImageInput(ClearableFileInput): 81 | """ 82 | Custom variant of the ClearableFileInput widget for rendering a ThumbnailImageField. It will display the 83 | thumbnail image instead of the image's filename. 84 | """ 85 | 86 | def get_template_substitution_values(self, value): 87 | return { 88 | 'initial': format_html('{}', 89 | value.get_thumbnail_url(), str(value)), 90 | 'initial_url': conditional_escape(value.url), 91 | } 92 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/models.py: -------------------------------------------------------------------------------- 1 | from django.core.validators import RegexValidator 2 | from django.db import models 3 | from django.conf import settings 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .fields import ThumbnailImageField 7 | 8 | 9 | def _gen_image_name(instance, _): 10 | """ 11 | Returns the upload path (relative to settings.MEDIA_ROOT) for the specified Team's image. 12 | """ 13 | 14 | # Must "return a Unix-style path (with forward slashes)" 15 | return 'team-images' + '/' + str(instance.user.id) + '.png' 16 | 17 | 18 | class Team(models.Model): 19 | """ 20 | Database representation of a team participating in the competition. 21 | This enhances the user model, where the team name, password, formal email address etc. are stored. It is 22 | particularly attuned to django.contrib.auth.models.User, but should work with other user models as well. 23 | """ 24 | 25 | user = models.OneToOneField(settings.AUTH_USER_MODEL, primary_key=True, on_delete=models.CASCADE) 26 | 27 | net_number = models.PositiveSmallIntegerField(null=True, unique=True) 28 | informal_email = models.EmailField(_('Informal email address')) 29 | image = ThumbnailImageField(upload_to=_gen_image_name, blank=True) 30 | affiliation = models.CharField(max_length=100, blank=True) 31 | country = models.CharField(max_length=100) 32 | # NOP teams don't get included in the scoring 33 | nop_team = models.BooleanField(default=False, db_index=True) 34 | 35 | class ActiveObjectsManager(models.Manager): 36 | def get_queryset(self): 37 | return super().get_queryset().filter(user__is_active=True) 38 | 39 | class ActiveNotNopObjectsManager(ActiveObjectsManager): 40 | def get_queryset(self): 41 | return super().get_queryset().filter(nop_team=False) 42 | 43 | # The first Manager in a class is used as default 44 | objects = models.Manager() 45 | # QuerySet that only returns Teams whose associated user object is not marked as inactive 46 | active_objects = ActiveObjectsManager() 47 | # QuerySet that only returns active Teams that are not marked as NOP team 48 | active_not_nop_objects = ActiveNotNopObjectsManager() 49 | 50 | def __str__(self): 51 | # pylint: disable=no-member 52 | return self.user.username 53 | 54 | 55 | class TeamDownload(models.Model): 56 | """ 57 | Database representation of a single type of per-team download. One file with the specified name can 58 | be provided per team in the filesystem hierarchy below `settings.TEAM_DOWNLOADS_ROOT`. 59 | """ 60 | 61 | filename = models.CharField(max_length=100, 62 | help_text=_('Name within the per-team filesystem hierarchy, see ' 63 | '"TEAM_DOWNLOADS_ROOT" setting'), 64 | validators=[RegexValidator(r'^[^/]+$', 65 | message=_('Must not contain slashes'))]) 66 | description = models.TextField() 67 | 68 | def __str__(self): 69 | # pylint: disable=invalid-str-returned 70 | return self.filename 71 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/templates/confirmation_mail.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %}{% url 'confirm_email' as confirm_path %}{% blocktrans %}Hi, 2 | 3 | someone (hopefully you) specified your email address as contact address for 4 | a {{ competition_name }} team. 5 | 6 | If you don't know what this is about, you can just ignore this mail. 7 | 8 | Otherwise, please visit this page to confirm your address: 9 | {{ protocol }}://{{ domain }}{{ confirm_path }}?user={{ user }}&token={{ token }} 10 | 11 | You need to do this, otherwise you will not be able to participate! 12 | 13 | Welcome to {{ competition_name }} and best regards, 14 | The organizing Team{% endblocktrans %}{% endautoescape %} 15 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/templates/edit_team.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 15 | 16 |
17 | {% csrf_token %} 18 | 19 | {{ user_form|as_div }} 20 | {{ team_form|as_div }} 21 | 22 | 23 |
24 | 25 | 26 | {% if delete_form %} 27 | 59 | 60 | 61 | {% endif %} 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/templates/mail_teams.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 9 | 10 |

11 | {% blocktrans %} 12 | Most mail servers limit the number of recipients per single message, so addresses are split into separate 13 | batches. 14 | {% endblocktrans %} 15 |

16 | 17 |
18 | {{ form|as_div }} 19 | 20 | 21 |
22 | 23 |
24 | {% for addresses in batches %} 25 | 26 | Mail to batch {{ forloop.counter }} 27 | 28 |
29 | 30 |
31 | {% endfor %} 32 |
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/templates/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 9 | 10 |

11 | {% blocktrans %} 12 | Want to register a team for {{ competition_name }}? There you go: 13 | {% endblocktrans %} 14 |

15 | 16 |
17 | {% csrf_token %} 18 | 19 | {{ user_form|as_div }} 20 | {{ team_form|as_div }} 21 | 22 | 23 |
24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/templates/team_downloads.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 8 | 9 |

10 | {% blocktrans %} 11 | These files are provided to your team personally. 12 | They are confidential, do not share them with anyone else! 13 | {% endblocktrans %} 14 |

15 | 16 | {% if downloads %} 17 |
    18 | {% for dl in downloads %} 19 |
  • 20 | {{ dl.filename }}: 21 | {{ dl.description }} 22 |
  • 23 | {% endfor %} 24 |
25 | {% else %} 26 | {% trans 'No downloads available at the moment.' %} 27 | {% endif %} 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/templates/team_list.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 8 | 9 | {% include 'competition_nav.html' with active='team_list' %} 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {% for team in teams %} 24 | 25 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 | 39 |
{% trans 'Name' %}{% trans 'Affiliation' %}{% trans 'Country' %}
26 | {% if team.image %} 27 | 28 | {{ team.user.username }} 30 | 31 | {% endif %} 32 | {{ team.user.username }}{% if team.affiliation %}{{ team.affiliation }}{% endif %}{{ team.country }}
40 |
41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/registration/util.py: -------------------------------------------------------------------------------- 1 | import os 2 | import locale 3 | import csv 4 | 5 | from django.contrib.auth.tokens import PasswordResetTokenGenerator 6 | 7 | 8 | class EmailConfirmationTokenGenerator(PasswordResetTokenGenerator): 9 | """ 10 | Custom variant of django.contrib.auth.tokens.PasswordResetTokenGenerator for usage in email confirmation 11 | tokens. 12 | """ 13 | 14 | key_salt = 'ctf_gameserver.web.registration.util.EmailConfirmationTokenGenerator' 15 | 16 | def _make_hash_value(self, user, timestamp): 17 | """ 18 | This is mostly a copy of the parent class' method. 19 | Instead of the password, the user's email address is included in the hash. 20 | """ 21 | login_timestamp = '' if user.last_login is None else user.last_login.replace(microsecond=0, 22 | tzinfo=None) 23 | return str(user.pk) + user.email + str(login_timestamp) + str(timestamp) 24 | 25 | 26 | email_token_generator = EmailConfirmationTokenGenerator() # pylint: disable=invalid-name 27 | 28 | 29 | def get_country_names(): 30 | """ 31 | Returns a list of (English) country names from the OKFN/Core Datasets "List of all countries with their 32 | 2 digit codes" list, which has to be available as a file called "countries.csv" in the same directory as 33 | this source file. 34 | """ 35 | 36 | csv_file_name = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'countries.csv') 37 | 38 | with open(csv_file_name, encoding='utf-8') as csv_file: 39 | csv_reader = csv.reader(csv_file) 40 | # Skip header line 41 | next(csv_reader) 42 | 43 | countries = [row[0] for row in csv_reader] 44 | # Some teams have members in multiple countries 45 | countries.append('International') 46 | 47 | return sorted(countries, key=locale.strxfrm) 48 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/scoring/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/admin.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect 2 | from django.utils.translation import gettext_lazy as _ 3 | from django.contrib import admin 4 | 5 | from ctf_gameserver.web.admin import admin_site 6 | from . import models, forms 7 | 8 | 9 | @admin.register(models.Service, site=admin_site) 10 | class ServiceAdmin(admin.ModelAdmin): 11 | 12 | prepopulated_fields = {'slug': ('name',)} 13 | 14 | 15 | @admin.register(models.Flag, site=admin_site) 16 | class FlagAdmin(admin.ModelAdmin): 17 | 18 | list_display = ('id', 'service', 'protecting_team', 'tick') 19 | list_filer = ('service', 'tick') 20 | search_fields = ('service__name', 'protecting_team__user__username', 'tick') 21 | 22 | 23 | @admin.register(models.Capture, site=admin_site) 24 | class CaptureAdmin(admin.ModelAdmin): 25 | 26 | class ServiceFilter(admin.SimpleListFilter): 27 | """ 28 | Admin list filter which allows to filter the captures by their flag's service. 29 | """ 30 | title = _('service') 31 | parameter_name = 'service' 32 | 33 | def lookups(self, request, model_admin): 34 | return models.Service.objects.values_list('slug', 'name') 35 | 36 | def queryset(self, request, queryset): 37 | if self.value(): 38 | return queryset.filter(flag__service__slug=self.value()) 39 | else: 40 | return queryset 41 | 42 | def protecting_team(self, capture): 43 | """ 44 | Returns the protecing team of the capture's flag for usage in `list_display`. 45 | """ 46 | return capture.flag.protecting_team 47 | 48 | def service(self, capture): 49 | """ 50 | Returns the service of the capture's flag for usage in `list_display`. 51 | """ 52 | return capture.flag.service 53 | 54 | def flag_tick(self, capture): 55 | """ 56 | Returns the tick of the capture's flag for usage in `list_display`. 57 | """ 58 | return capture.flag.tick 59 | 60 | list_display = ('id', 'capturing_team', 'protecting_team', 'service', 'flag_tick', 'timestamp') 61 | list_filter = (ServiceFilter,) 62 | search_fields = ('capturing_team__user__username', 'flag__protecting_team__user__username', 63 | 'flag__service__name') 64 | ordering = ('timestamp',) 65 | # A giant dropdown of *all* flags made the admin page unusably slow 66 | raw_id_fields = ('flag',) 67 | 68 | 69 | @admin.register(models.StatusCheck, site=admin_site) 70 | class StatusCheckAdmin(admin.ModelAdmin): 71 | 72 | list_display = ('id', 'service', 'team', 'tick', 'status') 73 | list_filter = ('service', 'tick', 'status') 74 | search_fields = ('service__name', 'team__user__username') 75 | ordering = ('tick', 'timestamp') 76 | 77 | 78 | @admin.register(models.GameControl, site=admin_site) 79 | class GameControlAdmin(admin.ModelAdmin): 80 | """ 81 | Admin object for the single GameControl object. Since at most one instance exists at any time, 'Add' and 82 | 'Delete links' are hidden and a request for the object list will directly redirect to the instance. 83 | """ 84 | 85 | form = forms.GameControlAdminForm 86 | 87 | def has_add_permission(self, request): 88 | return False 89 | 90 | def has_delete_permission(self, request, _=None): 91 | return False 92 | 93 | def changelist_view(self, request, _=None): 94 | game_control = models.GameControl.get_instance() 95 | return redirect('admin:scoring_gamecontrol_change', game_control.pk, permanent=True) 96 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/decorators.py: -------------------------------------------------------------------------------- 1 | from functools import wraps 2 | 3 | from django.shortcuts import redirect 4 | from django.conf import settings 5 | from django.http import JsonResponse 6 | from django.utils.translation import ugettext as _ 7 | from django.contrib import messages 8 | 9 | from .models import GameControl 10 | 11 | 12 | def registration_open_required(view): 13 | """ 14 | View decorator which prohibits access to the decorated view if registration is closed from the 15 | GameControl object. 16 | """ 17 | 18 | @wraps(view) 19 | def func(request, *args, **kwargs): 20 | if not GameControl.get_instance().registration_open: 21 | messages.error(request, _('Sorry, registration is currently closed.')) 22 | return redirect(settings.HOME_URL) 23 | 24 | return view(request, *args, **kwargs) 25 | 26 | return func 27 | 28 | 29 | def registration_closed_required(view): 30 | """ 31 | View decorator which only allows access to the decorated view if registration is closed from the 32 | GameControl object. 33 | Format of the response is currently always JSON. 34 | """ 35 | 36 | @wraps(view) 37 | def func(request, *args, **kwargs): 38 | if GameControl.get_instance().registration_open: 39 | return JsonResponse({'error': 'Not available yet'}, status=404) 40 | 41 | return view(request, *args, **kwargs) 42 | 43 | return func 44 | 45 | 46 | def before_competition_required(view): 47 | """ 48 | View decorator which prohibits access to the decorated view if the competition has already begun (i.e. 49 | running or over). 50 | """ 51 | 52 | @wraps(view) 53 | def func(request, *args, **kwargs): 54 | if GameControl.get_instance().competition_started(): 55 | messages.error(request, _('Sorry, that is only possible before the competition.')) 56 | return redirect(settings.HOME_URL) 57 | 58 | return view(request, *args, **kwargs) 59 | 60 | return func 61 | 62 | 63 | def services_public_required(resp_format): 64 | """ 65 | View decorator which prohibits access to the decorated view if information about the services is not 66 | public yet. 67 | 68 | Args: 69 | resp_format: Format of the response when the competition has not yet started. Supported options are 70 | 'html' and 'json'. 71 | """ 72 | 73 | def decorator(view): 74 | @wraps(view) 75 | def func(request, *args, **kwargs): 76 | game_control = GameControl.get_instance() 77 | if game_control.are_services_public(): 78 | return view(request, *args, **kwargs) 79 | 80 | if resp_format == 'json': 81 | return JsonResponse({'error': 'Not available yet'}, status=404) 82 | else: 83 | messages.error(request, _('Sorry, the page you requested is not available yet.')) 84 | return redirect(settings.HOME_URL) 85 | 86 | return func 87 | 88 | return decorator 89 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from . import models 5 | 6 | 7 | class GameControlAdminForm(forms.ModelForm): 8 | """ 9 | Form for the GameControl object, designed to be used in GameControlAdmin. 10 | """ 11 | 12 | # Ticks longer than 1 hours are possible but don't seem reasonable and would require addtional cleaning 13 | # logic below 14 | tick_duration = forms.IntegerField(min_value=1, max_value=3559, help_text=_('Duration of one tick in ' 15 | 'seconds')) 16 | 17 | class Meta: 18 | model = models.GameControl 19 | exclude = ('current_tick', 'cancel_checks') 20 | help_texts = { 21 | 'competition_name': _('Human-readable title of the CTF'), 22 | 'services_public': _('Time at which information about the services is public, but the actual ' 23 | 'game has not started yet'), 24 | 'valid_ticks': _('Number of ticks a flag is valid for'), 25 | 'flag_prefix': _('Static text prepended to every flag'), 26 | 'registration_confirm_text': _('If set, teams will have to confirm to this text (e.g. a link to ' 27 | 'T&C) when signing up. May contain HTML.'), 28 | 'min_net_number': _('If unset, team IDs will be used as net numbers'), 29 | 'max_net_number': _('(Inclusive) If unset, team IDs will be used as net numbers'), 30 | } 31 | 32 | def clean_tick_duration(self): 33 | tick_duration = self.cleaned_data['tick_duration'] 34 | 35 | # The timer of the gameserver's Controller component is configured with conditions for the minute 36 | # and seconds values 37 | if (tick_duration < 60 and 60 % tick_duration != 0) or \ 38 | (tick_duration > 60 and tick_duration % 60 != 0): 39 | raise forms.ValidationError(_('The tick duration has to be a multitude of 60!')) 40 | 41 | return tick_duration 42 | 43 | def clean(self): 44 | cleaned_data = super().clean() 45 | 46 | services_public = cleaned_data.get('services_public') 47 | start = cleaned_data.get('start') 48 | end = cleaned_data.get('end') 49 | 50 | if start is not None: 51 | if services_public is not None and services_public > start: 52 | raise forms.ValidationError(_('Services public time must not be after start time')) 53 | if end is not None and end <= start: 54 | raise forms.ValidationError(_('End time must be after start time')) 55 | 56 | return cleaned_data 57 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templates/competition_nav.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% comment %} 4 | Horizontal 'nav-pill' navigation for pages from the "competition" category of the main navigation. 5 | It should be used with the `include` template tag. The active page can be highlighted by supplying its view's 6 | name as 'active' argument using `include with`. 7 | {% endcomment %} 8 | 9 | {% if services_public %} 10 | 21 | {% endif %} 22 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templates/missing_checks.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block content %} 6 | 7 | 8 | 9 | 12 | 13 | 16 | 17 |
18 | 28 | 29 |
30 | 31 | 34 | 35 |
36 | 37 |
38 |
{% trans 'Min' %}
39 | 40 |
41 |
42 |
{% trans 'Max' %}
43 | 44 | 45 | 46 | 47 |
48 |
49 |
50 |
51 | 52 |
53 | 55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templates/scoreboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'base-wide.html' %} 2 | {% load i18n %} 3 | {% load dict_access %} 4 | {% load status_css_class %} 5 | {% load static %} 6 | 7 | {% block content %} 8 | 9 | 10 | 11 | 14 | 15 | 18 | 19 |
20 |
21 | {% include 'competition_nav.html' with active='scoreboard' %} 22 |
23 | 24 |

25 | {% blocktrans %} 26 | Tick: 27 | {% endblocktrans %} 28 |

29 |
30 | 31 |
32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for service in services %} 39 | 40 | {% endfor %} 41 | 45 | 49 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 67 | 68 | 71 | 72 | 73 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 |
{% trans 'Team' %}{{ service.name }} 42 | Offense 43 | {% trans 'Total Offense' %} 44 | 46 | Defense 47 | {% trans 'Total Defense' %} 48 | 50 | SLA 51 | {% trans 'Total SLA' %} 52 | {% trans 'Total' %}
94 |
95 | {% endblock %} 96 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templates/service_history.html: -------------------------------------------------------------------------------- 1 | {% extends 'base-wide.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block content %} 6 | 7 | 8 | 9 | 12 | 13 | 16 | 17 |
18 | 28 | 29 |
30 | 31 | 34 | 35 |
36 | 37 |
38 |
{% trans 'Min' %}
39 | 40 |
41 |
42 |
{% trans 'Max' %}
43 | 44 | 45 | 46 | 47 |
48 |
49 |
50 |
51 | 52 |
53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | {% endblock %} 64 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templates/service_status.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load dict_access %} 4 | {% load status_css_class %} 5 | {% load static %} 6 | 7 | {% block content %} 8 | 9 | 10 | 11 | 14 | 15 | 18 | 19 | {% include 'competition_nav.html' with active='service_status' %} 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 41 | 42 | 45 | 46 | 47 | 48 | 49 | 50 |
51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/scoring/templatetags/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/scoring/templatetags/status_css_class.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | register = template.Library() # pylint: disable=invalid-name 5 | 6 | CLASS_MAPPING = { 7 | _('up'): 'success', 8 | _('down'): 'danger', 9 | _('faulty'): 'danger', 10 | _('flag not found'): 'warning', 11 | _('recovering'): 'info', 12 | _('timeout'): 'active' 13 | } 14 | 15 | 16 | @register.filter 17 | def status_css_class(status): 18 | """ 19 | Template filter to get the appropriate Bootstrap CSS class for (the string representation of) a status 20 | from scoring.StatusCheck.STATUSES. Primarily designed for table rows, but the classes might work with 21 | other objects as well. 22 | """ 23 | 24 | # Use gray background for missing checks 25 | if not status: 26 | return 'active' 27 | 28 | return CLASS_MAPPING.get(status, '') 29 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/missing_checks.js: -------------------------------------------------------------------------------- 1 | /* jshint asi: true, sub: true, esversion: 6 */ 2 | 3 | 'use strict' 4 | 5 | 6 | $(document).ready(function() { 7 | 8 | setupDynamicContent('missing-checks.json', buildList) 9 | 10 | }) 11 | 12 | 13 | function buildList(data) { 14 | 15 | $('#selected-service').text(data['service-name']) 16 | $('#min-tick').val(data['min-tick']) 17 | $('#max-tick').val(data['max-tick']) 18 | 19 | // Extract raw DOM element from jQuery object 20 | let list = $('#check-list')[0] 21 | 22 | while (list.firstChild) { 23 | list.removeChild(list.firstChild) 24 | } 25 | 26 | for (const check of data['checks']) { 27 | let tickEntry = document.createElement('li') 28 | 29 | let prefix = document.createElement('strong') 30 | prefix.textContent = 'Tick ' + check['tick'] + ': ' 31 | tickEntry.appendChild(prefix) 32 | 33 | for (let i = 0; i < check['teams'].length; i++) { 34 | const teamID = check['teams'][i][0] 35 | const isTimeout = check['teams'][i][1] 36 | const teamName = data['all-teams'][teamID]['name'] 37 | const teamNetNo = data['all-teams'][teamID]['net-number'] 38 | 39 | let teamEntry 40 | if (data['graylog-search-url'] === undefined) { 41 | teamEntry = document.createElement('span') 42 | } else { 43 | teamEntry = document.createElement('a') 44 | teamEntry.href = encodeURI(data['graylog-search-url'] + 45 | '?rangetype=relative&relative=28800&' + 46 | 'q=service:' + data['service-slug'] + ' AND team:' + teamNetNo + 47 | ' AND tick:' + check['tick']) 48 | teamEntry.target = '_blank' 49 | if (isTimeout) { 50 | teamEntry.classList.add('text-muted') 51 | } 52 | } 53 | teamEntry.textContent = teamName + ' (' + teamNetNo + ')' 54 | tickEntry.appendChild(teamEntry) 55 | 56 | if (i != check['teams'].length - 1) { 57 | let separator = document.createElement('span') 58 | separator.textContent = ', ' 59 | tickEntry.appendChild(separator) 60 | } 61 | 62 | } 63 | 64 | list.appendChild(tickEntry) 65 | } 66 | 67 | list.hidden = false 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/progress_spinner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/static/progress_spinner.gif -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /admin/ 3 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/scoreboard.js: -------------------------------------------------------------------------------- 1 | /* jshint asi: true, sub: true, esversion: 6 */ 2 | 3 | 'use strict' 4 | 5 | 6 | $(document).ready(function() { 7 | $.getJSON('../scoreboard.json', {}, buildTable) 8 | }) 9 | 10 | function buildTable(data) { 11 | const statusDescriptions = data['status-descriptions'] 12 | 13 | $('#tick').text(data['tick']) 14 | 15 | // Extract raw DOM element from jQuery object 16 | const template = $('#team-template-row')[0] 17 | 18 | // Do not use jQuery here for performance reasons (we're creating a lot of elements) 19 | for (const team of data['teams']) { 20 | const entry = template.cloneNode(true) 21 | entry.setAttribute('id', `team-${team.id}-row`) 22 | const tds = entry.querySelectorAll('td') 23 | 24 | // Position 25 | tds[0].querySelector('strong').textContent = `${team.rank}.` 26 | 27 | // Image 28 | if (team['image'] === undefined) { 29 | // Delete all children 30 | tds[1].innerHTML = '' 31 | } else { 32 | tds[1].querySelector('a').setAttribute('href', team['image']) 33 | const img = tds[1].querySelector('img') 34 | img.setAttribute('src', team['thumbnail']) 35 | img.setAttribute('alt', team['name']) 36 | } 37 | 38 | // Name 39 | tds[2].querySelector('strong').textContent = team['name'] 40 | 41 | // Service 42 | const service_template = tds[3] 43 | for (const service of team['services']) { 44 | const service_node = service_template.cloneNode(true) 45 | 46 | const spans = service_node.querySelectorAll('span') 47 | spans[2].textContent = service['offense'].toFixed(2) 48 | spans[5].textContent = service['defense'].toFixed(2) 49 | spans[8].textContent = service['sla'].toFixed(2) 50 | 51 | service_node.querySelector('a').href += `#team-${team.id}-row` 52 | 53 | if (service['status'] !== '') { 54 | const statusClass = statusClasses[service['status']] 55 | spans[9].setAttribute('class', `text-${statusClass}`) 56 | spans[9].textContent = statusDescriptions[service['status']] 57 | 58 | service_node.setAttribute('class', statusClass) 59 | } 60 | 61 | service_template.parentNode.insertBefore(service_node, service_template) 62 | } 63 | service_template.parentNode.removeChild(service_template) 64 | 65 | // Offense 66 | tds[4].textContent = team['offense'].toFixed(2) 67 | // Defense 68 | tds[5].textContent = team['defense'].toFixed(2) 69 | // SLA 70 | tds[6].textContent = team['sla'].toFixed(2) 71 | // Total 72 | tds[7].querySelector('strong').textContent = team['total'].toFixed(2) 73 | 74 | template.parentNode.appendChild(entry) 75 | entry.hidden = false 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/service_history.js: -------------------------------------------------------------------------------- 1 | /* jshint asi: true, sub: true, esversion: 6 */ 2 | 3 | 'use strict' 4 | 5 | 6 | $(document).ready(function() { 7 | 8 | setupDynamicContent('service-history.json', buildTable) 9 | 10 | }) 11 | 12 | 13 | function buildTable(data) { 14 | 15 | $('#selected-service').text(data['service-name']) 16 | $('#min-tick').val(data['min-tick']) 17 | $('#max-tick').val(data['max-tick']) 18 | 19 | const statusDescriptions = data['status-descriptions'] 20 | 21 | // Extract raw DOM element from jQuery object 22 | let table = $('#history-table')[0] 23 | 24 | // Over a certain number of columns, do not show every tick number in the table head 25 | let tickTextEvery = 1 26 | if (data['max-tick'] - data['min-tick'] > 30) { 27 | tickTextEvery = 5 28 | } 29 | 30 | let tableHeadRow = $('#history-table thead tr')[0] 31 | while (tableHeadRow.firstChild) { 32 | tableHeadRow.removeChild(tableHeadRow.firstChild) 33 | } 34 | // Leave first two columns (team numbers & names) empty 35 | tableHeadRow.appendChild(document.createElement('th')) 36 | tableHeadRow.appendChild(document.createElement('th')) 37 | for (let i = data['min-tick']; i <= data['max-tick']; i++) { 38 | let col = document.createElement('th') 39 | if (i % tickTextEvery == 0) { 40 | col.textContent = i 41 | } 42 | col.classList.add('text-center') 43 | tableHeadRow.appendChild(col) 44 | } 45 | 46 | let tableBody = $('#history-table tbody')[0] 47 | while (tableBody.firstChild) { 48 | tableBody.removeChild(tableBody.firstChild) 49 | } 50 | 51 | for (const team of data['teams']) { 52 | // Do not use jQuery here for performance reasons (we're creating a lot of elements) 53 | let row = document.createElement('tr') 54 | 55 | let firstCol = document.createElement('td') 56 | firstCol.classList.add('text-muted') 57 | firstCol.textContent = team['net_number'] 58 | row.appendChild(firstCol) 59 | 60 | let secondCol = document.createElement('td') 61 | secondCol.textContent = team['name'] 62 | row.appendChild(secondCol) 63 | 64 | for (let i = 0; i < team['checks'].length; i++) { 65 | const check = team['checks'][i] 66 | const tick = data['min-tick'] + i 67 | 68 | let col = document.createElement('td') 69 | 70 | if (data['graylog-search-url'] === undefined) { 71 | col.innerHTML = ' ' 72 | } else { 73 | let link = document.createElement('a') 74 | link.href = encodeURI(data['graylog-search-url'] + '?rangetype=relative&relative=28800&' + 75 | 'q=service:' + data['service-slug'] + ' AND team:' + team['net_number'] + 76 | ' AND tick:' + tick) 77 | link.target = '_blank' 78 | link.innerHTML = ' ' 79 | col.appendChild(link) 80 | } 81 | 82 | col.title = statusDescriptions[check] 83 | if (check != -1) { 84 | col.classList.add(statusClasses[check]) 85 | } 86 | row.appendChild(col) 87 | } 88 | 89 | tableBody.appendChild(row) 90 | } 91 | 92 | table.hidden = false 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/service_status.js: -------------------------------------------------------------------------------- 1 | /* jshint asi: true, sub: true */ 2 | 3 | 'use strict' 4 | 5 | 6 | $(document).ready(function() { 7 | $.getJSON('../status.json', {}, buildTable) 8 | }) 9 | 10 | function buildTable(data) { 11 | const statusDescriptions = data['status-descriptions'] 12 | 13 | // Extract raw DOM element from jQuery object 14 | const table = $('#status-table')[0] 15 | 16 | // Tick header 17 | const tick_template = table.querySelectorAll('thead th')[2] 18 | for (const tick of data['ticks']) { 19 | const node = tick_template.cloneNode(true) 20 | node.querySelector('span').textContent = tick 21 | tick_template.parentNode.insertBefore(node, tick_template) 22 | } 23 | tick_template.parentNode.removeChild(tick_template) 24 | 25 | // Do not use jQuery here for performance reasons (we're creating a lot of elements) 26 | const template = $('#team-template-row')[0] 27 | for (const team of data['teams']) { 28 | const entry = template.cloneNode(true) 29 | entry.setAttribute('id', `team-${team.id}-row`) 30 | const tds = entry.querySelectorAll('td') 31 | if (!team.nop) { 32 | entry.setAttribute('class', '') 33 | } 34 | 35 | // image 36 | if (team['image'] === undefined) { 37 | tds[0].innerHTML = '' // delete all children 38 | } else { 39 | tds[0].querySelector('a').setAttribute('href', team['image']) 40 | const img = tds[0].querySelector('img') 41 | img.setAttribute('src', team['thumbnail']) 42 | img.setAttribute('alt', team['name']) 43 | } 44 | 45 | // Name 46 | tds[1].querySelector('strong').textContent = team['name'] 47 | 48 | // Service status per tick 49 | for (const statuses of team['ticks']) { 50 | const col = document.createElement('td') 51 | 52 | for (let i = 0; i < statuses.length; i++) { 53 | let status = statuses[i] 54 | 55 | const text = document.createTextNode(data['services'][i] + ': ') 56 | 57 | const span = document.createElement('span') 58 | let statusClass 59 | if (status === '') { 60 | // Not checked 61 | status = -1 // For `statusDescriptions` 62 | statusClass = 'muted' 63 | } else { 64 | statusClass = statusClasses[status] 65 | } 66 | span.setAttribute('class', `text-${statusClass}`) 67 | span.textContent = statusDescriptions[status] 68 | 69 | col.appendChild(text) 70 | col.appendChild(span) 71 | col.appendChild(document.createElement('br')) 72 | } 73 | 74 | entry.appendChild(col) 75 | } 76 | template.parentNode.appendChild(entry) 77 | } 78 | template.parentNode.removeChild(template) 79 | 80 | table.hidden = false 81 | } 82 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/service_util.js: -------------------------------------------------------------------------------- 1 | /* jshint asi: true, sub: true, esversion: 6 */ 2 | 3 | 'use strict' 4 | 5 | 6 | const statusClasses = { 7 | 0: 'success', 8 | 1: 'danger', 9 | 2: 'danger', 10 | 3: 'warning', 11 | 4: 'info', 12 | 5: 'active' 13 | } 14 | 15 | 16 | function setupDynamicContent(jsonPath, buildFunc) { 17 | 18 | function load(_) { 19 | loadDynamicContent(jsonPath, buildFunc) 20 | } 21 | 22 | $(window).bind('hashchange', load) 23 | $('#min-tick').change(load) 24 | $('#max-tick').change(load) 25 | $('#refresh').click(load) 26 | $('#load-current').click(function(_) { 27 | // Even though the current tick is contained in the JSON data, it might be outdated, so load the 28 | // table without a "to-tick" 29 | loadDynamicContent(jsonPath, buildFunc, true) 30 | }) 31 | 32 | loadDynamicContent(jsonPath, buildFunc) 33 | 34 | } 35 | 36 | 37 | function loadDynamicContent(jsonPath, buildFunc, ignoreMaxTick=false) { 38 | 39 | makeFieldsEditable(false) 40 | $('#load-spinner').attr('hidden', false) 41 | 42 | const serviceSlug = window.location.hash.slice(1) 43 | if (serviceSlug.length == 0) { 44 | $('#load-spinner').attr('hidden', true) 45 | makeFieldsEditable(true) 46 | return 47 | } 48 | 49 | const fromTick = parseInt($('#min-tick').val()) 50 | const toTick = parseInt($('#max-tick').val()) + 1 51 | if (isNaN(fromTick) || isNaN(toTick)) { 52 | return 53 | } 54 | 55 | let params = {'service': serviceSlug, 'from-tick': fromTick} 56 | if (!ignoreMaxTick) { 57 | params['to-tick'] = toTick 58 | } 59 | $.getJSON(jsonPath, params, function(data) { 60 | buildFunc(data) 61 | $('#load-spinner').attr('hidden', true) 62 | makeFieldsEditable(true) 63 | }) 64 | 65 | } 66 | 67 | 68 | function makeFieldsEditable(writeable) { 69 | 70 | $('#service-selector').attr('disabled', !writeable) 71 | $('#min-tick').attr('readonly', !writeable) 72 | $('#max-tick').attr('readonly', !writeable) 73 | $('#refresh').attr('disabled', !writeable) 74 | $('#load-current').attr('disabled', !writeable) 75 | 76 | } 77 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/static/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Style sheet for some custom styles which cannot be achieved with Bootstrap. 3 | */ 4 | 5 | 6 | #page-content { 7 | margin-bottom: 60px; 8 | } 9 | 10 | .nav-pills { 11 | margin-bottom: 30px; 12 | } 13 | 14 | .bg-block { 15 | padding: 15px; 16 | } 17 | 18 | .form-inline .form-group { 19 | margin-right: 10px; 20 | } 21 | 22 | .form-inline .help-block { 23 | /* Help texts don't really have much space in inline forms */ 24 | display: none; 25 | } 26 | 27 | .clearable-input-image { 28 | vertical-align: top; 29 | } 30 | 31 | .flatpage-content img { 32 | max-width: 100%; 33 | } 34 | 35 | th.border-left, td.border-left { 36 | border-left: 2px solid #DDDDDD; 37 | } 38 | 39 | th.border-right, td.border-right { 40 | border-right: 2px solid #DDDDDD; 41 | } 42 | 43 | .image-column { 44 | width: 80px; 45 | } 46 | 47 | .team-image { 48 | width: 50px; 49 | max-height: 50px; 50 | object-fit: contain; 51 | } 52 | 53 | .header-buttons { 54 | margin-top: 20px; 55 | } 56 | 57 | #mail-teams-content { 58 | margin-top: 40px; 59 | } 60 | 61 | img#load-spinner { 62 | width: 1.7em; 63 | } 64 | 65 | img#load-spinner, button#refresh { 66 | margin-right: 20px; 67 | } 68 | 69 | #history-table td { 70 | padding-left: 2px; 71 | padding-right: 2px; 72 | } 73 | 74 | #history-table td a { 75 | /* Make the whole table cell clickable */ 76 | display: block; 77 | } 78 | 79 | #history-table td a:hover { 80 | text-decoration: none; 81 | } 82 | 83 | #check-list { 84 | margin-top: 20px; 85 | padding-left: 20px; 86 | } 87 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/400.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 8 | 9 |

10 | {% trans 'Sorry to tell you, but something is wrong with your request.' %} 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 8 | 9 |

10 | {% trans 'You are not allowed to do that.' %} 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 8 | 9 |

10 | {% trans 'Sorry, but the page you are looking for is not there.' %} 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 | 8 | 9 |

10 | {% trans 'Oops, something went horribly wrong.' %} 11 |

12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/base-wide.html: -------------------------------------------------------------------------------- 1 | {% extends 'base-common.html' %} 2 | 3 | {% block container %} 4 |
5 | {% if messages %} 6 | {% for message in messages %} 7 | 8 | {% endfor %} 9 | {% endif %} 10 | 11 | {% block content %}{% endblock %} 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/base.html: -------------------------------------------------------------------------------- 1 | {% extends 'base-common.html' %} 2 | 3 | {% block container %} 4 |
5 | {% if messages %} 6 | {% for message in messages %} 7 | 8 | {% endfor %} 9 | {% endif %} 10 | 11 | {% block content %}{% endblock %} 12 |
13 | {% endblock %} 14 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 9 | 10 | {% if next %} 11 |

12 | {% if user.is_authenticated %} 13 | {% blocktrans %} 14 | Your account doesn't have access to this page. To proceed, please login with an account that has 15 | access. 16 | {% endblocktrans %} 17 | {% else %} 18 | {% blocktrans %} 19 | Please login to see this page. 20 | {% endblocktrans %} 21 | {% endif %} 22 |

23 | {% endif %} 24 | 25 |
26 | {% csrf_token %} 27 | 28 | {{ form|as_div }} 29 | 30 | 31 | 32 |

33 | {% trans 'Forgot password?' %} 34 |

35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 9 | 10 |
11 | {% csrf_token %} 12 | 13 | {{ form|as_div }} 14 | 15 | 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | 9 | 10 |
11 | {% csrf_token %} 12 | 13 | {{ form|as_div }} 14 | 15 | 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_reset_complete.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 |
7 | {% trans 'Your new password has been saved.' %} 8 |
9 | 10 | 13 | 14 |

15 | {% url 'login' as login_url %} 16 | {% blocktrans %} 17 | Proceed to login… 18 | {% endblocktrans %} 19 |

20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_reset_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 | {% if not validlink %} 7 |
8 | {% url 'password_reset' as reset_url %} 9 | {% blocktrans %} 10 | This password reset link has already been used. In case you forgot your password again, 11 | request a new reset link. 12 | {% endblocktrans %} 13 |
14 | {% endif %} 15 | 16 | 19 | 20 | 21 | {% if not validlink %} 22 |

23 | {% blocktrans %} 24 | Return to home page… 25 | {% endblocktrans %} 26 |

27 | {% else %} 28 |
29 | {% csrf_token %} 30 | 31 | {{ form|as_div }} 32 | 33 | 34 |
35 | {% endif %} 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load form_as_div %} 4 | 5 | {% block content %} 6 |
7 | {% blocktrans %} 8 | A password reset link has been sent to you via email. Click that link to set a new password. 9 | {% endblocktrans %} 10 |
11 | 12 | 15 | 16 |

17 | {% blocktrans %} 18 | Return to home page… 19 | {% endblocktrans %} 20 |

21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_reset_mail.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %}{% url 'password_reset_confirm' uidb64=uid token=token as path %}{% blocktrans with user.username as username %}Hi, 2 | 3 | someone (hopefully you) requested a password reset for your 4 | {{ competition_name }} team "{{ username }}". 5 | 6 | If you didn't initiate the reset, you can just ignore this mail. 7 | 8 | Otherwise, use this link to set a new password: 9 | {{ protocol}}://{{ domain }}{{ path }} 10 | 11 | Regards, 12 | The organizing Team{% endblocktrans %}{% endautoescape %} 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templates/password_reset_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %}{% blocktrans %}{{ competition_name }} password reset{% endblocktrans %}{% endautoescape %} 2 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | "Custom template tags and filters must live inside a Django app. If they relate to an existing app it makes 3 | sense to bundle them there; otherwise, you should create a new app to hold them." – 4 | https://docs.djangoproject.com/en/1.8/howto/custom-template-tags/ 5 | This is such an app to hold template tags. 6 | """ 7 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templatetags/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/templatetags/templatetags/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/templatetags/templatetags/dict_access.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() # pylint: disable=invalid-name 4 | 5 | 6 | @register.filter 7 | def dict_access(dictionary, key): 8 | """ 9 | Template filter to get the value from a dictionary depending on a variable key. 10 | """ 11 | 12 | return dictionary.get(key, '') 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/util.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import lazy 2 | 3 | 4 | def _format_proxy(proxy, *args, **kwargs): 5 | """ 6 | Helper function to enable string formatting of lazy translation objects. 7 | This appears to work alright, but I'm not quite sure. 8 | """ 9 | return str(proxy).format(*args, **kwargs) 10 | 11 | 12 | format_lazy = lazy(_format_proxy, str) # pylint: disable=invalid-name 13 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/vpnstatus/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/src/ctf_gameserver/web/vpnstatus/__init__.py -------------------------------------------------------------------------------- /src/ctf_gameserver/web/vpnstatus/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from ctf_gameserver.web.admin import admin_site 4 | from . import models 5 | 6 | 7 | @admin.register(models.VPNStatusCheck, site=admin_site) 8 | class VPNStatusCheckAdmin(admin.ModelAdmin): 9 | 10 | list_display = ('team', 'timestamp', 'wireguard_handshake_time') 11 | list_filter = ('team',) 12 | search_fields = ('team__user__username', 'team__net_number') 13 | ordering = ('timestamp', 'team') 14 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/vpnstatus/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | from ctf_gameserver.web.registration.models import Team 4 | 5 | 6 | class VPNStatusCheck(models.Model): 7 | """ 8 | Database representation of one VPN status check, consisting of the different check results (VPN, ping, 9 | etc.) for one team at one point in time. 10 | """ 11 | 12 | team = models.ForeignKey(Team, on_delete=models.CASCADE) 13 | wireguard_handshake_time = models.DateTimeField(null=True, blank=True) 14 | gateway_ping_rtt_ms = models.PositiveIntegerField(null=True, blank=True) 15 | demo_ping_rtt_ms = models.PositiveIntegerField(null=True, blank=True) 16 | vulnbox_ping_rtt_ms = models.PositiveIntegerField(null=True, blank=True) 17 | demo_service_ok = models.BooleanField() 18 | vulnbox_service_ok = models.BooleanField() 19 | timestamp = models.DateTimeField(auto_now_add=True) 20 | 21 | class Meta: 22 | verbose_name = 'VPN status check' 23 | 24 | def __str__(self): 25 | return 'VPN status check {:d}'.format(self.id) 26 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/vpnstatus/templates/status_history.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | {% block content %} 6 | 9 | 10 | {% if allow_team_selection %} 11 |
12 |
13 |
14 | 15 | 16 | 17 |
18 |
19 |
20 | {% endif %} 21 | 22 | {% if check_results is not None %} 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | {% for result in check_results %} 39 | 40 | 43 | 50 | {% if result.gateway_ping_rtt_ms is not None %} 51 | 54 | {% else %} 55 | 58 | {% endif %} 59 | {% if result.demo_ping_rtt_ms is not None %} 60 | 63 | {% else %} 64 | 67 | {% endif %} 68 | {% if result.demo_service_ok %} 69 | 72 | {% else %} 73 | 76 | {% endif %} 77 | {% if result.vulnbox_ping_rtt_ms is not None %} 78 | 81 | {% else %} 82 | 85 | {% endif %} 86 | {% if result.vulnbox_service_ok %} 87 | 90 | {% else %} 91 | 94 | {% endif %} 95 | 96 | {% endfor %} 97 | 98 |
{% trans 'Time' %} ({{ server_timezone }}){% trans 'Last WireGuard Handshake' %}{% trans 'Gateway Ping RTT' %}{% trans 'Testing Vulnbox Ping RTT' %}{% trans 'Testing Vulnbox Service' %}{% trans 'Vulnbox Ping RTT' %}{% trans 'Vulnbox Service' %}
41 | {{ result.timestamp | date }} {{ result.timestamp | time }} 42 | 44 | {% if result.wireguard_handshake_time is not None %} 45 | {{ result.wireguard_handshake_time | time }} 46 | {% else %} 47 | {% trans 'N/A' %} 48 | {% endif %} 49 | 52 | {{ result.gateway_ping_rtt_ms }} ms 53 | 56 | {% trans 'down' %} 57 | 61 | {{ result.demo_ping_rtt_ms }} ms 62 | 65 | {% trans 'down' %} 66 | 70 | {% trans 'up' %} 71 | 74 | {% trans 'down' %} 75 | 79 | {{ result.vulnbox_ping_rtt_ms }} ms 80 | 83 | {% trans 'down' %} 84 | 88 | {% trans 'up' %} 89 | 92 | {% trans 'down' %} 93 |
99 |
100 | {% endif %} 101 | {% endblock %} 102 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/vpnstatus/views.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from django.conf import settings 4 | from django.contrib.auth.decorators import login_required 5 | from django.http import Http404 6 | from django.shortcuts import get_object_or_404, render 7 | 8 | from ctf_gameserver.web.registration.models import Team 9 | from .models import VPNStatusCheck 10 | 11 | 12 | @login_required 13 | def status_history(request): 14 | 15 | if request.user.is_staff: 16 | allow_team_selection = True 17 | 18 | net_number_param = request.GET.get('net-number') 19 | if net_number_param is None: 20 | return render(request, 'status_history.html', { 21 | 'allow_team_selection': allow_team_selection, 22 | 'net_number': None, 23 | 'server_timezone': settings.TIME_ZONE, 24 | 'check_results': None 25 | }) 26 | try: 27 | net_number = int(net_number_param) 28 | except ValueError as e: 29 | # Cannot return status code 400 in the same easy way ¯\_(ツ)_/¯ 30 | raise Http404('Invalid net number') from e 31 | 32 | team = get_object_or_404(Team, net_number=net_number) 33 | else: 34 | allow_team_selection = False 35 | 36 | try: 37 | team = request.user.team 38 | except Team.DoesNotExist as e: 39 | raise Http404('User has no team') from e 40 | 41 | check_results = VPNStatusCheck.objects.filter(team=team).order_by('-timestamp')[:60].values() 42 | for result in check_results: 43 | if result['wireguard_handshake_time'] is None: 44 | result['wireguard_ok'] = False 45 | else: 46 | age = result['timestamp'] - result['wireguard_handshake_time'] 47 | result['wireguard_ok'] = age < timedelta(minutes=5) 48 | 49 | return render(request, 'status_history.html', { 50 | 'allow_team_selection': allow_team_selection, 51 | 'net_number': team.net_number, 52 | 'server_timezone': settings.TIME_ZONE, 53 | 'check_results': check_results 54 | }) 55 | -------------------------------------------------------------------------------- /src/ctf_gameserver/web/wsgi.py: -------------------------------------------------------------------------------- 1 | from django.core.wsgi import get_wsgi_application 2 | 3 | 4 | # pylint: disable=invalid-name 5 | application = get_wsgi_application() 6 | -------------------------------------------------------------------------------- /src/dev_manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | 6 | 7 | if __name__ == '__main__': 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ctf_gameserver.web.dev_settings') 9 | 10 | from django.core.management import execute_from_command_line 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /src/pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | # Use as many jobs as available CPU cores 3 | jobs = 0 4 | ignore = migrations, Keccak.py 5 | 6 | [REPORTS] 7 | reports = no 8 | 9 | [MESSAGES CONTROL] 10 | # Disable: 11 | # * All "too-many-"/"too-few-" style checks 12 | # * Complaints about 'TODO' messages, 13 | # * Complaints about `global` statements 14 | # * Complaints about missing docstrings 15 | # * Complaints about `else` branches after `return` 16 | # * "Method could be a function" messages 17 | # * "Similar lines" messages 18 | # * "Consider using 'with'" messages 19 | # * Advices to use f-strings instead of `format()` 20 | # * Advices to not raise bare Exceptions 21 | # * Messages about locally disabled messages 22 | disable = design, too-many-nested-blocks, fixme, global-statement, missing-docstring, no-else-return, duplicate-code, consider-using-with, consider-using-f-string, broad-exception-raised, locally-disabled 23 | # Variable names which would generally be invalid, but are accepted anyway 24 | good-names = e, f, fd, i, ip, j, k, _ 25 | 26 | [FORMAT] 27 | max-line-length = 109 28 | expected-line-ending-format = LF 29 | 30 | [TYPECHECK] 31 | # Don't issue "no-member" warnings for the default attributes of models, model instances and views 32 | generated-members = objects, id, request, DoesNotExist 33 | -------------------------------------------------------------------------------- /tests/checker/fixtures/integration.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 2, 5 | "fields": { 6 | "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", 7 | "last_login": null, 8 | "is_superuser": false, 9 | "username": "Team2", 10 | "first_name": "", 11 | "last_name": "", 12 | "email": "", 13 | "is_staff": false, 14 | "is_active": true, 15 | "date_joined": "2019-04-03T18:21:28.622Z", 16 | "groups": [], 17 | "user_permissions": [] 18 | } 19 | }, 20 | { 21 | "model": "auth.user", 22 | "pk": 3, 23 | "fields": { 24 | "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", 25 | "last_login": null, 26 | "is_superuser": false, 27 | "username": "Team3", 28 | "first_name": "", 29 | "last_name": "", 30 | "email": "", 31 | "is_staff": false, 32 | "is_active": true, 33 | "date_joined": "2019-04-03T18:21:28.622Z", 34 | "groups": [], 35 | "user_permissions": [] 36 | } 37 | }, 38 | { 39 | "model": "registration.team", 40 | "pk": 2, 41 | "fields": { 42 | "net_number": 92, 43 | "informal_email": "team2@example.org", 44 | "image": "", 45 | "affiliation": "", 46 | "country": "World", 47 | "nop_team": false 48 | } 49 | }, 50 | { 51 | "model": "registration.team", 52 | "pk": 3, 53 | "fields": { 54 | "net_number": 93, 55 | "informal_email": "team3@example.org", 56 | "image": "", 57 | "affiliation": "", 58 | "country": "World", 59 | "nop_team": false 60 | } 61 | }, 62 | { 63 | "model": "scoring.service", 64 | "pk": 1, 65 | "fields": { 66 | "name": "Service 1", 67 | "slug": "service1" 68 | } 69 | }, 70 | { 71 | "model": "scoring.service", 72 | "pk": 2, 73 | "fields": { 74 | "name": "Service 2", 75 | "slug": "service2" 76 | } 77 | }, 78 | { 79 | "model": "scoring.gamecontrol", 80 | "pk": 1, 81 | "fields": { 82 | "competition_name": "Test CTF", 83 | "services_public": null, 84 | "start": null, 85 | "end": null, 86 | "tick_duration": 180, 87 | "valid_ticks": 5, 88 | "current_tick": -1, 89 | "flag_prefix": "FLAG_", 90 | "registration_open": false 91 | } 92 | } 93 | ] 94 | -------------------------------------------------------------------------------- /tests/checker/fixtures/master.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 2, 5 | "fields": { 6 | "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", 7 | "last_login": null, 8 | "is_superuser": false, 9 | "username": "Team1", 10 | "first_name": "", 11 | "last_name": "", 12 | "email": "", 13 | "is_staff": false, 14 | "is_active": true, 15 | "date_joined": "2019-04-03T18:21:28.622Z", 16 | "groups": [], 17 | "user_permissions": [] 18 | } 19 | }, 20 | { 21 | "model": "registration.team", 22 | "pk": 2, 23 | "fields": { 24 | "net_number": 92, 25 | "informal_email": "team1@example.org", 26 | "image": "", 27 | "affiliation": "", 28 | "country": "World", 29 | "nop_team": false 30 | } 31 | }, 32 | { 33 | "model": "scoring.service", 34 | "pk": 1, 35 | "fields": { 36 | "name": "Service 1", 37 | "slug": "service1" 38 | } 39 | }, 40 | { 41 | "model": "scoring.flag", 42 | "pk": 1, 43 | "fields": { 44 | "service": 1, 45 | "protecting_team": 2, 46 | "tick": 1, 47 | "placement_start": null, 48 | "placement_end": null, 49 | "flagid": null 50 | } 51 | }, 52 | { 53 | "model": "scoring.flag", 54 | "pk": 2, 55 | "fields": { 56 | "service": 1, 57 | "protecting_team": 2, 58 | "tick": 2, 59 | "placement_start": null, 60 | "placement_end": null, 61 | "flagid": null 62 | } 63 | }, 64 | { 65 | "model": "scoring.flag", 66 | "pk": 3, 67 | "fields": { 68 | "service": 1, 69 | "protecting_team": 2, 70 | "tick": 3, 71 | "placement_start": null, 72 | "placement_end": null, 73 | "flagid": null 74 | } 75 | }, 76 | { 77 | "model": "scoring.gamecontrol", 78 | "pk": 1, 79 | "fields": { 80 | "competition_name": "Test CTF", 81 | "services_public": null, 82 | "start": null, 83 | "end": null, 84 | "tick_duration": 180, 85 | "valid_ticks": 5, 86 | "current_tick": -1, 87 | "flag_prefix": "FLAG_", 88 | "registration_open": false 89 | } 90 | } 91 | ] 92 | -------------------------------------------------------------------------------- /tests/checker/integration_basic_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from ctf_gameserver import checkerlib 4 | 5 | 6 | class TestChecker(checkerlib.BaseChecker): 7 | 8 | def place_flag(self, tick): 9 | if self.team != 92: 10 | raise Exception('Team {} != 92'.format(self.team)) 11 | if self.ip != '0.0.92.1': 12 | raise Exception('IP {} != 0.0.92.1'.format(self.ip)) 13 | if tick != 0: 14 | raise Exception('Tick {} != 0'.format(tick)) 15 | 16 | checkerlib.get_flag(tick) 17 | checkerlib.set_flagid('value identifier') 18 | return checkerlib.CheckResult.OK 19 | 20 | def check_service(self): 21 | return checkerlib.CheckResult.OK 22 | 23 | def check_flag(self, tick): 24 | if tick != 0: 25 | raise Exception('Tick {} != 0'.format(tick)) 26 | 27 | checkerlib.get_flag(tick) 28 | return checkerlib.CheckResult.OK 29 | 30 | 31 | if __name__ == '__main__': 32 | 33 | checkerlib.run_check(TestChecker) 34 | -------------------------------------------------------------------------------- /tests/checker/integration_down_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import errno 4 | 5 | from ctf_gameserver import checkerlib 6 | 7 | 8 | class TestChecker(checkerlib.BaseChecker): 9 | 10 | def place_flag(self, tick): 11 | checkerlib.get_flag(tick) 12 | raise OSError(errno.ETIMEDOUT, 'A timeout occurred') 13 | 14 | def check_service(self): 15 | return checkerlib.CheckResult.OK 16 | 17 | def check_flag(self, tick): 18 | checkerlib.get_flag(tick) 19 | return checkerlib.CheckResult.OK 20 | 21 | 22 | if __name__ == '__main__': 23 | 24 | checkerlib.run_check(TestChecker) 25 | -------------------------------------------------------------------------------- /tests/checker/integration_exception_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from ctf_gameserver import checkerlib 4 | 5 | 6 | class TestChecker(checkerlib.BaseChecker): 7 | 8 | def place_flag(self, tick): 9 | raise Exception('This is fine') 10 | 11 | def check_service(self): 12 | return checkerlib.CheckResult.OK 13 | 14 | def check_flag(self, tick): 15 | return checkerlib.CheckResult.OK 16 | 17 | 18 | if __name__ == '__main__': 19 | 20 | checkerlib.run_check(TestChecker) 21 | -------------------------------------------------------------------------------- /tests/checker/integration_multi_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from ctf_gameserver import checkerlib 4 | 5 | 6 | class TestChecker(checkerlib.BaseChecker): 7 | 8 | def place_flag(self, tick): 9 | self._tick = tick # pylint: disable=attribute-defined-outside-init 10 | 11 | if self.team not in (92, 93): 12 | raise Exception('Invalid team {}'.format(self.team)) 13 | 14 | checkerlib.get_flag(tick) 15 | 16 | if self.team == 92 and tick == 0: 17 | return checkerlib.CheckResult.FAULTY 18 | else: 19 | return checkerlib.CheckResult.OK 20 | 21 | def check_service(self): 22 | if self.team == 92 and self._tick == 1: 23 | return checkerlib.CheckResult.DOWN 24 | else: 25 | return checkerlib.CheckResult.OK 26 | 27 | def check_flag(self, tick): 28 | checkerlib.get_flag(tick) 29 | 30 | if self.team == 92 and self._tick == 2: 31 | if tick == 0: 32 | return checkerlib.CheckResult.FLAG_NOT_FOUND 33 | else: 34 | return checkerlib.CheckResult.OK 35 | elif self.team == 93 and self._tick == 1: 36 | return checkerlib.CheckResult.FAULTY 37 | else: 38 | return checkerlib.CheckResult.OK 39 | 40 | 41 | if __name__ == '__main__': 42 | 43 | checkerlib.run_check(TestChecker) 44 | -------------------------------------------------------------------------------- /tests/checker/integration_state_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from ctf_gameserver import checkerlib 4 | 5 | 6 | class TestChecker(checkerlib.BaseChecker): 7 | 8 | def place_flag(self, tick): 9 | if checkerlib.load_state('key2') is not None: 10 | raise Exception('Got state where there should be none') 11 | 12 | if tick == 0: 13 | if checkerlib.load_state('key1') is not None: 14 | raise Exception('Got state where there should be none') 15 | 16 | checkerlib.get_flag(tick) 17 | 18 | if self.team == 92: 19 | if tick == 0: 20 | checkerlib.store_state('key1', 'Wir können Zustände speichern 🥳') 21 | else: 22 | if checkerlib.load_state('key1') != 'Wir können Zustände speichern 🥳': 23 | raise Exception('Did not get stored state back') 24 | 25 | if tick == 0: 26 | checkerlib.store_state('🔑ser', 'Söze') 27 | if checkerlib.load_state('🔑ser') != 'Söze': 28 | raise Exception('Did not get stored state back') 29 | elif tick == 1: 30 | if checkerlib.load_state('🔑ser') != 'Söze': 31 | raise Exception('Did not get stored state back') 32 | checkerlib.store_state('🔑ser', ['Roger', '"Verbal"', 'Kint']) 33 | elif tick == 2: 34 | if checkerlib.load_state('🔑ser') != ['Roger', '"Verbal"', 'Kint']: 35 | raise Exception('Did not get stored state back') 36 | elif self.team == 93: 37 | if tick == 1: 38 | if checkerlib.load_state('key1') is not None: 39 | raise Exception('Got state where there should be none') 40 | data = [{'number': 42}, {'number': 1337}] 41 | checkerlib.store_state('key1', data) 42 | checkerlib.set_flagid('value identifier') 43 | elif tick >= 2: 44 | if checkerlib.load_state('key1') != [{'number': 42}, {'number': 1337}]: 45 | raise Exception('Did not get stored state back') 46 | 47 | return checkerlib.CheckResult.OK 48 | 49 | def check_service(self): 50 | return checkerlib.CheckResult.OK 51 | 52 | def check_flag(self, tick): 53 | return checkerlib.CheckResult.OK 54 | 55 | 56 | if __name__ == '__main__': 57 | 58 | checkerlib.run_check(TestChecker) 59 | -------------------------------------------------------------------------------- /tests/checker/integration_sudo_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | 5 | from ctf_gameserver import checkerlib 6 | 7 | 8 | class TestChecker(checkerlib.BaseChecker): 9 | 10 | def place_flag(self, tick): 11 | checkerlib.get_flag(tick) 12 | 13 | # Try to send a signal to our parent, this should not be possible when running as another user 14 | parent_pid = os.getppid() 15 | try: 16 | os.kill(parent_pid, 0) 17 | except PermissionError: 18 | return checkerlib.CheckResult.OK 19 | 20 | raise Exception('Should not be able to kill the parent') 21 | 22 | def check_service(self): 23 | return checkerlib.CheckResult.OK 24 | 25 | def check_flag(self, tick): 26 | checkerlib.get_flag(tick) 27 | return checkerlib.CheckResult.OK 28 | 29 | 30 | if __name__ == '__main__': 31 | 32 | checkerlib.run_check(TestChecker) 33 | -------------------------------------------------------------------------------- /tests/checker/integration_unfinished_checkerscript.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import time 5 | 6 | from ctf_gameserver import checkerlib 7 | 8 | 9 | if __name__ == '__main__': 10 | 11 | pidfile_path = os.environ['CHECKERSCRIPT_PIDFILE'] # pylint: disable=invalid-name 12 | with open(pidfile_path, 'w', encoding='ascii') as pidfile: 13 | pidfile.write(str(os.getpid())) 14 | 15 | checkerlib.store_state('key', 'Lorem ipsum dolor sit amet') 16 | 17 | while True: 18 | time.sleep(10) 19 | -------------------------------------------------------------------------------- /tests/controller/fixtures/main_loop.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 2, 5 | "fields": { 6 | "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", 7 | "last_login": null, 8 | "is_superuser": false, 9 | "username": "Team1", 10 | "first_name": "", 11 | "last_name": "", 12 | "email": "", 13 | "is_staff": false, 14 | "is_active": true, 15 | "date_joined": "2019-04-03T18:21:28.622Z", 16 | "groups": [], 17 | "user_permissions": [] 18 | } 19 | }, 20 | { 21 | "model": "auth.user", 22 | "pk": 3, 23 | "fields": { 24 | "password": "pbkdf2_sha256$36000$xOKKVRJp772Y$YbUoJ0N2rDg3xndTwZv+jHKrIWcJ209dOUZij007eXg=", 25 | "last_login": null, 26 | "is_superuser": false, 27 | "username": "Team2", 28 | "first_name": "", 29 | "last_name": "", 30 | "email": "", 31 | "is_staff": false, 32 | "is_active": true, 33 | "date_joined": "2019-04-03T18:21:46.918Z", 34 | "groups": [], 35 | "user_permissions": [] 36 | } 37 | }, 38 | { 39 | "model": "auth.user", 40 | "pk": 4, 41 | "fields": { 42 | "password": "pbkdf2_sha256$36000$UGfl968SDB0l$rnxu7pLZUqwvYXk14QBjwjddYFTBrpw99PcH2FxtaKo=", 43 | "last_login": null, 44 | "is_superuser": false, 45 | "username": "NOP", 46 | "first_name": "", 47 | "last_name": "", 48 | "email": "", 49 | "is_staff": false, 50 | "is_active": true, 51 | "date_joined": "2019-04-03T18:22:02Z", 52 | "groups": [], 53 | "user_permissions": [] 54 | } 55 | }, 56 | { 57 | "model": "registration.team", 58 | "pk": 2, 59 | "fields": { 60 | "informal_email": "team1@example.org", 61 | "image": "", 62 | "affiliation": "", 63 | "country": "World", 64 | "nop_team": false 65 | } 66 | }, 67 | { 68 | "model": "registration.team", 69 | "pk": 3, 70 | "fields": { 71 | "informal_email": "team2@example.org", 72 | "image": "", 73 | "affiliation": "", 74 | "country": "World", 75 | "nop_team": false 76 | } 77 | }, 78 | { 79 | "model": "registration.team", 80 | "pk": 4, 81 | "fields": { 82 | "informal_email": "nop@example.org", 83 | "image": "", 84 | "affiliation": "", 85 | "country": "World", 86 | "nop_team": true 87 | } 88 | }, 89 | { 90 | "model": "scoring.service", 91 | "pk": 1, 92 | "fields": { 93 | "name": "Service 1", 94 | "slug": "service1" 95 | } 96 | }, 97 | { 98 | "model": "scoring.service", 99 | "pk": 2, 100 | "fields": { 101 | "name": "Service 2", 102 | "slug": "service2" 103 | } 104 | }, 105 | { 106 | "model": "scoring.gamecontrol", 107 | "pk": 1, 108 | "fields": { 109 | "competition_name": "Test CTF", 110 | "services_public": null, 111 | "start": null, 112 | "end": null, 113 | "tick_duration": 180, 114 | "valid_ticks": 5, 115 | "current_tick": -1, 116 | "flag_prefix": "FAUST_", 117 | "registration_open": false 118 | } 119 | } 120 | ] 121 | -------------------------------------------------------------------------------- /tests/controller/fixtures/scoring.json.xz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fausecteam/ctf-gameserver/e4ae6b43231318486dfee231a1b5288a19ac223d/tests/controller/fixtures/scoring.json.xz -------------------------------------------------------------------------------- /tests/controller/test_scoring.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | 4 | from ctf_gameserver.controller import scoring 5 | from ctf_gameserver.lib.database import transaction_cursor 6 | from ctf_gameserver.lib.test_util import DatabaseTestCase 7 | 8 | 9 | class ScoringTest(DatabaseTestCase): 10 | 11 | fixtures = ['tests/controller/fixtures/scoring.json.xz'] 12 | 13 | def test_scoreboard(self): 14 | def r(val): 15 | return round(val, 6) 16 | 17 | ref_path = os.path.join(os.path.dirname(__file__), 'scoring_reference.csv') 18 | with open(ref_path, newline='', encoding='ascii') as ref_file: 19 | ref_reader = csv.reader(ref_file, quoting=csv.QUOTE_NONNUMERIC) 20 | ref_values = [(int(v[0]), int(v[1]), r(v[2]), r(v[3]), r(v[4]), r(v[5])) for v in ref_reader] 21 | 22 | scoring.calculate_scoreboard(self.connection) 23 | 24 | with transaction_cursor(self.connection) as cursor: 25 | cursor.execute('SELECT team_id, service_id, attack, defense, sla, total' 26 | ' FROM scoring_scoreboard ORDER BY team_id, service_id') 27 | values = [(v[0], v[1], r(v[2]), r(v[3]), r(v[4]), r(v[5])) for v in cursor.fetchall()] 28 | 29 | self.assertEqual(values, ref_values) 30 | 31 | 32 | class EmptyScoringTest(DatabaseTestCase): 33 | """ 34 | Make sure that scoreboard calculation works on an empty database. 35 | """ 36 | 37 | def test_scoreboard(self): 38 | scoring.calculate_scoreboard(self.connection) 39 | -------------------------------------------------------------------------------- /tests/controller/test_sleep_seconds.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import datetime 3 | from unittest import TestCase 4 | from unittest.mock import Mock 5 | 6 | from ctf_gameserver.controller import controller 7 | 8 | 9 | class SleepSecondsTest(TestCase): 10 | 11 | metrics = defaultdict(Mock) 12 | 13 | def test_before(self): 14 | now = datetime.datetime.now(datetime.timezone.utc) 15 | control_info = { 16 | 'start': now+datetime.timedelta(minutes=5), 17 | 'end': now+datetime.timedelta(minutes=10), 18 | 'tick_duration': 60, 19 | 'current_tick': -1 20 | } 21 | 22 | sleep_seconds = controller.get_sleep_seconds(control_info, self.metrics, now) 23 | self.assertEqual(sleep_seconds, 300) 24 | 25 | def test_start(self): 26 | now = datetime.datetime.now(datetime.timezone.utc) 27 | control_info = { 28 | 'start': now, 29 | 'end': now+datetime.timedelta(minutes=10), 30 | 'tick_duration': 60, 31 | 'current_tick': -1 32 | } 33 | 34 | sleep_seconds = controller.get_sleep_seconds(control_info, self.metrics, now) 35 | self.assertEqual(sleep_seconds, 0) 36 | 37 | def test_during_1(self): 38 | now = datetime.datetime.now(datetime.timezone.utc) 39 | control_info = { 40 | 'start': now, 41 | 'end': now+datetime.timedelta(minutes=10), 42 | 'tick_duration': 60, 43 | 'current_tick': 0 44 | } 45 | 46 | sleep_seconds = controller.get_sleep_seconds(control_info, self.metrics, now) 47 | self.assertEqual(sleep_seconds, 60) 48 | 49 | def test_during_2(self): 50 | now = datetime.datetime.now(datetime.timezone.utc) 51 | control_info = { 52 | 'start': now-datetime.timedelta(seconds=200), 53 | 'end': now+datetime.timedelta(minutes=10), 54 | 'tick_duration': 60, 55 | 'current_tick': 3 56 | } 57 | 58 | sleep_seconds = controller.get_sleep_seconds(control_info, self.metrics, now) 59 | self.assertEqual(sleep_seconds, 40) 60 | 61 | def test_late(self): 62 | now = datetime.datetime.now(datetime.timezone.utc) 63 | control_info = { 64 | 'start': now-datetime.timedelta(seconds=200), 65 | 'end': now+datetime.timedelta(minutes=10), 66 | 'tick_duration': 60, 67 | # We should already be in tick 3 68 | 'current_tick': 2 69 | } 70 | 71 | sleep_seconds = controller.get_sleep_seconds(control_info, self.metrics, now) 72 | self.assertEqual(sleep_seconds, 0) 73 | -------------------------------------------------------------------------------- /tests/lib/test_args.py: -------------------------------------------------------------------------------- 1 | import socket 2 | from unittest import TestCase 3 | 4 | from ctf_gameserver.lib import args 5 | 6 | 7 | class HostPortTest(TestCase): 8 | 9 | def test_ipv4(self): 10 | host, port, family = args.parse_host_port('127.0.0.1:22') 11 | self.assertEqual(host, '127.0.0.1') 12 | self.assertEqual(port, 22) 13 | self.assertEqual(family, socket.AF_INET) 14 | 15 | def test_ipv6(self): 16 | host, port, family = args.parse_host_port('[::1]:8000') 17 | self.assertEqual(host, '::1') 18 | self.assertEqual(port, 8000) 19 | self.assertEqual(family, socket.AF_INET6) 20 | 21 | def test_hostname(self): 22 | parsed = args.parse_host_port('localhost:1337') 23 | self.assertEqual(parsed[1], 1337) 24 | # Can't know about host and family for sure 25 | 26 | def test_invalid(self): 27 | with self.assertRaises(ValueError): 28 | args.parse_host_port('::1') 29 | -------------------------------------------------------------------------------- /tests/lib/test_date_time.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from unittest import TestCase 3 | 4 | import pytz 5 | 6 | from ctf_gameserver.lib import date_time 7 | 8 | 9 | class EnsureUTCAwareTest(TestCase): 10 | 11 | def test_none(self): 12 | self.assertIsNone(date_time.ensure_utc_aware(None)) 13 | 14 | def test_datetime_utc(self): 15 | dt_in = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc) 16 | dt_out = date_time.ensure_utc_aware(dt_in) 17 | 18 | self.assertIs(dt_in, dt_out) 19 | self.assertEqual(dt_out.utcoffset().total_seconds(), 0) 20 | 21 | def test_datetime_berlin(self): 22 | timezone = pytz.timezone('Europe/Berlin') 23 | dt_in = datetime.datetime(2000, 1, 1, tzinfo=timezone) 24 | dt_out = date_time.ensure_utc_aware(dt_in) 25 | 26 | self.assertIs(dt_in, dt_out) 27 | self.assertEqual(dt_out.tzinfo, timezone) 28 | 29 | def test_datetime_unaware(self): 30 | dt_in = datetime.datetime(2000, 1, 1) 31 | dt_out = date_time.ensure_utc_aware(dt_in) 32 | 33 | self.assertIsNotNone(dt_out.tzinfo) 34 | self.assertEqual(dt_out.utcoffset().total_seconds(), 0) 35 | 36 | def test_time_utc(self): 37 | t_in = datetime.time(tzinfo=datetime.timezone.utc) 38 | t_out = date_time.ensure_utc_aware(t_in) 39 | 40 | self.assertIs(t_in, t_out) 41 | self.assertEqual(t_out.utcoffset().total_seconds(), 0) 42 | 43 | def test_time_unaware(self): 44 | t_in = datetime.time() 45 | t_out = date_time.ensure_utc_aware(t_in) 46 | 47 | self.assertIsNotNone(t_out.tzinfo) 48 | self.assertEqual(t_out.utcoffset().total_seconds(), 0) 49 | -------------------------------------------------------------------------------- /tests/lib/test_flag.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import random 3 | import unittest 4 | from unittest.mock import patch 5 | 6 | from ctf_gameserver.lib import flag 7 | 8 | 9 | class FlagTestCase(unittest.TestCase): 10 | 11 | def test_deterministic(self): 12 | now = self._now() 13 | flag1 = flag.generate(now, 12, 13, b'secret') 14 | flag2 = flag.generate(now, 12, 13, b'secret') 15 | self.assertEqual(flag1, flag2) 16 | 17 | def test_valid_flag(self): 18 | expiration = self._now() + datetime.timedelta(seconds=12) 19 | flag_id = 12 20 | team = 13 21 | test_flag = flag.generate(expiration, flag_id, team, b'secret') 22 | flag_id_, team_ = flag.verify(test_flag, b'secret') 23 | self.assertEqual(flag_id, flag_id_) 24 | self.assertEqual(team, team_) 25 | 26 | def test_old_flag(self): 27 | expiration = self._now() - datetime.timedelta(seconds=12) 28 | test_flag = flag.generate(expiration, 12, 13, b'secret', 'FLAGPREFIX-') 29 | with self.assertRaises(flag.FlagExpired): 30 | flag.verify(test_flag, b'secret', 'FLAGPREFIX-') 31 | 32 | def test_invalid_format(self): 33 | with self.assertRaises(flag.InvalidFlagFormat): 34 | flag.verify('ABC123', b'secret') 35 | 36 | def test_invalid_mac(self): 37 | test_flag = flag.generate(self._now(), 12, 13, b'secret') 38 | 39 | # Replace last character of the flag with a differnt one 40 | chars = set("0123456789") 41 | try: 42 | chars.remove(test_flag[-1]) 43 | except KeyError: 44 | pass 45 | wrong_flag = test_flag[:-1] + random.choice(list(chars)) 46 | 47 | with self.assertRaises(flag.InvalidFlagMAC): 48 | flag.verify(wrong_flag, b'secret') 49 | 50 | @patch('ctf_gameserver.lib.flag._now') 51 | def test_known_flags(self, now_mock): 52 | expected_flags = [ 53 | 'FAUST_Q1RGLRmVnOVTRVJBRV9tRpcBKDNOCUPW', 54 | 'FAUST_Q1RGLRml7uVTRVJBRV9IP7yOZriI07tT', 55 | 'FAUST_Q1RGLRmVnOVTRVJBRV/EFBYyQ5hGkkhc', 56 | 'FAUST_Q1RGLRml7uVTRVJBRV9+4LvDGpI37WnR', 57 | 'FAUST_Q1RGLRmVnOVTRVJBRXe71HlVK0TqWwjD', 58 | 'FAUST_Q1RGLRml7uVTRVJBRXdsFhEI3jhxey9I', 59 | 'FAUST_Q1RGLRmVnOVTRVJBRXfGLg3ip26nfSaS', 60 | 'FAUST_Q1RGLRml7uVTRVJBRXcQmzzAV65TUUFp', 61 | 'FAUST_Q1RGLRmVnOVTRVJ8RV/j9Ys/9UjHdsfL', 62 | 'FAUST_Q1RGLRml7uVTRVJ8RV/QpLXRXAao2VOL', 63 | 'FAUST_Q1RGLRmVnOVTRVJ8RV9MXCvXvUVKmW6+', 64 | 'FAUST_Q1RGLRml7uVTRVJ8RV9JoxKWWPdJ1BE0', 65 | 'FAUST_Q1RGLRmVnOVTRVJ8RXfMkW+dK2FfyJlQ', 66 | 'FAUST_Q1RGLRml7uVTRVJ8RXdxXbELYwjVp8Ku', 67 | 'FAUST_Q1RGLRmVnOVTRVJ8RXePbyjg1uvCeQcH', 68 | 'FAUST_Q1RGLRml7uVTRVJ8RXf/lT8Q1kehBFw9' 69 | ] 70 | actual_flags = [] 71 | 72 | for flag_id in (23, 42): 73 | for team in (13, 37): 74 | for secret in (b'secret1', b'secret2'): 75 | timestamp1 = datetime.datetime(2020, 6, 1, 10, 0, tzinfo=datetime.timezone.utc) 76 | timestamp2 = datetime.datetime(2020, 6, 13, 10, 0, tzinfo=datetime.timezone.utc) 77 | for timestamp in (timestamp1, timestamp2): 78 | actual_flag = flag.generate(timestamp, flag_id, team, secret, 'FAUST_') 79 | actual_flags.append(actual_flag) 80 | 81 | now_mock.return_value = timestamp - datetime.timedelta(seconds=5) 82 | actual_flag_id, actual_team = flag.verify(actual_flag, secret, 'FAUST_') 83 | self.assertEqual(actual_flag_id, flag_id) 84 | self.assertEqual(actual_team, team) 85 | 86 | self.assertEqual(actual_flags, expected_flags) 87 | 88 | def _now(self): 89 | return datetime.datetime.now(datetime.timezone.utc) 90 | -------------------------------------------------------------------------------- /tests/vpnstatus/fixtures/status.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "auth.user", 4 | "pk": 2, 5 | "fields": { 6 | "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", 7 | "last_login": null, 8 | "is_superuser": false, 9 | "username": "Team1", 10 | "first_name": "", 11 | "last_name": "", 12 | "email": "", 13 | "is_staff": false, 14 | "is_active": true, 15 | "date_joined": "2019-04-03T18:21:28.622Z", 16 | "groups": [], 17 | "user_permissions": [] 18 | } 19 | }, 20 | { 21 | "model": "auth.user", 22 | "pk": 3, 23 | "fields": { 24 | "password": "pbkdf2_sha256$36000$xOKKVRJp772Y$YbUoJ0N2rDg3xndTwZv+jHKrIWcJ209dOUZij007eXg=", 25 | "last_login": null, 26 | "is_superuser": false, 27 | "username": "Team2", 28 | "first_name": "", 29 | "last_name": "", 30 | "email": "", 31 | "is_staff": false, 32 | "is_active": true, 33 | "date_joined": "2019-04-03T18:21:46.918Z", 34 | "groups": [], 35 | "user_permissions": [] 36 | } 37 | }, 38 | { 39 | "model": "auth.user", 40 | "pk": 4, 41 | "fields": { 42 | "password": "pbkdf2_sha256$36000$UGfl968SDB0l$rnxu7pLZUqwvYXk14QBjwjddYFTBrpw99PcH2FxtaKo=", 43 | "last_login": null, 44 | "is_superuser": false, 45 | "username": "NOP", 46 | "first_name": "", 47 | "last_name": "", 48 | "email": "", 49 | "is_staff": false, 50 | "is_active": true, 51 | "date_joined": "2019-04-03T18:22:02Z", 52 | "groups": [], 53 | "user_permissions": [] 54 | } 55 | }, 56 | { 57 | "model": "registration.team", 58 | "pk": 2, 59 | "fields": { 60 | "informal_email": "team1@example.org", 61 | "image": "", 62 | "affiliation": "", 63 | "country": "World", 64 | "nop_team": false, 65 | "net_number": 102 66 | } 67 | }, 68 | { 69 | "model": "registration.team", 70 | "pk": 3, 71 | "fields": { 72 | "informal_email": "team2@example.org", 73 | "image": "", 74 | "affiliation": "", 75 | "country": "World", 76 | "nop_team": false, 77 | "net_number": 103 78 | } 79 | }, 80 | { 81 | "model": "registration.team", 82 | "pk": 4, 83 | "fields": { 84 | "informal_email": "nop@example.org", 85 | "image": "", 86 | "affiliation": "", 87 | "country": "World", 88 | "nop_team": true, 89 | "net_number": 104 90 | } 91 | }, 92 | { 93 | "model": "scoring.gamecontrol", 94 | "pk": 1, 95 | "fields": { 96 | "competition_name": "Test CTF", 97 | "services_public": null, 98 | "start": null, 99 | "end": null, 100 | "tick_duration": 180, 101 | "valid_ticks": 5, 102 | "current_tick": 6, 103 | "flag_prefix": "FAUST_", 104 | "registration_open": false 105 | } 106 | } 107 | ] 108 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | # Test with the version in Debian Stable and the latest Python version 3 | envlist = py39,py311 4 | recreate = True 5 | 6 | [testenv] 7 | commands = pytest --cov=src --cov-report=term --cov-report=html:{envlogdir}/htmlcov --basetemp={envtmpdir} {posargs} tests 8 | deps = pytest 9 | pytest-cov 10 | psycopg2-binary 11 | 12 | 13 | # Yep, this really is a place to put config for pycodestyle 14 | [pycodestyle] 15 | show-source = True 16 | exclude = migrations, Keccak.py 17 | # Relax whitespace rules around operators 18 | ignore = E226, E251, W504 19 | max-line-length = 109 20 | --------------------------------------------------------------------------------