diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index c2dd4bb..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,97 +0,0 @@ -version: 2.1 - -commands: - apt_get: - parameters: - args: - type: string - steps: - - run: - command: apt-get -y -qq << parameters.args >> - environment: - DEBIAN_FRONTEND: noninteractive - prepare_debian: - steps: - - apt_get: - args: update - - apt_get: - # Required for CircleCI `store_*` and `restore_*` operations - args: install ca-certificates - -jobs: - # Test with Tox, a recent Python version and libraries from PyPI - test_tox_37: - docker: - - image: python:3.7-buster - steps: - - checkout - - run: pip install tox - # Make sure we have our dependencies, which are not required for Tox but for `make build` - - run: pip install -e . - - run: make build - - run: tox -e py37 -- --junitxml=.tox/py37/log/test-results/pytest/results.xml - - store_test_results: - path: .tox/py37/log/test-results - - store_artifacts: - path: .tox/py37/log/test-results - destination: py37/test-results - - store_artifacts: - path: .tox/py37/log/htmlcov - destination: py37/htmlcov - - build_deb_package: - docker: - - image: debian:buster - steps: - - checkout - - prepare_debian - - apt_get: - args: install --no-install-recommends devscripts dpkg-dev equivs - # Add `--yes` to mk-build-deps' default options for apt-get - - run: mk-build-deps --install --tool 'apt-get -o Debug::pkgProblemResolver=yes --no-install-recommends --yes' debian/control - - run: dpkg-buildpackage --unsigned-changes --unsigned-buildinfo - - run: | - mkdir deb-packages - cp ../*.deb deb-packages/ - - persist_to_workspace: - root: deb-packages - paths: - - "*.deb" - - store_artifacts: - path: deb-packages - destination: deb-packages - - # Test with Python and libraries from Debian Stable sources - test_debian: - docker: - - image: debian:buster - steps: - - checkout - - prepare_debian - - attach_workspace: - at: deb-packages - # Install our package in order to install its dependencies - - apt_get: - args: install --no-install-recommends ./deb-packages/*.deb - - apt_get: - args: install make curl unzip python3-pytest python3-pytest-cov - - run: make build - - run: pytest-3 --junitxml=test-results/pytest/results.xml --cov=src --cov-report=term --cov-report=html tests - - store_test_results: - path: test-results - - store_artifacts: - path: test-results - destination: test-results - - store_artifacts: - path: htmlcov - destination: htmlcov - -workflows: - version: 2 - workflow: - jobs: - - test_tox_37 - - build_deb_package - - test_debian: - requires: - - build_deb_package diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..eb15db8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + - push + - pull_request + +jobs: + # Test with Tox, a recent Python version and libraries from PyPI + test_tox: + name: Test with Tox + runs-on: ubuntu-latest + container: python:3.10-bullseye + permissions: + # Required for "EnricoMi/publish-unit-test-result-action" + checks: write + steps: + - uses: actions/checkout@v2 + - name: Setup dependencies + run: | + pip install tox + # Make sure we have our dependencies, which are not required for Tox but for `make build` + pip install -e . + - run: make build + - run: tox -e py310 -- --junitxml=.tox/py310/log/results.xml + - run: find . + - name: Publish unit test results + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + files: .tox/py*/log/results.xml + comment_mode: "off" + - name: Archive unit test results + uses: actions/upload-artifact@v2 + if: always() + with: + name: tox-test-results + path: .tox/py*/log/results.xml + if-no-files-found: error + - name: Archive code coverage results + uses: actions/upload-artifact@v2 + if: always() + with: + name: tox-code-coverage-report + path: .tox/py*/log/htmlcov + if-no-files-found: error + + build_deb_package: + name: Build Debian package + runs-on: ubuntu-latest + container: debian:bullseye + steps: + - uses: actions/checkout@v2 + - run: echo 'deb http://deb.debian.org/debian/ bullseye-backports main' >> /etc/apt/sources.list + - run: apt-get --yes update + - run: apt-get --yes install --no-install-recommends devscripts dpkg-dev equivs + # It's a bit ugly to do this explicitly, but we really need Django from backports + - run: apt-get --yes install -t bullseye-backports python3-django + # Add `--yes` to mk-build-deps' default options for apt-get + - run: mk-build-deps --install --tool 'apt-get --yes -o Debug::pkgProblemResolver=yes --no-install-recommends' debian/control + - run: dpkg-buildpackage --unsigned-changes --unsigned-buildinfo + - run: mv ../ctf-gameserver_*.deb . + - name: Store Debian package + uses: actions/upload-artifact@v2 + with: + name: deb-package + path: ctf-gameserver_*.deb + if-no-files-found: error + + # Test with Python and libraries from Debian Stable sources + test_debian: + name: Test with Debian + runs-on: ubuntu-latest + container: debian:bullseye + needs: build_deb_package + steps: + - uses: actions/checkout@v2 + - uses: actions/download-artifact@v2 + with: + name: deb-package + - run: echo 'deb http://deb.debian.org/debian/ bullseye-backports main' >> /etc/apt/sources.list + - run: apt-get --yes update + # It's a bit ugly to do this explicitly, but we really need Django from backports + - run: apt-get --yes install -t bullseye-backports python3-django + # Install our package in order to install its dependencies + - run: apt-get --yes install --no-install-recommends ./ctf-gameserver_*.deb + - run: apt-get --yes install make curl unzip python3-pytest python3-pytest-cov + - run: make build + - run: pytest-3 --junitxml=results.xml --cov=src --cov-report=term --cov-report=html tests + - name: Archive unit test results + uses: actions/upload-artifact@v2 + if: always() + with: + name: debian-test-results + path: results.xml + if-no-files-found: error + - name: Archive code coverage results + uses: actions/upload-artifact@v2 + if: always() + with: + name: debian-code-coverage-report + path: htmlcov + if-no-files-found: error diff --git a/conf/checker/checkermaster.env b/conf/checker/checkermaster.env index 7d5480b..08dc266 100644 --- a/conf/checker/checkermaster.env +++ b/conf/checker/checkermaster.env @@ -1,7 +1,5 @@ CTF_DBNAME="DUMMY" CTF_DBUSER="DUMMY" -CTF_STATEDBNAME="DUMMY" -CTF_STATEDBUSER="DUMMY" CTF_SUDOUSER="ctf-checkerrunner" CTF_IPPATTERN="0.0.%s.2" diff --git a/conf/controller/ctf-flagid.service b/conf/controller/ctf-flagid.service deleted file mode 100644 index ecb7196..0000000 --- a/conf/controller/ctf-flagid.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=CTF Gameserver Flagid Exporter -After=postgresql.service - -[Service] -Type=oneshot -EnvironmentFile=/etc/ctf-gameserver/flagid.env -ExecStart=/usr/bin/ctf-flagid -User=nobody -Group=nogroup diff --git a/conf/controller/ctf-flagid.timer b/conf/controller/ctf-flagid.timer deleted file mode 100644 index 0c9aa03..0000000 --- a/conf/controller/ctf-flagid.timer +++ /dev/null @@ -1,9 +0,0 @@ -[Unit] -Description=CTF Gameserver Flagid Exporter - -[Timer] -OnCalendar=minutely -AccuracySec=5s - -[Install] -WantedBy=timers.target diff --git a/conf/controller/flagid.env b/conf/controller/flagid.env deleted file mode 100644 index 36cfca1..0000000 --- a/conf/controller/flagid.env +++ /dev/null @@ -1,5 +0,0 @@ -CTF_DBNAME="DUMMY" -CTF_DBUSER="DUMMY" -CTF_STATEDBNAME="DUMMY" -CTF_STATEDBUSER="DUMMY" -CTF_OUTPUT="/tmp/flagid.json" diff --git a/conf/web/prod_settings.py b/conf/web/prod_settings.py index 2b6ef1b..b2c4156 100644 --- a/conf/web/prod_settings.py +++ b/conf/web/prod_settings.py @@ -8,9 +8,6 @@ from ctf_gameserver.web.base_settings import * -# The human-readable title of your CTF -COMPETITION_NAME = '' - # Content Security Policy header in the format `directive: [values]`, see e.g # http://www.html5rocks.com/en/tutorials/security/content-security-policy/ for an explanation # The initially selected directives should cover most sensitive cases, but still allow YouTube embeds, @@ -32,7 +29,7 @@ # See https://docs.djangoproject.com/en/1.8/ref/settings/#databases DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'ENGINE': 'django.db.backends.postgresql', 'HOST': '', 'PORT': '', 'NAME': '', diff --git a/debian/install b/debian/install index 8876423..1555994 100644 --- a/debian/install +++ b/debian/install @@ -3,10 +3,7 @@ conf/checker/ctf-checkermaster@.service lib/systemd/system examples/checker/sudoers.d/ctf-checker etc/sudoers.d conf/controller/controller.env etc/ctf-gameserver -conf/controller/flagid.env etc/ctf-gameserver conf/controller/ctf-controller.service lib/systemd/system -conf/controller/ctf-flagid.service lib/systemd/system -conf/controller/ctf-flagid.timer lib/systemd/system conf/submission/submission.env etc/ctf-gameserver conf/submission/ctf-submission@.service lib/systemd/system diff --git a/doc/source/general.rst b/doc/source/general.rst index 57a42fe..a43d4e3 100644 --- a/doc/source/general.rst +++ b/doc/source/general.rst @@ -10,7 +10,7 @@ can be (somewhat) restricted. Checkermaster ^^^^^^^^^^^^^ - - full access on ``checkerstate`` + - full access on ``scoring_checkerstate`` - read on ``scoring_gamecontrol`` - write on ``scoring_statuscheck`` - write on ``scoring_statuscheck_id_seq`` diff --git a/doc/source/setup.rst b/doc/source/setup.rst index 3e27cc2..921ce17 100644 --- a/doc/source/setup.rst +++ b/doc/source/setup.rst @@ -12,7 +12,6 @@ machine on that network. ctf-gameserver has been checked out and built createuser -P faustctf createdb -O faustctf faustctf - createdb -O faustctf checkerstate Website ------- @@ -56,19 +55,6 @@ submission service and even use iptables to do some loadbalancing. The submission server is using an event-based architecture and is single-threaded. -The database for the checkerstate needs to be set up manually and -should contain exactly one table: - -.. code-block:: sql - - CREATE TABLE checkerstate ( - team_net_no INTEGER, - service_id INTEGER, - identifier CHARACTER VARYING (128), - data TEXT, - PRIMARY KEY (team_net_no, service_id, identifier) - ); - Checker ------- diff --git a/docs/checkers/go-library.md b/docs/checkers/go-library.md index 78adaee..2b50ef9 100644 --- a/docs/checkers/go-library.md +++ b/docs/checkers/go-library.md @@ -23,7 +23,8 @@ API To create a Checker Script, implement the `checkerlib.Checker` interface with the following methods: * `PlaceFlag(ip string, team int, tick int) (checkerlib.Result, error)`: Called once per Script execution to - place a flag for the current tick. Use `checkerlib.GetFlag(tick, nil)` to get the flag. + place a flag for the current tick. Use `checkerlib.GetFlag(tick, nil)` to get the flag and (optionally) + `SetFlagID(data string)` to store the flag ID. * `CheckService(ip string, team int) (Result, error)`: Called once per Script execution to determine general service health. * `CheckFlag(ip string, team int, tick int) (checkerlib.Result, error)`: Determine if the flag for the given diff --git a/docs/checkers/index.md b/docs/checkers/index.md index 59b44b2..3c09c49 100644 --- a/docs/checkers/index.md +++ b/docs/checkers/index.md @@ -76,10 +76,22 @@ source code line. Persistent State ---------------- Through special load and store commands to the Master, Checker Scripts can keep persistent state cross -ticks. State is identified by a string key and may consist of arbitrary binary data. State is kept -separately per team (and service), but not separated by tick. The Master makes sure that state stored in +ticks. State is identified by a string key and must consist of valid UTF-8 data. However, [Checker Script +libraries](#checker-script-libraries) may allow to store arbitrary data and handle serialization. State is +kept separately per team (and service), but not separated by tick. The Master makes sure that state stored in one tick can be loaded in subsequent ones, regardless of the Master instances involved. +Flag IDs +-------- +In some cases, you want to provide teams with an identifier which helps retrieving an individual Flag. For +example, consider a case where an exploit allows read access to a key/value store. To get Flag data, teams +still have to know the keys under which valid Flags are stored. This can also help to reduce load on your +service, because keys don't have to be brute-forced and a listing is not necessary. + +For this purpose, we provide the concept of **Flag IDs**. One ID can be stored per Flag by the Checker +Script. It is provided to teams as JSON by the CTF Gameserver web component. Flag IDs must be UTF-8 strings +with a maximum length of 100 characters. They are purely optional, not every service needs to provide them. + IPC Protocol ------------ All communication with the Master is initiated by the Checker Script. The Master will handle the Script's diff --git a/docs/checkers/python-library.md b/docs/checkers/python-library.md index 36f0c2a..4a3e9f5 100644 --- a/docs/checkers/python-library.md +++ b/docs/checkers/python-library.md @@ -46,6 +46,7 @@ care of calling your methods, merging the results and submitting them to the Che ### Functions * `get_flag(tick: int) -> str`: Get the flag for the given tick (for the checked team). +* `set_flagid(data: str) -> None`: Store the Flag ID for the current tick. * `store_state(key: str, data: Any) -> None`: Store arbitrary Python data persistently across runs. * `load_state(key: str) -> Any`: Retrieve data stored through `store_state()`. * `run_check(checker_cls: Type[BaseChecker]) -> None`: Start the check. diff --git a/examples/checker/example_checker.env b/examples/checker/example_checker.env index 655897e..301e47d 100644 --- a/examples/checker/example_checker.env +++ b/examples/checker/example_checker.env @@ -1,5 +1,4 @@ CTF_SERVICE="example_slug" CTF_CHECKERSCRIPT="/path/to/example_checker.py" -CTF_MAXCHECKDURATION="90" CTF_CHECKERCOUNT="1" CTF_INTERVAL="10" diff --git a/go/checkerlib/lib.go b/go/checkerlib/lib.go index f9bc18b..e31de17 100644 --- a/go/checkerlib/lib.go +++ b/go/checkerlib/lib.go @@ -141,7 +141,17 @@ func genFlag(team, service, timestamp int, payload, secret []byte) string { mac := d.Sum(nil) b.Write(mac[:9]) - return "FAUST_" + base64.StdEncoding.EncodeToString(b.Bytes()) + return "FLAG_" + base64.StdEncoding.EncodeToString(b.Bytes()) +} + +// SetFlagID stores the Flag ID for the current team and tick. +func SetFlagID(data string) { + if ipc.in != nil { + ipc.SendRecv("FLAGID", data) + // Wait for acknowledgement, result is ignored + } else { + log.Printf("Storing Flag ID: %q", data) + } } // StoreState allows a Checker Script to store data (serialized via diff --git a/scripts/controller/ctf-flagid b/scripts/controller/ctf-flagid deleted file mode 100755 index 2123f4e..0000000 --- a/scripts/controller/ctf-flagid +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import logging -import json -import os - -import psycopg2 -import psycopg2.extras - -from ctf_gameserver.lib.args import get_arg_parser_with_db - - -def process_row(row, service_names, result): - service = service_names[row.service_id] - - if not service in result: - result[service] = dict() - - if not row.team_id in result[service]: - result[service][row.team_id] = dict() - - result[service][row.team_id][row.identifier] = row.data.tobytes().decode() - - -def clean_dict(result): - result = result.copy() - for service in result: - for team_id in result[service]: - last5 = sorted(result[service][team_id].items())[-5:] - result[service][team_id] = [ i[1] for i in last5 ] - - return result - - -# TODO: adapt to IPv6 -# TODO: adapt to net_number -def update_flagids(db, service_names, fname): - result = dict() - with db: - with db.cursor(cursor_factory=psycopg2.extras.NamedTupleCursor) as cursor: - cursor.execute("SELECT service_id, format('10.66.%s.2', team_id) as team_id, data, identifier FROM checkerstate WHERE identifier LIKE 'flagid_%'") - rows = cursor.fetchall() - for row in rows: - process_row(row, service_names, result) - - result = clean_dict(result) - - with open("%s.tmp" % fname, "w") as fh: - json.dump(result, fh, indent=2) - os.rename("%s.tmp" % fname, fname) - - -def main(): - logging.basicConfig() - - parser = get_arg_parser_with_db('CTF Gameserver flag ID helper') - parser.add_argument('--output', type=str, required=True, - help="location where flagid file will be written to") - - group = parser.add_argument_group('statedb', 'Checker state database') - group.add_argument('--statedbname', type=str, required=True, - help='Name of the used database') - group.add_argument('--statedbuser', type=str, required=True, - help='username for database access') - group.add_argument('--statedbpassword', type=str, - help='password for database access if needed') - group.add_argument('--statedbhost', type=str, - help='hostname of the database. If unspecified ' - 'ctf-submission will connect via default UNIX socket') - - args = parser.parse_args() - - numeric_level = getattr(logging, args.loglevel.upper()) - logging.getLogger().setLevel(numeric_level) - - db = psycopg2.connect(host=args.dbhost, - database=args.dbname, - user=args.dbuser, - password=args.dbpassword) - - service_names = dict() - with db: - with db.cursor(cursor_factory=psycopg2.extras.NamedTupleCursor) as cursor: - cursor.execute("SELECT id, name FROM scoring_service") - rows = cursor.fetchall() - for row in rows: - service_names[row.id] = row.name - - - statedb = psycopg2.connect(host=args.statedbhost, - database=args.statedbname, - user=args.statedbuser, - password=args.statedbpassword) - - update_flagids(statedb, service_names, args.output) - - -if __name__ == '__main__': - main() diff --git a/scripts/submission/ctf-submission b/scripts/submission/ctf-submission index ceb3caf..a13c156 100755 --- a/scripts/submission/ctf-submission +++ b/scripts/submission/ctf-submission @@ -42,18 +42,18 @@ def main(): with dbconnection: with dbconnection.cursor() as cursor: - cursor.execute('''SELECT start, "end", valid_ticks, tick_duration + cursor.execute('''SELECT competition_name, start, "end", valid_ticks, tick_duration, flag_prefix FROM scoring_gamecontrol''') - conteststart, contestend, flagvalidity, tickduration = cursor.fetchone() + contestname, conteststart, contestend, flagvalidity, tickduration, flagprefix = cursor.fetchone() logging.debug("Starting asyncore") for family in (socket.AF_INET6, socket.AF_INET): try: flagserver.FlagServer(family, args.listen, args.port, - dbconnection, args.secret, conteststart, + dbconnection, args.secret, contestname, conteststart, contestend, flagvalidity, tickduration, - team_regex) + flagprefix, team_regex) break except socket.gaierror as e: if e.errno not in [socket.EAI_ADDRFAMILY, socket.EAI_NONAME]: diff --git a/setup.py b/setup.py index 73d32ba..46b13f9 100755 --- a/setup.py +++ b/setup.py @@ -16,7 +16,7 @@ install_requires = [ 'ConfigArgParse', - 'Django == 1.11.*, >= 1.11.19', + 'Django == 3.2.*', 'Markdown', 'Pillow', 'prometheus_client', @@ -46,7 +46,6 @@ 'scripts/checker/ctf-checkermaster', 'scripts/checker/ctf-logviewer', 'scripts/controller/ctf-controller', - 'scripts/controller/ctf-flagid', 'scripts/submission/ctf-submission' ], package_data = { @@ -57,6 +56,7 @@ 'templates/*.txt', 'static/robots.txt', 'static/*.css', + 'static/*.gif', 'static/*.js', 'static/ext/jquery.min.js', 'static/ext/bootstrap/css/*', diff --git a/src/ctf_gameserver/checker/database.py b/src/ctf_gameserver/checker/database.py index 252e1b7..a2c157f 100644 --- a/src/ctf_gameserver/checker/database.py +++ b/src/ctf_gameserver/checker/database.py @@ -10,7 +10,7 @@ def get_control_info(db_conn, prohibit_changes=False): """ with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('SELECT start, valid_ticks, tick_duration FROM scoring_gamecontrol') + cursor.execute('SELECT start, valid_ticks, tick_duration, flag_prefix FROM scoring_gamecontrol') result = cursor.fetchone() if result is None: @@ -19,7 +19,8 @@ def get_control_info(db_conn, prohibit_changes=False): return { 'contest_start': result[0], 'valid_ticks': result[1], - 'tick_duration': result[2] + 'tick_duration': result[2], + 'flag_prefix': result[3] } @@ -97,6 +98,11 @@ def get_new_tasks(db_conn, service_id, task_count, prohibit_changes=False): """ with transaction_cursor(db_conn, prohibit_changes) as cursor: + # We need a lock on the whole table to prevent deadlocks because of `ORDER BY RANDOM` + # See https://github.com/fausecteam/ctf-gameserver/issues/62 + # "There is no UNLOCK TABLE command; locks are always released at transaction end" + cursor.execute('LOCK TABLE scoring_flag IN EXCLUSIVE MODE') + cursor.execute('SELECT flag.id, flag.protecting_team_id, flag.tick, team.net_number' ' FROM scoring_flag flag, scoring_gamecontrol control, registration_team team' ' WHERE flag.placement_start is NULL' @@ -104,8 +110,7 @@ def get_new_tasks(db_conn, service_id, task_count, prohibit_changes=False): ' AND flag.service_id = %s' ' AND flag.protecting_team_id = team.user_id' ' ORDER BY RANDOM()' - ' LIMIT %s' - ' FOR UPDATE OF flag', (service_id, task_count)) + ' LIMIT %s', (service_id, task_count)) tasks = cursor.fetchall() # Mark placement as in progress @@ -120,21 +125,30 @@ def get_new_tasks(db_conn, service_id, task_count, prohibit_changes=False): } for task in tasks] +def _net_no_to_team_id(cursor, team_net_no, fake_team_id): + + cursor.execute('SELECT user_id FROM registration_team WHERE net_number = %s', (team_net_no,)) + data = cursor.fetchone() + + # Only do this after executing the SQL query, because we want to ensure the query works + if fake_team_id is not None: + return fake_team_id + elif data is None: + return None + + return data[0] + + def commit_result(db_conn, service_id, team_net_no, tick, result, prohibit_changes=False, fake_team_id=None): """ Saves the result from a Checker run to game database. """ with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('SELECT user_id FROM registration_team' - ' WHERE net_number = %s', (team_net_no,)) - data = cursor.fetchone() - if data is None: - if fake_team_id is None: - logging.error('No team found with net number %d, cannot commit result', team_net_no) - return - data = (fake_team_id,) - team_id = data[0] + team_id = _net_no_to_team_id(cursor, team_net_no, fake_team_id) + if team_id is None: + logging.error('No team found with net number %d, cannot commit result', team_net_no) + return cursor.execute('INSERT INTO scoring_statuscheck' ' (service_id, team_id, tick, status, timestamp)' @@ -148,16 +162,39 @@ def commit_result(db_conn, service_id, team_net_no, tick, result, prohibit_chang tick)) -def load_state(db_conn, service_id, team_net_no, identifier, prohibit_changes=False): +def set_flagid(db_conn, service_id, team_net_no, tick, flagid, prohibit_changes=False, fake_team_id=None): + """ + Stores a Flag ID in database. + In case of conflict, the previous Flag ID gets overwritten. + """ + + with transaction_cursor(db_conn, prohibit_changes) as cursor: + team_id = _net_no_to_team_id(cursor, team_net_no, fake_team_id) + if team_id is None: + logging.error('No team found with net number %d, cannot commit result', team_net_no) + return + + # (In case of `prohibit_changes`,) PostgreSQL checks the database grants even if nothing is matched + # by `WHERE` + cursor.execute('UPDATE scoring_flag' + ' SET flagid = %s' + ' WHERE service_id = %s AND protecting_team_id = %s AND tick = %s', (flagid, + service_id, + team_id, + tick)) + + +def load_state(db_conn, service_id, team_net_no, key, prohibit_changes=False): """ - Loads Checker data from state database. + Loads Checker state data from database. """ with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('SELECT data FROM checkerstate' - ' WHERE service_id = %s' - ' AND team_net_no = %s' - ' AND identifier = %s', (service_id, team_net_no, identifier)) + cursor.execute('SELECT data FROM scoring_checkerstate state, registration_team team' + ' WHERE state.service_id = %s' + ' AND state.key = %s' + ' AND team.net_number = %s' + ' AND state.team_id = team.user_id', (service_id, key, team_net_no)) data = cursor.fetchone() if data is None: @@ -165,15 +202,19 @@ def load_state(db_conn, service_id, team_net_no, identifier, prohibit_changes=Fa return data[0] -def store_state(db_conn, service_id, team_net_no, identifier, data, prohibit_changes=False): +def store_state(db_conn, service_id, team_net_no, key, data, prohibit_changes=False, fake_team_id=None): """ - Stores Checker data in state database. + Stores Checker state data in database. """ with transaction_cursor(db_conn, prohibit_changes) as cursor: + team_id = _net_no_to_team_id(cursor, team_net_no, fake_team_id) + if team_id is None: + logging.error('No team found with net number %d, cannot store state', team_net_no) + return + # (In case of `prohibit_changes`,) PostgreSQL checks the database grants even if no CONFLICT occurs - cursor.execute('INSERT INTO checkerstate (service_id, team_net_no, identifier, data)' + cursor.execute('INSERT INTO scoring_checkerstate (service_id, team_id, key, data)' ' VALUES (%s, %s, %s, %s)' - ' ON CONFLICT (service_id, team_net_no, identifier)' - ' DO UPDATE SET data = EXCLUDED.data', (service_id, team_net_no, identifier, - data)) + ' ON CONFLICT (service_id, team_id, key)' + ' DO UPDATE SET data = EXCLUDED.data', (service_id, team_id, key, data)) diff --git a/src/ctf_gameserver/checker/master.py b/src/ctf_gameserver/checker/master.py index fa0ad5c..6fdea93 100644 --- a/src/ctf_gameserver/checker/master.py +++ b/src/ctf_gameserver/checker/master.py @@ -19,7 +19,7 @@ from . import database, metrics from .supervisor import RunnerSupervisor -from .supervisor import ACTION_FLAG, ACTION_LOAD, ACTION_STORE, ACTION_RESULT +from .supervisor import ACTION_FLAG, ACTION_FLAGID, ACTION_LOAD, ACTION_STORE, ACTION_RESULT def main(): @@ -32,16 +32,6 @@ def main(): arg_parser.add_argument('--metrics-listen', type=str, help='Expose Prometheus metrics via HTTP ' '(":")') - group = arg_parser.add_argument_group('statedb', 'Checker state database') - group.add_argument('--statedbhost', type=str, help='Hostname of the database. If unspecified, the ' - 'default Unix socket will be used.') - group.add_argument('--statedbname', type=str, required=True, - help='Name of the used database') - group.add_argument('--statedbuser', type=str, required=True, - help='User name for database access') - group.add_argument('--statedbpassword', type=str, - help='Password for database access if needed') - group = arg_parser.add_argument_group('check', 'Check parameters') group.add_argument('--service', type=str, required=True, help='Slug of the service') @@ -112,12 +102,15 @@ def main(): target=metrics.run_collector, args=(args.service, metrics.checker_metrics_factory, metrics_queue, metrics_send) ) + # Terminate the process when the parent process exits + metrics_collector_process.daemon = True metrics_collector_process.start() logging.info('Started metrics collector process') metrics_server_process = multiprocessing.Process( target=metrics.run_http_server, args=(metrics_host, metrics_port, metrics_family, metrics_queue, metrics_recv) ) + metrics_server_process.daemon = True metrics_server_process.start() logging.info('Started metrics HTTP server process') @@ -130,49 +123,40 @@ def main(): # Connect to databases try: - game_db_conn = psycopg2.connect(host=args.dbhost, database=args.dbname, user=args.dbuser, - password=args.dbpassword) - except psycopg2.OperationalError as e: - logging.error('Could not establish connection to game database: %s', e) - return os.EX_UNAVAILABLE - logging.info('Established connection to game database') - - try: - state_db_conn = psycopg2.connect(host=args.statedbhost, database=args.statedbname, - user=args.statedbuser, password=args.statedbpassword) + db_conn = psycopg2.connect(host=args.dbhost, database=args.dbname, user=args.dbuser, + password=args.dbpassword) except psycopg2.OperationalError as e: - logging.error('Could not establish connection to state database: %s', e) + logging.error('Could not establish connection to database: %s', e) return os.EX_UNAVAILABLE - logging.info('Established connection to state database') + logging.info('Established connection to database') # Keep our mental model easy by always using (timezone-aware) UTC for dates and times - with transaction_cursor(game_db_conn) as cursor: - cursor.execute('SET TIME ZONE "UTC"') - with transaction_cursor(state_db_conn) as cursor: + with transaction_cursor(db_conn) as cursor: cursor.execute('SET TIME ZONE "UTC"') # Check database grants try: try: - database.get_control_info(game_db_conn, prohibit_changes=True) + database.get_control_info(db_conn, prohibit_changes=True) except DBDataError as e: logging.warning('Invalid database state: %s', e) try: - service_id = database.get_service_attributes(game_db_conn, args.service, + service_id = database.get_service_attributes(db_conn, args.service, prohibit_changes=True)['id'] except DBDataError as e: logging.warning('Invalid database state: %s', e) service_id = 1337 # Use dummy value for subsequent grant checks try: - database.get_current_tick(game_db_conn, prohibit_changes=True) + database.get_current_tick(db_conn, prohibit_changes=True) except DBDataError as e: logging.warning('Invalid database state: %s', e) - database.get_task_count(game_db_conn, service_id, prohibit_changes=True) - database.get_new_tasks(game_db_conn, service_id, 1, prohibit_changes=True) - database.commit_result(game_db_conn, service_id, 1, 0, 0, prohibit_changes=True, fake_team_id=1) - database.load_state(state_db_conn, service_id, 1, 'identifier', prohibit_changes=True) - database.store_state(state_db_conn, service_id, 1, 'identifier', 'data', prohibit_changes=True) + database.get_task_count(db_conn, service_id, prohibit_changes=True) + database.get_new_tasks(db_conn, service_id, 1, prohibit_changes=True) + database.commit_result(db_conn, service_id, 1, 2147483647, 0, prohibit_changes=True, fake_team_id=1) + database.set_flagid(db_conn, service_id, 1, 0, 'id', prohibit_changes=True, fake_team_id=1) + database.load_state(db_conn, service_id, 1, 'key', prohibit_changes=True) + database.store_state(db_conn, service_id, 1, 'key', 'data', prohibit_changes=True, fake_team_id=1) except psycopg2.ProgrammingError as e: if e.pgcode == postgres_errors.INSUFFICIENT_PRIVILEGE: # Log full exception because only the backtrace will tell which kind of permission is missing @@ -185,9 +169,9 @@ def main(): while True: try: - master_loop = MasterLoop(game_db_conn, state_db_conn, args.service, args.checkerscript, - args.sudouser, args.stddeviations, args.checkercount, args.interval, - args.ippattern, flag_secret, logging_params, metrics_queue) + master_loop = MasterLoop(db_conn, args.service, args.checkerscript, args.sudouser, + args.stddeviations, args.checkercount, args.interval, args.ippattern, + flag_secret, logging_params, metrics_queue) break except DBDataError as e: logging.warning('Waiting for valid database state: %s', e) @@ -200,26 +184,24 @@ def sigterm_handler(_, __): master_loop.shutting_down = True signal.signal(signal.SIGTERM, sigterm_handler) - while True: - master_loop.step() - if master_loop.shutting_down and master_loop.get_running_script_count() == 0: - break - - if args.metrics_listen is not None: - metrics_server_process.terminate() - metrics_collector_process.terminate() - metrics_server_process.join() - metrics_collector_process.join() + try: + while True: + master_loop.step() + if master_loop.shutting_down and master_loop.get_running_script_count() == 0: + break + except: # noqa, pylint: disable=bare-except + logging.exception('Aborting due to unexpected error:') + master_loop.supervisor.terminate_runners() + return os.EX_SOFTWARE return os.EX_OK class MasterLoop: - def __init__(self, game_db_conn, state_db_conn, service_slug, checker_script, sudo_user, std_dev_count, - checker_count, interval, ip_pattern, flag_secret, logging_params, metrics_queue): - self.game_db_conn = game_db_conn - self.state_db_conn = state_db_conn + def __init__(self, db_conn, service_slug, checker_script, sudo_user, std_dev_count, checker_count, + interval, ip_pattern, flag_secret, logging_params, metrics_queue): + self.db_conn = db_conn self.checker_script = checker_script self.sudo_user = sudo_user self.std_dev_count = std_dev_count @@ -231,7 +213,7 @@ def __init__(self, game_db_conn, state_db_conn, service_slug, checker_script, su self.metrics_queue = metrics_queue self.refresh_control_info() - self.service = database.get_service_attributes(self.game_db_conn, service_slug) + self.service = database.get_service_attributes(self.db_conn, service_slug) self.service['slug'] = service_slug self.supervisor = RunnerSupervisor(metrics_queue) @@ -242,10 +224,11 @@ def __init__(self, game_db_conn, state_db_conn, service_slug, checker_script, su self.shutting_down = False def refresh_control_info(self): - control_info = database.get_control_info(self.game_db_conn) + control_info = database.get_control_info(self.db_conn) self.contest_start = control_info['contest_start'] self.tick_duration = datetime.timedelta(seconds=control_info['tick_duration']) self.flag_valid_ticks = control_info['valid_ticks'] + self.flag_prefix = control_info['flag_prefix'] def step(self): """ @@ -264,6 +247,8 @@ def step(self): try: if req['action'] == ACTION_FLAG: resp = self.handle_flag_request(req['info'], req['param']) + elif req['action'] == ACTION_FLAGID: + self.handle_flagid_request(req['info'], req['param']) elif req['action'] == ACTION_LOAD: resp = self.handle_load_request(req['info'], req['param']) elif req['action'] == ACTION_STORE: @@ -316,14 +301,17 @@ def handle_flag_request(self, task_info, params): self.refresh_control_info() expiration = self.contest_start + (self.flag_valid_ticks + tick) * self.tick_duration - return flag_lib.generate(task_info['team'], self.service['id'], self.flag_secret, payload, - expiration.timestamp()) + return flag_lib.generate(task_info['team'], self.service['id'], self.flag_secret, self.flag_prefix, + payload, expiration.timestamp()) + + def handle_flagid_request(self, task_info, param): + database.set_flagid(self.db_conn, self.service['id'], task_info['team'], task_info['tick'], param) def handle_load_request(self, task_info, param): - return database.load_state(self.state_db_conn, self.service['id'], task_info['team'], param) + return database.load_state(self.db_conn, self.service['id'], task_info['team'], param) def handle_store_request(self, task_info, params): - database.store_state(self.state_db_conn, self.service['id'], task_info['team'], params['key'], + database.store_state(self.db_conn, self.service['id'], task_info['team'], params['key'], params['data']) def handle_result_request(self, task_info, param): @@ -344,7 +332,7 @@ def handle_result_request(self, task_info, param): logging.info('Result from Checker Script for team %d (net number %d) in tick %d: %s', task_info['_team_id'], task_info['team'], task_info['tick'], check_result) metrics.inc(self.metrics_queue, 'completed_tasks', labels={'result': check_result.name}) - database.commit_result(self.game_db_conn, self.service['id'], task_info['team'], task_info['tick'], + database.commit_result(self.db_conn, self.service['id'], task_info['team'], task_info['tick'], result) def launch_tasks(self): @@ -353,14 +341,14 @@ def change_tick(new_tick): self.update_launch_params(new_tick) self.known_tick = new_tick - current_tick = database.get_current_tick(self.game_db_conn) + current_tick = database.get_current_tick(self.db_conn) if current_tick < 0: # Competition not running yet return if current_tick != self.known_tick: change_tick(current_tick) - tasks = database.get_new_tasks(self.game_db_conn, self.service['id'], self.tasks_per_launch) + tasks = database.get_new_tasks(self.db_conn, self.service['id'], self.tasks_per_launch) # The current tick might have changed since calling `database.get_current_tick()`, so terminate the # old Runners; `database.get_new_tasks()` only returns tasks for one single tick @@ -396,13 +384,13 @@ def update_launch_params(self, tick): # We don't know any bounds on Checker Script Runtime at the beginning check_duration = self.tick_duration.total_seconds() else: - check_duration = database.get_check_duration(self.game_db_conn, self.service['id'], + check_duration = database.get_check_duration(self.db_conn, self.service['id'], self.std_dev_count) if check_duration is None: # No complete flag placements so far check_duration = self.tick_duration.total_seconds() - total_tasks = database.get_task_count(self.game_db_conn, self.service['id']) + total_tasks = database.get_task_count(self.db_conn, self.service['id']) local_tasks = math.ceil(total_tasks / self.checker_count) margin_seconds = self.tick_duration.total_seconds() / 6 diff --git a/src/ctf_gameserver/checker/supervisor.py b/src/ctf_gameserver/checker/supervisor.py index 6b654f8..58d9689 100644 --- a/src/ctf_gameserver/checker/supervisor.py +++ b/src/ctf_gameserver/checker/supervisor.py @@ -16,6 +16,7 @@ ACTION_FLAG = 'FLAG' +ACTION_FLAGID = 'FLAGID' ACTION_LOAD = 'LOAD' ACTION_STORE = 'STORE' ACTION_LOG = 'LOG' @@ -24,6 +25,7 @@ ACTIONS = [ ACTION_FLAG, + ACTION_FLAGID, ACTION_LOAD, ACTION_STORE, ACTION_LOG, @@ -42,9 +44,26 @@ def __init__(self, metrics_queue): # Timeout if there are no requests when all Runners are done or blocking self.queue_timeout = 1 + # Currently active processes by custom identifier (getting reset periodically) + self.processes = {} + # Runner processes from before any resets, which are waiting to be joined, by PID + # Cannot use a Python set because multiprocessing.Processes are not hashable + self.remaining_processes = {} self._reset() def _reset(self): + for proc, _, _ in self.processes.values(): + self.remaining_processes[proc.pid] = proc + + # Prevent zombies from accumulating without blocking + still_remaining_processes = {} + for proc in self.remaining_processes.values(): + if proc.is_alive(): + still_remaining_processes[proc.pid] = proc + else: + proc.join() + self.remaining_processes = still_remaining_processes + self.work_queue = multiprocessing.Queue() self.processes = {} self.start_times = {} @@ -229,10 +248,21 @@ def sigterm_handler(_, __): script_logger.warning('[RUNNER] Terminating Checker Script') # Yeah kids, this is how Unix works pgid = -1 * proc.pid - kill_args = ['kill', '-KILL', str(pgid)] + # Avoid using kill(1) because of https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1005376 + kill_args = ['python3', '-c', f'import os; import signal; os.kill({pgid}, signal.SIGKILL)'] if sudo_user is not None: kill_args = ['sudo', '--user='+sudo_user, '--'] + kill_args subprocess.check_call(kill_args) + # Best-effort attempt to join zombies, primarily for CI runs without an init process + # Use a timeout to guarantee the Runner itself will always exit within a reasonable time frame + # This will not work if the timeout expires or if our child fork()ed again; those zombies will be + # handled by the init process during regular execution + try: + proc.wait(5) + except subprocess.TimeoutExpired: + pass + # Raises a SystemExit exception, so that the `finally` clause from run_checker_script() will be + # executed sys.exit(1) signal.signal(signal.SIGTERM, sigterm_handler) diff --git a/src/ctf_gameserver/checkerlib/__init__.py b/src/ctf_gameserver/checkerlib/__init__.py index e3ba976..0aacdda 100644 --- a/src/ctf_gameserver/checkerlib/__init__.py +++ b/src/ctf_gameserver/checkerlib/__init__.py @@ -1 +1 @@ -from .lib import BaseChecker, CheckResult, get_flag, load_state, run_check, store_state +from .lib import BaseChecker, CheckResult, get_flag, set_flagid, load_state, run_check, store_state diff --git a/src/ctf_gameserver/checkerlib/lib.py b/src/ctf_gameserver/checkerlib/lib.py index 67d4811..89378ed 100644 --- a/src/ctf_gameserver/checkerlib/lib.py +++ b/src/ctf_gameserver/checkerlib/lib.py @@ -127,7 +127,7 @@ def get_flag(tick: int, payload: bytes = b'') -> str: # Return dummy flag when launched locally if payload == b'': payload = None - return ctf_gameserver.lib.flag.generate(team, 42, b'TOPSECRET', payload, tick) + return ctf_gameserver.lib.flag.generate(team, 42, b'TOPSECRET', payload=payload, timestamp=tick) payload_b64 = base64.b64encode(payload).decode('ascii') _send_ctrl_message({'action': 'FLAG', 'param': {'tick': tick, 'payload': payload_b64}}) @@ -135,6 +135,19 @@ def get_flag(tick: int, payload: bytes = b'') -> str: return result['response'] +def set_flagid(data: str) -> None: + """ + Stores the Flag ID for the current team and tick. + """ + + if not _launched_without_runner(): + _send_ctrl_message({'action': 'FLAGID', 'param': data}) + # Wait for acknowledgement + _recv_ctrl_message() + else: + print('Storing Flag ID: {}'.format(data)) + + def store_state(key: str, data: Any) -> None: """ Allows a Checker Script to store arbitrary Python data persistently across runs. Data is stored per diff --git a/src/ctf_gameserver/controller/controller.py b/src/ctf_gameserver/controller/controller.py index 6e87afd..a5025c0 100644 --- a/src/ctf_gameserver/controller/controller.py +++ b/src/ctf_gameserver/controller/controller.py @@ -176,7 +176,11 @@ def sleep(duration): if ((control_info['end'] - control_info['start']).total_seconds() % control_info['tick_duration']) != 0: logging.warning('Competition duration not divisible by tick duration, strange things might happen') - if (not nonstop) and (now > control_info['end']): + if now < control_info['start']: + logging.info('Competition has not started yet') + return + + if (not nonstop) and (now >= control_info['end']): # Do not stop the program because a daemon might get restarted if it exits # Prevent a busy loop in case we have not slept above as the hypothetic next tick would be overdue logging.info('Competition is already over') diff --git a/src/ctf_gameserver/lib/database.py b/src/ctf_gameserver/lib/database.py index 805b733..7a56d71 100644 --- a/src/ctf_gameserver/lib/database.py +++ b/src/ctf_gameserver/lib/database.py @@ -1,5 +1,4 @@ from contextlib import contextmanager -import re import sqlite3 @@ -78,13 +77,14 @@ def _translate_operation(operation): Translates Psycopg2 features to their SQLite counterparts on a best-effort base. """ + # Apart from being a best effort, this also changes the semantics, but SQLite just doesn't support + # "LOCK TABLE" + if operation.startswith('LOCK TABLE'): + return '' + # The placeholder is always "%s" in Psycopg2, "even if a different placeholder (such as a %d for # integers or %f for floats) may look more appropriate" operation = operation.replace('%s', '?') operation = operation.replace('NOW()', "DATETIME('now')") - # Apart from being a best effort, this also changes the semantics, but SQLite just doesn't support - # "FOR UPDATE" - operation = re.sub(r'FOR UPDATE OF \S+', '', operation) - return operation diff --git a/src/ctf_gameserver/lib/flag.py b/src/ctf_gameserver/lib/flag.py index 89b0b63..5db4b8d 100644 --- a/src/ctf_gameserver/lib/flag.py +++ b/src/ctf_gameserver/lib/flag.py @@ -18,7 +18,7 @@ VALID = 900 -def generate(team_net_no, service_id, secret, payload=None, timestamp=None): +def generate(team_net_no, service_id, secret, prefix='FLAG_', payload=None, timestamp=None): """ Generates a flag for the given arguments. This is deterministic and should always return the same result for the same arguments (and the same time, if no timestamp is explicitly specified). @@ -46,10 +46,10 @@ def generate(team_net_no, service_id, secret, payload=None, timestamp=None): protected_data += payload mac = _gen_mac(secret, protected_data) - return PREFIX + '_' + base64.b64encode(protected_data + mac).decode('ascii') + return prefix + base64.b64encode(protected_data + mac).decode('ascii') -def verify(flag, secret): +def verify(flag, secret, prefix='FLAG_'): """ Verfies flag validity and returns data from the flag. Will raise an appropriate exception if verification fails. @@ -58,11 +58,11 @@ def verify(flag, secret): Data from the flag as a tuple of (team, service, payload, timestamp) """ - if not flag.startswith(PREFIX + '_'): + if not flag.startswith(prefix): raise InvalidFlagFormat() try: - raw_flag = base64.b64decode(flag.split('_')[1]) + raw_flag = base64.b64decode(flag[len(prefix):]) except binascii.Error: raise InvalidFlagFormat() diff --git a/src/ctf_gameserver/submission/flagserver.py b/src/ctf_gameserver/submission/flagserver.py index 073d624..3dbde28 100644 --- a/src/ctf_gameserver/submission/flagserver.py +++ b/src/ctf_gameserver/submission/flagserver.py @@ -10,9 +10,9 @@ from ctf_gameserver.lib import flag class FlagHandler(asynchat.async_chat): - def __init__(self, sock, addr, dbconnection, secret, + def __init__(self, sock, addr, dbconnection, secret, contestname, conteststart, contestend, flagvalidity, tickduration, - team_regex): + flagprefix, team_regex): asynchat.async_chat.__init__(self, sock=sock) ipaddr, port = addr[:2] # IPv4 returns two values, IPv6 four @@ -29,6 +29,7 @@ def __init__(self, sock, addr, dbconnection, secret, self._cursor = None self._dbconnection = dbconnection self._secret = base64.b64decode(secret) + self._contestname = contestname self.buffer = b'' self._logger.info("Accepted connection from Team (Net Number) %s", self.capturing_team) self._banner() @@ -36,6 +37,7 @@ def __init__(self, sock, addr, dbconnection, secret, self._contestend = contestend self._flagvalidity = flagvalidity self._tickduration = tickduration + self._flagprefix = flagprefix def _reply(self, message): self._logger.debug("-> %s", message.decode('utf-8')) @@ -72,7 +74,7 @@ def _handle_flag(self): return try: - protecting_team, service, _, timestamp = flag.verify(curflag, self._secret) + protecting_team, service, _, timestamp = flag.verify(curflag, self._secret, self._flagprefix) except flag.InvalidFlagFormat: self._reply(b"Flag not recognized") return @@ -154,8 +156,8 @@ def _store_capture(self, protecting_team, service, timestamp): def _banner(self): - self.push(u"Flag submission server\n" - u"One flag per line please!\n".encode('utf-8')) + self.push(u"{} flag submission server\n" + u"One flag per line please!\n".format(self._contestname).encode('utf-8')) def collect_incoming_data(self, data): diff --git a/src/ctf_gameserver/web/admin.py b/src/ctf_gameserver/web/admin.py index fabce45..4d86c5f 100644 --- a/src/ctf_gameserver/web/admin.py +++ b/src/ctf_gameserver/web/admin.py @@ -1,11 +1,12 @@ from django.contrib import admin -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ +from django.utils.functional import classproperty +from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from django.contrib.auth.admin import UserAdmin from .registration.models import Team from .registration.admin import InlineTeamAdmin +from .scoring.models import GameControl from .util import format_lazy @@ -14,11 +15,19 @@ class CTFAdminSite(admin.AdminSite): Custom variant of the AdminSite which replaces the default headers and titles. """ - site_header = format_lazy(_('{competition_name} administration'), - competition_name=settings.COMPETITION_NAME) - site_title = site_header index_title = _('Administration home') + # Declare this lazily through a classproperty in order to avoid a circular dependency when creating + # migrations + @classproperty + def site_header(cls): # pylint: disable=no-self-argument + return format_lazy(_('{competition_name} administration'), + competition_name=GameControl.get_instance().competition_name) + + @classproperty + def site_title(cls): # pylint: disable=no-self-argument + return cls.site_header + admin_site = CTFAdminSite() # pylint: disable=invalid-name diff --git a/src/ctf_gameserver/web/base_settings.py b/src/ctf_gameserver/web/base_settings.py index c2d539d..76cf7a4 100644 --- a/src/ctf_gameserver/web/base_settings.py +++ b/src/ctf_gameserver/web/base_settings.py @@ -17,6 +17,8 @@ THUMBNAIL_SIZE = (100, 100) +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + INSTALLED_APPS = ( 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -51,11 +53,11 @@ 'context_processors': [ 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.request', 'django.template.context_processors.i18n', 'django.template.context_processors.static', 'django.template.context_processors.media', - 'ctf_gameserver.web.context_processors.competition_name', - 'ctf_gameserver.web.context_processors.competition_status', + 'ctf_gameserver.web.context_processors.game_control', 'ctf_gameserver.web.context_processors.flatpage_nav' ] } @@ -94,8 +96,10 @@ DATETIME_FORMAT = DATE_FORMAT + ' ' + TIME_FORMAT SHORT_DATETIME_FORMAT = SHORT_DATE_FORMAT + ' ' + TIME_FORMAT -PASSWORD_RESET_TIMEOUT_DAYS = 1 +PASSWORD_RESET_TIMEOUT = 86400 CSRF_COOKIE_HTTPONLY = True SECURE_CONTENT_TYPE_NOSNIFF = True SECURE_BROWSER_XSS_FILTER = True X_FRAME_OPTIONS = 'DENY' +CSRF_COOKIE_SAMESITE = 'Lax' +SESSION_COOKIE_SAMESITE = 'Lax' diff --git a/src/ctf_gameserver/web/context_processors.py b/src/ctf_gameserver/web/context_processors.py index 4643781..70e8bbf 100644 --- a/src/ctf_gameserver/web/context_processors.py +++ b/src/ctf_gameserver/web/context_processors.py @@ -4,25 +4,17 @@ from .flatpages import models as flatpages_models -def competition_name(_): +def game_control(_): """ - Context processor that adds the CTF's title to the context. + Context processor which adds information from the Game Control table to the context. """ - return {'COMPETITION_NAME': settings.COMPETITION_NAME} - - -def competition_status(_): - """ - Context processor which adds information about the competition's status (whether it is running or over - and whether registration is open) to the context. - """ - - game_control = scoring_models.GameControl.get_instance() + control_instance = scoring_models.GameControl.get_instance() return { - 'registration_open': game_control.registration_open, - 'services_public': game_control.are_services_public() + 'competition_name': control_instance.competition_name, + 'registration_open': control_instance.registration_open, + 'services_public': control_instance.are_services_public() } diff --git a/src/ctf_gameserver/web/dev_settings.py b/src/ctf_gameserver/web/dev_settings.py index c4ab780..f2b3d0d 100644 --- a/src/ctf_gameserver/web/dev_settings.py +++ b/src/ctf_gameserver/web/dev_settings.py @@ -8,8 +8,6 @@ from .base_settings import * -COMPETITION_NAME = 'Development CTF' - CSP_POLICIES = { # The debug error page uses inline JavaScript and CSS 'script-src': ["'self'", "'unsafe-inline'"], diff --git a/src/ctf_gameserver/web/flatpages/admin.py b/src/ctf_gameserver/web/flatpages/admin.py index f9c5ee2..e8a6711 100644 --- a/src/ctf_gameserver/web/flatpages/admin.py +++ b/src/ctf_gameserver/web/flatpages/admin.py @@ -1,4 +1,4 @@ -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.contrib import admin from ctf_gameserver.web.admin import admin_site diff --git a/src/ctf_gameserver/web/flatpages/forms.py b/src/ctf_gameserver/web/flatpages/forms.py index 30ed656..2dfeffc 100644 --- a/src/ctf_gameserver/web/flatpages/forms.py +++ b/src/ctf_gameserver/web/flatpages/forms.py @@ -1,7 +1,7 @@ from django import forms from django.utils.text import slugify from django.utils.safestring import mark_safe -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from . import models diff --git a/src/ctf_gameserver/web/flatpages/templates/flatpage.html b/src/ctf_gameserver/web/flatpages/templates/flatpage.html index c306d16..d414330 100644 --- a/src/ctf_gameserver/web/flatpages/templates/flatpage.html +++ b/src/ctf_gameserver/web/flatpages/templates/flatpage.html @@ -7,7 +7,7 @@

{% block title %} {% if page.is_home_page %} - {{ COMPETITION_NAME }} + {{ competition_name }} {% else %} {{ page.title }} {% endif %} diff --git a/src/ctf_gameserver/web/forms.py b/src/ctf_gameserver/web/forms.py index c9b15af..226684d 100644 --- a/src/ctf_gameserver/web/forms.py +++ b/src/ctf_gameserver/web/forms.py @@ -1,8 +1,9 @@ from django import forms -from django.conf import settings -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.contrib.auth.forms import AuthenticationForm, PasswordResetForm +from .scoring.models import GameControl + class TeamAuthenticationForm(AuthenticationForm): """ @@ -23,7 +24,7 @@ class FormalPasswordResetForm(PasswordResetForm): def send_mail(self, subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name=None): - context['competition_name'] = settings.COMPETITION_NAME + context['competition_name'] = GameControl.get_instance().competition_name return super().send_mail(subject_template_name, email_template_name, context, from_email, to_email, html_email_template_name) diff --git a/src/ctf_gameserver/web/registration/admin.py b/src/ctf_gameserver/web/registration/admin.py index 501e27e..49ccb15 100644 --- a/src/ctf_gameserver/web/registration/admin.py +++ b/src/ctf_gameserver/web/registration/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from .models import Team from .forms import AdminTeamForm diff --git a/src/ctf_gameserver/web/registration/forms.py b/src/ctf_gameserver/web/registration/forms.py index 2ec6dc0..12284dc 100644 --- a/src/ctf_gameserver/web/registration/forms.py +++ b/src/ctf_gameserver/web/registration/forms.py @@ -3,7 +3,7 @@ from django.template import loader from django.conf import settings from django.utils.safestring import mark_safe -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User from django.contrib.sites.shortcuts import get_current_site @@ -93,8 +93,10 @@ def send_confirmation_mail(self, request): Args: request: The HttpRequest from which this function is being called """ + competition_name = scoring_models.GameControl.get_instance().competition_name + context = { - 'competition_name': settings.COMPETITION_NAME, + 'competition_name': competition_name, 'protocol': 'https' if request.is_secure() else 'http', 'domain': get_current_site(request), 'user': self.instance.pk, @@ -102,7 +104,7 @@ def send_confirmation_mail(self, request): } message = loader.render_to_string('confirmation_mail.txt', context) - send_mail(settings.COMPETITION_NAME+' email confirmation', message, settings.DEFAULT_FROM_EMAIL, + send_mail(competition_name+' email confirmation', message, settings.DEFAULT_FROM_EMAIL, [self.instance.email]) diff --git a/src/ctf_gameserver/web/registration/models.py b/src/ctf_gameserver/web/registration/models.py index 5dcce53..111ec7a 100644 --- a/src/ctf_gameserver/web/registration/models.py +++ b/src/ctf_gameserver/web/registration/models.py @@ -1,6 +1,6 @@ from django.db import models from django.conf import settings -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from .fields import ThumbnailImageField diff --git a/src/ctf_gameserver/web/registration/templates/edit_team.html b/src/ctf_gameserver/web/registration/templates/edit_team.html index 05a5062..41bc8ba 100644 --- a/src/ctf_gameserver/web/registration/templates/edit_team.html +++ b/src/ctf_gameserver/web/registration/templates/edit_team.html @@ -4,10 +4,12 @@ {% block content %} diff --git a/src/ctf_gameserver/web/registration/templates/mail_teams.html b/src/ctf_gameserver/web/registration/templates/mail_teams.html index 2640a58..754c407 100644 --- a/src/ctf_gameserver/web/registration/templates/mail_teams.html +++ b/src/ctf_gameserver/web/registration/templates/mail_teams.html @@ -4,7 +4,7 @@ {% block content %}

diff --git a/src/ctf_gameserver/web/registration/templates/register.html b/src/ctf_gameserver/web/registration/templates/register.html index faa3530..61035bd 100644 --- a/src/ctf_gameserver/web/registration/templates/register.html +++ b/src/ctf_gameserver/web/registration/templates/register.html @@ -9,7 +9,7 @@

{% block title %}{% trans 'Registration' %}{% endblock %}

{% blocktrans %} - Want to register a team for {{ COMPETITION_NAME }}? There you go: + Want to register a team for {{ competition_name }}? There you go: {% endblocktrans %}

diff --git a/src/ctf_gameserver/web/registration/templates/team_list.html b/src/ctf_gameserver/web/registration/templates/team_list.html index cd3dd48..4450a68 100644 --- a/src/ctf_gameserver/web/registration/templates/team_list.html +++ b/src/ctf_gameserver/web/registration/templates/team_list.html @@ -3,7 +3,7 @@ {% block content %} {% include 'competition_nav.html' with active='team_list' %} diff --git a/src/ctf_gameserver/web/registration/views.py b/src/ctf_gameserver/web/registration/views.py index 7d4f223..496716e 100644 --- a/src/ctf_gameserver/web/registration/views.py +++ b/src/ctf_gameserver/web/registration/views.py @@ -12,7 +12,7 @@ from django.contrib.auth.decorators import login_required from django.contrib.admin.views.decorators import staff_member_required -from ctf_gameserver.web.scoring.decorators import registration_open_required +from ctf_gameserver.web.scoring.decorators import before_competition_required, registration_open_required import ctf_gameserver.web.scoring.models as scoring_models from . import forms from .models import Team @@ -89,15 +89,23 @@ def edit_team(request): user_form = forms.UserForm(prefix='user', instance=request.user) team_form = forms.TeamForm(prefix='team', instance=team) + game_control = scoring_models.GameControl.get_instance() + # Theoretically, there can be cases where registration is still open (meaning Teams can be edited) after + # the competition has begun; this is usually not much of a problem, but deleting a Team in that situation + # will break scoring + show_delete_button = not game_control.competition_started() + return render(request, 'edit_team.html', { 'team': team, 'user_form': user_form, 'team_form': team_form, + 'show_delete_button': show_delete_button, 'delete_form': None }) @login_required +@before_competition_required @registration_open_required @transaction.atomic def delete_team(request): diff --git a/src/ctf_gameserver/web/scoring/admin.py b/src/ctf_gameserver/web/scoring/admin.py index 781761d..1dc0375 100644 --- a/src/ctf_gameserver/web/scoring/admin.py +++ b/src/ctf_gameserver/web/scoring/admin.py @@ -1,5 +1,5 @@ from django.shortcuts import redirect -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from django.contrib import admin from ctf_gameserver.web.admin import admin_site diff --git a/src/ctf_gameserver/web/scoring/decorators.py b/src/ctf_gameserver/web/scoring/decorators.py index 9fa1728..968db18 100644 --- a/src/ctf_gameserver/web/scoring/decorators.py +++ b/src/ctf_gameserver/web/scoring/decorators.py @@ -43,6 +43,23 @@ def func(request, *args, **kwargs): return func +def before_competition_required(view): + """ + View decorator which prohibits access to the decorated view if the competition has already begun (i.e. + running or over). + """ + + @wraps(view) + def func(request, *args, **kwargs): + if GameControl.get_instance().competition_started(): + messages.error(request, _('Sorry, that is only possible before the competition.')) + return redirect(settings.HOME_URL) + + return view(request, *args, **kwargs) + + return func + + def services_public_required(resp_format): """ View decorator which prohibits access to the decorated view if information about the services is not diff --git a/src/ctf_gameserver/web/scoring/forms.py b/src/ctf_gameserver/web/scoring/forms.py index ea1bcd5..b3cf4d4 100644 --- a/src/ctf_gameserver/web/scoring/forms.py +++ b/src/ctf_gameserver/web/scoring/forms.py @@ -1,5 +1,5 @@ from django import forms -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from . import models @@ -18,10 +18,12 @@ class Meta: model = models.GameControl exclude = ('current_tick',) help_texts = { + 'competition_name': _('Human-readable title of the CTF'), 'services_public': _('Time at which information about the services is public, but the actual ' 'game has not started yet'), 'valid_ticks': _('Number of ticks a flag is valid for'), - 'registration_confirm_text': _('If set, teams will have to confirm to this text (e.g. a link to' + 'flag_prefix': _('Static text prepended to every flag'), + 'registration_confirm_text': _('If set, teams will have to confirm to this text (e.g. a link to ' 'T&C) when signing up. May contain HTML.'), 'min_net_number': _('If unset, team IDs will be used as net numbers'), 'max_net_number': _('(Inclusive) If unset, team IDs will be used as net numbers'), @@ -39,11 +41,16 @@ def clean_tick_duration(self): return tick_duration def clean(self): - services_public = self.cleaned_data['services_public'] - start = self.cleaned_data['start'] - end = self.cleaned_data['end'] - - if services_public > start: - raise forms.ValidationError(_('Services public time must not be after start time')) - if end <= start: - raise forms.ValidationError(_('End time must be after start time')) + cleaned_data = super().clean() + + services_public = cleaned_data.get('services_public') + start = cleaned_data.get('start') + end = cleaned_data.get('end') + + if start is not None: + if services_public is not None and services_public > start: + raise forms.ValidationError(_('Services public time must not be after start time')) + if end is not None and end <= start: + raise forms.ValidationError(_('End time must be after start time')) + + return cleaned_data diff --git a/src/ctf_gameserver/web/scoring/models.py b/src/ctf_gameserver/web/scoring/models.py index 2104ffd..53ef7a4 100644 --- a/src/ctf_gameserver/web/scoring/models.py +++ b/src/ctf_gameserver/web/scoring/models.py @@ -1,7 +1,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from ctf_gameserver.web.registration.models import Team @@ -26,10 +26,12 @@ class Flag(models.Model): service = models.ForeignKey(Service, on_delete=models.CASCADE) protecting_team = models.ForeignKey(Team, on_delete=models.CASCADE) - tick = models.PositiveSmallIntegerField() + tick = models.PositiveIntegerField() # NULL means the flag has been generated, but not yet placed placement_start = models.DateTimeField(null=True, blank=True, default=None) placement_end = models.DateTimeField(null=True, blank=True, default=None) + # Optional identifier to help Teams retrieve the Flag, we don't enforce this to uniquely identify a Flag + flagid = models.CharField(max_length=100, null=True, blank=True, default=None) # Bonus points for capturing this flag bonus = models.FloatField(null=True, blank=True, default=None) @@ -49,9 +51,9 @@ class Capture(models.Model): Database representation of a capture, i.e. the (successful) submission of a particular flag by one team. """ - flag = models.ForeignKey(Flag, on_delete=models.CASCADE) + flag = models.ForeignKey(Flag, on_delete=models.PROTECT) capturing_team = models.ForeignKey(Team, on_delete=models.CASCADE) - tick = models.PositiveSmallIntegerField() + tick = models.PositiveIntegerField() timestamp = models.DateTimeField(auto_now_add=True) class Meta: @@ -80,7 +82,7 @@ class StatusCheck(models.Model): service = models.ForeignKey(Service, on_delete=models.CASCADE) team = models.ForeignKey(Team, on_delete=models.CASCADE) - tick = models.PositiveSmallIntegerField(db_index=True) + tick = models.PositiveIntegerField(db_index=True) # REVISIT: Add check constraint for the values as soon as we have Django >= 2.2 status = models.PositiveSmallIntegerField(choices=[(i, t) for t, i in STATUSES.items()]) timestamp = models.DateTimeField(auto_now_add=True) @@ -114,7 +116,25 @@ class Meta: ordering = ('team', '-total', '-attack', '-defense') def __str__(self): - return 'Score for team {:d}'.format(self.team) + return 'Score for team {}'.format(self.team) + + +class CheckerState(models.Model): + """ + Persistent state from Checker Scripts. + """ + + service = models.ForeignKey(Service, on_delete=models.CASCADE) + team = models.ForeignKey(Team, on_delete=models.CASCADE) + key = models.CharField(max_length=100) + data = models.TextField() + + class Meta: + unique_together = ('service', 'team', 'key') + index_together = ('service', 'team', 'key') + + def __str__(self): + return 'Checker state "{}" for service {} and team {}'.format(self.key, self.service, self.team) class GameControl(models.Model): @@ -122,6 +142,7 @@ class GameControl(models.Model): Single-row database table to store control information for the competition. """ + competition_name = models.CharField(max_length=100, default='My A/D CTF') # Start and end times for the whole competition: Make them NULL-able (for the initial state), but not # blank-able (have to be set upon editing); "services_public" is the point at which information about the # services is public, but the actual game has not started yet @@ -132,8 +153,9 @@ class GameControl(models.Model): tick_duration = models.PositiveSmallIntegerField(default=180) # Number of ticks a flag is valid for including the one it was generated in valid_ticks = models.PositiveSmallIntegerField(default=5) - current_tick = models.SmallIntegerField(default=-1) - registration_open = models.BooleanField(default=True) + current_tick = models.IntegerField(default=-1) + flag_prefix = models.CharField(max_length=20, default='FLAG_') + registration_open = models.BooleanField(default=False) registration_confirm_text = models.TextField(blank=True) min_net_number = models.PositiveIntegerField(null=True, blank=True) max_net_number = models.PositiveIntegerField(null=True, blank=True) @@ -171,6 +193,15 @@ def are_services_public(self): return self.services_public <= timezone.now() + def competition_started(self): + """ + Indicates whether the competition has already begun (i.e. running or over). + """ + if self.start is None or self.end is None: + return False + + return self.start <= timezone.now() + def competition_over(self): """ Indicates whether the competition is already over. diff --git a/src/ctf_gameserver/web/scoring/templates/service_history.html b/src/ctf_gameserver/web/scoring/templates/service_history.html index 40cffff..10d4089 100644 --- a/src/ctf_gameserver/web/scoring/templates/service_history.html +++ b/src/ctf_gameserver/web/scoring/templates/service_history.html @@ -16,7 +16,7 @@

{% block title %}{% trans 'Service History' %}{% endblock %}

- -
+
{% trans 'Min' %}
diff --git a/src/ctf_gameserver/web/scoring/templates/service_status.html b/src/ctf_gameserver/web/scoring/templates/service_status.html index 595034f..6bc5d57 100644 --- a/src/ctf_gameserver/web/scoring/templates/service_status.html +++ b/src/ctf_gameserver/web/scoring/templates/service_status.html @@ -14,7 +14,7 @@ {% include 'competition_nav.html' with active='service_status' %} diff --git a/src/ctf_gameserver/web/scoring/templatetags/status_css_class.py b/src/ctf_gameserver/web/scoring/templatetags/status_css_class.py index ee4b5bf..37419fa 100644 --- a/src/ctf_gameserver/web/scoring/templatetags/status_css_class.py +++ b/src/ctf_gameserver/web/scoring/templatetags/status_css_class.py @@ -1,5 +1,5 @@ from django import template -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ register = template.Library() # pylint: disable=invalid-name diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index aef7bc6..2665369 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.conf import settings from django.contrib.admin.views.decorators import staff_member_required from django.db.models import Max @@ -171,8 +173,22 @@ def teams_json(_): teams = registration_models.Team.active_objects.values_list('net_number', flat=True) + game_control = models.GameControl.get_instance() + # Only publish Flag IDs after the respective Tick is over + flagid_max_tick = game_control.current_tick - 1 + flagid_min_tick = flagid_max_tick - game_control.valid_ticks + + flag_ids = defaultdict(lambda: defaultdict(lambda: [])) + for flag in models.Flag.objects.exclude(flagid=None) \ + .exclude(flagid='') \ + .filter(tick__gte=flagid_min_tick, tick__lte=flagid_max_tick) \ + .select_related('service', 'protecting_team') \ + .only('service__name', 'protecting_team__net_number', 'flagid'): + flag_ids[flag.service.name][flag.protecting_team.net_number].append(flag.flagid) + response = { - 'teams': list(teams) + 'teams': list(teams), + 'flag_ids': flag_ids } return JsonResponse(response, json_dumps_params={'indent': 2}) diff --git a/src/ctf_gameserver/web/static/progress_spinner.gif b/src/ctf_gameserver/web/static/progress_spinner.gif new file mode 100644 index 0000000..c97ec6e Binary files /dev/null and b/src/ctf_gameserver/web/static/progress_spinner.gif differ diff --git a/src/ctf_gameserver/web/static/service_history.js b/src/ctf_gameserver/web/static/service_history.js index dd93f0d..43059f6 100644 --- a/src/ctf_gameserver/web/static/service_history.js +++ b/src/ctf_gameserver/web/static/service_history.js @@ -22,8 +22,13 @@ $(document).ready(function() { function loadTable(_, ignoreMaxTick=false) { + makeFieldsEditable(false) + $('#load-spinner').attr('hidden', false) + const serviceSlug = window.location.hash.slice(1) if (serviceSlug.length == 0) { + $('#load-spinner').attr('hidden', true) + makeFieldsEditable(true) return } @@ -37,7 +42,22 @@ function loadTable(_, ignoreMaxTick=false) { if (!ignoreMaxTick) { params['to-tick'] = toTick } - $.getJSON('service-history.json', params, buildTable) + $.getJSON('service-history.json', params, function(data) { + buildTable(data) + $('#load-spinner').attr('hidden', true) + makeFieldsEditable(true) + }) + +} + + +function makeFieldsEditable(writeable) { + + $('#service-selector').attr('disabled', !writeable) + $('#min-tick').attr('readonly', !writeable) + $('#max-tick').attr('readonly', !writeable) + $('#refresh').attr('disabled', !writeable) + $('#load-current').attr('disabled', !writeable) } @@ -63,7 +83,8 @@ function buildTable(data) { while (tableHeadRow.firstChild) { tableHeadRow.removeChild(tableHeadRow.firstChild) } - // Leave first column (team names) empty + // Leave first two columns (team numbers & names) empty + tableHeadRow.appendChild(document.createElement('th')) tableHeadRow.appendChild(document.createElement('th')) for (let i = data['min-tick']; i <= data['max-tick']; i++) { let col = document.createElement('th') @@ -84,9 +105,14 @@ function buildTable(data) { let row = document.createElement('tr') let firstCol = document.createElement('td') - firstCol.textContent = team['name'] + firstCol.classList.add('text-muted') + firstCol.textContent = team['net_number'] row.appendChild(firstCol) + let secondCol = document.createElement('td') + secondCol.textContent = team['name'] + row.appendChild(secondCol) + for (let i = 0; i < team['checks'].length; i++) { const check = team['checks'][i] const tick = data['min-tick'] + i diff --git a/src/ctf_gameserver/web/static/style.css b/src/ctf_gameserver/web/static/style.css index ff02222..a918352 100644 --- a/src/ctf_gameserver/web/static/style.css +++ b/src/ctf_gameserver/web/static/style.css @@ -68,8 +68,12 @@ th.border-right, td.border-right { display: block; } -button#refresh { - margin-right: 25px; +img#load-spinner { + width: 1.7em; +} + +img#load-spinner, button#refresh { + margin-right: 20px; } #history-table td a:hover { diff --git a/src/ctf_gameserver/web/templates/400.html b/src/ctf_gameserver/web/templates/400.html index 879e92d..46a7d89 100644 --- a/src/ctf_gameserver/web/templates/400.html +++ b/src/ctf_gameserver/web/templates/400.html @@ -3,7 +3,7 @@ {% block content %}

diff --git a/src/ctf_gameserver/web/templates/404.html b/src/ctf_gameserver/web/templates/404.html index 601e07e..54eed1e 100644 --- a/src/ctf_gameserver/web/templates/404.html +++ b/src/ctf_gameserver/web/templates/404.html @@ -3,7 +3,7 @@ {% block content %}

diff --git a/src/ctf_gameserver/web/templates/500.html b/src/ctf_gameserver/web/templates/500.html index ff95a40..ec43b4f 100644 --- a/src/ctf_gameserver/web/templates/500.html +++ b/src/ctf_gameserver/web/templates/500.html @@ -3,7 +3,7 @@ {% block content %}

diff --git a/src/ctf_gameserver/web/templates/base-common.html b/src/ctf_gameserver/web/templates/base-common.html index 793c785..a684b1f 100644 --- a/src/ctf_gameserver/web/templates/base-common.html +++ b/src/ctf_gameserver/web/templates/base-common.html @@ -19,10 +19,10 @@ - {% block title %}{% endblock %} | {{ COMPETITION_NAME }} + {% block title %}{% endblock %} | {{ competition_name }} - + @@ -37,7 +37,7 @@ - {{ COMPETITION_NAME }} + {{ competition_name }}

diff --git a/src/ctf_gameserver/web/templates/password_reset_confirm.html b/src/ctf_gameserver/web/templates/password_reset_confirm.html index 241fef2..55cfab2 100644 --- a/src/ctf_gameserver/web/templates/password_reset_confirm.html +++ b/src/ctf_gameserver/web/templates/password_reset_confirm.html @@ -14,7 +14,7 @@ {% endif %}

diff --git a/src/ctf_gameserver/web/templates/password_reset_done.html b/src/ctf_gameserver/web/templates/password_reset_done.html index 2b5a9fd..f6190f4 100644 --- a/src/ctf_gameserver/web/templates/password_reset_done.html +++ b/src/ctf_gameserver/web/templates/password_reset_done.html @@ -10,7 +10,7 @@

diff --git a/src/ctf_gameserver/web/urls.py b/src/ctf_gameserver/web/urls.py index 177e17a..cb1850f 100644 --- a/src/ctf_gameserver/web/urls.py +++ b/src/ctf_gameserver/web/urls.py @@ -1,8 +1,7 @@ from django.conf import settings -from django.conf.urls import url from django.conf.urls.static import static from django.contrib.auth import views as auth_views -from django.urls import reverse_lazy +from django.urls import re_path as url, reverse_lazy from .registration import views as registration_views from .scoring import views as scoring_views diff --git a/tests/checker/fixtures/integration.json b/tests/checker/fixtures/integration.json index af952dc..41f4882 100644 --- a/tests/checker/fixtures/integration.json +++ b/tests/checker/fixtures/integration.json @@ -79,11 +79,14 @@ "model": "scoring.gamecontrol", "pk": 1, "fields": { + "competition_name": "Test CTF", + "services_public": null, "start": null, "end": null, "tick_duration": 180, "valid_ticks": 5, "current_tick": -1, + "flag_prefix": "FLAG_", "registration_open": false } } diff --git a/tests/checker/fixtures/master.json b/tests/checker/fixtures/master.json index 5f8a105..bd16edf 100644 --- a/tests/checker/fixtures/master.json +++ b/tests/checker/fixtures/master.json @@ -46,6 +46,7 @@ "tick": 1, "placement_start": null, "placement_end": null, + "flagid": null, "bonus": null } }, @@ -58,6 +59,7 @@ "tick": 2, "placement_start": null, "placement_end": null, + "flagid": null, "bonus": null } }, @@ -70,6 +72,7 @@ "tick": 3, "placement_start": null, "placement_end": null, + "flagid": null, "bonus": null } }, @@ -77,11 +80,14 @@ "model": "scoring.gamecontrol", "pk": 1, "fields": { + "competition_name": "Test CTF", + "services_public": null, "start": null, "end": null, "tick_duration": 180, "valid_ticks": 5, "current_tick": -1, + "flag_prefix": "FLAG_", "registration_open": false } } diff --git a/tests/checker/integration_basic_checkerscript.py b/tests/checker/integration_basic_checkerscript.py index 986bee6..9ad614f 100755 --- a/tests/checker/integration_basic_checkerscript.py +++ b/tests/checker/integration_basic_checkerscript.py @@ -14,6 +14,7 @@ def place_flag(self, tick): raise Exception('Tick {} != 0'.format(tick)) checkerlib.get_flag(tick) + checkerlib.set_flagid('value identifier') return checkerlib.CheckResult.OK def check_service(self): diff --git a/tests/checker/integration_state_checkerscript.py b/tests/checker/integration_state_checkerscript.py index 357e1d0..96b82bc 100755 --- a/tests/checker/integration_state_checkerscript.py +++ b/tests/checker/integration_state_checkerscript.py @@ -39,6 +39,7 @@ def place_flag(self, tick): raise Exception('Got state where there should be none') data = [{'number': 42}, {'number': 1337}] checkerlib.store_state('key1', data) + checkerlib.set_flagid('value identifier') elif tick >= 2: if checkerlib.load_state('key1') != [{'number': 42}, {'number': 1337}]: raise Exception('Did not get stored state back') diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index a24df0d..1f9381a 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -19,16 +19,6 @@ class IntegrationTest(DatabaseTestCase): fixtures = ['tests/checker/fixtures/integration.json'] def setUp(self): - self.state_db_conn = sqlite3.connect(':memory:') - with transaction_cursor(self.state_db_conn) as cursor: - cursor.execute('CREATE TABLE checkerstate (' - ' team_net_no INTEGER,' - ' service_id INTEGER,' - ' identifier CHARACTER VARYING (128),' - ' data TEXT, ' - ' PRIMARY KEY (team_net_no, service_id, identifier)' - ')') - self.check_duration_patch = patch('ctf_gameserver.checker.database.get_check_duration') check_duration_mock = self.check_duration_patch.start() check_duration_mock.return_value = None @@ -41,8 +31,8 @@ def test_basic(self, monotonic_mock): checkerscript_path = os.path.join(os.path.dirname(__file__), 'integration_basic_checkerscript.py') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) master_loop.supervisor.queue_timeout = 0.01 # Sanity check before any tick @@ -85,14 +75,17 @@ def test_basic(self, monotonic_mock): cursor.execute('SELECT status FROM scoring_statuscheck' ' WHERE service_id=1 AND team_id=2 AND tick=0') self.assertEqual(cursor.fetchone()[0], CheckResult.OK.value) + cursor.execute('SELECT flagid FROM scoring_flag' + ' WHERE service_id=1 AND protecting_team_id=2 AND tick=0') + self.assertEqual(cursor.fetchone()[0], 'value identifier') @patch('ctf_gameserver.checker.master.get_monotonic_time') def test_missing_checkerscript(self, monotonic_mock): checkerscript_path = os.path.join(os.path.dirname(__file__), 'does not exist') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') @@ -121,8 +114,8 @@ def test_exception(self, monotonic_mock): 'integration_exception_checkerscript.py') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') @@ -151,8 +144,8 @@ def test_down(self, monotonic_mock): 'integration_down_checkerscript.py') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') @@ -187,8 +180,8 @@ def test_unfinished(self, monotonic_mock, warning_mock): os.environ['CHECKERSCRIPT_PIDFILE'] = checkerscript_pidfile.name monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') @@ -246,8 +239,8 @@ def test_multi_teams_ticks(self, monotonic_mock): 'integration_multi_checkerscript.py') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) # Tick 0 with transaction_cursor(self.connection) as cursor: @@ -338,14 +331,14 @@ def test_state(self, monotonic_mock): 'integration_state_checkerscript.py') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) - with transaction_cursor(self.state_db_conn) as cursor: + with transaction_cursor(self.connection) as cursor: # Prepopulate state for the non-checked service to ensure we'll never get this data returned data = 'gAN9cQBYAwAAAGZvb3EBWAMAAABiYXJxAnMu' - cursor.execute('INSERT INTO checkerstate (team_net_no, service_id, identifier, data)' - ' VALUES (92, 2, %s, %s), (93, 2, %s, %s)', ('key1', data, 'key2', data)) + cursor.execute('INSERT INTO scoring_checkerstate (team_id, service_id, key, data)' + ' VALUES (2, 2, %s, %s), (3, 2, %s, %s)', ('key1', data, 'key2', data)) # Tick 0 with transaction_cursor(self.connection) as cursor: @@ -367,6 +360,9 @@ def test_state(self, monotonic_mock): cursor.execute('SELECT COUNT(*) FROM scoring_statuscheck WHERE status=%s', (CheckResult.OK.value,)) self.assertEqual(cursor.fetchone()[0], 2) + cursor.execute('SELECT flagid FROM scoring_flag' + ' WHERE service_id=1 AND protecting_team_id=3 AND tick=0') + self.assertIsNone(cursor.fetchone()[0]) # Tick 1 with transaction_cursor(self.connection) as cursor: @@ -387,6 +383,9 @@ def test_state(self, monotonic_mock): cursor.execute('SELECT COUNT(*) FROM scoring_statuscheck WHERE status=%s', (CheckResult.OK.value,)) self.assertEqual(cursor.fetchone()[0], 4) + cursor.execute('SELECT flagid FROM scoring_flag' + ' WHERE service_id=1 AND protecting_team_id=3 AND tick=1') + self.assertEqual(cursor.fetchone()[0], 'value identifier') # Tick 2 with transaction_cursor(self.connection) as cursor: @@ -407,14 +406,17 @@ def test_state(self, monotonic_mock): cursor.execute('SELECT COUNT(*) FROM scoring_statuscheck WHERE status=%s', (CheckResult.OK.value,)) self.assertEqual(cursor.fetchone()[0], 6) + cursor.execute('SELECT flagid FROM scoring_flag' + ' WHERE service_id=1 AND protecting_team_id=3 AND tick=2') + self.assertIsNone(cursor.fetchone()[0]) @patch('ctf_gameserver.checker.master.get_monotonic_time') def test_shutdown(self, monotonic_mock): checkerscript_path = '/dev/null' monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, None, - 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, None, 2, 1, 10, + '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') @@ -442,8 +444,8 @@ def test_sudo(self, monotonic_mock): 'integration_sudo_checkerscript.py') monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, - 'ctf-checkerrunner', 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, 'ctf-checkerrunner', 2, 1, + 10, '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') @@ -482,8 +484,8 @@ def test_sudo_unfinished(self, monotonic_mock, warning_mock): os.environ['CHECKERSCRIPT_PIDFILE'] = checkerscript_pidfile.name monotonic_mock.return_value = 10 - master_loop = MasterLoop(self.connection, self.state_db_conn, 'service1', checkerscript_path, - 'ctf-checkerrunner', 2, 1, 10, '0.0.%s.1', b'secret', {}, DummyQueue()) + master_loop = MasterLoop(self.connection, 'service1', checkerscript_path, 'ctf-checkerrunner', 2, 1, + 10, '0.0.%s.1', b'secret', {}, DummyQueue()) with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') diff --git a/tests/checker/test_master.py b/tests/checker/test_master.py index eb5e019..3aa9121 100644 --- a/tests/checker/test_master.py +++ b/tests/checker/test_master.py @@ -13,8 +13,8 @@ class MasterTest(DatabaseTestCase): fixtures = ['tests/checker/fixtures/master.json'] def setUp(self): - self.master_loop = MasterLoop(self.connection, None, 'service1', '/dev/null', None, 2, 8, 10, - '0.0.%s.1', b'secret', {}, DummyQueue()) + self.master_loop = MasterLoop(self.connection, 'service1', '/dev/null', None, 2, 8, 10, '0.0.%s.1', + b'secret', {}, DummyQueue()) def test_handle_flag_request(self): with transaction_cursor(self.connection) as cursor: diff --git a/tests/checker/test_metrics.py b/tests/checker/test_metrics.py index 7bbbb1a..c19c98a 100644 --- a/tests/checker/test_metrics.py +++ b/tests/checker/test_metrics.py @@ -70,16 +70,21 @@ def tearDown(self): def test_gauge(self): metrics.set(self.queue, 'plain_gauge', 42) + # "If multiple processes are enqueuing objects, it is possible for the objects to be received at the + # other end out-of-order" + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('plain_gauge 42.0', resp.text) metrics.inc(self.queue, 'plain_gauge') + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('plain_gauge 43.0', resp.text) metrics.dec(self.queue, 'plain_gauge', 1.5) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('plain_gauge 41.5', resp.text) @@ -88,6 +93,7 @@ def test_custom_label(self): metrics.set(self.queue, 'instance_gauge', 42, {'instance': 1}) metrics.set(self.queue, 'instance_gauge', 13.37, {'instance': 2}) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('instance_gauge{instance="1"} 42.0', resp.text) @@ -96,22 +102,26 @@ def test_custom_label(self): def test_service_label(self): metrics.set(self.queue, 'service_gauge', 23) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('service_gauge{service="test"} 23.0', resp.text) def test_counter(self): metrics.inc(self.queue, 'counter') + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('counter_total 1.0', resp.text) metrics.inc(self.queue, 'counter') + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('counter_total 2.0', resp.text) metrics.inc(self.queue, 'counter', 0) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('counter_total 2.0', resp.text) @@ -122,6 +132,7 @@ def test_multiple(self): metrics.set(self.queue, 'service_gauge', 42) metrics.inc(self.queue, 'counter') + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('plain_gauge 1337.0', resp.text) @@ -131,12 +142,14 @@ def test_multiple(self): def test_summary(self): metrics.observe(self.queue, 'summary', 10) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('summary_count 1.0', resp.text) self.assertIn('summary_sum 10.0', resp.text) metrics.observe(self.queue, 'summary', 20) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('summary_count 2.0', resp.text) @@ -144,6 +157,7 @@ def test_summary(self): def test_histogram(self): metrics.observe(self.queue, 'histogram', 0.02, {'instance': 3}) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('histogram_bucket{instance="3",le="0.01",service="test"} 0.0', resp.text) @@ -151,6 +165,7 @@ def test_histogram(self): self.assertIn('histogram_bucket{instance="3",le="10.0",service="test"} 1.0', resp.text) metrics.observe(self.queue, 'histogram', 0.5, {'instance': 3}) + time.sleep(0.1) resp = requests.get(self.metrics_url) self.assertEqual(resp.status_code, 200) self.assertIn('histogram_bucket{instance="3",le="0.25",service="test"} 1.0', resp.text) diff --git a/tests/controller/fixtures/main_loop.json b/tests/controller/fixtures/main_loop.json index 377041a..ef96a15 100644 --- a/tests/controller/fixtures/main_loop.json +++ b/tests/controller/fixtures/main_loop.json @@ -106,11 +106,14 @@ "model": "scoring.gamecontrol", "pk": 1, "fields": { + "competition_name": "Test CTF", + "services_public": null, "start": null, "end": null, "tick_duration": 180, "valid_ticks": 5, "current_tick": -1, + "flag_prefix": "FAUST_", "registration_open": false } } diff --git a/tests/controller/test_main_loop.py b/tests/controller/test_main_loop.py index 19dd819..103951f 100644 --- a/tests/controller/test_main_loop.py +++ b/tests/controller/test_main_loop.py @@ -143,7 +143,7 @@ def test_last_tick(self, sleep_mock, _): def test_shortly_after_game(self, sleep_mock, _): with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now", "-1441 minutes"), ' - ' end = datetime("now", "-1 minutes"), ' + ' end = datetime("now"), ' ' current_tick=479') controller.main_loop_step(self.connection, self.metrics, False) diff --git a/tests/lib/test_flag.py b/tests/lib/test_flag.py index 9908d14..9950f17 100644 --- a/tests/lib/test_flag.py +++ b/tests/lib/test_flag.py @@ -28,9 +28,9 @@ def test_valid_flag(self): def test_old_flag(self): timestamp = int(time.time() - 12) - test_flag = flag.generate(12, 13, b'secret', timestamp=timestamp) + test_flag = flag.generate(12, 13, b'secret', 'FLAGPREFIX-', timestamp=timestamp) with self.assertRaises(flag.FlagExpired): - flag.verify(test_flag, b'secret') + flag.verify(test_flag, b'secret', 'FLAGPREFIX-') def test_invalid_format(self): with self.assertRaises(flag.InvalidFlagFormat): @@ -93,12 +93,12 @@ def test_known_flags(self, time_mock): for secret in (b'secret1', b'secret2'): for payload in (None, b'payload1'): for timestamp in (1591000000, 1592000000): - actual_flag = flag.generate(team, service, secret, payload, timestamp) + actual_flag = flag.generate(team, service, secret, 'FAUST_', payload, timestamp) actual_flags.append(actual_flag) time_mock.return_value = timestamp - 5 actual_team, actual_service, actual_payload, actual_timestamp = \ - flag.verify(actual_flag, secret) + flag.verify(actual_flag, secret, 'FAUST_') self.assertEqual(actual_team, team) self.assertEqual(actual_service, service) if payload is not None: diff --git a/tests/test_submission.py b/tests/test_submission.py index 73e094d..c85d4b3 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -8,10 +8,10 @@ class UserInputTestCase(unittest.TestCase): def setUp(self): - self._handler = FlagHandler(None, ("203.0.113.42", 1337), None, 'c2VjcmV0', + self._handler = FlagHandler(None, ("203.0.113.42", 1337), None, 'c2VjcmV0', 'Test CTF', datetime.datetime.now(tz=datetime.timezone.utc), datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(minutes=10), - None, None, re.compile(r'^203\.0\.(\d+)\.\d+$')) + None, None, 'FLAG_', re.compile(r'^203\.0\.(\d+)\.\d+$')) def test_empty(self): diff --git a/tox.ini b/tox.ini index bc28504..2f7ced4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] # Test with the version in Debian Stable and the latest Python version -envlist = py37,py38 +envlist = py39,py310 recreate = True [testenv]