From c6adba1c9d15063e15527acbe6c322dcc39ada92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 2 Apr 2022 14:15:30 +0200 Subject: [PATCH 01/63] Update flag (string) format * Replace service ID with flag ID * Use 64-bit timestamps * Remove payload * Increase MAC size Submission is broken for now, but we're in the process of rewriting it anyway. Co-authored-by: Simon Ruderich --- go/checkerlib/lib.go | 24 ++---- src/ctf_gameserver/checker/database.py | 1 + src/ctf_gameserver/checker/master.py | 15 +--- src/ctf_gameserver/checkerlib/lib.py | 14 ++-- src/ctf_gameserver/lib/flag.py | 81 ++++++++++--------- tests/checker/test_master.py | 8 +- tests/checkerlib/test_local.py | 14 ++-- tests/lib/test_flag.py | 108 +++++++++++-------------- 8 files changed, 120 insertions(+), 145 deletions(-) diff --git a/go/checkerlib/lib.go b/go/checkerlib/lib.go index e31de17..431d209 100644 --- a/go/checkerlib/lib.go +++ b/go/checkerlib/lib.go @@ -8,7 +8,6 @@ import ( "encoding/base64" "encoding/binary" "encoding/json" - "hash/crc32" "io/ioutil" "log" "net" @@ -103,11 +102,10 @@ var teamId int // for local runner only // GetFlag may be called by Checker Scripts to get the flag for a given tick, // for the team and service of the current run. The returned flag can be used // for both placement and checks. -func GetFlag(tick int, payload []byte) string { +func GetFlag(tick int) string { if ipc.in != nil { x := ipc.SendRecv("FLAG", map[string]interface{}{ - "tick": tick, - "payload": base64.StdEncoding.EncodeToString(payload), + "tick": tick, }) return x.(string) } @@ -116,31 +114,23 @@ func GetFlag(tick int, payload []byte) string { if teamId == 0 { panic("GetFlag() must be called through RunCheck()") } - return genFlag(teamId, 42, tick, payload, []byte("TOPSECRET")) + return genFlag(tick, 42, teamId, []byte("TOPSECRET")) } -func genFlag(team, service, timestamp int, payload, secret []byte) string { +func genFlag(timestamp, flag, team int, secret []byte) string { // From src/ctf_gameserver/lib/flag.py var b bytes.Buffer - binary.Write(&b, binary.BigEndian, int32(timestamp)) + binary.Write(&b, binary.BigEndian, uint64(timestamp)) + binary.Write(&b, binary.BigEndian, uint32(flag)) binary.Write(&b, binary.BigEndian, uint16(team)) - binary.Write(&b, binary.BigEndian, byte(service)) - if len(payload) == 0 { - binary.Write(&b, binary.BigEndian, crc32.ChecksumIEEE(b.Bytes())) - binary.Write(&b, binary.BigEndian, int32(0)) - } else if len(payload) != 8 { - panic("len(payload) must be 8") - } else { - b.Write(payload) - } d := sha3.New256() d.Write(secret) d.Write(b.Bytes()) mac := d.Sum(nil) - b.Write(mac[:9]) + b.Write(mac[:10]) return "FLAG_" + base64.StdEncoding.EncodeToString(b.Bytes()) } diff --git a/src/ctf_gameserver/checker/database.py b/src/ctf_gameserver/checker/database.py index a2c157f..5e9a446 100644 --- a/src/ctf_gameserver/checker/database.py +++ b/src/ctf_gameserver/checker/database.py @@ -119,6 +119,7 @@ def get_new_tasks(db_conn, service_id, task_count, prohibit_changes=False): ' WHERE id = %s', [(task[0],) for task in tasks]) return [{ + 'flag': task[0], 'team_id': task[1], 'team_net_no': task[3], 'tick': task[2] diff --git a/src/ctf_gameserver/checker/master.py b/src/ctf_gameserver/checker/master.py index 6fdea93..668ec80 100644 --- a/src/ctf_gameserver/checker/master.py +++ b/src/ctf_gameserver/checker/master.py @@ -289,20 +289,12 @@ def handle_flag_request(self, task_info, params): except (KeyError, ValueError): return None - try: - payload = base64.b64decode(params['payload']) - except KeyError: - payload = None - - if payload == b'': - payload = None - # We need current value for self.contest_start which might have changed 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, self.flag_prefix, - payload, expiration.timestamp()) + return flag_lib.generate(expiration, task_info['flag'], task_info['team'], self.flag_secret, + self.flag_prefix) def handle_flagid_request(self, task_info, param): database.set_flagid(self.db_conn, self.service['id'], task_info['team'], task_info['tick'], param) @@ -362,7 +354,8 @@ def change_tick(new_tick): # Information in task_info should be somewhat human-readable, because it also ends up in Checker # Script logs - task_info = {'service': self.service['slug'], + task_info = {'flag': task['flag'], + 'service': self.service['slug'], 'team': task['team_net_no'], '_team_id': task['team_id'], 'tick': task['tick']} diff --git a/src/ctf_gameserver/checkerlib/lib.py b/src/ctf_gameserver/checkerlib/lib.py index 89378ed..a00a26a 100644 --- a/src/ctf_gameserver/checkerlib/lib.py +++ b/src/ctf_gameserver/checkerlib/lib.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import base64 +import datetime import errno import http.client import json @@ -113,24 +114,23 @@ def check_flag(self, tick: int) -> CheckResult: raise NotImplementedError('check_flag() must be implemented by the subclass') -def get_flag(tick: int, payload: bytes = b'') -> str: +def get_flag(tick: int) -> str: """ May be called by Checker Scripts to get the flag for a given tick, for the team and service of the current run. The returned flag can be used for both placement and checks. """ + # Return dummy flag when launched locally if _launched_without_runner(): try: team = get_flag._team # pylint: disable=protected-access except AttributeError: raise Exception('get_flag() must be called through run_check()') - # Return dummy flag when launched locally - if payload == b'': - payload = None - return ctf_gameserver.lib.flag.generate(team, 42, b'TOPSECRET', payload=payload, timestamp=tick) + expiration = datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) + expiration += datetime.timedelta(minutes=tick) + return ctf_gameserver.lib.flag.generate(expiration, 42, team, b'TOPSECRET') - payload_b64 = base64.b64encode(payload).decode('ascii') - _send_ctrl_message({'action': 'FLAG', 'param': {'tick': tick, 'payload': payload_b64}}) + _send_ctrl_message({'action': 'FLAG', 'param': {'tick': tick}}) result = _recv_ctrl_message() return result['response'] diff --git a/src/ctf_gameserver/lib/flag.py b/src/ctf_gameserver/lib/flag.py index 8bdfcfe..8d93ab6 100644 --- a/src/ctf_gameserver/lib/flag.py +++ b/src/ctf_gameserver/lib/flag.py @@ -1,47 +1,38 @@ import base64 import binascii +import datetime import hashlib from hmac import compare_digest import struct -import time -import zlib # Length of the MAC (in bytes) -MAC_LEN = 9 -# Length of the payload (in bytes) -PAYLOAD_LEN = 8 -# timestamp + team + service + payload -DATA_LEN = 4 + 2 + 1 + PAYLOAD_LEN -# Flag validity in seconds -VALID = 900 +MAC_LEN = 10 +# expiration_timestamp + flag.id + protecting_team.net_no +DATA_LEN = 8 + 4 + 2 +# Static string with which flags get XOR-ed to make them look more random (just for the looks) +XOR_STRING = b'CTF-GAMESERVER' -def generate(team_net_no, service_id, secret, prefix='FLAG_', payload=None, timestamp=None): +def generate(expiration_time, flag_id, team_net_no, secret, prefix='FLAG_'): """ - 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). + Generates a flag for the given arguments, i.e. the MAC-protected string that gets placed in services and + captured by teams. This is deterministic and should always return the same result for the same arguments. Args: + expiration_time: Datetime object (preferably timezone-aware) at which the flag expires + flag_id: ID (primary key) of the flag's associated database entry team_net_no: Net number of the team protecting this flag - service_id: ID of the service this flag belongs to - payload: 8 bytes of data to store in the flag, defaults to zero-padded - CRC32(timestamp, team, service) - timestamp: Timestamp at which the flag expires, defaults to 15 minutes in the future + secret: Secret used for the MAC + prefix: String to prepend to the generated flag """ - if timestamp is None: - timestamp = time.time() + VALID + if flag_id < 0 or flag_id > 2**32 - 1: + raise ValueError('Flag ID must fit in unsigned 32 bits') + if team_net_no < 0 or team_net_no > 2**16 - 1: + raise ValueError('Team net number must fit in unsigned 16 bits') - if team_net_no > 65535: - raise ValueError('Team net number must fit in 16 bits') - protected_data = struct.pack("!i H c", int(timestamp), team_net_no, bytes([service_id])) - - if payload is None: - payload = struct.pack("!I I", zlib.crc32(protected_data), 0) - if len(payload) != PAYLOAD_LEN: - raise ValueError('Payload {} must be {:d} bytes long'.format(repr(payload), PAYLOAD_LEN)) - - protected_data += payload + protected_data = struct.pack('! Q I H', int(expiration_time.timestamp()), flag_id, team_net_no) + protected_data = bytes([c ^ d for c, d in zip(protected_data, XOR_STRING)]) mac = _gen_mac(secret, protected_data) return prefix + base64.b64encode(protected_data + mac).decode('ascii') @@ -49,11 +40,16 @@ def generate(team_net_no, service_id, secret, prefix='FLAG_', payload=None, time def verify(flag, secret, prefix='FLAG_'): """ - Verfies flag validity and returns data from the flag. + Verifies flag validity and returns data from the flag. Will raise an appropriate exception if verification fails. + Args: + flag: MAC-protected flag string + secret: Secret used for the MAC + prefix: String to prepend to the generated flag + Returns: - Data from the flag as a tuple of (team, service, payload, timestamp) + Data from the flag as a tuple of (flag_id, team_net_no) """ if not flag.startswith(prefix): @@ -61,7 +57,7 @@ def verify(flag, secret, prefix='FLAG_'): try: raw_flag = base64.b64decode(flag[len(prefix):]) - except binascii.Error: + except (ValueError, binascii.Error): raise InvalidFlagFormat() try: @@ -73,12 +69,13 @@ def verify(flag, secret, prefix='FLAG_'): if not compare_digest(mac, flag_mac): raise InvalidFlagMAC() - timestamp, team, service = struct.unpack("!i H c", protected_data[:7]) - payload = protected_data[7:] - if time.time() - timestamp > 0: - raise FlagExpired(time.time() - timestamp) + protected_data = bytes([c ^ d for c, d in zip(protected_data, XOR_STRING)]) + expiration_timestamp, flag_id, team_net_no = struct.unpack('! Q I H', protected_data) + expiration_time = datetime.datetime.fromtimestamp(expiration_timestamp, datetime.timezone.utc) + if expiration_time < _now(): + raise FlagExpired(expiration_time) - return (int(team), int.from_bytes(service, 'big'), payload, timestamp) + return (flag_id, team_net_no) def _gen_mac(secret, protected_data): @@ -90,6 +87,14 @@ def _gen_mac(secret, protected_data): return sha3.digest()[:MAC_LEN] +def _now(): + """ + Wrapper around datetime.datetime.now() to enable mocking in test cases. + """ + + return datetime.datetime.now(datetime.timezone.utc) + + class FlagVerificationError(Exception): """ Base class for all Flag Exceptions. @@ -112,3 +117,7 @@ class FlagExpired(FlagVerificationError): """ Flag is already expired. """ + + def __init__(self, expiration_time): + super().__init__(f'Flag expired since {expiration_time}') + self.expiration_time = expiration_time diff --git a/tests/checker/test_master.py b/tests/checker/test_master.py index 3aa9121..d955c88 100644 --- a/tests/checker/test_master.py +++ b/tests/checker/test_master.py @@ -21,6 +21,7 @@ def test_handle_flag_request(self): cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') task_info = { + 'flag': 1, 'service': 'service1', '_team_id': 2, 'team': 92, @@ -31,11 +32,9 @@ def test_handle_flag_request(self): resp1 = self.master_loop.handle_flag_request(task_info, params1) params2 = {'tick': 1} resp2 = self.master_loop.handle_flag_request(task_info, params2) - params3 = {'tick': 1, 'payload': 'TmV2ZXIgZ28='} - resp3 = self.master_loop.handle_flag_request(task_info, params3) + # "params3" and "resp3" don't exist anymore self.assertEqual(resp1, resp2) - self.assertNotEqual(resp1, resp3) params4 = {'tick': 2} resp4 = self.master_loop.handle_flag_request(task_info, params4) @@ -59,6 +58,7 @@ def test_handle_flag_request(self): def test_handle_result_request(self): task_info = { + 'flag': 1, 'service': 'service1', '_team_id': 2, 'team': 92, @@ -77,6 +77,7 @@ def test_handle_result_request(self): ' WHERE service_id = 1 AND protecting_team_id = 2 AND tick = 1') self.assertGreaterEqual(cursor.fetchone()[0], start_time) + task_info['flag'] = 2 task_info['tick'] = 2 param = CheckResult.FAULTY.value start_time = datetime.datetime.utcnow().replace(microsecond=0) @@ -89,6 +90,7 @@ def test_handle_result_request(self): ' WHERE service_id = 1 AND protecting_team_id = 2 AND tick = 2') self.assertGreaterEqual(cursor.fetchone()[0], start_time) + task_info['flag'] = 3 task_info['tick'] = 3 param = 'Not an int' self.assertIsNone(self.master_loop.handle_result_request(task_info, param)) diff --git a/tests/checkerlib/test_local.py b/tests/checkerlib/test_local.py index 77d0e79..2945012 100644 --- a/tests/checkerlib/test_local.py +++ b/tests/checkerlib/test_local.py @@ -26,17 +26,15 @@ def tearDown(self): def test_get_flag(self): checkerlib.get_flag._team = 1 # pylint: disable=protected-access - team1_tick1_flag1 = checkerlib.get_flag(1, b'fooobaar') - team1_tick2_flag1 = checkerlib.get_flag(2, b'fooobaar') - team1_tick1_flag2 = checkerlib.get_flag(1, b'fooobaar') - team1_tick2_flag2 = checkerlib.get_flag(2) + team1_tick1_flag1 = checkerlib.get_flag(1) + team1_tick1_flag2 = checkerlib.get_flag(1) + team1_tick2_flag = checkerlib.get_flag(2) checkerlib.get_flag._team = 2 # pylint: disable=protected-access - team2_tick1_flag1 = checkerlib.get_flag(1, b'fooobaar') + team2_tick1_flag = checkerlib.get_flag(1) self.assertEqual(team1_tick1_flag1, team1_tick1_flag2) - self.assertNotEqual(team1_tick1_flag1, team1_tick2_flag1) - self.assertNotEqual(team1_tick2_flag1, team1_tick2_flag2) - self.assertNotEqual(team1_tick1_flag1, team2_tick1_flag1) + self.assertNotEqual(team1_tick1_flag1, team1_tick2_flag) + self.assertNotEqual(team1_tick1_flag1, team2_tick1_flag) def test_state_primitive(self): self.assertIsNone(checkerlib.load_state('primitive')) diff --git a/tests/lib/test_flag.py b/tests/lib/test_flag.py index 9950f17..b3b9956 100644 --- a/tests/lib/test_flag.py +++ b/tests/lib/test_flag.py @@ -1,3 +1,4 @@ +import datetime import random import time import unittest @@ -9,26 +10,23 @@ class FlagTestCase(unittest.TestCase): def test_deterministic(self): - tstamp = time.time() - flag1 = flag.generate(12, 13, b'secret', timestamp=tstamp) - flag2 = flag.generate(12, 13, b'secret', timestamp=tstamp) + now = self._now() + flag1 = flag.generate(now, 12, 13, b'secret') + flag2 = flag.generate(now, 12, 13, b'secret') self.assertEqual(flag1, flag2) def test_valid_flag(self): - payload = b'\x01\x02\x03\x04\x05\x06\x07\x08' - timestamp = int(time.time() + 12) - team = 12 - service = 13 - flag1 = flag.generate(team, service, b'secret', payload=payload, timestamp=timestamp) - team_, service_, payload_, timestamp_ = flag.verify(flag1, b'secret') + expiration = self._now() + datetime.timedelta(seconds=12) + flag_id = 12 + team = 13 + test_flag = flag.generate(expiration, flag_id, team, b'secret') + flag_id_, team_ = flag.verify(test_flag, b'secret') + self.assertEqual(flag_id, flag_id_) self.assertEqual(team, team_) - self.assertEqual(service, service_) - self.assertEqual(payload, payload_) - self.assertEqual(timestamp, timestamp_) def test_old_flag(self): - timestamp = int(time.time() - 12) - test_flag = flag.generate(12, 13, b'secret', 'FLAGPREFIX-', timestamp=timestamp) + expiration = self._now() - datetime.timedelta(seconds=12) + test_flag = flag.generate(expiration, 12, 13, b'secret', 'FLAGPREFIX-') with self.assertRaises(flag.FlagExpired): flag.verify(test_flag, b'secret', 'FLAGPREFIX-') @@ -37,7 +35,7 @@ def test_invalid_format(self): flag.verify('ABC123', b'secret') def test_invalid_mac(self): - test_flag = flag.generate(12, 13, b'secret') + test_flag = flag.generate(self._now(), 12, 13, b'secret') # Replace last character of the flag with a differnt one chars = set("0123456789") @@ -50,59 +48,43 @@ def test_invalid_mac(self): with self.assertRaises(flag.InvalidFlagMAC): flag.verify(wrong_flag, b'secret') - @patch('time.time') - def test_known_flags(self, time_mock): + @patch('ctf_gameserver.lib.flag._now') + def test_known_flags(self, now_mock): expected_flags = [ - 'FAUST_XtS7wAAXDQ1BPIUAAAAAWz1i7pmtp/HY', - 'FAUST_XuP+AAAXDfJgMq8AAAAA347vpisJsQcT', - 'FAUST_XtS7wAAXDXBheWxvYWQxDcM2TNj0lCu7', - 'FAUST_XuP+AAAXDXBheWxvYWQxmIKxwcEmDxQX', - 'FAUST_XtS7wAAXDQ1BPIUAAAAAxR5C9S9LXgdi', - 'FAUST_XuP+AAAXDfJgMq8AAAAAcqut7dVSvNOl', - 'FAUST_XtS7wAAXDXBheWxvYWQxrwHKd/CNJgkB', - 'FAUST_XuP+AAAXDXBheWxvYWQxn04LGjrQe4V8', - 'FAUST_XtS7wAAXJTj0lH8AAAAArnmozMnyfMVb', - 'FAUST_XuP+AAAXJcfVmlUAAAAAkUsJ65SCvAZW', - 'FAUST_XtS7wAAXJXBheWxvYWQxIg0Sd0Ll06VT', - 'FAUST_XuP+AAAXJXBheWxvYWQxGiKffkwjRTte', - 'FAUST_XtS7wAAXJTj0lH8AAAAAUAFSjo0EF01t', - 'FAUST_XuP+AAAXJcfVmlUAAAAAoeDpxI2QPjsv', - 'FAUST_XtS7wAAXJXBheWxvYWQx+aIiPy4SC0+Q', - 'FAUST_XuP+AAAXJXBheWxvYWQxYObaBlsWnmOE', - 'FAUST_XtS7wAAqDWepdDsAAAAAfPNLsph2Jw8v', - 'FAUST_XuP+AAAqDZiIehEAAAAA3Q30bTHWo1l9', - 'FAUST_XtS7wAAqDXBheWxvYWQxLdYijGTcd2O3', - 'FAUST_XuP+AAAqDXBheWxvYWQxsOqqEGk2u52r', - 'FAUST_XtS7wAAqDWepdDsAAAAAhQontK+Uoy9h', - 'FAUST_XuP+AAAqDZiIehEAAAAAYmCIMEQJotc4', - 'FAUST_XtS7wAAqDXBheWxvYWQxvzsgxZioxnxY', - 'FAUST_XuP+AAAqDXBheWxvYWQxLDqRTwxgE3yG', - 'FAUST_XtS7wAAqJVIc3MEAAAAAdGtfQQkMd1VT', - 'FAUST_XuP+AAAqJa090usAAAAAk+AN2kRqIVQs', - 'FAUST_XtS7wAAqJXBheWxvYWQxjF+S/iA5tzxY', - 'FAUST_XuP+AAAqJXBheWxvYWQxabGjcfYW9js/', - 'FAUST_XtS7wAAqJVIc3MEAAAAAWUdMoH5KV/un', - 'FAUST_XuP+AAAqJa090usAAAAAUeOaXaFLlwEj', - 'FAUST_XtS7wAAqJXBheWxvYWQxFKDfL+/qydWm', - 'FAUST_XuP+AAAqJXBheWxvYWQxzirUWWy6MwKe' + 'FAUST_Q1RGLRmVnOVTRVJBRV9tRpcBKDNOCUPW', + 'FAUST_Q1RGLRml7uVTRVJBRV9IP7yOZriI07tT', + 'FAUST_Q1RGLRmVnOVTRVJBRV/EFBYyQ5hGkkhc', + 'FAUST_Q1RGLRml7uVTRVJBRV9+4LvDGpI37WnR', + 'FAUST_Q1RGLRmVnOVTRVJBRXe71HlVK0TqWwjD', + 'FAUST_Q1RGLRml7uVTRVJBRXdsFhEI3jhxey9I', + 'FAUST_Q1RGLRmVnOVTRVJBRXfGLg3ip26nfSaS', + 'FAUST_Q1RGLRml7uVTRVJBRXcQmzzAV65TUUFp', + 'FAUST_Q1RGLRmVnOVTRVJ8RV/j9Ys/9UjHdsfL', + 'FAUST_Q1RGLRml7uVTRVJ8RV/QpLXRXAao2VOL', + 'FAUST_Q1RGLRmVnOVTRVJ8RV9MXCvXvUVKmW6+', + 'FAUST_Q1RGLRml7uVTRVJ8RV9JoxKWWPdJ1BE0', + 'FAUST_Q1RGLRmVnOVTRVJ8RXfMkW+dK2FfyJlQ', + 'FAUST_Q1RGLRml7uVTRVJ8RXdxXbELYwjVp8Ku', + 'FAUST_Q1RGLRmVnOVTRVJ8RXePbyjg1uvCeQcH', + 'FAUST_Q1RGLRml7uVTRVJ8RXf/lT8Q1kehBFw9' ] actual_flags = [] - for team in (23, 42): - for service in (13, 37): + for flag_id in (23, 42): + for team in (13, 37): 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, 'FAUST_', payload, timestamp) - actual_flags.append(actual_flag) + timestamp1 = datetime.datetime(2020, 6, 1, 10, 0, tzinfo=datetime.timezone.utc) + timestamp2 = datetime.datetime(2020, 6, 13, 10, 0, tzinfo=datetime.timezone.utc) + for timestamp in (timestamp1, timestamp2): + actual_flag = flag.generate(timestamp, flag_id, team, secret, 'FAUST_') + actual_flags.append(actual_flag) - time_mock.return_value = timestamp - 5 - actual_team, actual_service, actual_payload, actual_timestamp = \ - flag.verify(actual_flag, secret, 'FAUST_') - self.assertEqual(actual_team, team) - self.assertEqual(actual_service, service) - if payload is not None: - self.assertEqual(actual_payload, payload) - self.assertEqual(actual_timestamp, timestamp) + now_mock.return_value = timestamp - datetime.timedelta(seconds=5) + actual_flag_id, actual_team = flag.verify(actual_flag, secret, 'FAUST_') + self.assertEqual(actual_flag_id, flag_id) + self.assertEqual(actual_team, team) self.assertEqual(actual_flags, expected_flags) + + def _now(self): + return datetime.datetime.now(datetime.timezone.utc) From 6e941055f1192c579aa4c8b5de8f1a59607344dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Mon, 18 Apr 2022 12:18:34 +0200 Subject: [PATCH 02/63] Submission: Rewrite using asyncio Co-authored-by: Simon Ruderich --- docs/submission.md | 70 ++++ scripts/submission/ctf-submission | 65 +--- src/ctf_gameserver/submission/__init__.py | 1 + src/ctf_gameserver/submission/database.py | 87 +++++ src/ctf_gameserver/submission/flagserver.py | 185 ---------- src/ctf_gameserver/submission/submission.py | 352 ++++++++++++++++++++ src/ctf_gameserver/web/scoring/models.py | 1 + tests/submission/fixtures/server.json | 180 ++++++++++ tests/submission/test_server.py | 276 +++++++++++++++ tests/test_submission.py | 57 ---- 10 files changed, 970 insertions(+), 304 deletions(-) create mode 100644 docs/submission.md create mode 100644 src/ctf_gameserver/submission/database.py delete mode 100644 src/ctf_gameserver/submission/flagserver.py create mode 100644 src/ctf_gameserver/submission/submission.py create mode 100644 tests/submission/fixtures/server.json create mode 100644 tests/submission/test_server.py delete mode 100644 tests/test_submission.py diff --git a/docs/submission.md b/docs/submission.md new file mode 100644 index 0000000..bf49de3 --- /dev/null +++ b/docs/submission.md @@ -0,0 +1,70 @@ +Flag Submission +=============== + +In order to score points for captured flags, the flags are submitted over a simple TCP-based plaintext +protocol. That protocol was agreed upon by the organizers of several A/D CTFs in [this GitHub +discussion](https://github.com/enowars/specification/issues/14). + +Definitions +----------- +* **Whitespace** consists of one or more space (ASCII `0x20`) and/or tab (ASCII `0x09`) characters. +* **Newline** is a single `\n` (ASCII `0x0a`) character. +* **Flags** are sequences of arbitrary characters, except whitespace and newlines. + +Protocol +-------- +The client connects to the server on a TCP port specified by the respective CTF. The server MAY send a +welcome banner, consisting of anything except two subsequent newlines. The server MUST indicate that the +welcome sequence has finished by sending two subsequent newlines (`\n\n`). + +If a general error with the connection or its configuration renders the server inoperable, it MAY send an +arbitrary error message and close the connection before sending the welcome sequence. The error message MUST +NOT contain two subsequent newlines. + +To submit a flag, the client MUST send the flag followed by a single newline. +The server's response MUST consist of: + +1. A repetition of the submitted flag +2. Whitespace +3. One of the response codes defined below +4. Optionally: Whitespace, followed by a custom message consisting of any characters except newlines +5. Newline + +During a single connection, the client MAY submit an arbitrary number of flags. When the client is finished, +it MUST close the TCP connection. The server MAY close the connection on inactivity for a certain amount of +time. + +The client MAY send flags without waiting for the welcome sequence or responses to previously submitted +flags. The server MAY send the responses in an arbitrary order; the connection between flags and responses +can be derived from the flag repetition in the response. + +Response Codes +-------------- +* `OK`: The flag was valid, has been accepted by the server and will be considered for scoring. +* `DUP`: The flag was already submitted before (by the same team). +* `OWN`: The flag belongs to (i.e. is supposed to be protected by) the submitting team. +* `OLD`: The flag has expired and cannot be submitted anymore. +* `INV`: The flag is not valid. +* `ERR`: The server encountered an internal error. It MAY close the TCP connection. Submission may be retried + at a later point. + +The server MUST implement `OK`, `INV`, and `ERR`. Other response codes are optional. The client MUST be able +to handle all specified response codes. For extensibility, the client SHOULD be able to handle any response +codes consisting of uppercase ASCII letters. + +Example +------- +"C:" and "S:" indicate lines sent by the client and server, respectively. Each line includes the terminating +newline. + +``` +S: Welcome to Example CTF flag submission! 🌈 +S: Please submit one flag per line. +S: +C: FLAG{4578616d706c65} +S: FLAG{4578616d706c65} OK +C: πŸ΄β€β˜ οΈ +C: FLAG{πŸ€”πŸ§™β€β™‚οΈπŸ‘»πŸ’©πŸŽ‰} +S: FLAG{πŸ€”πŸ§™β€β™‚οΈπŸ‘»πŸ’©πŸŽ‰} DUP You already submitted this flag +S: πŸ΄β€β˜ οΈ INV Bad flag format +``` diff --git a/scripts/submission/ctf-submission b/scripts/submission/ctf-submission index 4d73e61..d690e5c 100755 --- a/scripts/submission/ctf-submission +++ b/scripts/submission/ctf-submission @@ -1,68 +1,9 @@ #!/usr/bin/env python3 -import asyncore -import argparse -import logging -import re -import socket +import sys -import psycopg2 - -from ctf_gameserver.lib import daemon -from ctf_gameserver.lib.args import get_arg_parser_with_db -from ctf_gameserver.submission import flagserver - - -def main(): - logging.basicConfig(format='%(asctime)s %(levelname)s(%(name)s): %(message)s', - datefmt='%m/%d/%Y %H:%M:%S') - - parser = get_arg_parser_with_db('CTF Gameserver flag submission server') - parser.add_argument('-l', '--listen', type=str, default="localhost", - help='address or hostname to listen on') - parser.add_argument('-p', '--port', type=int, default="6666", - help="Port to listen on") - parser.add_argument('--secret', type=str, required=True, - help="base64 random string consistent with checkers") - parser.add_argument('--teamregex', type=str, required=True, - help='Python regex to extract the team ID, must ' - 'contain a match group for the team ID') - args = parser.parse_args() - - team_regex = re.compile(args.teamregex) - - numeric_level = getattr(logging, args.loglevel.upper()) - logging.getLogger().setLevel(numeric_level) - - logging.debug("connecting to database") - dbconnection = psycopg2.connect(host=args.dbhost, - database=args.dbname, - user=args.dbuser, - password=args.dbpassword) - - with dbconnection: - with dbconnection.cursor() as cursor: - cursor.execute('''SELECT competition_name, start, "end", valid_ticks, tick_duration, flag_prefix - FROM scoring_gamecontrol''') - 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, contestname, conteststart, - contestend, flagvalidity, tickduration, - flagprefix, team_regex) - break - except socket.gaierror as e: - if e.errno != socket.EAI_ADDRFAMILY: - raise - - daemon.notify("READY=1") - - asyncore.loop() +from ctf_gameserver.submission import main if __name__ == '__main__': - main() + sys.exit(main()) diff --git a/src/ctf_gameserver/submission/__init__.py b/src/ctf_gameserver/submission/__init__.py index e69de29..f48a529 100644 --- a/src/ctf_gameserver/submission/__init__.py +++ b/src/ctf_gameserver/submission/__init__.py @@ -0,0 +1 @@ +from .submission import main diff --git a/src/ctf_gameserver/submission/database.py b/src/ctf_gameserver/submission/database.py new file mode 100644 index 0000000..a0186c1 --- /dev/null +++ b/src/ctf_gameserver/submission/database.py @@ -0,0 +1,87 @@ +import sqlite3 + +# See https://github.com/PyCQA/pylint/issues/2948 for Pylint behavior +from psycopg2.errors import UniqueViolation # pylint: disable=no-name-in-module + +from ctf_gameserver.lib.database import transaction_cursor +from ctf_gameserver.lib.date_time import ensure_utc_aware +from ctf_gameserver.lib.exceptions import DBDataError + + +def get_static_info(db_conn): + """ + Returns the competition's name and the flag prefix, as configured in the database. + """ + + with transaction_cursor(db_conn) as cursor: + cursor.execute('SELECT competition_name, flag_prefix FROM scoring_gamecontrol') + result = cursor.fetchone() + + if result is None: + raise DBDataError('Game control information has not been configured') + + return result + + +def get_dynamic_info(db_conn): + """ + Returns the competition's start and end time, as stored in the database. + """ + + with transaction_cursor(db_conn) as cursor: + cursor.execute('SELECT start, "end" FROM scoring_gamecontrol') + result = cursor.fetchone() + + if result is None: + raise DBDataError('Game control information has not been configured') + + return (ensure_utc_aware(result[0]), ensure_utc_aware(result[1])) + + +def team_is_nop(db_conn, team_net_no): + """ + Returns whether the team with the given net number is marked as NOP team. + """ + + with transaction_cursor(db_conn) as cursor: + cursor.execute('SELECT nop_team FROM registration_team WHERE net_number = %s', (team_net_no,)) + result = cursor.fetchone() + + if result is None: + return False + + return result[0] + + +def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False): + """ + Stores a capture of the given flag by the given team in the database. + """ + + with transaction_cursor(db_conn, prohibit_changes) as cursor: + cursor.execute('SELECT user_id FROM registration_team WHERE net_number = %s', + (capturing_team_net_no,)) + result = cursor.fetchone() + if result is None: + raise TeamNotExisting() + capturing_team_id = result[0] + + try: + cursor.execute('INSERT INTO scoring_capture (flag_id, capturing_team_id, timestamp, tick)' + ' VALUES (%s, %s, NOW(), (SELECT current_tick FROM scoring_gamecontrol))', + (flag_id, capturing_team_id)) + except (UniqueViolation, sqlite3.IntegrityError): + # pylint: disable=raise-missing-from + raise DuplicateCapture() + + +class TeamNotExisting(DBDataError): + """ + Indicates that a Team for the given parameters could not be found in the database. + """ + + +class DuplicateCapture(DBDataError): + """ + Indicates that a Flag has already been captured by a Team before. + """ diff --git a/src/ctf_gameserver/submission/flagserver.py b/src/ctf_gameserver/submission/flagserver.py deleted file mode 100644 index 3dbde28..0000000 --- a/src/ctf_gameserver/submission/flagserver.py +++ /dev/null @@ -1,185 +0,0 @@ -import asynchat -import asyncore -import socket -import logging -import datetime -import base64 - -import psycopg2 - -from ctf_gameserver.lib import flag - -class FlagHandler(asynchat.async_chat): - def __init__(self, sock, addr, dbconnection, secret, contestname, - conteststart, contestend, flagvalidity, tickduration, - flagprefix, team_regex): - asynchat.async_chat.__init__(self, sock=sock) - - ipaddr, port = addr[:2] # IPv4 returns two values, IPv6 four - match = team_regex.match(ipaddr) - if match is None: - logging.warning("IP %s does not match team regex", ipaddr) - self.push("Cannot find team for client IP, aborting\n".encode('ascii')) - self.close() - return - - self.capturing_team = int(match.group(1)) - self.set_terminator(b"\n") - self._logger = logging.getLogger("%13s %5d" % (ipaddr, port)) - 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() - self._conteststart = conteststart - self._contestend = contestend - self._flagvalidity = flagvalidity - self._tickduration = tickduration - self._flagprefix = flagprefix - - def _reply(self, message): - self._logger.debug("-> %s", message.decode('utf-8')) - self.push(message + b"\n") - - def _get_tick(self, timestamp): - tick = ((timestamp - self._conteststart.timestamp()) / self._tickduration) \ - - self._flagvalidity - return int(tick + 0.2) - - def _handle_flag(self): - if self.buffer == b'': - self._reply(b"418 I'm a teapot!") - return - - if self.buffer == b'666': - self._reply(b"So this, then, was the kernel of the brute!") - return - - now = datetime.datetime.now(tz=datetime.timezone.utc) - if now < self._conteststart: - self._reply(b"Contest didn't even start yet!") - return - - if now > self._contestend: - self._reply(b"Contest already over!") - return - - try: - curflag = self.buffer.decode('us-ascii') - except UnicodeDecodeError as e: - self._reply(u"Flags should be of the Format [-_a-zA-Z0-9]+" - .encode('utf-8')) - return - - try: - protecting_team, service, _, timestamp = flag.verify(curflag, self._secret, self._flagprefix) - except flag.InvalidFlagFormat: - self._reply(b"Flag not recognized") - return - except flag.InvalidFlagMAC: - self._reply(b"No such Flag") - return - except flag.FlagExpired as e: - self._reply((u"Flag expired since %.1f seconds" % e.args).encode('utf-8')) - return - - if protecting_team == self.capturing_team: - self._reply(b"Can't submit a flag for your own team") - return - - try: - result = self._store_capture(protecting_team, service, timestamp) - if result: - self._reply(u"Thank you for your submission!".encode('utf-8')) - - except psycopg2.DatabaseError as psqle: - self._logger.exception("Error while inserting values into database") - self._logger.warning("%s: %s", psqle.diag.severity, psqle.diag.message_primary) - self._logger.info(psqle.diag.internal_query) - self._reply(u"Something went wrong with your submission!".encode('utf-8')) - - - def _store_capture(self, protecting_team, service, timestamp): - with self._dbconnection: - with self._dbconnection.cursor() as cursor: - cursor.execute("""SELECT user_id FROM registration_team WHERE net_number = %s""", - (protecting_team,)) - data = cursor.fetchone() - if data is None: - self._reply(u"Unknown team net".encode("utf-8")) - return False - protecting_team_id = data[0] - - cursor.execute("""SELECT user_id FROM registration_team WHERE net_number = %s""", - (self.capturing_team,)) - data = cursor.fetchone() - if data is None: - self._reply(u"Unknown team net".encode("utf-8")) - return False - capturing_team_id = data[0] - - cursor.execute("""SELECT nop_team FROM registration_team WHERE user_id = %s""", - (protecting_team_id,)) - nopp, = cursor.fetchone() - if nopp: - self._reply(u"Can not submit flags for the NOP team".encode("utf-8")) - return False - - tick = self._get_tick(timestamp) - cursor.execute("""SELECT id FROM scoring_flag - WHERE service_id = %s - AND protecting_team_id = %s - AND tick = %s""", - (service, protecting_team_id, tick)) - flag_id = cursor.fetchone()[0] - - cursor.execute("""SELECT count(*) FROM scoring_capture - WHERE flag_id = %s - AND capturing_team_id = %s""", - (flag_id, capturing_team_id)) - count = cursor.fetchone()[0] - - if count > 0: - self._reply(u"Flags should only be submitted once!".encode('utf-8')) - return False - - cursor.execute("""INSERT INTO scoring_capture - (flag_id, capturing_team_id, timestamp, tick) - VALUES - (%s, %s, now(), - (SELECT current_tick - FROM scoring_gamecontrol))""", - (flag_id, capturing_team_id)) - return True - - - def _banner(self): - 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): - self.buffer = self.buffer + data - - - def found_terminator(self): - self._logger.debug("<- %s", self.buffer.decode('utf-8')) - self._handle_flag() - self.buffer = b'' - - -class FlagServer(asyncore.dispatcher): - def __init__(self, family, host, port, *args): - asyncore.dispatcher.__init__(self) - self.create_socket(family=family) - self.set_reuse_addr() - self.bind((host, port)) - self.listen(5) - self._otherargs = args - self._logger = logging.getLogger("server") - - def handle_accepted(self, sock, addr): - self._logger.info('Incoming connection from %s', repr(addr)) - FlagHandler(sock, addr, *self._otherargs) diff --git a/src/ctf_gameserver/submission/submission.py b/src/ctf_gameserver/submission/submission.py new file mode 100644 index 0000000..76ec24b --- /dev/null +++ b/src/ctf_gameserver/submission/submission.py @@ -0,0 +1,352 @@ +import asyncio +import base64 +from binascii import Error as BinasciiError +import datetime +import logging +import os +import re +import sqlite3 +import time + +import prometheus_client +import psycopg2 +from psycopg2 import errorcodes as postgres_errors + +from ctf_gameserver.lib import daemon +import ctf_gameserver.lib.flag as flag_lib +from ctf_gameserver.lib.args import get_arg_parser_with_db, parse_host_port +from ctf_gameserver.lib.database import transaction_cursor +from ctf_gameserver.lib.exceptions import DBDataError +from ctf_gameserver.lib.metrics import start_metrics_server + +from . import database + + +TIMEOUT_SECONDS = 300 + + +def main(): + + arg_parser = get_arg_parser_with_db('CTF Gameserver Submission Server') + arg_parser.add_argument('--listen', default="localhost:6666", + help='Address and port to listen on (":")') + arg_parser.add_argument('--flagsecret', required=True, + help='Base64 string used as secret in flag generation') + arg_parser.add_argument('--teamregex', required=True, + help='Python regex (with match group) to extract team net number from ' + 'connecting IP address') + arg_parser.add_argument('--metrics-listen', help='Expose Prometheus metrics via HTTP (":")') + + args = arg_parser.parse_args() + + logging.basicConfig(format='[%(levelname)s] %(message)s') + numeric_loglevel = getattr(logging, args.loglevel.upper()) + logging.getLogger().setLevel(numeric_loglevel) + + try: + listen_host, listen_port, _ = parse_host_port(args.listen) + except ValueError: + logging.error('Listen address needs to be specified as ":"') + return os.EX_USAGE + + try: + flag_secret = base64.b64decode(args.flagsecret) + except BinasciiError: + logging.error('Flag secret must be valid Base64') + return os.EX_USAGE + + try: + team_regex = re.compile(args.teamregex) + except re.error: + logging.error('Team regex must be a valid regular expression') + return os.EX_USAGE + if team_regex.groups != 1: + logging.error('Team regex must contain one match group') + return os.EX_USAGE + + try: + 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 database connection: %s', e) + return os.EX_UNAVAILABLE + logging.info('Established database connection') + + # Keep our mental model easy by always using (timezone-aware) UTC for dates and times + with transaction_cursor(db_conn) as cursor: + cursor.execute('SET TIME ZONE "UTC"') + + # Check database grants + try: + try: + database.get_static_info(db_conn) + database.get_dynamic_info(db_conn) + except DBDataError as e: + logging.warning('Invalid database state: %s', e) + + database.team_is_nop(db_conn, 1) + database.add_capture(db_conn, 2147483647, 1, prohibit_changes=True) + 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 + logging.exception('Missing database permissions:') + return os.EX_NOPERM + else: + raise + + if args.metrics_listen is not None: + try: + metrics_host, metrics_port, metrics_family = parse_host_port(args.metrics_listen) + except ValueError: + logging.error('Metrics listen address needs to be specified as ":"') + return os.EX_USAGE + + start_metrics_server(metrics_host, metrics_port, metrics_family) + + metrics = make_metrics() + metrics['start_timestamp'].set_to_current_time() + + try: + competition_name, flag_prefix = database.get_static_info(db_conn) + except DBDataError as e: + logging.error('Invalid database state, exiting: %s', e) + return os.EX_DATAERR + + daemon.notify('READY=1') + + asyncio.run(serve(listen_host, listen_port, db_conn, { + 'flag_secret': flag_secret, + 'team_regex': team_regex, + 'competition_name': competition_name, + 'flag_prefix': flag_prefix, + 'metrics': metrics + })) + + return os.EX_OK + + +def make_metrics(registry=prometheus_client.REGISTRY): + + metrics = {} + metric_prefix = 'ctf_submission_' + + counters = [ + ('connections', 'Total number of connections', ['team_net_no']), + ('flags_ok', 'Number of submitted valid flags', ['team_net_no']), + ('flags_dup', 'Number of submitted duplicate flags', ['team_net_no']), + ('flags_old', 'Number of submitted expired flags', ['team_net_no']), + ('flags_own', 'Number of submitted own flags', ['team_net_no']), + ('flags_inv', 'Number of submitted invalid flags', ['team_net_no']), + ('flags_err', 'Number of submitted flags which resulted in an error', ['team_net_no']), + ('server_kills', 'Number of times the server was force-restarted due to fatal errors', []), + ('unhandled_exceptions', 'Number of unexpected exceptions in client connections', []) + ] + for name, doc, labels in counters: + metrics[name] = prometheus_client.Counter(metric_prefix+name, doc, labels, registry=registry) + + gauges = [ + ('start_timestamp', '(Unix) timestamp when the process was started', []), + ('open_connections', 'Number of currently open connections', ['team_net_no']) + ] + for name, doc, labels in gauges: + metrics[name] = prometheus_client.Gauge(metric_prefix+name, doc, labels, registry=registry) + + histograms = [ + ('submission_duration', 'Time spent processing a single flag in seconds', []) + ] + for name, doc, labels in histograms: + # The default buckets seem appropriate for our use case + metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, labels, registry=registry) + + return metrics + + +async def serve(host, port, db_conn, params): + + async def wrapper(reader, writer): + metrics = params['metrics'] + + try: + await handle_connection(reader, writer, db_conn, params) + except KillServerException: + logging.error('Encountered fatal error, exiting') + metrics['server_kills'].inc() + # pylint: disable=protected-access + os._exit(os.EX_IOERR) + except: # noqa, pylint: disable=bare-except + logging.exception('Exception in client connection, closing the connection:') + metrics['unhandled_exceptions'].inc() + writer.close() + + logging.info('Starting server on %s:%d', host, port) + server = await asyncio.start_server(wrapper, host, port) + + async with server: + await server.serve_forever() + + +async def handle_connection(reader, writer, db_conn, params): + """ + Coroutine managing the protocol flow with a single client. + """ + + metrics = params['metrics'] + client_addr = writer.get_extra_info('peername')[0] + + try: + client_net_no = _match_net_number(params['team_regex'], client_addr) + except ValueError: + logging.error('Could not match client address %s with team, closing the connection', client_addr) + metrics['connections'].labels(-1).inc() + writer.write(b'Error: Could not match your IP address with a team\n') + writer.close() + return + + metrics['connections'].labels(client_net_no).inc() + metrics['open_connections'].labels(client_net_no).inc() + + try: + await handle_team_connection(reader, writer, db_conn, params, client_addr, client_net_no) + finally: + metrics['open_connections'].labels(client_net_no).dec() + + +async def handle_team_connection(reader, writer, db_conn, params, client_addr, client_net_no): + """ + Continuation of handle_connection() for when the net number is already known. + Communication with the database happens synchronously because psycopg2 does not support asyncio. We do + not think that is a practical issue. + """ + + metrics = params['metrics'] + + def log(level_name, message, *args): + level = logging.getLevelName(level_name) + logging.log(level, '%d [%s]: ' + message, client_net_no, client_addr, *args) + + log('INFO', 'Accepted connection from %s (team net number %d)', client_addr, client_net_no) + + writer.write(f'{params["competition_name"]} Flag Submission Server\n'.encode('utf-8')) + writer.write(b'One flag per line please!\n\n') + + line_start_time = None + + while True: + # Record duration of the previous loop iteration + if line_start_time is not None: + duration_seconds = (time.monotonic_ns() - line_start_time) / 10**9 + metrics['submission_duration'].observe(duration_seconds) + + # Prevent asyncio buffer of unbounded size (i.e. memory leak) if the client never reads our responses + try: + await asyncio.wait_for(writer.drain(), TIMEOUT_SECONDS) + except: # noqa, pylint: disable=bare-except + log('INFO', 'Write timeout expired') + break + + try: + line = await asyncio.wait_for(reader.readline(), TIMEOUT_SECONDS) + except asyncio.TimeoutError: + log('INFO', 'Read timeout expired') + break + + line_start_time = time.monotonic_ns() + + if not line.endswith(b'\n'): + # EOF + break + raw_flag = line[:-1] + + try: + flag = raw_flag.decode('ascii') + except UnicodeDecodeError: + writer.write(raw_flag + b' INV Invalid flag\n') + log('INFO', 'Flag %s rejected due to bad encoding', repr(raw_flag)) + metrics['flags_inv'].labels(client_net_no).inc() + continue + + try: + flag_id, protecting_net_no = flag_lib.verify(flag, params['flag_secret'], params['flag_prefix']) + except flag_lib.InvalidFlagFormat: + writer.write(raw_flag + b' INV Invalid flag\n') + log('INFO', 'Flag %s rejected due to invalid format', repr(flag)) + metrics['flags_inv'].labels(client_net_no).inc() + continue + except flag_lib.InvalidFlagMAC: + writer.write(raw_flag + b' INV Invalid flag\n') + log('INFO', 'Flag %s rejected due to invalid MAC', repr(flag)) + metrics['flags_inv'].labels(client_net_no).inc() + continue + except flag_lib.FlagExpired as e: + writer.write(raw_flag + b' OLD Flag has expired\n') + log('INFO', 'Flag %s rejected because it has expired since %s', repr(flag), + e.expiration_time.isoformat()) + metrics['flags_old'].labels(client_net_no).inc() + continue + + if protecting_net_no == client_net_no: + writer.write(raw_flag + b' OWN You cannot submit your own flag\n') + log('INFO', 'Flag %s rejected because it is protected by submitting team', repr(flag)) + metrics['flags_own'].labels(client_net_no).inc() + continue + + try: + now = datetime.datetime.now(datetime.timezone.utc) + start, end = database.get_dynamic_info(db_conn) + if now < start: + writer.write(raw_flag + b' ERR Competition has not even started yet\n') + log('INFO', 'Flag %s rejected because competition has not started', repr(flag)) + metrics['flags_err'].labels(client_net_no).inc() + continue + if now >= end: + writer.write(raw_flag + b' ERR Competition is over\n') + log('INFO', 'Flag %s rejected because competition is over', repr(flag)) + metrics['flags_err'].labels(client_net_no).inc() + continue + + if database.team_is_nop(db_conn, protecting_net_no): + writer.write(raw_flag + b' INV You cannot submit flags of a NOP team\n') + log('INFO', 'Flag %s rejected because it is protected by a NOP team', repr(flag)) + metrics['flags_inv'].labels(client_net_no).inc() + continue + + try: + database.add_capture(db_conn, flag_id, client_net_no) + writer.write(raw_flag + b' OK\n') + log('INFO', 'Flag %s accepted', repr(flag)) + metrics['flags_ok'].labels(client_net_no).inc() + except database.DuplicateCapture: + writer.write(raw_flag + b' DUP You already submitted this flag\n') + log('INFO', 'Flag %s rejected because it has already been submitted before', repr(flag)) + metrics['flags_dup'].labels(client_net_no).inc() + except database.TeamNotExisting: + writer.write(raw_flag + b' ERR Could not find team\n') + log('WARNING', 'Flag %s: Could not find team for net number %d in database', repr(flag), + client_net_no) + metrics['flags_err'].labels(client_net_no).inc() + except (psycopg2.Error, sqlite3.Error) as e: + logging.exception('Database error:') + raise KillServerException() from e + + log('INFO', 'Closing connection') + writer.close() + + +def _match_net_number(regex, addr): + """ + Determines the net number for an address using the given regex. Implemented as separate function to + enable mocking in test cases. + """ + + match = regex.search(addr) + if not match: + raise ValueError() + + return int(match.group(1)) + + +class KillServerException(Exception): + """ + Indicates that a fatal error occured and the server shall be stopped (and then usually get restarted + through systemd). + """ diff --git a/src/ctf_gameserver/web/scoring/models.py b/src/ctf_gameserver/web/scoring/models.py index 53ef7a4..9482293 100644 --- a/src/ctf_gameserver/web/scoring/models.py +++ b/src/ctf_gameserver/web/scoring/models.py @@ -57,6 +57,7 @@ class Capture(models.Model): timestamp = models.DateTimeField(auto_now_add=True) class Meta: + # This constraint is necessary for correct behavior of the submission server unique_together = ('flag', 'capturing_team') index_together = ('flag', 'capturing_team') diff --git a/tests/submission/fixtures/server.json b/tests/submission/fixtures/server.json new file mode 100644 index 0000000..d6ad43a --- /dev/null +++ b/tests/submission/fixtures/server.json @@ -0,0 +1,180 @@ +[ +{ + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", + "last_login": null, + "is_superuser": false, + "username": "Team1", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2019-04-03T18:21:28.622Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$36000$xOKKVRJp772Y$YbUoJ0N2rDg3xndTwZv+jHKrIWcJ209dOUZij007eXg=", + "last_login": null, + "is_superuser": false, + "username": "Team2", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2019-04-03T18:21:46.918Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$36000$UGfl968SDB0l$rnxu7pLZUqwvYXk14QBjwjddYFTBrpw99PcH2FxtaKo=", + "last_login": null, + "is_superuser": false, + "username": "NOP", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2019-04-03T18:22:02Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "registration.team", + "pk": 2, + "fields": { + "informal_email": "team1@example.org", + "image": "", + "affiliation": "", + "country": "World", + "nop_team": false, + "net_number": 102 + } +}, +{ + "model": "registration.team", + "pk": 3, + "fields": { + "informal_email": "team2@example.org", + "image": "", + "affiliation": "", + "country": "World", + "nop_team": false, + "net_number": 103 + } +}, +{ + "model": "registration.team", + "pk": 4, + "fields": { + "informal_email": "nop@example.org", + "image": "", + "affiliation": "", + "country": "World", + "nop_team": true, + "net_number": 104 + } +}, +{ + "model": "scoring.service", + "pk": 1, + "fields": { + "name": "Service 1", + "slug": "service1" + } +}, +{ + "model": "scoring.flag", + "pk": 1, + "fields": { + "service": 1, + "protecting_team": 2, + "tick": 1, + "placement_start": null, + "placement_end": null, + "flagid": null, + "bonus": null + } +}, +{ + "model": "scoring.flag", + "pk": 2, + "fields": { + "service": 1, + "protecting_team": 2, + "tick": 2, + "placement_start": null, + "placement_end": null, + "flagid": null, + "bonus": null + } +}, +{ + "model": "scoring.flag", + "pk": 3, + "fields": { + "service": 1, + "protecting_team": 4, + "tick": 6, + "placement_start": null, + "placement_end": null, + "flagid": null, + "bonus": null + } +}, +{ + "model": "scoring.flag", + "pk": 4, + "fields": { + "service": 1, + "protecting_team": 2, + "tick": 6, + "placement_start": null, + "placement_end": null, + "flagid": null, + "bonus": null + } +}, +{ + "model": "scoring.flag", + "pk": 5, + "fields": { + "service": 1, + "protecting_team": 3, + "tick": 6, + "placement_start": null, + "placement_end": null, + "flagid": null, + "bonus": null + } +}, +{ + "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": 6, + "flag_prefix": "FAUST_", + "registration_open": false + } +} +] diff --git a/tests/submission/test_server.py b/tests/submission/test_server.py new file mode 100644 index 0000000..c78ede9 --- /dev/null +++ b/tests/submission/test_server.py @@ -0,0 +1,276 @@ +import asyncio +from collections import defaultdict +import datetime +from unittest.mock import Mock +from unittest.mock import patch + +from ctf_gameserver.lib.database import transaction_cursor +from ctf_gameserver.lib.flag import generate as generate_flag +from ctf_gameserver.lib.test_util import DatabaseTestCase +from ctf_gameserver.submission.submission import serve + + +class ServerTest(DatabaseTestCase): + + fixtures = ['tests/submission/fixtures/server.json'] + flag_prefix = 'FAUST_' + flag_secret = b'topsecret' + metrics = defaultdict(Mock) + + async def connect(self): + task = asyncio.create_task(serve('localhost', 6666, self.connection, { + 'flag_secret': self.flag_secret, + 'team_regex': None, + 'competition_name': 'Test CTF', + 'flag_prefix': self.flag_prefix, + 'metrics': self.metrics + })) + + for _ in range(50): + try: + reader, writer = await asyncio.open_connection('localhost', 6666) + break + except OSError: + await asyncio.sleep(0.1) + + return (task, reader, writer) + + @patch('ctf_gameserver.submission.submission._match_net_number') + def test_basic(self, net_number_mock): + async def coroutine(): + net_number_mock.return_value = 103 + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now"), ' + ' end = datetime("now", "+1 hour")') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 0) + + task, reader, writer = await self.connect() + await reader.readuntil(b'\n\n') + + expiration_time = datetime.datetime.now() + datetime.timedelta(seconds=60) + flag = generate_flag(expiration_time, 4, 102, self.flag_secret, self.flag_prefix).encode('ascii') + writer.write(flag + b'\n') + + response = await reader.readline() + self.assertEqual(response, flag + b' OK\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + cursor.execute('SELECT flag_id, capturing_team_id, tick FROM scoring_capture') + captured_flag, capturing_team, capture_tick = cursor.fetchone() + + self.assertEqual(capture_count, 1) + self.assertEqual(captured_flag, 4) + self.assertEqual(capturing_team, 3) + self.assertEqual(capture_tick, 6) + + writer.close() + task.cancel() + + asyncio.run(coroutine()) + + @patch('ctf_gameserver.submission.submission._match_net_number') + def test_multiple(self, net_number_mock): + async def coroutine(): + net_number_mock.return_value = 103 + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now"), ' + ' end = datetime("now", "+1 hour")') + + task, reader, writer = await self.connect() + await reader.readuntil(b'\n\n') + + expiration_time = datetime.datetime.now() - datetime.timedelta(seconds=1) + old_flag = generate_flag(expiration_time, 1, 102, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(old_flag + b'\n') + + response = await reader.readline() + self.assertEqual(response, old_flag + b' OLD Flag has expired\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 0) + + expiration_time = datetime.datetime.now() + datetime.timedelta(seconds=60) + + own_flag = generate_flag(expiration_time, 5, 103, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(own_flag + b'\n') + + response = await reader.readline() + self.assertEqual(response, own_flag + b' OWN You cannot submit your own flag\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 0) + + nop_flag = generate_flag(expiration_time, 3, 104, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(nop_flag + b'\n') + + response = await reader.readline() + self.assertEqual(response, nop_flag + b' INV You cannot submit flags of a NOP team\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 0) + + valid_flag = generate_flag(expiration_time, 4, 102, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(valid_flag + b'\n') + + response = await reader.readline() + self.assertEqual(response, valid_flag + b' OK\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + cursor.execute('SELECT flag_id FROM scoring_capture') + captured_flag = cursor.fetchone()[0] + self.assertEqual(capture_count, 1) + self.assertEqual(captured_flag, 4) + + writer.write(valid_flag + b'\n') + response = await reader.readline() + self.assertEqual(response, valid_flag + b' DUP You already submitted this flag\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 1) + + writer.close() + task.cancel() + + asyncio.run(coroutine()) + + @patch('ctf_gameserver.submission.submission._match_net_number') + def test_out_of_order(self, net_number_mock): + async def coroutine(): + net_number_mock.return_value = 103 + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now"), ' + ' end = datetime("now", "+1 hour")') + + task, reader, writer = await self.connect() + + expiration_time = datetime.datetime.now() + datetime.timedelta(seconds=60) + + flag1 = generate_flag(expiration_time, 2, 102, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(flag1 + b'\n') + + flag2 = generate_flag(expiration_time, 4, 102, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(flag2 + b'\n') + + own_flag = generate_flag(expiration_time, 5, 103, self.flag_secret, + self.flag_prefix).encode('ascii') + writer.write(own_flag + b'\n') + + await reader.readuntil(b'\n\n') + + response = await reader.readline() + self.assertEqual(response, flag1 + b' OK\n') + response = await reader.readline() + self.assertEqual(response, flag2 + b' OK\n') + response = await reader.readline() + self.assertEqual(response, own_flag + b' OWN You cannot submit your own flag\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + cursor.execute('SELECT flag_id FROM scoring_capture ORDER BY flag_id') + captured_flag1 = cursor.fetchone()[0] + captured_flag2 = cursor.fetchone()[0] + self.assertEqual(capture_count, 2) + self.assertEqual(captured_flag1, 2) + self.assertEqual(captured_flag2, 4) + + writer.close() + task.cancel() + + asyncio.run(coroutine()) + + @patch('ctf_gameserver.submission.submission._match_net_number') + def test_after_competition(self, net_number_mock): + async def coroutine(): + net_number_mock.return_value = 103 + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now", "-1 hour"), ' + ' end = datetime("now")') + + task, reader, writer = await self.connect() + await reader.readuntil(b'\n\n') + + expiration_time = datetime.datetime.now() + datetime.timedelta(seconds=60) + flag = generate_flag(expiration_time, 4, 102, self.flag_secret, self.flag_prefix).encode('ascii') + writer.write(flag + b'\n') + + response = await reader.readline() + self.assertEqual(response, flag + b' ERR Competition is over\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 0) + + writer.close() + task.cancel() + + asyncio.run(coroutine()) + + @patch('ctf_gameserver.submission.submission._match_net_number') + def test_invalid(self, net_number_mock): + async def coroutine(): + net_number_mock.return_value = 103 + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now", "-1 hour"), ' + ' end = datetime("now")') + + task, reader, writer = await self.connect() + await reader.readuntil(b'\n\n') + + flag = 'ΓΌberflΓ€g'.encode('utf8') + writer.write(flag + b'\n') + response = await reader.readline() + self.assertEqual(response, flag + b' INV Invalid flag\n') + + flag = b'' + writer.write(flag + b'\n') + response = await reader.readline() + self.assertEqual(response, flag + b' INV Invalid flag\n') + + flag = b'NOTFAUST_Q1RGLSUQmjVTRTmXRZ4ELKTzKyqagXcS' + writer.write(flag + b'\n') + response = await reader.readline() + self.assertEqual(response, flag + b' INV Invalid flag\n') + + flag = b'FAUST_Q1RGLSUQmjVTRTmXRZ4ELKTzKyqagXc\0' + writer.write(flag + b'\n') + response = await reader.readline() + self.assertEqual(response, flag + b' INV Invalid flag\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 0) + + writer.close() + task.cancel() + + asyncio.run(coroutine()) diff --git a/tests/test_submission.py b/tests/test_submission.py deleted file mode 100644 index c85d4b3..0000000 --- a/tests/test_submission.py +++ /dev/null @@ -1,57 +0,0 @@ -import datetime -import re -import unittest -from unittest import mock - -from ctf_gameserver.submission.flagserver import FlagHandler -from ctf_gameserver.lib import flag - -class UserInputTestCase(unittest.TestCase): - def setUp(self): - 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, 'FLAG_', re.compile(r'^203\.0\.(\d+)\.\d+$')) - - - def test_empty(self): - self._handler.buffer = b"" - self.assertIsNone(self._handler._handle_flag()) - - - def test_nonascii(self): - with mock.patch.object(self._handler, '_reply') as reply: - self._handler.buffer = b'\xf3' - self._handler._handle_flag() - reply.assert_called_with(b"Flags should be of the Format [-_a-zA-Z0-9]+") - - with mock.patch.object(self._handler, '_reply') as reply: - self._handler.buffer = u'ΓΌmlΓ€ut'.encode('utf-8') - self._handler._handle_flag() - reply.assert_called_with(b"Flags should be of the Format [-_a-zA-Z0-9]+") - - - def test_out_of_contest(self): - with mock.patch.object(self._handler, '_reply') as reply: - with mock.patch.object(self._handler, '_conteststart', - new=datetime.datetime.now(tz=datetime.timezone.utc) + - datetime.timedelta(minutes=10)) as start: - self._handler.buffer = b'SOMETHING' - self._handler._handle_flag() - reply.assert_called_with(b"Contest didn't even start yet!") - - with mock.patch.object(self._handler, '_contestend', - new=datetime.datetime.now(tz=datetime.timezone.utc) - - datetime.timedelta(minutes=10)) as start: - self._handler.buffer = b'SOMETHING' - self._handler._handle_flag() - reply.assert_called_with(b"Contest already over!") - - - def test_not_against_self(self): - with mock.patch.object(self._handler, '_reply') as reply: - testflag = flag.generate(113, 12, b'secret') - self._handler.buffer = testflag.encode('us-ascii') - self._handler._handle_flag() - reply.assert_called_with(b"Can't submit a flag for your own team") - From b65a308874f04f29fd75582096c159dff126781a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Mon, 18 Apr 2022 12:27:11 +0200 Subject: [PATCH 03/63] Examples: Update Go checker to use the latest checkerlib --- examples/checker/example_checker_go/go.mod | 2 +- examples/checker/example_checker_go/go.sum | 4 ++-- examples/checker/example_checker_go/main.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/checker/example_checker_go/go.mod b/examples/checker/example_checker_go/go.mod index e05101c..90804b0 100644 --- a/examples/checker/example_checker_go/go.mod +++ b/examples/checker/example_checker_go/go.mod @@ -1,3 +1,3 @@ module example_checker -require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20200614124532-4fe436aebc60 +require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220418102344-6e941055f119 diff --git a/examples/checker/example_checker_go/go.sum b/examples/checker/example_checker_go/go.sum index 2fa391a..f0bb74e 100644 --- a/examples/checker/example_checker_go/go.sum +++ b/examples/checker/example_checker_go/go.sum @@ -1,5 +1,5 @@ -github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20200614124532-4fe436aebc60 h1:aENwKyAp53iRLs+xUgwpdhmT9zOs6NotYAlibMwlR+k= -github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20200614124532-4fe436aebc60/go.mod h1:kn1Odhtr8wDBWd90gXD8AX2VXJYjtV6qk+TP+f/jz+s= +github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220418102344-6e941055f119 h1:VXdxqaul0zNKAO1xOpKjhcbBPXLbqkbUMBSbNYWtRsQ= +github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220418102344-6e941055f119/go.mod h1:kn1Odhtr8wDBWd90gXD8AX2VXJYjtV6qk+TP+f/jz+s= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/examples/checker/example_checker_go/main.go b/examples/checker/example_checker_go/main.go index dd666fd..47d7dda 100644 --- a/examples/checker/example_checker_go/main.go +++ b/examples/checker/example_checker_go/main.go @@ -23,7 +23,7 @@ func (c checker) PlaceFlag(ip string, team int, tick int) (checkerlib.Result, er } defer conn.Close() - flag := checkerlib.GetFlag(tick, nil) + flag := checkerlib.GetFlag(tick) _, err = fmt.Fprintf(conn, "SET %d %s\n", tick, flag) if err != nil { @@ -86,7 +86,7 @@ func (c checker) CheckFlag(ip string, team int, tick int) (checkerlib.Result, er } log.Printf("Received response to GET command: %q", line) - flag := checkerlib.GetFlag(tick, nil) + flag := checkerlib.GetFlag(tick) if line != flag { log.Print("Received wrong response to GET command") return checkerlib.ResultFlagNotFound, nil From 4e84b1c30339bf4c404f6dbf04650cc398a3af10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 11:10:19 +0200 Subject: [PATCH 04/63] Submission: Replace linter directive with `raise from None` See https://stackoverflow.com/a/24752607 --- src/ctf_gameserver/submission/database.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ctf_gameserver/submission/database.py b/src/ctf_gameserver/submission/database.py index a0186c1..4ad681e 100644 --- a/src/ctf_gameserver/submission/database.py +++ b/src/ctf_gameserver/submission/database.py @@ -71,8 +71,7 @@ def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False) ' VALUES (%s, %s, NOW(), (SELECT current_tick FROM scoring_gamecontrol))', (flag_id, capturing_team_id)) except (UniqueViolation, sqlite3.IntegrityError): - # pylint: disable=raise-missing-from - raise DuplicateCapture() + raise DuplicateCapture() from None class TeamNotExisting(DBDataError): From 7cf14908667676918f254457d8b0480f554e68fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 11:21:53 +0200 Subject: [PATCH 05/63] Remove symlinks to source directory We don't think these are required (anymore), but they cause errors with pytest in CI because "test_util.py" gets collected as well. --- scripts/checker/ctf_gameserver | 1 - scripts/controller/ctf_gameserver | 1 - scripts/submission/ctf_gameserver | 1 - tests/ctf_gameserver | 1 - 4 files changed, 4 deletions(-) delete mode 120000 scripts/checker/ctf_gameserver delete mode 120000 scripts/controller/ctf_gameserver delete mode 120000 scripts/submission/ctf_gameserver delete mode 120000 tests/ctf_gameserver diff --git a/scripts/checker/ctf_gameserver b/scripts/checker/ctf_gameserver deleted file mode 120000 index f954ec1..0000000 --- a/scripts/checker/ctf_gameserver +++ /dev/null @@ -1 +0,0 @@ -../../src/ctf_gameserver \ No newline at end of file diff --git a/scripts/controller/ctf_gameserver b/scripts/controller/ctf_gameserver deleted file mode 120000 index f954ec1..0000000 --- a/scripts/controller/ctf_gameserver +++ /dev/null @@ -1 +0,0 @@ -../../src/ctf_gameserver \ No newline at end of file diff --git a/scripts/submission/ctf_gameserver b/scripts/submission/ctf_gameserver deleted file mode 120000 index f954ec1..0000000 --- a/scripts/submission/ctf_gameserver +++ /dev/null @@ -1 +0,0 @@ -../../src/ctf_gameserver \ No newline at end of file diff --git a/tests/ctf_gameserver b/tests/ctf_gameserver deleted file mode 120000 index bdbd0a4..0000000 --- a/tests/ctf_gameserver +++ /dev/null @@ -1 +0,0 @@ -../src/ctf_gameserver \ No newline at end of file From 7e57d90d69549306e217ac9cf2a35330b9e5821e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 12:08:47 +0200 Subject: [PATCH 06/63] Fix submission server in GitHub Actions --- tests/submission/test_server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/submission/test_server.py b/tests/submission/test_server.py index c78ede9..e49351b 100644 --- a/tests/submission/test_server.py +++ b/tests/submission/test_server.py @@ -18,7 +18,9 @@ class ServerTest(DatabaseTestCase): metrics = defaultdict(Mock) async def connect(self): - task = asyncio.create_task(serve('localhost', 6666, self.connection, { + # For this to work on GitHub Actions (in Docker), we need to use the v4 address instead of + # "localhost" + task = asyncio.create_task(serve('127.0.0.1', 6666, self.connection, { 'flag_secret': self.flag_secret, 'team_regex': None, 'competition_name': 'Test CTF', @@ -28,7 +30,7 @@ async def connect(self): for _ in range(50): try: - reader, writer = await asyncio.open_connection('localhost', 6666) + reader, writer = await asyncio.open_connection('127.0.0.1', 6666) break except OSError: await asyncio.sleep(0.1) From 254f5585c2b6f0e2388bcf06f08da6fcbb116024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 12:35:13 +0200 Subject: [PATCH 07/63] Submission: Add test case for multiple clients --- tests/submission/test_server.py | 54 ++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/submission/test_server.py b/tests/submission/test_server.py index e49351b..c248fe5 100644 --- a/tests/submission/test_server.py +++ b/tests/submission/test_server.py @@ -78,7 +78,7 @@ async def coroutine(): asyncio.run(coroutine()) @patch('ctf_gameserver.submission.submission._match_net_number') - def test_multiple(self, net_number_mock): + def test_multiple_flags(self, net_number_mock): async def coroutine(): net_number_mock.return_value = 103 @@ -206,6 +206,58 @@ async def coroutine(): asyncio.run(coroutine()) + @patch('ctf_gameserver.submission.submission._match_net_number') + def test_multiple_clients(self, net_number_mock): + async def coroutine(): + net_number_mock.side_effect = [103, 102] + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now"), ' + ' end = datetime("now", "+1 hour")') + + task, reader_103, writer_103 = await self.connect() + await reader_103.readuntil(b'\n\n') + reader_102, writer_102 = await asyncio.open_connection('127.0.0.1', 6666) + await reader_102.readuntil(b'\n\n') + + expiration_time = datetime.datetime.now() + datetime.timedelta(seconds=60) + + flag_102 = generate_flag(expiration_time, 4, 102, self.flag_secret, + self.flag_prefix).encode('ascii') + writer_103.write(flag_102 + b'\n') + writer_102.write(flag_102 + b'\n') + + response = await reader_103.readline() + self.assertEqual(response, flag_102 + b' OK\n') + response = await reader_102.readline() + self.assertEqual(response, flag_102 + b' OWN You cannot submit your own flag\n') + + flag_103 = generate_flag(expiration_time, 4, 103, self.flag_secret, + self.flag_prefix).encode('ascii') + writer_103.write(flag_103 + b'\n') + writer_102.write(flag_103 + b'\n') + + response = await reader_103.readline() + self.assertEqual(response, flag_103 + b' OWN You cannot submit your own flag\n') + response = await reader_102.readline() + self.assertEqual(response, flag_103 + b' OK\n') + + writer_103.write(flag_102 + b'\n') + + response = await reader_103.readline() + self.assertEqual(response, flag_102 + b' DUP You already submitted this flag\n') + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT COUNT(*) FROM scoring_capture') + capture_count = cursor.fetchone()[0] + self.assertEqual(capture_count, 2) + + writer_102.close() + writer_103.close() + task.cancel() + + asyncio.run(coroutine()) + @patch('ctf_gameserver.submission.submission._match_net_number') def test_after_competition(self, net_number_mock): async def coroutine(): From b727719a54465cd8910c14bb4b3e2674c1a5394c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 12:57:46 +0200 Subject: [PATCH 08/63] Adjust unit and environment file for rewritten submission server --- conf/controller/controller.env | 7 ++---- conf/submission/ctf-submission@.service | 27 +++++++++++++++++---- conf/submission/submission.env | 3 ++- src/ctf_gameserver/submission/submission.py | 9 ++++--- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/conf/controller/controller.env b/conf/controller/controller.env index 0209dc8..fbd49e4 100644 --- a/conf/controller/controller.env +++ b/conf/controller/controller.env @@ -1,5 +1,2 @@ -CTF_LOGLEVEL="INFO" -CTF_DBHOST="localhost" -CTF_DBNAME="ctf_gameserver" -CTF_DBUSER="ctf_controller" -CTF_DBPASSWORD="PASSWORD" +CTF_DBNAME="DUMMY" +CTF_DBUSER="DUMMY" diff --git a/conf/submission/ctf-submission@.service b/conf/submission/ctf-submission@.service index 6af7c23..fc01437 100644 --- a/conf/submission/ctf-submission@.service +++ b/conf/submission/ctf-submission@.service @@ -1,16 +1,33 @@ [Unit] Description=CTF Flag-Submission Service -Wants=postgresql.service After=postgresql.service [Service] Type=notify +DynamicUser=yes +# Python breaks without HOME environment variable and with `DynamicUser` +Environment=HOME=/tmp EnvironmentFile=/etc/ctf-gameserver/submission.env -ExecStart=/usr/bin/ctf-submission --port %i -User=nobody -Group=nogroup -RestartSec=10 +ExecStart=/usr/bin/ctf-submission --listen-port %i Restart=on-failure +RestartSec=5 + +# Security options +CapabilityBoundingSet= +LockPersonality=yes +MemoryDenyWriteExecute=yes +NoNewPrivileges=yes +PrivateDevices=yes +PrivateTmp=yes +PrivateUsers=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectKernelModules=yes +ProtectKernelTunables=yes +ProtectSystem=strict +RestrictNamespaces=yes +RestrictRealtime=yes +SystemCallArchitectures=native [Install] WantedBy=multi-user.target diff --git a/conf/submission/submission.env b/conf/submission/submission.env index b0e290f..7d121cc 100644 --- a/conf/submission/submission.env +++ b/conf/submission/submission.env @@ -1,4 +1,5 @@ CTF_DBNAME="DUMMY" CTF_DBUSER="DUMMY" -CTF_SECRET="DUMMY" + +CTF_FLAGSECRET="RFVNTVlTRUNSRVQ=" CTF_TEAMREGEX="^0\.0\.(\d+)\.\d+$" diff --git a/src/ctf_gameserver/submission/submission.py b/src/ctf_gameserver/submission/submission.py index 76ec24b..d60c100 100644 --- a/src/ctf_gameserver/submission/submission.py +++ b/src/ctf_gameserver/submission/submission.py @@ -28,8 +28,8 @@ def main(): arg_parser = get_arg_parser_with_db('CTF Gameserver Submission Server') - arg_parser.add_argument('--listen', default="localhost:6666", - help='Address and port to listen on (":")') + arg_parser.add_argument('--listenhost', default="localhost", help='Address to listen on') + arg_parser.add_argument('--listenport', default="6666", help='Port to listen on') arg_parser.add_argument('--flagsecret', required=True, help='Base64 string used as secret in flag generation') arg_parser.add_argument('--teamregex', required=True, @@ -43,10 +43,11 @@ def main(): numeric_loglevel = getattr(logging, args.loglevel.upper()) logging.getLogger().setLevel(numeric_loglevel) + listen_host = args.listenhost try: - listen_host, listen_port, _ = parse_host_port(args.listen) + listen_port = int(args.listenport) except ValueError: - logging.error('Listen address needs to be specified as ":"') + logging.error('Listen port must be an integer') return os.EX_USAGE try: From e4ffd06512fa8b8129635805de87efd2d58ebe5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 13:39:44 +0200 Subject: [PATCH 09/63] Controller: Use set_to_current_time() for metric --- src/ctf_gameserver/controller/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/controller/controller.py b/src/ctf_gameserver/controller/controller.py index a5025c0..86c2e09 100644 --- a/src/ctf_gameserver/controller/controller.py +++ b/src/ctf_gameserver/controller/controller.py @@ -68,7 +68,7 @@ def main(): start_metrics_server(metrics_host, metrics_port, metrics_family) metrics = make_metrics(db_conn) - metrics['start_timestamp'].set(time.time()) + metrics['start_timestamp'].set_to_current_time() daemon.notify('READY=1') From 6ce70e522e74ff8eed8233db7741920e1183ad91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 23 Apr 2022 14:24:38 +0200 Subject: [PATCH 10/63] Fix linter errors, fail on linter errors, lint in CI Co-authored-by: Simon Ruderich --- .github/workflows/ci.yml | 10 +++++++++- Makefile | 7 +++---- src/ctf_gameserver/checker/supervisor.py | 1 + src/ctf_gameserver/checkerlib/lib.py | 8 ++++---- src/ctf_gameserver/lib/args.py | 4 ++-- src/ctf_gameserver/lib/daemon.py | 2 +- src/ctf_gameserver/lib/flag.py | 4 ++-- src/ctf_gameserver/lib/metrics.py | 2 +- src/ctf_gameserver/web/flatpages/models.py | 5 ++--- src/ctf_gameserver/web/registration/forms.py | 3 +-- src/ctf_gameserver/web/registration/util.py | 2 +- src/ctf_gameserver/web/scoring/models.py | 2 +- src/ctf_gameserver/web/scoring/views.py | 10 ++-------- src/ctf_gameserver/web/urls.py | 2 +- src/pylintrc | 5 ++++- tests/checker/integration_multi_checkerscript.py | 2 +- tests/checker/integration_unfinished_checkerscript.py | 2 +- tests/checker/test_integration.py | 1 - tests/lib/test_flag.py | 1 - tests/submission/test_server.py | 2 +- 20 files changed, 38 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb15db8..9f075b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,15 @@ on: - pull_request jobs: + lint: + name: Lint soure code + runs-on: ubuntu-latest + container: python:3.10-bullseye + steps: + - uses: actions/checkout@v2 + - run: pip install -e .[dev] + - run: make lint + # Test with Tox, a recent Python version and libraries from PyPI test_tox: name: Test with Tox @@ -22,7 +31,6 @@ jobs: 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() diff --git a/Makefile b/Makefile index 72ee8c6..77667cd 100644 --- a/Makefile +++ b/Makefile @@ -42,10 +42,9 @@ test: lint: # Run Pylint, pycodestyle and Bandit to check the code for potential errors, style guideline violations # and security issues - # REVISIT: Respect exit codes when linter issues are fixed - -pylint --rcfile $(SOURCE_DIR)/pylintrc $(SOURCE_DIR) $(TESTS_DIR)/test_*.py - -pycodestyle $(SOURCE_DIR) $(TESTS_DIR) - -bandit --ini bandit.ini -r $(SOURCE_DIR) + pylint --rcfile $(SOURCE_DIR)/pylintrc $(SOURCE_DIR) $(TESTS_DIR) + pycodestyle $(SOURCE_DIR) $(TESTS_DIR) + bandit --ini bandit.ini -r $(SOURCE_DIR) docs_site: mkdocs.yml $(wildcard docs/* docs/*/*) mkdocs build --strict diff --git a/src/ctf_gameserver/checker/supervisor.py b/src/ctf_gameserver/checker/supervisor.py index 58d9689..1e33b5e 100644 --- a/src/ctf_gameserver/checker/supervisor.py +++ b/src/ctf_gameserver/checker/supervisor.py @@ -195,6 +195,7 @@ def filter(self, record): # Work-around for missing IPv6 support in Python's # logging.handlers.DatagramHandler (https://bugs.python.org/issue14855) class GELFHandler(graypy.GELFHandler): + # pylint: disable=invalid-name def makeSocket(self): return socket.socket(logging_params['gelf']['family'], socket.SOCK_DGRAM) diff --git a/src/ctf_gameserver/checkerlib/lib.py b/src/ctf_gameserver/checkerlib/lib.py index a00a26a..934143b 100644 --- a/src/ctf_gameserver/checkerlib/lib.py +++ b/src/ctf_gameserver/checkerlib/lib.py @@ -125,7 +125,7 @@ def get_flag(tick: int) -> str: try: team = get_flag._team # pylint: disable=protected-access except AttributeError: - raise Exception('get_flag() must be called through run_check()') + raise Exception('get_flag() must be called through run_check()') from None expiration = datetime.datetime(1970, 1, 1, 0, 0, tzinfo=datetime.timezone.utc) expiration += datetime.timedelta(minutes=tick) return ctf_gameserver.lib.flag.generate(expiration, 42, team, b'TOPSECRET') @@ -163,12 +163,12 @@ def store_state(key: str, data: Any) -> None: _recv_ctrl_message() else: try: - with open(_LOCAL_STATE_PATH, 'r') as f: + with open(_LOCAL_STATE_PATH, 'r', encoding='utf-8') as f: state = json.load(f) except FileNotFoundError: state = {} state[key] = serialized_data - with open(_LOCAL_STATE_PATH, 'w') as f: + with open(_LOCAL_STATE_PATH, 'w', encoding='utf-8') as f: json.dump(state, f, indent=4) @@ -186,7 +186,7 @@ def load_state(key: str) -> Any: return None else: try: - with open(_LOCAL_STATE_PATH, 'r') as f: + with open(_LOCAL_STATE_PATH, 'r', encoding='utf-8') as f: state = json.load(f) except FileNotFoundError: return None diff --git a/src/ctf_gameserver/lib/args.py b/src/ctf_gameserver/lib/args.py index 031a8d9..656507c 100644 --- a/src/ctf_gameserver/lib/args.py +++ b/src/ctf_gameserver/lib/args.py @@ -42,7 +42,7 @@ def parse_host_port(text): try: addrinfo = socket.getaddrinfo(url_parts.hostname, url_parts.port) - except socket.gaierror: - raise ValueError('Could not determine address family') + except socket.gaierror as e: + raise ValueError('Could not determine address family') from e return (url_parts.hostname, url_parts.port, addrinfo[0][0]) diff --git a/src/ctf_gameserver/lib/daemon.py b/src/ctf_gameserver/lib/daemon.py index 4e0afaf..af36910 100644 --- a/src/ctf_gameserver/lib/daemon.py +++ b/src/ctf_gameserver/lib/daemon.py @@ -5,6 +5,6 @@ def notify(*args, **kwargs): try: import systemd.daemon # pylint: disable=import-outside-toplevel - return systemd.daemon.notify(*args, **kwargs) + systemd.daemon.notify(*args, **kwargs) except ImportError: logging.info('Ignoring daemon notification due to missing systemd module') diff --git a/src/ctf_gameserver/lib/flag.py b/src/ctf_gameserver/lib/flag.py index 8d93ab6..f34efba 100644 --- a/src/ctf_gameserver/lib/flag.py +++ b/src/ctf_gameserver/lib/flag.py @@ -58,12 +58,12 @@ def verify(flag, secret, prefix='FLAG_'): try: raw_flag = base64.b64decode(flag[len(prefix):]) except (ValueError, binascii.Error): - raise InvalidFlagFormat() + raise InvalidFlagFormat() from None try: protected_data, flag_mac = raw_flag[:DATA_LEN], raw_flag[DATA_LEN:] except IndexError: - raise InvalidFlagFormat() + raise InvalidFlagFormat() from None mac = _gen_mac(secret, protected_data) if not compare_digest(mac, flag_mac): diff --git a/src/ctf_gameserver/lib/metrics.py b/src/ctf_gameserver/lib/metrics.py index 6ce2e6f..42501df 100644 --- a/src/ctf_gameserver/lib/metrics.py +++ b/src/ctf_gameserver/lib/metrics.py @@ -22,7 +22,7 @@ class FamilyServer(simple_server.WSGIServer): class SilentHandler(simple_server.WSGIRequestHandler): - def log_message(self, _, *args): + def log_message(self, _, *args): # pylint: disable=arguments-differ """ Doesn't log anything. """ diff --git a/src/ctf_gameserver/web/flatpages/models.py b/src/ctf_gameserver/web/flatpages/models.py index b13f07d..ba274a9 100644 --- a/src/ctf_gameserver/web/flatpages/models.py +++ b/src/ctf_gameserver/web/flatpages/models.py @@ -16,7 +16,7 @@ class Category(models.Model): class Meta: ordering = ('ordering', 'title') - def __str__(self): + def __str__(self): # pylint: disable=invalid-str-returned return self.title @@ -54,7 +54,7 @@ def get_queryset(self): # QuerySet that only returns Flatpages without a category, but not the home page objects_without_category = ObjectsWithoutCategoryManager() - def __str__(self): + def __str__(self): # pylint: disable=invalid-str-returned return self.title def clean(self): @@ -63,7 +63,6 @@ def clean(self): when category is NULL. Django's constraint validation skips this case, and the actual constraint's behavior is database-specific. """ - # pylint: disable=bad-whitespace, bad-continuation if self.category is None and type(self)._default_manager.filter( category = self.category, title = self.title diff --git a/src/ctf_gameserver/web/registration/forms.py b/src/ctf_gameserver/web/registration/forms.py index 12284dc..89f9411 100644 --- a/src/ctf_gameserver/web/registration/forms.py +++ b/src/ctf_gameserver/web/registration/forms.py @@ -150,7 +150,7 @@ def clean_image(self): return self.cleaned_data['image'] - def save(self, user, commit=True): # pylint: disable=arguments-differ + def save(self, user, commit=True): # pylint: disable=arguments-renamed """ save() variant which takes as an additional parameter the user model to be associated with the team. """ @@ -214,7 +214,6 @@ class MailTeamsForm(forms.Form): """ # Use short property names because they will end up in (visible) GET parameters - # pylint: disable=bad-whitespace addrs = forms.ChoiceField( choices = [('formal', 'Formal'), ('informal', 'Informal')], label = _('Address type'), diff --git a/src/ctf_gameserver/web/registration/util.py b/src/ctf_gameserver/web/registration/util.py index 82c63f3..5ac0a31 100644 --- a/src/ctf_gameserver/web/registration/util.py +++ b/src/ctf_gameserver/web/registration/util.py @@ -35,7 +35,7 @@ def get_country_names(): csv_file_name = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'countries.csv') - with open(csv_file_name, encoding='utf8') as csv_file: + with open(csv_file_name, encoding='utf-8') as csv_file: csv_reader = csv.reader(csv_file) # Skip header line next(csv_reader) diff --git a/src/ctf_gameserver/web/scoring/models.py b/src/ctf_gameserver/web/scoring/models.py index 9482293..cc3fd28 100644 --- a/src/ctf_gameserver/web/scoring/models.py +++ b/src/ctf_gameserver/web/scoring/models.py @@ -14,7 +14,7 @@ class Service(models.Model): name = models.CharField(max_length=30, unique=True) slug = models.SlugField(max_length=30, unique=True) - def __str__(self): + def __str__(self): # pylint: disable=invalid-str-returned return self.name diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index 2665369..bc44696 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -122,10 +122,7 @@ def service_status_json(_): game_control = models.GameControl.get_instance() to_tick = game_control.current_tick - from_tick = to_tick - 4 - - if from_tick < 0: - from_tick = 0 + from_tick = max(to_tick - 4, 0) statuses = calculations.team_statuses(from_tick, to_tick) services = models.Service.objects.all().order_by('name') @@ -199,10 +196,7 @@ def service_history(request): game_control = models.GameControl.get_instance() max_tick = game_control.current_tick - min_tick = max_tick - 30 - - if min_tick < 0: - min_tick = 0 + min_tick = max(max_tick - 30, 0) return render(request, 'service_history.html', { 'services': models.Service.objects.all().order_by('name'), diff --git a/src/ctf_gameserver/web/urls.py b/src/ctf_gameserver/web/urls.py index cb1850f..2245f11 100644 --- a/src/ctf_gameserver/web/urls.py +++ b/src/ctf_gameserver/web/urls.py @@ -9,7 +9,7 @@ from .admin import admin_site from .forms import TeamAuthenticationForm, FormalPasswordResetForm -# pylint: disable=invalid-name, bad-continuation +# pylint: disable=invalid-name urlpatterns = [ diff --git a/src/pylintrc b/src/pylintrc index f139161..1e450e8 100644 --- a/src/pylintrc +++ b/src/pylintrc @@ -14,8 +14,11 @@ reports = no # * Complaints about missing docstrings # * Complaints about `else` branches after `return` # * "Method could be a function" messages +# * "Similar lines" messages +# * "Consider using 'with'" messages +# * Advices to use f-strings instead of `format()` # * Messages about locally disabled messages -disable = design, too-many-nested-blocks, fixme, global-statement, missing-docstring, no-else-return, no-self-use, locally-disabled +disable = design, too-many-nested-blocks, fixme, global-statement, missing-docstring, no-else-return, no-self-use, duplicate-code, consider-using-with, consider-using-f-string, locally-disabled # Variable names which would generally be invalid, but are accepted anyway good-names = e, f, fd, i, ip, j, k, _ diff --git a/tests/checker/integration_multi_checkerscript.py b/tests/checker/integration_multi_checkerscript.py index 85cfa7d..855ef94 100755 --- a/tests/checker/integration_multi_checkerscript.py +++ b/tests/checker/integration_multi_checkerscript.py @@ -8,7 +8,7 @@ class TestChecker(checkerlib.BaseChecker): def place_flag(self, tick): self._tick = tick # pylint: disable=attribute-defined-outside-init - if self.team != 92 and self.team != 93: + if self.team not in (92, 93): raise Exception('Invalid team {}'.format(self.team)) checkerlib.get_flag(tick) diff --git a/tests/checker/integration_unfinished_checkerscript.py b/tests/checker/integration_unfinished_checkerscript.py index 45df0f5..33de8a0 100755 --- a/tests/checker/integration_unfinished_checkerscript.py +++ b/tests/checker/integration_unfinished_checkerscript.py @@ -9,7 +9,7 @@ if __name__ == '__main__': pidfile_path = os.environ['CHECKERSCRIPT_PIDFILE'] # pylint: disable=invalid-name - with open(pidfile_path, 'w') as pidfile: + with open(pidfile_path, 'w', encoding='ascii') as pidfile: pidfile.write(str(os.getpid())) checkerlib.get_flag(1) diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index 1f9381a..e5a05b9 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -2,7 +2,6 @@ from unittest import SkipTest from unittest.mock import patch import shutil -import sqlite3 import subprocess import tempfile import time diff --git a/tests/lib/test_flag.py b/tests/lib/test_flag.py index b3b9956..95e6d3e 100644 --- a/tests/lib/test_flag.py +++ b/tests/lib/test_flag.py @@ -1,6 +1,5 @@ import datetime import random -import time import unittest from unittest.mock import patch diff --git a/tests/submission/test_server.py b/tests/submission/test_server.py index c248fe5..b42ab6d 100644 --- a/tests/submission/test_server.py +++ b/tests/submission/test_server.py @@ -299,7 +299,7 @@ async def coroutine(): task, reader, writer = await self.connect() await reader.readuntil(b'\n\n') - flag = 'ΓΌberflΓ€g'.encode('utf8') + flag = 'ΓΌberflΓ€g'.encode('utf-8') writer.write(flag + b'\n') response = await reader.readline() self.assertEqual(response, flag + b' INV Invalid flag\n') From 2894769b304941eb1ef969a00cde4daed8ac7aea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Wed, 27 Apr 2022 21:15:21 +0200 Subject: [PATCH 11/63] Submission: Use separate environment files for service instances This allows specifying the metrics port per-instance. Co-authored-by: Simon Ruderich --- conf/submission/ctf-submission@.service | 3 ++- src/ctf_gameserver/submission/submission.py | 9 ++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/conf/submission/ctf-submission@.service b/conf/submission/ctf-submission@.service index fc01437..a04eb43 100644 --- a/conf/submission/ctf-submission@.service +++ b/conf/submission/ctf-submission@.service @@ -8,7 +8,8 @@ DynamicUser=yes # Python breaks without HOME environment variable and with `DynamicUser` Environment=HOME=/tmp EnvironmentFile=/etc/ctf-gameserver/submission.env -ExecStart=/usr/bin/ctf-submission --listen-port %i +EnvironmentFile=-/etc/ctf-gameserver/submission-%i.env +ExecStart=/usr/bin/ctf-submission Restart=on-failure RestartSec=5 diff --git a/src/ctf_gameserver/submission/submission.py b/src/ctf_gameserver/submission/submission.py index d60c100..76ec24b 100644 --- a/src/ctf_gameserver/submission/submission.py +++ b/src/ctf_gameserver/submission/submission.py @@ -28,8 +28,8 @@ def main(): arg_parser = get_arg_parser_with_db('CTF Gameserver Submission Server') - arg_parser.add_argument('--listenhost', default="localhost", help='Address to listen on') - arg_parser.add_argument('--listenport', default="6666", help='Port to listen on') + arg_parser.add_argument('--listen', default="localhost:6666", + help='Address and port to listen on (":")') arg_parser.add_argument('--flagsecret', required=True, help='Base64 string used as secret in flag generation') arg_parser.add_argument('--teamregex', required=True, @@ -43,11 +43,10 @@ def main(): numeric_loglevel = getattr(logging, args.loglevel.upper()) logging.getLogger().setLevel(numeric_loglevel) - listen_host = args.listenhost try: - listen_port = int(args.listenport) + listen_host, listen_port, _ = parse_host_port(args.listen) except ValueError: - logging.error('Listen port must be an integer') + logging.error('Listen address needs to be specified as ":"') return os.EX_USAGE try: From 29740d9ec043e497d6834648d1d79ffca21032cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Wed, 27 Apr 2022 22:07:57 +0200 Subject: [PATCH 12/63] Docs: Add link to Submission to navigation --- mkdocs.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mkdocs.yml b/mkdocs.yml index 6dae9a5..d985452 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,7 @@ nav: - General: checkers/index.md - checkers/python-library.md - checkers/go-library.md + - Submission: submission.md site_dir: docs_site markdown_extensions: From c6c58632b791d4f067fb8fe1d67abf6fed49e5e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 30 Apr 2022 13:45:37 +0200 Subject: [PATCH 13/63] Web: Add "Missing Checks" page for staff members Closes: https://github.com/fausecteam/ctf-gameserver/issues/55 Co-authored-by: Simon Ruderich --- .../web/scoring/templates/missing_checks.html | 56 ++++++++++++ src/ctf_gameserver/web/scoring/views.py | 90 +++++++++++++++++++ .../web/static/missing_checks.js | 65 ++++++++++++++ src/ctf_gameserver/web/static/scoreboard.js | 2 +- .../web/static/service_history.js | 58 +----------- src/ctf_gameserver/web/static/service_util.js | 65 ++++++++++++++ src/ctf_gameserver/web/static/style.css | 21 +++-- .../web/templates/base-common.html | 1 + src/ctf_gameserver/web/urls.py | 8 ++ 9 files changed, 302 insertions(+), 64 deletions(-) create mode 100644 src/ctf_gameserver/web/scoring/templates/missing_checks.html create mode 100644 src/ctf_gameserver/web/static/missing_checks.js diff --git a/src/ctf_gameserver/web/scoring/templates/missing_checks.html b/src/ctf_gameserver/web/scoring/templates/missing_checks.html new file mode 100644 index 0000000..abbcf1f --- /dev/null +++ b/src/ctf_gameserver/web/scoring/templates/missing_checks.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block content %} + + + + + + + +
+ + +
+ + + +
+ +
+
{% trans 'Min' %}
+ +
+
+
{% trans 'Max' %}
+ + + + +
+
+
+
+ +
+ +
+{% endblock %} diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index bc44696..ffa40a6 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -234,6 +234,7 @@ def service_history_json(request): .only('tick', 'status', 'team__user__id', 'team__net_number') \ .order_by('team__user__id', 'tick') + # Get teams separately to reduce size of "status_checks" result teams = registration_models.Team.active_objects.select_related('user').only('user__username').in_bulk() max_team_id = registration_models.Team.active_objects.aggregate(Max('user__id'))['user__id__max'] @@ -303,3 +304,92 @@ def _get_status_descriptions(): status_descriptions[-1] = 'not checked' return status_descriptions + + +@staff_member_required +def missing_checks(request): + + game_control = models.GameControl.get_instance() + # For the current tick, "not checked" can also mean "still scheduled or running", so it makes little + # sense to include it + max_tick = game_control.current_tick - 1 + min_tick = max(max_tick - 30, 0) + + return render(request, 'missing_checks.html', { + 'services': models.Service.objects.all().order_by('name'), + 'initial_min_tick': min_tick, + 'initial_max_tick': max_tick + }) + + +@staff_member_required +def missing_checks_json(request): + """ + View which returns the teams with status "not checked" per tick for a specific service. The result is in + JSON format as required by the JavaScript code in def missing_checks(). + This can help to find unhandled exceptions in checker scripts, as "not checked" normally shouldn't occur. + """ + + service_slug = request.GET.get('service') + if service_slug is None: + return JsonResponse({'error': 'Service must be specified'}, status=400) + + try: + service = models.Service.objects.get(slug=service_slug) + except models.Service.DoesNotExist: + return JsonResponse({'error': 'Unknown service'}, status=404) + + max_tick = models.GameControl.get_instance().current_tick - 1 + try: + from_tick = int(request.GET.get('from-tick', 0)) + to_tick = int(request.GET.get('to-tick', max_tick+1)) + except ValueError: + return JsonResponse({'error': 'Ticks must be integers'}) + + all_flags = models.Flag.objects.filter(service=service) \ + .filter(tick__gte=from_tick, tick__lt=to_tick) \ + .values_list('tick', 'protecting_team') + all_status_checks = models.StatusCheck.objects.filter(service=service) \ + .filter(tick__gte=from_tick, tick__lt=to_tick) \ + .values_list('tick', 'team') + checks_missing = all_flags.difference(all_status_checks).order_by('-tick', 'protecting_team') + + result = [] + current_tick = {'tick': -1} + + def append_result(): + nonlocal current_tick + + # First call of `append_result()` (before any checks have been processed) has no data to add + if current_tick['tick'] != -1: + result.append(current_tick) + + for check in checks_missing: + # Status checks are ordered by tick, finalize result for a tick when it changes + if current_tick['tick'] != check[0]: + append_result() + current_tick = {'tick': check[0], 'teams': []} + + current_tick['teams'].append(check[1]) + + # Add result from last iteration + append_result() + + teams = registration_models.Team.active_objects.select_related('user') \ + .only('pk', 'net_number', 'user__username') + teams_dict = {} + for team in teams: + teams_dict[team.pk] = {'name': team.user.username, 'net-number': team.net_number} + + response = { + 'checks': result, + 'all-teams': teams_dict, + 'min-tick': from_tick, + 'max-tick': to_tick-1, + 'service-name': service.name, + 'service-slug': service.slug + } + if hasattr(settings, 'GRAYLOG_SEARCH_URL'): + response['graylog-search-url'] = settings.GRAYLOG_SEARCH_URL + + return JsonResponse(response) diff --git a/src/ctf_gameserver/web/static/missing_checks.js b/src/ctf_gameserver/web/static/missing_checks.js new file mode 100644 index 0000000..26d8677 --- /dev/null +++ b/src/ctf_gameserver/web/static/missing_checks.js @@ -0,0 +1,65 @@ +/* jshint asi: true, sub: true, esversion: 6 */ + +'use strict' + + +$(document).ready(function() { + + setupDynamicContent('missing-checks.json', buildList) + +}) + + +function buildList(data) { + + $('#selected-service').text(data['service-name']) + $('#min-tick').val(data['min-tick']) + $('#max-tick').val(data['max-tick']) + + // Extract raw DOM element from jQuery object + let list = $('#check-list')[0] + + while (list.firstChild) { + list.removeChild(list.firstChild) + } + + for (const check of data['checks']) { + let tickEntry = document.createElement('li') + + let prefix = document.createElement('strong') + prefix.textContent = 'Tick ' + check['tick'] + ': ' + tickEntry.appendChild(prefix) + + for (let i = 0; i < check['teams'].length; i++) { + const teamID = check['teams'][i] + const teamName = data['all-teams'][teamID]['name'] + const teamNetNo = data['all-teams'][teamID]['net-number'] + + let teamEntry + if (data['graylog-search-url'] === undefined) { + teamEntry = document.createElement('span') + } else { + teamEntry = document.createElement('a') + teamEntry.href = encodeURI(data['graylog-search-url'] + + '?rangetype=relative&relative=28800&' + + 'q=service:' + data['service-slug'] + ' AND team:' + teamNetNo + + ' AND tick:' + check['tick']) + teamEntry.target = '_blank' + } + teamEntry.textContent = teamName + ' (' + teamNetNo + ')' + tickEntry.appendChild(teamEntry) + + if (i != check['teams'].length - 1) { + let separator = document.createElement('span') + separator.textContent = ', ' + tickEntry.appendChild(separator) + } + + } + + list.appendChild(tickEntry) + } + + list.hidden = false + +} diff --git a/src/ctf_gameserver/web/static/scoreboard.js b/src/ctf_gameserver/web/static/scoreboard.js index 869c75b..fbca811 100644 --- a/src/ctf_gameserver/web/static/scoreboard.js +++ b/src/ctf_gameserver/web/static/scoreboard.js @@ -1,4 +1,4 @@ -/* jshint asi: true, sub: true */ +/* jshint asi: true, sub: true, esversion: 6 */ 'use strict' diff --git a/src/ctf_gameserver/web/static/service_history.js b/src/ctf_gameserver/web/static/service_history.js index 43059f6..1da47d7 100644 --- a/src/ctf_gameserver/web/static/service_history.js +++ b/src/ctf_gameserver/web/static/service_history.js @@ -1,65 +1,13 @@ -/* jshint asi: true, sub: true */ +/* jshint asi: true, sub: true, esversion: 6 */ 'use strict' $(document).ready(function() { - $(window).bind('hashchange', function(e) { - loadTable() - }) - $('#min-tick').change(loadTable) - $('#max-tick').change(loadTable) - $('#refresh').click(loadTable) - $('#load-current').click(function(e) { - // Even though the current tick is contained in the JSON data, it might be outdated, so load the - // table without a "to-tick" - loadTable(e, true) - }) - - loadTable() -}) - - -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 - } - - const fromTick = parseInt($('#min-tick').val()) - const toTick = parseInt($('#max-tick').val()) + 1 - if (isNaN(fromTick) || isNaN(toTick)) { - return - } - - let params = {'service': serviceSlug, 'from-tick': fromTick} - if (!ignoreMaxTick) { - params['to-tick'] = toTick - } - $.getJSON('service-history.json', params, function(data) { - buildTable(data) - $('#load-spinner').attr('hidden', true) - makeFieldsEditable(true) - }) - -} + setupDynamicContent('service-history.json', buildTable) -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) - -} +}) function buildTable(data) { diff --git a/src/ctf_gameserver/web/static/service_util.js b/src/ctf_gameserver/web/static/service_util.js index a75f1ae..c231eeb 100644 --- a/src/ctf_gameserver/web/static/service_util.js +++ b/src/ctf_gameserver/web/static/service_util.js @@ -1,3 +1,5 @@ +/* jshint asi: true, sub: true, esversion: 6 */ + 'use strict' @@ -8,3 +10,66 @@ const statusClasses = { 3: 'warning', 4: 'info' } + + +function setupDynamicContent(jsonPath, buildFunc) { + + function load(_) { + loadDynamicContent(jsonPath, buildFunc) + } + + $(window).bind('hashchange', load) + $('#min-tick').change(load) + $('#max-tick').change(load) + $('#refresh').click(load) + $('#load-current').click(function(_) { + // Even though the current tick is contained in the JSON data, it might be outdated, so load the + // table without a "to-tick" + loadDynamicContent(jsonPath, buildFunc, true) + }) + + loadDynamicContent(jsonPath, buildFunc) + +} + + +function loadDynamicContent(jsonPath, buildFunc, 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 + } + + const fromTick = parseInt($('#min-tick').val()) + const toTick = parseInt($('#max-tick').val()) + 1 + if (isNaN(fromTick) || isNaN(toTick)) { + return + } + + let params = {'service': serviceSlug, 'from-tick': fromTick} + if (!ignoreMaxTick) { + params['to-tick'] = toTick + } + $.getJSON(jsonPath, params, function(data) { + buildFunc(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) + +} diff --git a/src/ctf_gameserver/web/static/style.css b/src/ctf_gameserver/web/static/style.css index a918352..e37e152 100644 --- a/src/ctf_gameserver/web/static/style.css +++ b/src/ctf_gameserver/web/static/style.css @@ -58,6 +58,14 @@ th.border-right, td.border-right { margin-top: 40px; } +img#load-spinner { + width: 1.7em; +} + +img#load-spinner, button#refresh { + margin-right: 20px; +} + #history-table td { padding-left: 2px; padding-right: 2px; @@ -68,14 +76,11 @@ th.border-right, td.border-right { display: block; } -img#load-spinner { - width: 1.7em; -} - -img#load-spinner, button#refresh { - margin-right: 20px; -} - #history-table td a:hover { text-decoration: none; } + +#check-list { + margin-top: 20px; + padding-left: 20px; +} diff --git a/src/ctf_gameserver/web/templates/base-common.html b/src/ctf_gameserver/web/templates/base-common.html index a684b1f..f276f14 100644 --- a/src/ctf_gameserver/web/templates/base-common.html +++ b/src/ctf_gameserver/web/templates/base-common.html @@ -96,6 +96,7 @@ {% if user.is_staff %}
  • {% trans 'Administration' %}
  • {% trans 'Service History' %}
  • +
  • {% trans 'Missing Checks' %}
  • {% trans 'Email Teams' %}
  • {% endif %}
  • {% trans 'Logout' %}
  • diff --git a/src/ctf_gameserver/web/urls.py b/src/ctf_gameserver/web/urls.py index 2245f11..19067c3 100644 --- a/src/ctf_gameserver/web/urls.py +++ b/src/ctf_gameserver/web/urls.py @@ -104,6 +104,14 @@ scoring_views.service_history_json, name='service_history_json' ), + url(r'^internal/missing-checks$', + scoring_views.missing_checks, + name='missing_checks' + ), + url(r'^internal/missing-checks\.json$', + scoring_views.missing_checks_json, + name='missing_checks.json' + ), url(r'^admin/', admin_site.urls), # Multiple seperate URL patterns have to be used to work around From 71c92e339f2bdc0d5913f4c1313db5b4bc53e443 Mon Sep 17 00:00:00 2001 From: nename0 <26363498+nename0@users.noreply.github.com> Date: Sat, 30 Apr 2022 09:25:51 +0200 Subject: [PATCH 14/63] go-checkerlib: make LoadState() more usable --- docs/checkers/go-library.md | 4 +++- go/checkerlib/lib.go | 28 ++++++++++++++-------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/checkers/go-library.md b/docs/checkers/go-library.md index 2b50ef9..57006e6 100644 --- a/docs/checkers/go-library.md +++ b/docs/checkers/go-library.md @@ -36,7 +36,9 @@ care of calling your methods, merging the results and submitting them to the Che ### Persistent State * `StoreState(key string, data interface{})`: Store data persistently across runs (serialized as JSON). -* `LoadState(key string) interface{}`: Retrieve data stored through `StoreState()`. + Data is stored per service and team with the given key as an additional identifier. +* `LoadState(key string, data interface{}) bool`: Retrieve data stored through `StoreState()` (deserialized into + `data`). Returns `true` if any state was found. ### Helper functions * `Dial(network, address string) (net.Conn, error)`: Calls `net.DialTimeout()` with an appropriate timeout. diff --git a/go/checkerlib/lib.go b/go/checkerlib/lib.go index 431d209..c3f5602 100644 --- a/go/checkerlib/lib.go +++ b/go/checkerlib/lib.go @@ -190,22 +190,22 @@ func StoreState(key string, data interface{}) { } } -// LoadState allows to retrieve data stored through StoreState. If no data -// exists for the given key (and the current service and team), nil is -// returned. -func LoadState(key string) interface{} { - var data string +// LoadState allows to retrieve data stored through StoreState by unmarshalling it into data. +// If no data exists for the given key (and the current service and team) +// false is returned. +func LoadState(key string, data interface{}) bool { + var dataJson string if ipc.in != nil { x := ipc.SendRecv("LOAD", key) if x == nil { - return nil + return false } - data = x.(string) + dataJson = x.(string) } else { x, err := ioutil.ReadFile(localStatePath) if err != nil { if os.IsNotExist(err) { - return nil + return false } panic(err) } @@ -217,23 +217,23 @@ func LoadState(key string) interface{} { } var ok bool - data, ok = state[key] + dataJson, ok = state[key] if !ok { - return nil + return false } } // Deserialize data - x, err := base64.StdEncoding.DecodeString(data) + x, err := base64.StdEncoding.DecodeString(dataJson) if err != nil { panic(err) } - var res interface{} - err = json.Unmarshal(x, &res) + + err = json.Unmarshal(x, data) if err != nil { panic(err) } - return res + return true } // RunCheck launches the execution of the specified Checker implementation. From 5bd6dd64a41d319a2e17597453972de554f4280a Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 30 Apr 2022 17:19:44 +0200 Subject: [PATCH 15/63] Examples: Update Go checker to use the latest checkerlib --- examples/checker/example_checker_go/go.mod | 2 +- examples/checker/example_checker_go/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/checker/example_checker_go/go.mod b/examples/checker/example_checker_go/go.mod index 90804b0..daf5fc2 100644 --- a/examples/checker/example_checker_go/go.mod +++ b/examples/checker/example_checker_go/go.mod @@ -1,3 +1,3 @@ module example_checker -require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220418102344-6e941055f119 +require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220430151133-71c92e339f2b diff --git a/examples/checker/example_checker_go/go.sum b/examples/checker/example_checker_go/go.sum index f0bb74e..04cf833 100644 --- a/examples/checker/example_checker_go/go.sum +++ b/examples/checker/example_checker_go/go.sum @@ -1,5 +1,5 @@ -github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220418102344-6e941055f119 h1:VXdxqaul0zNKAO1xOpKjhcbBPXLbqkbUMBSbNYWtRsQ= -github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220418102344-6e941055f119/go.mod h1:kn1Odhtr8wDBWd90gXD8AX2VXJYjtV6qk+TP+f/jz+s= +github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220430151133-71c92e339f2b h1:X4aEXqjiMPGoEU6IE326T5r/RZORoKRqwbmbo2NPXBQ= +github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220430151133-71c92e339f2b/go.mod h1:kn1Odhtr8wDBWd90gXD8AX2VXJYjtV6qk+TP+f/jz+s= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= From 6151d5c1869b96ee680752de4bef17739a2cc164 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 7 May 2022 11:09:23 +0200 Subject: [PATCH 16/63] Web: Drastic performance improvement to Service History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … by using ORM hints correctly. Co-authored-by: Simon Ruderich --- src/ctf_gameserver/web/scoring/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index ffa40a6..710c937 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -230,12 +230,13 @@ def service_history_json(request): status_checks = models.StatusCheck.objects.filter(service=service) \ .filter(tick__gte=from_tick, tick__lt=to_tick) \ - .select_related('team') \ + .select_related('team', 'team__user') \ .only('tick', 'status', 'team__user__id', 'team__net_number') \ .order_by('team__user__id', 'tick') # Get teams separately to reduce size of "status_checks" result - teams = registration_models.Team.active_objects.select_related('user').only('user__username').in_bulk() + teams = registration_models.Team.active_objects.select_related('user').only('net_number', + 'user__username').in_bulk() max_team_id = registration_models.Team.active_objects.aggregate(Max('user__id'))['user__id__max'] result = [] From 711ecbea8272dcb58ffdfb5f6e7ca890d0d07950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 7 May 2022 13:36:22 +0200 Subject: [PATCH 17/63] Web: DB query performance improvements for Registered Teams, Scoreboard and Service Status Co-authored-by: Simon Ruderich --- src/ctf_gameserver/web/registration/views.py | 2 +- .../web/scoring/calculations.py | 31 ++++++++++++++----- src/ctf_gameserver/web/scoring/views.py | 14 +++++---- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/ctf_gameserver/web/registration/views.py b/src/ctf_gameserver/web/registration/views.py index 496716e..c324093 100644 --- a/src/ctf_gameserver/web/registration/views.py +++ b/src/ctf_gameserver/web/registration/views.py @@ -23,7 +23,7 @@ class TeamList(ListView): - queryset = Team.active_not_nop_objects.order_by('user__username') + queryset = Team.active_not_nop_objects.select_related('user').order_by('user__username') context_object_name = 'teams' template_name = 'team_list.html' diff --git a/src/ctf_gameserver/web/scoring/calculations.py b/src/ctf_gameserver/web/scoring/calculations.py index 1c4d212..cc1307a 100644 --- a/src/ctf_gameserver/web/scoring/calculations.py +++ b/src/ctf_gameserver/web/scoring/calculations.py @@ -6,7 +6,7 @@ from . import models -def scores(): +def scores(select_related_fields=None, only_fields=None): """ Returns the scores as currently stored in the database as an OrderedDict in this format: @@ -18,8 +18,18 @@ def scores(): }} The result is sorted by the total points. + "select_related_fields" and "only_fields" can provide lists of fields for database query optimization + using Django's select_related() resp. only(). """ + if select_related_fields is None: + select_related_fields = [] + select_related_fields = list(set(select_related_fields + ['service', 'team'])) + if only_fields is None: + only_fields = [] + only_fields = list(set(only_fields + + ['attack', 'defense', 'sla', 'total', 'service__id', 'team__user__id'])) + # No good way to invalidate the cache, so use a generic key with a short timeout cache_key = 'scores' cached_scores = cache.get(cache_key) @@ -29,7 +39,7 @@ def scores(): team_scores = defaultdict(lambda: {'offense': [{}, 0], 'defense': [{}, 0], 'sla': [{}, 0], 'total': 0}) - for score in models.ScoreBoard.objects.select_related('team', 'service').all(): + for score in models.ScoreBoard.objects.select_related(*select_related_fields).only(*only_fields).all(): team_scores[score.team]['offense'][0][score.service] = score.attack team_scores[score.team]['offense'][1] += score.attack team_scores[score.team]['defense'][0][score.service] = score.defense @@ -44,7 +54,7 @@ def scores(): return sorted_team_scores -def team_statuses(from_tick, to_tick): +def team_statuses(from_tick, to_tick, select_related_team_fields=None, only_team_fields=None): """ Returns the statuses of all teams and all services in the specified range of ticks. The result is an OrderedDict sorted by the teams' names in this format: @@ -56,6 +66,8 @@ def team_statuses(from_tick, to_tick): }} If a check did not happen ("Not checked"), no status will be contained in the result. + The "select_related_team_fields" and "only_team_fields" parameters can provide lists of fields for + database query optimization using Django's select_related() resp. only() for queries on the "Team" model. """ cache_key = 'team-statuses_{:d}-{:d}'.format(from_tick, to_tick) @@ -67,14 +79,19 @@ def team_statuses(from_tick, to_tick): statuses = OrderedDict() teams = {} - for team in Team.active_objects.all().order_by('user__username'): + team_qset = Team.active_objects + if select_related_team_fields is not None: + team_qset = team_qset.select_related(*select_related_team_fields) + if only_team_fields is not None: + team_qset = team_qset.only(*only_team_fields) + + for team in team_qset.order_by('user__username').all(): statuses[team] = defaultdict(lambda: {}) teams[team.pk] = team - status_checks = models.StatusCheck.objects.filter(tick__gte=from_tick, tick__lte=to_tick)\ - .select_related('service', 'team') + status_checks = models.StatusCheck.objects.filter(tick__gte=from_tick, tick__lte=to_tick) for check in status_checks: - statuses[teams[check.team.pk]][check.tick][check.service] = check.status + statuses[teams[check.team_id]][check.tick][check.service_id] = check.status # Convert defaultdicts to dicts because serialization in `cache.set()` can't handle them otherwise for key, val in statuses.items(): diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index 710c937..361bdfd 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -33,8 +33,9 @@ def scoreboard_json(_): else: to_tick = game_control.current_tick - 1 - scores = calculations.scores() - statuses = calculations.team_statuses(to_tick, to_tick) + scores = calculations.scores(['team', 'team__user', 'service'], + ['team__image', 'team__user__username', 'service__name']) + statuses = calculations.team_statuses(to_tick, to_tick, only_team_fields=['user_id']) services = models.Service.objects.all() response = { @@ -68,7 +69,7 @@ def scoreboard_json(_): defense = 0 sla = 0 try: - status = statuses[team][to_tick][service] + status = statuses[team][to_tick][service.pk] except KeyError: status = '' team_entry['services'].append({ @@ -92,7 +93,7 @@ def scoreboard_json_ctftime(_): tasks = ['Offense', 'Defense', 'SLA'] standings = [] - scores = calculations.scores() + scores = calculations.scores(['team', 'team__user'], ['team__user__username']) for rank, (team, points) in enumerate(scores.items(), start=1): standings.append({ @@ -124,7 +125,8 @@ def service_status_json(_): to_tick = game_control.current_tick from_tick = max(to_tick - 4, 0) - statuses = calculations.team_statuses(from_tick, to_tick) + statuses = calculations.team_statuses(from_tick, to_tick, ['user'], ['image', 'nop_team', + 'user__username']) services = models.Service.objects.all().order_by('name') response = { @@ -149,7 +151,7 @@ def service_status_json(_): tick_services = [] for service in services: try: - tick_services.append(tick_statuses[tick][service]) + tick_services.append(tick_statuses[tick][service.pk]) except KeyError: tick_services.append('') team_entry['ticks'].append(tick_services) From e58fdee36504d64dd10770ea1a71819b8e112a96 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Mon, 6 Jun 2022 12:34:29 +0200 Subject: [PATCH 18/63] Submission: Don't fail during permission check if team doesn't exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Felix Dreißig --- src/ctf_gameserver/submission/database.py | 4 +++- src/ctf_gameserver/submission/submission.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ctf_gameserver/submission/database.py b/src/ctf_gameserver/submission/database.py index 4ad681e..263fdaf 100644 --- a/src/ctf_gameserver/submission/database.py +++ b/src/ctf_gameserver/submission/database.py @@ -53,7 +53,7 @@ def team_is_nop(db_conn, team_net_no): return result[0] -def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False): +def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False, fake_team_id=None): """ Stores a capture of the given flag by the given team in the database. """ @@ -62,6 +62,8 @@ def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False) cursor.execute('SELECT user_id FROM registration_team WHERE net_number = %s', (capturing_team_net_no,)) result = cursor.fetchone() + if fake_team_id is not None: + result = (fake_team_id,) if result is None: raise TeamNotExisting() capturing_team_id = result[0] diff --git a/src/ctf_gameserver/submission/submission.py b/src/ctf_gameserver/submission/submission.py index 76ec24b..8da6353 100644 --- a/src/ctf_gameserver/submission/submission.py +++ b/src/ctf_gameserver/submission/submission.py @@ -85,7 +85,7 @@ def main(): logging.warning('Invalid database state: %s', e) database.team_is_nop(db_conn, 1) - database.add_capture(db_conn, 2147483647, 1, prohibit_changes=True) + database.add_capture(db_conn, 2147483647, 1, prohibit_changes=True, fake_team_id=42) 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 From 40c11e66971aa6d70b6f6482b708a7b7ec27caf8 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Mon, 6 Jun 2022 15:21:29 +0200 Subject: [PATCH 19/63] Checker: Replace old class graypy.GELFHandler with GELFUDPHandler graypy.GELFHandler was removed after version 0.9.x and is no longer available. --- src/ctf_gameserver/checker/supervisor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/checker/supervisor.py b/src/ctf_gameserver/checker/supervisor.py index 1e33b5e..f51969f 100644 --- a/src/ctf_gameserver/checker/supervisor.py +++ b/src/ctf_gameserver/checker/supervisor.py @@ -194,7 +194,7 @@ def filter(self, record): # Work-around for missing IPv6 support in Python's # logging.handlers.DatagramHandler (https://bugs.python.org/issue14855) - class GELFHandler(graypy.GELFHandler): + class GELFHandler(graypy.GELFUDPHandler): # pylint: disable=invalid-name def makeSocket(self): return socket.socket(logging_params['gelf']['family'], socket.SOCK_DGRAM) From cb2f053cb44af950281c4abd811552c966bd14b3 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 11 Jun 2022 11:02:10 +0200 Subject: [PATCH 20/63] Checker: Use flag of requested tick instead of current tick --- src/ctf_gameserver/checker/database.py | 14 +++++++++++- src/ctf_gameserver/checker/master.py | 8 ++++--- tests/checker/test_master.py | 31 +++++++++++++++++--------- 3 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/ctf_gameserver/checker/database.py b/src/ctf_gameserver/checker/database.py index 5e9a446..885184c 100644 --- a/src/ctf_gameserver/checker/database.py +++ b/src/ctf_gameserver/checker/database.py @@ -119,13 +119,25 @@ def get_new_tasks(db_conn, service_id, task_count, prohibit_changes=False): ' WHERE id = %s', [(task[0],) for task in tasks]) return [{ - 'flag': task[0], 'team_id': task[1], 'team_net_no': task[3], 'tick': task[2] } for task in tasks] +def get_flag_id(db_conn, service_id, team_id, tick, prohibit_changes=False, fake_flag_id=None): + + with transaction_cursor(db_conn, prohibit_changes) as cursor: + cursor.execute('SELECT id FROM scoring_flag' + ' WHERE tick = %s' + ' AND service_id = %s' + ' AND protecting_team_id = %s', (tick, service_id, team_id)) + data = cursor.fetchone() + if fake_flag_id is not None: + data = (fake_flag_id,) + return data[0] + + 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,)) diff --git a/src/ctf_gameserver/checker/master.py b/src/ctf_gameserver/checker/master.py index 668ec80..691ad04 100644 --- a/src/ctf_gameserver/checker/master.py +++ b/src/ctf_gameserver/checker/master.py @@ -153,6 +153,7 @@ def main(): database.get_task_count(db_conn, service_id, prohibit_changes=True) database.get_new_tasks(db_conn, service_id, 1, prohibit_changes=True) + database.get_flag_id(db_conn, service_id, 1, 1, prohibit_changes=True, fake_flag_id=42) 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) @@ -292,8 +293,10 @@ def handle_flag_request(self, task_info, params): # We need current value for self.contest_start which might have changed self.refresh_control_info() + flag_id = database.get_flag_id(self.db_conn, self.service['id'], task_info['_team_id'], tick) + expiration = self.contest_start + (self.flag_valid_ticks + tick) * self.tick_duration - return flag_lib.generate(expiration, task_info['flag'], task_info['team'], self.flag_secret, + return flag_lib.generate(expiration, flag_id, task_info['team'], self.flag_secret, self.flag_prefix) def handle_flagid_request(self, task_info, param): @@ -354,8 +357,7 @@ def change_tick(new_tick): # Information in task_info should be somewhat human-readable, because it also ends up in Checker # Script logs - task_info = {'flag': task['flag'], - 'service': self.service['slug'], + task_info = {'service': self.service['slug'], 'team': task['team_net_no'], '_team_id': task['team_id'], 'tick': task['tick']} diff --git a/tests/checker/test_master.py b/tests/checker/test_master.py index d955c88..88995f3 100644 --- a/tests/checker/test_master.py +++ b/tests/checker/test_master.py @@ -5,6 +5,7 @@ from ctf_gameserver.checker.metrics import DummyQueue from ctf_gameserver.lib.checkresult import CheckResult from ctf_gameserver.lib.database import transaction_cursor +from ctf_gameserver.lib.flag import verify from ctf_gameserver.lib.test_util import DatabaseTestCase @@ -13,36 +14,49 @@ class MasterTest(DatabaseTestCase): fixtures = ['tests/checker/fixtures/master.json'] def setUp(self): + self.secret = b'secret' self.master_loop = MasterLoop(self.connection, 'service1', '/dev/null', None, 2, 8, 10, '0.0.%s.1', - b'secret', {}, DummyQueue()) + self.secret, {}, DummyQueue()) def test_handle_flag_request(self): with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=NOW()') task_info = { - 'flag': 1, 'service': 'service1', '_team_id': 2, 'team': 92, - 'tick': 1 + 'tick': 2 } - params1 = {'tick': 1} + params1 = {'tick': 2} resp1 = self.master_loop.handle_flag_request(task_info, params1) - params2 = {'tick': 1} + flag_id1, team1 = verify(resp1, self.secret) + + params2 = {'tick': 2} resp2 = self.master_loop.handle_flag_request(task_info, params2) + flag_id2, team2 = verify(resp2, self.secret) # "params3" and "resp3" don't exist anymore self.assertEqual(resp1, resp2) + self.assertEqual(flag_id1, 2) + self.assertEqual(team1, 92) + self.assertEqual(flag_id2, 2) + self.assertEqual(team2, 92) - params4 = {'tick': 2} + params4 = {'tick': 1} resp4 = self.master_loop.handle_flag_request(task_info, params4) - params5 = {'tick': 2} + flag_id4, team4 = verify(resp4, self.secret) + params5 = {'tick': 1} resp5 = self.master_loop.handle_flag_request(task_info, params5) + flag_id5, team5 = verify(resp5, self.secret) self.assertEqual(resp4, resp5) self.assertNotEqual(resp1, resp4) + self.assertEqual(flag_id4, 1) + self.assertEqual(team4, 92) + self.assertEqual(flag_id5, 1) + self.assertEqual(team5, 92) params6 = {} self.assertIsNone(self.master_loop.handle_flag_request(task_info, params6)) @@ -58,7 +72,6 @@ def test_handle_flag_request(self): def test_handle_result_request(self): task_info = { - 'flag': 1, 'service': 'service1', '_team_id': 2, 'team': 92, @@ -77,7 +90,6 @@ def test_handle_result_request(self): ' WHERE service_id = 1 AND protecting_team_id = 2 AND tick = 1') self.assertGreaterEqual(cursor.fetchone()[0], start_time) - task_info['flag'] = 2 task_info['tick'] = 2 param = CheckResult.FAULTY.value start_time = datetime.datetime.utcnow().replace(microsecond=0) @@ -90,7 +102,6 @@ def test_handle_result_request(self): ' WHERE service_id = 1 AND protecting_team_id = 2 AND tick = 2') self.assertGreaterEqual(cursor.fetchone()[0], start_time) - task_info['flag'] = 3 task_info['tick'] = 3 param = 'Not an int' self.assertIsNone(self.master_loop.handle_result_request(task_info, param)) From c58fcae85aeb4bf3af44cd32ed1184bf0a1379df Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 18 Jun 2022 15:51:21 +0200 Subject: [PATCH 21/63] Web: Remove leftover "load cache" in templates Forgotten in c4d3c12 (Web: Convert scoreboard and service status to client-side rendering, 2020-06-26) Co-authored-by: Felix Dreissig Co-authored-by: Fabian Fleischer --- src/ctf_gameserver/web/scoring/templates/scoreboard.html | 1 - src/ctf_gameserver/web/scoring/templates/service_status.html | 1 - 2 files changed, 2 deletions(-) diff --git a/src/ctf_gameserver/web/scoring/templates/scoreboard.html b/src/ctf_gameserver/web/scoring/templates/scoreboard.html index 66005f0..b6cd7ca 100644 --- a/src/ctf_gameserver/web/scoring/templates/scoreboard.html +++ b/src/ctf_gameserver/web/scoring/templates/scoreboard.html @@ -1,6 +1,5 @@ {% extends 'base-wide.html' %} {% load i18n %} -{% load cache %} {% load dict_access %} {% load status_css_class %} {% load static %} diff --git a/src/ctf_gameserver/web/scoring/templates/service_status.html b/src/ctf_gameserver/web/scoring/templates/service_status.html index 6bc5d57..3661aa4 100644 --- a/src/ctf_gameserver/web/scoring/templates/service_status.html +++ b/src/ctf_gameserver/web/scoring/templates/service_status.html @@ -1,6 +1,5 @@ {% extends 'base.html' %} {% load i18n %} -{% load cache %} {% load dict_access %} {% load status_css_class %} {% load static %} From 4e9b6ab214b973169fd36b20e9ebc30568ded152 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sun, 19 Jun 2022 00:31:01 +0200 Subject: [PATCH 22/63] Submission: Handle connection reset errors more gracefully Co-authored-by: Felix Dreissig Co-authored-by: Fabian Fleischer --- src/ctf_gameserver/submission/submission.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ctf_gameserver/submission/submission.py b/src/ctf_gameserver/submission/submission.py index 8da6353..d69b0b3 100644 --- a/src/ctf_gameserver/submission/submission.py +++ b/src/ctf_gameserver/submission/submission.py @@ -165,6 +165,7 @@ async def serve(host, port, db_conn, params): async def wrapper(reader, writer): metrics = params['metrics'] + client_addr = writer.get_extra_info('peername')[0] try: await handle_connection(reader, writer, db_conn, params) @@ -173,8 +174,11 @@ async def wrapper(reader, writer): metrics['server_kills'].inc() # pylint: disable=protected-access os._exit(os.EX_IOERR) + except ConnectionError: + logging.warning('[%s]: Client connection error, closing the connection', client_addr) + writer.close() except: # noqa, pylint: disable=bare-except - logging.exception('Exception in client connection, closing the connection:') + logging.exception('[%s]: Exception in client connection, closing the connection:', client_addr) metrics['unhandled_exceptions'].inc() writer.close() @@ -196,7 +200,7 @@ async def handle_connection(reader, writer, db_conn, params): try: client_net_no = _match_net_number(params['team_regex'], client_addr) except ValueError: - logging.error('Could not match client address %s with team, closing the connection', client_addr) + logging.error('[%s]: Could not match client address with team, closing the connection', client_addr) metrics['connections'].labels(-1).inc() writer.write(b'Error: Could not match your IP address with a team\n') writer.close() From 14fe9e3fd965feedb84f012f2a463075774fe257 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sun, 19 Jun 2022 00:47:06 +0200 Subject: [PATCH 23/63] Submission: Fix startup before contest start Current tick is -1 before the contest which violates a check constraint on scoring_capture. Co-authored-by: Felix Dreissig Co-authored-by: Fabian Fleischer --- src/ctf_gameserver/submission/database.py | 12 +++++++++--- src/ctf_gameserver/submission/submission.py | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/ctf_gameserver/submission/database.py b/src/ctf_gameserver/submission/database.py index 263fdaf..f4e54a8 100644 --- a/src/ctf_gameserver/submission/database.py +++ b/src/ctf_gameserver/submission/database.py @@ -53,7 +53,8 @@ def team_is_nop(db_conn, team_net_no): return result[0] -def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False, fake_team_id=None): +def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False, fake_team_id=None, + fake_tick=None): """ Stores a capture of the given flag by the given team in the database. """ @@ -68,10 +69,15 @@ def add_capture(db_conn, flag_id, capturing_team_net_no, prohibit_changes=False, raise TeamNotExisting() capturing_team_id = result[0] + cursor.execute('SELECT current_tick FROM scoring_gamecontrol') + result = cursor.fetchone() + if fake_tick is not None: + result = (fake_tick,) + tick = result[0] + try: cursor.execute('INSERT INTO scoring_capture (flag_id, capturing_team_id, timestamp, tick)' - ' VALUES (%s, %s, NOW(), (SELECT current_tick FROM scoring_gamecontrol))', - (flag_id, capturing_team_id)) + ' VALUES (%s, %s, NOW(), %s)', (flag_id, capturing_team_id, tick)) except (UniqueViolation, sqlite3.IntegrityError): raise DuplicateCapture() from None diff --git a/src/ctf_gameserver/submission/submission.py b/src/ctf_gameserver/submission/submission.py index d69b0b3..06fdd68 100644 --- a/src/ctf_gameserver/submission/submission.py +++ b/src/ctf_gameserver/submission/submission.py @@ -85,7 +85,7 @@ def main(): logging.warning('Invalid database state: %s', e) database.team_is_nop(db_conn, 1) - database.add_capture(db_conn, 2147483647, 1, prohibit_changes=True, fake_team_id=42) + database.add_capture(db_conn, 2147483647, 1, prohibit_changes=True, fake_team_id=42, fake_tick=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 From 35b3d6485485fcaadd5cc8fd9c4cb58231dcd1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Wed, 22 Jun 2022 23:40:50 +0200 Subject: [PATCH 24/63] Controller, checker: Pre-declare possible metric label values This should make the labels exposed even while they have no data. Because of this necessity, remove the theoretical possibility to add more labels in loops. Co-authored-by: Simon Ruderich --- src/ctf_gameserver/checker/metrics.py | 54 ++++++++++++--------- src/ctf_gameserver/controller/controller.py | 14 +++--- tests/checker/test_metrics.py | 9 ++-- 3 files changed, 45 insertions(+), 32 deletions(-) diff --git a/src/ctf_gameserver/checker/metrics.py b/src/ctf_gameserver/checker/metrics.py index 1c3e3c2..467db20 100644 --- a/src/ctf_gameserver/checker/metrics.py +++ b/src/ctf_gameserver/checker/metrics.py @@ -4,6 +4,7 @@ import prometheus_client +from ctf_gameserver.lib.checkresult import CheckResult from ctf_gameserver.lib.metrics import SilentHandler @@ -50,40 +51,49 @@ class HTTPGenMessage: """ -def checker_metrics_factory(registry): +def checker_metrics_factory(registry, service): metrics = {} metric_prefix = 'ctf_checkermaster_' counters = [ - ('started_tasks', 'Number of started Checker Script instances', []), - ('completed_tasks', 'Number of successfully completed checks', ['result']), - ('terminated_tasks', 'Number of Checker Script instances forcibly terminated', []) + ('started_tasks', 'Number of started Checker Script instances'), + ('terminated_tasks', 'Number of Checker Script instances forcibly terminated') ] - for name, doc, labels in counters: - metrics[name] = prometheus_client.Counter(metric_prefix+name, doc, labels+['service'], - registry=registry) + for name, doc in counters: + metrics[name] = prometheus_client.Counter(metric_prefix+name, doc, ['service'], registry=registry) + # Pre-declare possible label value + metrics[name].labels(service) + + metrics['completed_tasks'] = prometheus_client.Counter( + metric_prefix+'completed_tasks', 'Number of successfully completed checks', ['result', 'service'], + registry=registry + ) + for result in CheckResult: + # Pre-declare possible label value combinations + metrics['completed_tasks'].labels(result.name, service) gauges = [ - ('start_timestamp', '(Unix timestamp when the process was started', []), - ('interval_length_seconds', 'Configured launch interval length', []), - ('last_launch_timestamp', '(Unix) timestamp when tasks were launched the last time', []), - ('tasks_per_launch_count', 'Number of checks to start in one launch interval', []), - ('max_task_duration_seconds', 'Currently estimated maximum runtime of one check', []) + ('start_timestamp', '(Unix timestamp when the process was started'), + ('interval_length_seconds', 'Configured launch interval length'), + ('last_launch_timestamp', '(Unix) timestamp when tasks were launched the last time'), + ('tasks_per_launch_count', 'Number of checks to start in one launch interval'), + ('max_task_duration_seconds', 'Currently estimated maximum runtime of one check') ] - for name, doc, labels in gauges: - metrics[name] = prometheus_client.Gauge(metric_prefix+name, doc, labels+['service'], - registry=registry) + for name, doc in gauges: + metrics[name] = prometheus_client.Gauge(metric_prefix+name, doc, ['service'], registry=registry) + metrics[name].labels(service) histograms = [ - ('task_launch_delay_seconds', 'Differences between supposed and actual task launch times', [], + ('task_launch_delay_seconds', 'Differences between supposed and actual task launch times', (0.01, 0.03, 0.05, 0.1, 0.3, 0.5, 1, 3, 5, 10, 30, 60, float('inf'))), - ('script_duration_seconds', 'Observed runtimes of Checker Scripts', [], + ('script_duration_seconds', 'Observed runtimes of Checker Scripts', (1, 3, 5, 8, 10, 20, 30, 45, 60, 90, 120, 150, 180, 240, 300, float('inf'))) ] - for name, doc, labels, buckets in histograms: - metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, labels+['service'], - buckets=buckets, registry=registry) + for name, doc, buckets in histograms: + metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, ['service'], buckets=buckets, + registry=registry) + metrics[name].labels(service) return metrics @@ -96,14 +106,14 @@ def run_collector(service, metrics_factory, in_queue, pipe_to_server): Args: service: Slug of this checker instance's service. - metrics_factory: Callable returning a dict of the mtrics to use mapping from name to Metric object. + metrics_factory: Callable returning a dict of the metrics, mapping from name to Metric object. in_queue: Queue over which MetricsMessages and HTTPGenMessages are received. pipe_to_server: Pipe to which text representations of the metrics are sent in response to HTTPGenMessages. """ registry = prometheus_client.CollectorRegistry() - metrics = metrics_factory(registry) + metrics = metrics_factory(registry, service) def handle_metrics_message(msg): try: diff --git a/src/ctf_gameserver/controller/controller.py b/src/ctf_gameserver/controller/controller.py index 86c2e09..b1b8c93 100644 --- a/src/ctf_gameserver/controller/controller.py +++ b/src/ctf_gameserver/controller/controller.py @@ -82,18 +82,18 @@ def make_metrics(db_conn, registry=prometheus_client.REGISTRY): metric_prefix = 'ctf_controller_' gauges = [ - ('start_timestamp', '(Unix) timestamp when the process was started', []), - ('current_tick', 'The current tick', []) + ('start_timestamp', '(Unix) timestamp when the process was started'), + ('current_tick', 'The current tick') ] - for name, doc, labels in gauges: - metrics[name] = prometheus_client.Gauge(metric_prefix+name, doc, labels, registry=registry) + for name, doc in gauges: + metrics[name] = prometheus_client.Gauge(metric_prefix+name, doc, registry=registry) histograms = [ - ('tick_change_delay_seconds', 'Differences between supposed and actual tick change times', [], + ('tick_change_delay_seconds', 'Differences between supposed and actual tick change times', (1, 3, 5, 10, 30, 60, float('inf'))) ] - for name, doc, labels, buckets in histograms: - metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, labels, buckets=buckets, + for name, doc, buckets in histograms: + metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, buckets=buckets, registry=registry) class DatabaseCollector: diff --git a/tests/checker/test_metrics.py b/tests/checker/test_metrics.py index c19c98a..2757662 100644 --- a/tests/checker/test_metrics.py +++ b/tests/checker/test_metrics.py @@ -24,13 +24,16 @@ class MetricsTest(TestCase): metrics_url = 'http://127.0.0.1:9002/metrics' def setUp(self): - def metrics_factory(registry): + def metrics_factory(registry, service): + service_gauge = prometheus_client.Gauge('service_gauge', 'Gauge with "service" label', + ['service'], registry=registry) + service_gauge.labels(service) + return { 'plain_gauge': prometheus_client.Gauge('plain_gauge', 'Simple gauge', registry=registry), 'instance_gauge': prometheus_client.Gauge('instance_gauge', 'Gauge with custom label', ['instance'], registry=registry), - 'service_gauge': prometheus_client.Gauge('service_gauge', 'Gauge with "service" label', - ['service'], registry=registry), + 'service_gauge': service_gauge, 'counter': prometheus_client.Counter('counter', 'Simple counter', registry=registry), 'summary': prometheus_client.Summary('summary', 'Simple summary', registry=registry), 'histogram': prometheus_client.Histogram('histogram', 'Histogram with custom and "service" ' From 6876ba3c7f97c8fcbf7a6879c22574eca6c540ab Mon Sep 17 00:00:00 2001 From: nename0 <26363498+nename0@users.noreply.github.com> Date: Sat, 25 Jun 2022 13:28:17 +0200 Subject: [PATCH 25/63] fix attack scoring Without this - `bonus` always is an integer. Which we do not want. e.g two teams capturing a flag must be bonus 0.5. - `bonus` column is completely hidden from scoreboard. Which makes scoring super intransparent for teams --- doc/controller/scoring.sql | 2 +- src/ctf_gameserver/controller/database.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/controller/scoring.sql b/doc/controller/scoring.sql index 80be9c4..673adb7 100644 --- a/doc/controller/scoring.sql +++ b/doc/controller/scoring.sql @@ -64,7 +64,7 @@ WITH ) SELECT team_id, service_id, - coalesce(attack, 0)::double precision as attack, + (coalesce(attack, 0)+coalesce(bonus, 0))::double precision as attack, coalesce(bonus, 0) as bonus, coalesce(defense, 0)::double precision as defense, coalesce(sla, 0) as sla, diff --git a/src/ctf_gameserver/controller/database.py b/src/ctf_gameserver/controller/database.py index ab7af3f..eb88e98 100644 --- a/src/ctf_gameserver/controller/database.py +++ b/src/ctf_gameserver/controller/database.py @@ -40,7 +40,7 @@ def update_scoring(db_conn): with transaction_cursor(db_conn) as cursor: cursor.execute('UPDATE scoring_flag as outerflag' - ' SET bonus = 1 / (' + ' SET bonus = 1.0 / (' ' SELECT greatest(1, count(*))' ' FROM scoring_flag' ' LEFT OUTER JOIN scoring_capture ON scoring_capture.flag_id = scoring_flag.id' From 56f7d74b2fbbbe5b8fa482d61316cea6b68a3792 Mon Sep 17 00:00:00 2001 From: nename0 <26363498+nename0@users.noreply.github.com> Date: Fri, 1 Jul 2022 07:31:52 +0200 Subject: [PATCH 26/63] fix race condition where tick could increase too much If just 'now < end' then later sleep_seconds cloud be 0 and thus the tick is increased one too much. --- src/ctf_gameserver/controller/controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/controller/controller.py b/src/ctf_gameserver/controller/controller.py index b1b8c93..d8fe3e7 100644 --- a/src/ctf_gameserver/controller/controller.py +++ b/src/ctf_gameserver/controller/controller.py @@ -188,7 +188,7 @@ def sleep(duration): return # Check if we really need to increase the tick because of the capping to 60 seconds from above - if get_sleep_seconds(control_info, metrics) <= 0: + if get_sleep_seconds(control_info, metrics, now) <= 0: logging.info('After tick %d, increasing tick to the next one', control_info['current_tick']) database.increase_tick(db_conn) database.update_scoring(db_conn) From da0d9c92df5e7bc489fcec880f42540d7a6a4659 Mon Sep 17 00:00:00 2001 From: nename0 <26363498+nename0@users.noreply.github.com> Date: Fri, 1 Jul 2022 13:04:55 +0200 Subject: [PATCH 27/63] eagerly update flag bonus and update scoreboard on game end Fix #71 - Update the flag bonus not just when the flag has expired but as long as it is not expired (5 ticks). This way the bonus of a capture does not need 5 ticks to show up one the scoreboard. Note however that this way the bonus (and thus the attack points) might decrease when other teams capture the same flag in later ticks - call update_scoring when game has ended to include last tick in scoreboard --- src/ctf_gameserver/controller/controller.py | 3 +++ src/ctf_gameserver/controller/database.py | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/ctf_gameserver/controller/controller.py b/src/ctf_gameserver/controller/controller.py index d8fe3e7..2bc7880 100644 --- a/src/ctf_gameserver/controller/controller.py +++ b/src/ctf_gameserver/controller/controller.py @@ -181,6 +181,9 @@ def sleep(duration): return if (not nonstop) and (now >= control_info['end']): + # Update scoring for last tick of game + database.update_scoring(db_conn) + # 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/controller/database.py b/src/ctf_gameserver/controller/database.py index eb88e98..ec06002 100644 --- a/src/ctf_gameserver/controller/database.py +++ b/src/ctf_gameserver/controller/database.py @@ -46,8 +46,9 @@ def update_scoring(db_conn): ' LEFT OUTER JOIN scoring_capture ON scoring_capture.flag_id = scoring_flag.id' ' WHERE scoring_capture.flag_id = outerflag.id)' ' FROM scoring_gamecontrol' - ' WHERE outerflag.tick + scoring_gamecontrol.valid_ticks < ' - ' scoring_gamecontrol.current_tick AND outerflag.bonus IS NULL') + ' WHERE outerflag.tick >=' + ' scoring_gamecontrol.current_tick - scoring_gamecontrol.valid_ticks' + ' OR outerflag.bonus IS NULL') cursor.execute('REFRESH MATERIALIZED VIEW "scoring_scoreboard"') From fa5816ef1c89ad9a63578e19c06137dbd623d664 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Thu, 7 Jul 2022 23:18:58 +0200 Subject: [PATCH 28/63] Web: Sort team list in teams.json to prevent leaks Closes: https://github.com/fausecteam/ctf-gameserver/issues/74 --- src/ctf_gameserver/web/scoring/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index 361bdfd..8e26924 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -186,7 +186,7 @@ def teams_json(_): flag_ids[flag.service.name][flag.protecting_team.net_number].append(flag.flagid) response = { - 'teams': list(teams), + 'teams': sorted(list(teams)), 'flag_ids': flag_ids } From d1dfb3000ddf9705065981f9e58b4f08d2b0d5dc Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 18 Feb 2023 09:07:07 +0100 Subject: [PATCH 29/63] Checker: Go: update dependecies Dependabot reported security vulnerabilities in golang.org/x/crypto which don't affect us. --- go/checkerlib/go.mod | 9 +++++---- go/checkerlib/go.sum | 14 ++++---------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/go/checkerlib/go.mod b/go/checkerlib/go.mod index b61bea5..4ca164f 100644 --- a/go/checkerlib/go.mod +++ b/go/checkerlib/go.mod @@ -1,6 +1,7 @@ module github.com/fausecteam/ctf-gameserver/go/checkerlib -require ( - golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 - golang.org/x/sys v0.0.0-20200610111108-226ff32320da // indirect -) +go 1.19 + +require golang.org/x/crypto v0.6.0 + +require golang.org/x/sys v0.5.0 // indirect diff --git a/go/checkerlib/go.sum b/go/checkerlib/go.sum index c6a9484..1ee4ca9 100644 --- a/go/checkerlib/go.sum +++ b/go/checkerlib/go.sum @@ -1,10 +1,4 @@ -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= -golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200610111108-226ff32320da h1:bGb80FudwxpeucJUjPYJXuJ8Hk91vNtfvrymzwiei38= -golang.org/x/sys v0.0.0-20200610111108-226ff32320da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From ed1c4381f7192bd0f60c98978bf5e654a7ee00d6 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 18 Feb 2023 09:11:57 +0100 Subject: [PATCH 30/63] Examples: Update Go checker to use the latest checkerlib --- examples/checker/example_checker_go/go.mod | 9 ++++++++- examples/checker/example_checker_go/go.sum | 17 ++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/examples/checker/example_checker_go/go.mod b/examples/checker/example_checker_go/go.mod index daf5fc2..c0091c4 100644 --- a/examples/checker/example_checker_go/go.mod +++ b/examples/checker/example_checker_go/go.mod @@ -1,3 +1,10 @@ module example_checker -require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220430151133-71c92e339f2b +go 1.19 + +require github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20230218080707-d1dfb3000ddf + +require ( + golang.org/x/crypto v0.6.0 // indirect + golang.org/x/sys v0.5.0 // indirect +) diff --git a/examples/checker/example_checker_go/go.sum b/examples/checker/example_checker_go/go.sum index 04cf833..ac384ff 100644 --- a/examples/checker/example_checker_go/go.sum +++ b/examples/checker/example_checker_go/go.sum @@ -1,11 +1,6 @@ -github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220430151133-71c92e339f2b h1:X4aEXqjiMPGoEU6IE326T5r/RZORoKRqwbmbo2NPXBQ= -github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20220430151133-71c92e339f2b/go.mod h1:kn1Odhtr8wDBWd90gXD8AX2VXJYjtV6qk+TP+f/jz+s= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9 h1:vEg9joUBmeBcK9iSJftGNf3coIG4HqZElCPehJsfAYM= -golang.org/x/crypto v0.0.0-20200604202706-70a84ac30bf9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200610111108-226ff32320da h1:bGb80FudwxpeucJUjPYJXuJ8Hk91vNtfvrymzwiei38= -golang.org/x/sys v0.0.0-20200610111108-226ff32320da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20230218080707-d1dfb3000ddf h1:F5ijjN6Wg9DVtJ/hKVcKoOnNgsaHtsqc2Adqk/NSTUE= +github.com/fausecteam/ctf-gameserver/go/checkerlib v0.0.0-20230218080707-d1dfb3000ddf/go.mod h1:Hb+eKFbWT/6ELb4Qokkr5Pw6jaihL13lfCaWPsgTdBg= +golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= From dabfb8c38a23b6442520ff5b8b424751226c6845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 18 Feb 2023 10:57:46 +0100 Subject: [PATCH 31/63] Fix/disable linting errors for new Pylint version --- src/ctf_gameserver/web/dev_settings.py | 2 +- src/pylintrc | 3 ++- tests/checker/test_metrics.py | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ctf_gameserver/web/dev_settings.py b/src/ctf_gameserver/web/dev_settings.py index f2b3d0d..6f0e9ea 100644 --- a/src/ctf_gameserver/web/dev_settings.py +++ b/src/ctf_gameserver/web/dev_settings.py @@ -44,6 +44,6 @@ DEBUG = True -INTERNAL_IPS = ('127.0.0.1') +INTERNAL_IPS = ['127.0.0.1'] GRAYLOG_SEARCH_URL = 'http://localhost:9000/search' diff --git a/src/pylintrc b/src/pylintrc index 1e450e8..64a307c 100644 --- a/src/pylintrc +++ b/src/pylintrc @@ -17,8 +17,9 @@ reports = no # * "Similar lines" messages # * "Consider using 'with'" messages # * Advices to use f-strings instead of `format()` +# * Advices to not raise bare Exceptions # * Messages about locally disabled messages -disable = design, too-many-nested-blocks, fixme, global-statement, missing-docstring, no-else-return, no-self-use, duplicate-code, consider-using-with, consider-using-f-string, locally-disabled +disable = design, too-many-nested-blocks, fixme, global-statement, missing-docstring, no-else-return, duplicate-code, consider-using-with, consider-using-f-string, broad-exception-raised, locally-disabled # Variable names which would generally be invalid, but are accepted anyway good-names = e, f, fd, i, ip, j, k, _ diff --git a/tests/checker/test_metrics.py b/tests/checker/test_metrics.py index 2757662..9ceca7d 100644 --- a/tests/checker/test_metrics.py +++ b/tests/checker/test_metrics.py @@ -1,3 +1,5 @@ +# pylint: disable=missing-timeout + import multiprocessing import socket import time From 035683a8fffe02bc1fd57e60c7928cdece82b0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 18 Feb 2023 10:58:55 +0100 Subject: [PATCH 32/63] Makefile: Add "run_web" target --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 77667cd..43c3f28 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ EXT_DIR ?= $(WEB_DIR)/static/ext DEV_MANAGE ?= src/dev_manage.py TESTS_DIR ?= tests -.PHONY: dev build ext migrations test lint clean +.PHONY: dev build ext migrations run_web test lint clean .INTERMEDIATE: bootstrap.zip dev: $(WEB_DIR)/dev-db.sqlite3 ext @@ -36,6 +36,9 @@ $(WEB_DIR)/registration/countries.csv: curl https://raw.githubusercontent.com/datasets/country-list/master/data.csv -o $@ +run_web: + $(DEV_MANAGE) runserver + test: pytest --cov $(SOURCE_DIR) $(TESTS_DIR) From 9cb7caa53a19e2a73ea5a2703fd068a3fe80dbee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 18 Feb 2023 11:00:30 +0100 Subject: [PATCH 33/63] Add VS Code devcontainer config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit … and support non-interactive use of `make dev`. --- .devcontainer.json | 6 ++++++ Makefile | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .devcontainer.json diff --git a/.devcontainer.json b/.devcontainer.json new file mode 100644 index 0000000..8335b90 --- /dev/null +++ b/.devcontainer.json @@ -0,0 +1,6 @@ +{ + "image": "python:3.9-alpine", + "updateContentCommand": "apk --no-cache add git curl build-base jpeg-dev zlib-dev", + "postCreateCommand": "pip3 install --editable .[dev] && make dev", + "extensions": ["ms-python.python"] +} diff --git a/Makefile b/Makefile index 43c3f28..ed70775 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ migrations: $(WEB_DIR)/registration/countries.csv $(WEB_DIR)/dev-db.sqlite3: migrations $(WEB_DIR)/registration/countries.csv $(DEV_MANAGE) migrate - $(DEV_MANAGE) createsuperuser --username admin --email '' + DJANGO_SUPERUSER_PASSWORD=password $(DEV_MANAGE) createsuperuser --no-input --username admin --email 'admin@example.org' $(EXT_DIR)/jquery.min.js: mkdir -p $(EXT_DIR) From c86e8a02ad2d7b67cb84510258dca1ab19fd9eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 18 Feb 2023 11:02:38 +0100 Subject: [PATCH 34/63] Update CI to use Python 3.11 instead of 3.10 --- .github/workflows/ci.yml | 6 +++--- tox.ini | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f075b4..f8f442a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,7 @@ jobs: lint: name: Lint soure code runs-on: ubuntu-latest - container: python:3.10-bullseye + container: python:3.11-bullseye steps: - uses: actions/checkout@v2 - run: pip install -e .[dev] @@ -18,7 +18,7 @@ jobs: test_tox: name: Test with Tox runs-on: ubuntu-latest - container: python:3.10-bullseye + container: python:3.11-bullseye permissions: # Required for "EnricoMi/publish-unit-test-result-action" checks: write @@ -30,7 +30,7 @@ jobs: # 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: tox -e py311 -- --junitxml=.tox/py311/log/results.xml - name: Publish unit test results uses: EnricoMi/publish-unit-test-result-action@v1 if: always() diff --git a/tox.ini b/tox.ini index 2f7ced4..6e025eb 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 = py39,py310 +envlist = py39,py311 recreate = True [testenv] From 255a02863a7eccd6c08154a53c8659b217f63f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 18 Feb 2023 11:19:20 +0100 Subject: [PATCH 35/63] CI: Update upstream GitHub Actions --- .github/workflows/ci.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8f442a..e025a9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest container: python:3.11-bullseye steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - run: pip install -e .[dev] - run: make lint @@ -23,7 +23,7 @@ jobs: # Required for "EnricoMi/publish-unit-test-result-action" checks: write steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Setup dependencies run: | pip install tox @@ -32,20 +32,20 @@ jobs: - run: make build - run: tox -e py311 -- --junitxml=.tox/py311/log/results.xml - name: Publish unit test results - uses: EnricoMi/publish-unit-test-result-action@v1 + uses: EnricoMi/publish-unit-test-result-action@v2 if: always() with: files: .tox/py*/log/results.xml comment_mode: "off" - name: Archive unit test results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 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 + uses: actions/upload-artifact@v3 if: always() with: name: tox-code-coverage-report @@ -57,7 +57,7 @@ jobs: runs-on: ubuntu-latest container: debian:bullseye steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - 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 @@ -68,7 +68,7 @@ jobs: - run: dpkg-buildpackage --unsigned-changes --unsigned-buildinfo - run: mv ../ctf-gameserver_*.deb . - name: Store Debian package - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: deb-package path: ctf-gameserver_*.deb @@ -81,8 +81,8 @@ jobs: container: debian:bullseye needs: build_deb_package steps: - - uses: actions/checkout@v2 - - uses: actions/download-artifact@v2 + - uses: actions/checkout@v3 + - uses: actions/download-artifact@v3 with: name: deb-package - run: echo 'deb http://deb.debian.org/debian/ bullseye-backports main' >> /etc/apt/sources.list @@ -95,14 +95,14 @@ jobs: - 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 + uses: actions/upload-artifact@v3 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 + uses: actions/upload-artifact@v3 if: always() with: name: debian-code-coverage-report From ec17e9b00c77f2b42f28fda53aa3b295d7fc5d9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 22 Apr 2023 11:44:26 +0200 Subject: [PATCH 36/63] Web: Show team number in user dropdown on every page Closes: https://github.com/fausecteam/ctf-gameserver/issues/76 Co-authored-by: Simon Ruderich --- .../web/registration/templates/edit_team.html | 7 ------- src/ctf_gameserver/web/templates/base-common.html | 6 ++++++ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/ctf_gameserver/web/registration/templates/edit_team.html b/src/ctf_gameserver/web/registration/templates/edit_team.html index 41bc8ba..697f3f4 100644 --- a/src/ctf_gameserver/web/registration/templates/edit_team.html +++ b/src/ctf_gameserver/web/registration/templates/edit_team.html @@ -13,13 +13,6 @@

    {% block title %}{% trans 'Edit Team' %}{% endblock %}

    -{% if team and team.net_number %} -
    - - -
    -{% endif %} -
    {% csrf_token %} diff --git a/src/ctf_gameserver/web/templates/base-common.html b/src/ctf_gameserver/web/templates/base-common.html index f276f14..c717a8d 100644 --- a/src/ctf_gameserver/web/templates/base-common.html +++ b/src/ctf_gameserver/web/templates/base-common.html @@ -90,14 +90,20 @@ From 78352a7da1f40f34db8b337229bc6eadc736f71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 22 Apr 2023 13:47:09 +0200 Subject: [PATCH 37/63] Checker: Fix broken checker script in test case The call to `get_flag()` used the wrong tick, causing an exception. This lead to the script getting killed because of a communication error. This was hidden most of the time because of a race condition: The kill instruction from above was not executed before test_unfinished() checked whether the process was still running and before the script got terminated as part of the test itself. Co-authored-by: Simon Ruderich --- tests/checker/integration_unfinished_checkerscript.py | 2 +- tests/checker/test_integration.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/checker/integration_unfinished_checkerscript.py b/tests/checker/integration_unfinished_checkerscript.py index 33de8a0..cf79908 100755 --- a/tests/checker/integration_unfinished_checkerscript.py +++ b/tests/checker/integration_unfinished_checkerscript.py @@ -12,7 +12,7 @@ with open(pidfile_path, 'w', encoding='ascii') as pidfile: pidfile.write(str(os.getpid())) - checkerlib.get_flag(1) + checkerlib.store_state('key', 'Lorem ipsum dolor sit amet') while True: time.sleep(10) diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index e5a05b9..9445efa 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -195,6 +195,11 @@ def test_unfinished(self, monotonic_mock, warning_mock): master_loop.supervisor.queue_timeout = 10 self.assertTrue(master_loop.step()) + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT data FROM scoring_checkerstate WHERE service_id=1 AND team_id=2') + state_result = cursor.fetchone() + self.assertEqual(state_result[0], 'gASVHgAAAAAAAACMGkxvcmVtIGlwc3VtIGRvbG9yIHNpdCBhbWV0lC4=') + checkerscript_pidfile.seek(0) checkerscript_pid = int(checkerscript_pidfile.read()) # Ensure process is running by sending signal 0 From 739fc6f2883fe533ea985f6b364ac86f64c29d1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 22 Apr 2023 14:26:00 +0200 Subject: [PATCH 38/63] Checker: Terminate Checker Scripts after competition has finished Closes: https://github.com/fausecteam/ctf-gameserver/issues/66 Co-authored-by: Simon Ruderich --- src/ctf_gameserver/checker/database.py | 6 +-- src/ctf_gameserver/checker/master.py | 6 ++- src/ctf_gameserver/controller/controller.py | 2 + src/ctf_gameserver/controller/database.py | 8 ++- src/ctf_gameserver/web/scoring/forms.py | 2 +- src/ctf_gameserver/web/scoring/models.py | 2 + tests/checker/test_integration.py | 57 +++++++++++++++++++++ tests/controller/test_main_loop.py | 31 +++++++++-- 8 files changed, 105 insertions(+), 9 deletions(-) diff --git a/src/ctf_gameserver/checker/database.py b/src/ctf_gameserver/checker/database.py index 885184c..a493a0e 100644 --- a/src/ctf_gameserver/checker/database.py +++ b/src/ctf_gameserver/checker/database.py @@ -44,17 +44,17 @@ def get_service_attributes(db_conn, service_slug, prohibit_changes=False): def get_current_tick(db_conn, prohibit_changes=False): """ - Reads the current tick from the game database. + Reads the current tick and the "cancel_checks" field from the game database. """ with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('SELECT current_tick FROM scoring_gamecontrol') + cursor.execute('SELECT current_tick, cancel_checks FROM scoring_gamecontrol') result = cursor.fetchone() if result is None: raise DBDataError('Game control information has not been configured') - return result[0] + return result def get_check_duration(db_conn, service_id, std_dev_count, prohibit_changes=False): diff --git a/src/ctf_gameserver/checker/master.py b/src/ctf_gameserver/checker/master.py index 691ad04..c45af84 100644 --- a/src/ctf_gameserver/checker/master.py +++ b/src/ctf_gameserver/checker/master.py @@ -336,12 +336,16 @@ def change_tick(new_tick): self.update_launch_params(new_tick) self.known_tick = new_tick - current_tick = database.get_current_tick(self.db_conn) + current_tick, cancel_checks = 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) + elif cancel_checks: + # Competition over + self.supervisor.terminate_runners() + return tasks = database.get_new_tasks(self.db_conn, self.service['id'], self.tasks_per_launch) diff --git a/src/ctf_gameserver/controller/controller.py b/src/ctf_gameserver/controller/controller.py index 2bc7880..e9058c3 100644 --- a/src/ctf_gameserver/controller/controller.py +++ b/src/ctf_gameserver/controller/controller.py @@ -181,6 +181,8 @@ def sleep(duration): return if (not nonstop) and (now >= control_info['end']): + database.cancel_checks(db_conn) + # Update scoring for last tick of game database.update_scoring(db_conn) diff --git a/src/ctf_gameserver/controller/database.py b/src/ctf_gameserver/controller/database.py index ec06002..239ce06 100644 --- a/src/ctf_gameserver/controller/database.py +++ b/src/ctf_gameserver/controller/database.py @@ -27,7 +27,7 @@ def get_control_info(db_conn, prohibit_changes=False): def increase_tick(db_conn, prohibit_changes=False): with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('UPDATE scoring_gamecontrol SET current_tick = current_tick + 1') + cursor.execute('UPDATE scoring_gamecontrol SET current_tick = current_tick + 1, cancel_checks = 0') # Create flags for every service and team in the new tick cursor.execute('INSERT INTO scoring_flag (service_id, protecting_team_id, tick)' ' SELECT service.id, team.user_id, control.current_tick' @@ -36,6 +36,12 @@ def increase_tick(db_conn, prohibit_changes=False): ' WHERE auth_user.id = team.user_id AND auth_user.is_active') +def cancel_checks(db_conn, prohibit_changes=False): + + with transaction_cursor(db_conn, prohibit_changes) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks = 1') + + def update_scoring(db_conn): with transaction_cursor(db_conn) as cursor: diff --git a/src/ctf_gameserver/web/scoring/forms.py b/src/ctf_gameserver/web/scoring/forms.py index b3cf4d4..e1504f7 100644 --- a/src/ctf_gameserver/web/scoring/forms.py +++ b/src/ctf_gameserver/web/scoring/forms.py @@ -16,7 +16,7 @@ class GameControlAdminForm(forms.ModelForm): class Meta: model = models.GameControl - exclude = ('current_tick',) + exclude = ('current_tick', 'cancel_checks') help_texts = { 'competition_name': _('Human-readable title of the CTF'), 'services_public': _('Time at which information about the services is public, but the actual ' diff --git a/src/ctf_gameserver/web/scoring/models.py b/src/ctf_gameserver/web/scoring/models.py index cc3fd28..e45d88a 100644 --- a/src/ctf_gameserver/web/scoring/models.py +++ b/src/ctf_gameserver/web/scoring/models.py @@ -155,6 +155,8 @@ class GameControl(models.Model): # Number of ticks a flag is valid for including the one it was generated in valid_ticks = models.PositiveSmallIntegerField(default=5) current_tick = models.IntegerField(default=-1) + # Instruct Checker Runners to cancel any running checks + cancel_checks = models.BooleanField(default=False) flag_prefix = models.CharField(max_length=20, default='FLAG_') registration_open = models.BooleanField(default=False) registration_confirm_text = models.TextField(blank=True) diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index 9445efa..36757ff 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -237,6 +237,63 @@ def test_unfinished(self, monotonic_mock, warning_mock): del os.environ['CHECKERSCRIPT_PIDFILE'] checkerscript_pidfile.close() + @patch('logging.warning') + @patch('ctf_gameserver.checker.master.get_monotonic_time') + def test_cancel_checks(self, monotonic_mock, warning_mock): + checkerscript_path = os.path.join(os.path.dirname(__file__), + 'integration_unfinished_checkerscript.py') + + checkerscript_pidfile = tempfile.NamedTemporaryFile() + os.environ['CHECKERSCRIPT_PIDFILE'] = checkerscript_pidfile.name + + monotonic_mock.return_value = 10 + 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()') + cursor.execute('UPDATE scoring_gamecontrol SET current_tick=0') + cursor.execute('INSERT INTO scoring_flag (service_id, protecting_team_id, tick)' + ' VALUES (1, 2, 0)') + monotonic_mock.return_value = 20 + + master_loop.supervisor.queue_timeout = 0.01 + # Checker Script gets started, will return False because no messages yet + self.assertFalse(master_loop.step()) + master_loop.supervisor.queue_timeout = 10 + self.assertTrue(master_loop.step()) + + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT data FROM scoring_checkerstate WHERE service_id=1 AND team_id=2') + state_result = cursor.fetchone() + self.assertEqual(state_result[0], 'gASVHgAAAAAAAACMGkxvcmVtIGlwc3VtIGRvbG9yIHNpdCBhbWV0lC4=') + + checkerscript_pidfile.seek(0) + checkerscript_pid = int(checkerscript_pidfile.read()) + # Ensure process is running by sending signal 0 + os.kill(checkerscript_pid, 0) + + with transaction_cursor(self.connection) as cursor: + cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks=1') + + master_loop.supervisor.queue_timeout = 0.01 + monotonic_mock.return_value = 190 + self.assertFalse(master_loop.step()) + # Poll whether the process has been killed + for _ in range(100): + try: + os.kill(checkerscript_pid, 0) + except ProcessLookupError: + break + time.sleep(0.1) + with self.assertRaises(ProcessLookupError): + os.kill(checkerscript_pid, 0) + + warning_mock.assert_called_with('Terminating all %d Runner processes', 1) + + del os.environ['CHECKERSCRIPT_PIDFILE'] + checkerscript_pidfile.close() + @patch('ctf_gameserver.checker.master.get_monotonic_time') def test_multi_teams_ticks(self, monotonic_mock): checkerscript_path = os.path.join(os.path.dirname(__file__), diff --git a/tests/controller/test_main_loop.py b/tests/controller/test_main_loop.py index 103951f..273ff72 100644 --- a/tests/controller/test_main_loop.py +++ b/tests/controller/test_main_loop.py @@ -54,6 +54,11 @@ def test_first_tick(self, sleep_mock, _): new_tick = cursor.fetchone()[0] self.assertEqual(new_tick, 0) + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT cancel_checks FROM scoring_gamecontrol') + cancel_checks = cursor.fetchone()[0] + self.assertFalse(cancel_checks) + with transaction_cursor(self.connection) as cursor: cursor.execute('SELECT COUNT(*) FROM scoring_flag') total_flag_count = cursor.fetchone()[0] @@ -101,9 +106,9 @@ def test_next_tick_undue(self, sleep_mock, _): @patch('time.sleep') def test_next_tick_overdue(self, sleep_mock, _): with transaction_cursor(self.connection) as cursor: - cursor.execute('UPDATE scoring_gamecontrol SET start = datetime("now", "-19 minutes"), ' - ' end = datetime("now", "+1421 minutes"), ' - ' current_tick=5') + cursor.execute('UPDATE scoring_gamecontrol SET start=datetime("now", "-19 minutes"), ' + ' end=datetime("now", "+1421 minutes"), ' + ' current_tick=5, cancel_checks=1') controller.main_loop_step(self.connection, self.metrics, False) @@ -114,6 +119,11 @@ def test_next_tick_overdue(self, sleep_mock, _): new_tick = cursor.fetchone()[0] self.assertEqual(new_tick, 6) + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT cancel_checks FROM scoring_gamecontrol') + cancel_checks = cursor.fetchone()[0] + self.assertFalse(cancel_checks) + with transaction_cursor(self.connection) as cursor: cursor.execute('SELECT COUNT(*) FROM scoring_flag WHERE tick=6') tick_flag_count = cursor.fetchone()[0] @@ -156,6 +166,11 @@ def test_shortly_after_game(self, sleep_mock, _): new_tick = cursor.fetchone()[0] self.assertEqual(new_tick, 479) + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT cancel_checks FROM scoring_gamecontrol') + cancel_checks = cursor.fetchone()[0] + self.assertTrue(cancel_checks) + with transaction_cursor(self.connection) as cursor: cursor.execute('SELECT COUNT(*) FROM scoring_flag') total_flag_count = cursor.fetchone()[0] @@ -178,6 +193,11 @@ def test_long_after_game(self, sleep_mock, _): new_tick = cursor.fetchone()[0] self.assertEqual(new_tick, 479) + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT cancel_checks FROM scoring_gamecontrol') + cancel_checks = cursor.fetchone()[0] + self.assertTrue(cancel_checks) + with transaction_cursor(self.connection) as cursor: cursor.execute('SELECT COUNT(*) FROM scoring_flag') total_flag_count = cursor.fetchone()[0] @@ -198,6 +218,11 @@ def test_after_game_nonstop(self, sleep_mock, _): new_tick = cursor.fetchone()[0] self.assertEqual(new_tick, 480) + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT cancel_checks FROM scoring_gamecontrol') + cancel_checks = cursor.fetchone()[0] + self.assertFalse(cancel_checks) + with transaction_cursor(self.connection) as cursor: cursor.execute('SELECT COUNT(*) FROM scoring_flag WHERE tick=480') tick_flag_count = cursor.fetchone()[0] From 9e57a41adcec15c51b8a4cf01e228188287951f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 20 May 2023 11:18:47 +0200 Subject: [PATCH 39/63] Web: Use services instead of Attack/Defense/SLA in CTFTime scoreboard JSON Closes: https://github.com/fausecteam/ctf-gameserver/issues/75 Co-authored-by: Simon Ruderich --- src/ctf_gameserver/web/scoring/views.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ctf_gameserver/web/scoring/views.py b/src/ctf_gameserver/web/scoring/views.py index 8e26924..1dc1817 100644 --- a/src/ctf_gameserver/web/scoring/views.py +++ b/src/ctf_gameserver/web/scoring/views.py @@ -91,23 +91,26 @@ def scoreboard_json_ctftime(_): see https://ctftime.org/json-scoreboard-feed. """ - tasks = ['Offense', 'Defense', 'SLA'] standings = [] scores = calculations.scores(['team', 'team__user'], ['team__user__username']) - for rank, (team, points) in enumerate(scores.items(), start=1): + for rank, (team, team_points) in enumerate(scores.items(), start=1): + task_stats = defaultdict(lambda: {'points': 0.0}) + for point_type in ('offense', 'defense', 'sla'): + for service, points in team_points[point_type][0].items(): + task_stats[service.name]['points'] += points + + for service_name in task_stats: + task_stats[service_name] = {'points': round(task_stats[service_name]['points'], 4)} + standings.append({ 'pos': rank, 'team': team.user.username, - 'score': round(points['total'], 4), - 'taskStats': { - 'Offense': {'points': round(points['offense'][1], 4)}, - 'Defense': {'points': round(points['defense'][1], 4)}, - 'SLA': {'points': round(points['sla'][1], 4)} - } + 'score': round(team_points['total'], 4), + 'taskStats': task_stats }) - return JsonResponse({'tasks': tasks, 'standings': standings}) + return JsonResponse({'tasks': list(task_stats.keys()), 'standings': standings}) @services_public_required('html') From e97ee0de92cc962f35ece7098f42c3242e7a530a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 20 May 2023 13:18:55 +0200 Subject: [PATCH 40/63] Web: Add support for per-team file downloads Closes: https://github.com/fausecteam/ctf-gameserver/issues/42 Co-authored-by: b3ny4 Co-authored-by: Simon Ruderich --- .gitignore | 1 + conf/web/prod_settings.py | 7 +++ src/ctf_gameserver/web/admin.py | 2 +- src/ctf_gameserver/web/dev_settings.py | 1 + src/ctf_gameserver/web/registration/admin.py | 17 ++----- .../web/registration/admin_inline.py | 17 +++++++ src/ctf_gameserver/web/registration/models.py | 17 +++++++ .../templates/team_downloads.html | 28 +++++++++++ src/ctf_gameserver/web/registration/views.py | 49 ++++++++++++++++++- .../web/templates/base-common.html | 3 +- src/ctf_gameserver/web/urls.py | 9 ++++ 11 files changed, 135 insertions(+), 16 deletions(-) create mode 100644 src/ctf_gameserver/web/registration/admin_inline.py create mode 100644 src/ctf_gameserver/web/registration/templates/team_downloads.html diff --git a/.gitignore b/.gitignore index a200bec..5b7626a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ __pycache__/ # Web component /src/ctf_gameserver/web/dev-db.sqlite3 +/src/ctf_gameserver/web/team_downloads/ /src/ctf_gameserver/web/uploads/ /src/ctf_gameserver/web/static/ext/ /src/ctf_gameserver/web/registration/countries.csv diff --git a/conf/web/prod_settings.py b/conf/web/prod_settings.py index b2c4156..4da8d68 100644 --- a/conf/web/prod_settings.py +++ b/conf/web/prod_settings.py @@ -67,6 +67,13 @@ # ("/uploads" by default) MEDIA_ROOT = '' +# Base filesystem path where files for per-team downloads are stored, optional without per-team downloads +# A hierarchy with a directory per team (called by net number) is expected below this path +# For example, file "vpn.conf" for the team with net number 42 must be in: +# /42/vpn.conf +# This directory must *not* be served by a web server +TEAM_DOWNLOADS_ROOT = '' + # The backend used to store user sessions SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db' diff --git a/src/ctf_gameserver/web/admin.py b/src/ctf_gameserver/web/admin.py index 4d86c5f..5f2ad6e 100644 --- a/src/ctf_gameserver/web/admin.py +++ b/src/ctf_gameserver/web/admin.py @@ -5,7 +5,7 @@ from django.contrib.auth.admin import UserAdmin from .registration.models import Team -from .registration.admin import InlineTeamAdmin +from .registration.admin_inline import InlineTeamAdmin from .scoring.models import GameControl from .util import format_lazy diff --git a/src/ctf_gameserver/web/dev_settings.py b/src/ctf_gameserver/web/dev_settings.py index 6f0e9ea..b9b9bce 100644 --- a/src/ctf_gameserver/web/dev_settings.py +++ b/src/ctf_gameserver/web/dev_settings.py @@ -34,6 +34,7 @@ DEFAULT_FROM_EMAIL = 'ctf-gameserver.web@localhost' MEDIA_ROOT = os.path.join(BASE_DIR, 'uploads') +TEAM_DOWNLOADS_ROOT = os.path.join(BASE_DIR, 'team_downloads') SESSION_ENGINE = 'django.contrib.sessions.backends.db' diff --git a/src/ctf_gameserver/web/registration/admin.py b/src/ctf_gameserver/web/registration/admin.py index 49ccb15..1f71478 100644 --- a/src/ctf_gameserver/web/registration/admin.py +++ b/src/ctf_gameserver/web/registration/admin.py @@ -1,17 +1,10 @@ from django.contrib import admin -from django.utils.translation import gettext_lazy as _ -from .models import Team -from .forms import AdminTeamForm +from ctf_gameserver.web.admin import admin_site +from .models import TeamDownload -class InlineTeamAdmin(admin.StackedInline): - """ - InlineModelAdmin for Team objects. Primarily designed to be used within a UserAdmin. - """ +@admin.register(TeamDownload, site=admin_site) +class FlagAdmin(admin.ModelAdmin): - model = Team - form = AdminTeamForm - - # Abuse the plural title as headline, since more than one team will never be edited using this inline - verbose_name_plural = _('Associated team') + search_fields = ('filename', 'description') diff --git a/src/ctf_gameserver/web/registration/admin_inline.py b/src/ctf_gameserver/web/registration/admin_inline.py new file mode 100644 index 0000000..49ccb15 --- /dev/null +++ b/src/ctf_gameserver/web/registration/admin_inline.py @@ -0,0 +1,17 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import Team +from .forms import AdminTeamForm + + +class InlineTeamAdmin(admin.StackedInline): + """ + InlineModelAdmin for Team objects. Primarily designed to be used within a UserAdmin. + """ + + model = Team + form = AdminTeamForm + + # Abuse the plural title as headline, since more than one team will never be edited using this inline + verbose_name_plural = _('Associated team') diff --git a/src/ctf_gameserver/web/registration/models.py b/src/ctf_gameserver/web/registration/models.py index 111ec7a..20506b8 100644 --- a/src/ctf_gameserver/web/registration/models.py +++ b/src/ctf_gameserver/web/registration/models.py @@ -1,3 +1,4 @@ +from django.core.validators import RegexValidator from django.db import models from django.conf import settings from django.utils.translation import gettext_lazy as _ @@ -49,3 +50,19 @@ def get_queryset(self): def __str__(self): # pylint: disable=no-member return self.user.username + + +class TeamDownload(models.Model): + """ + Database representation of a single type of per-team download. One file with the specified name can + be provided per team in the file system hierarchy below `settings.TEAM_DOWNLOADS_ROOT`. + """ + + filename = models.CharField(max_length=100, help_text=_('Name within the per-team filesystem hierarchy'), + validators=[RegexValidator(r'^[^/]+$', + message=_('Must not contain slashes'))]) + description = models.TextField() + + def __str__(self): + # pylint: disable=invalid-str-returned + return self.filename diff --git a/src/ctf_gameserver/web/registration/templates/team_downloads.html b/src/ctf_gameserver/web/registration/templates/team_downloads.html new file mode 100644 index 0000000..ded4b2a --- /dev/null +++ b/src/ctf_gameserver/web/registration/templates/team_downloads.html @@ -0,0 +1,28 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block content %} + + +

    + {% blocktrans %} + These files are provided to your team personally. + They are confidential, do not share them with anyone else! + {% endblocktrans %} +

    + +{% if downloads %} +
      + {% for dl in downloads %} +
    • + {{ dl.filename }}: + {{ dl.description }} +
    • + {% endfor %} +
    +{% else %} +{% trans 'No downloads available at the moment.' %} +{% endif %} +{% endblock %} diff --git a/src/ctf_gameserver/web/registration/views.py b/src/ctf_gameserver/web/registration/views.py index c324093..a6cb822 100644 --- a/src/ctf_gameserver/web/registration/views.py +++ b/src/ctf_gameserver/web/registration/views.py @@ -1,9 +1,11 @@ import logging +from pathlib import Path import random from django.db import transaction, IntegrityError +from django.http import FileResponse, Http404 from django.views.generic import ListView -from django.shortcuts import render, redirect +from django.shortcuts import get_object_or_404, render, redirect from django.conf import settings from django.utils.safestring import mark_safe from django.utils.translation import ugettext as _ @@ -15,7 +17,7 @@ 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 +from .models import Team, TeamDownload from .util import email_token_generator User = get_user_model() # pylint: disable=invalid-name @@ -208,6 +210,49 @@ def confirm_email(request): return redirect(settings.HOME_URL) +@login_required +def list_team_downloads(request): + """ + Provides an HTML listing of available per-team downloads for the logged-in user. + """ + + try: + team = request.user.team + except Team.DoesNotExist as e: + raise Http404('User has no team') from e + + team_downloads_root = Path(settings.TEAM_DOWNLOADS_ROOT) + + downloads = [] + for download in TeamDownload.objects.order_by('filename'): + fs_path = team_downloads_root / str(team.net_number) / download.filename + if fs_path.is_file(): + downloads.append(download) + + return render(request, 'team_downloads.html', {'downloads': downloads}) + + +def get_team_download(request, filename): + """ + Delivers a single per-team download to the logged-in user. + """ + + try: + team = request.user.team + except Team.DoesNotExist as e: + raise Http404('User has no team') from e + + get_object_or_404(TeamDownload, filename=filename) + + team_downloads_root = Path(settings.TEAM_DOWNLOADS_ROOT) + fs_path = team_downloads_root / str(team.net_number) / filename + + if not fs_path.is_file(): + raise Http404('File not found') + + return FileResponse(fs_path.open('rb'), as_attachment=True) + + @staff_member_required def mail_teams(request): """ diff --git a/src/ctf_gameserver/web/templates/base-common.html b/src/ctf_gameserver/web/templates/base-common.html index c717a8d..e2a95f2 100644 --- a/src/ctf_gameserver/web/templates/base-common.html +++ b/src/ctf_gameserver/web/templates/base-common.html @@ -93,9 +93,10 @@ {% if user.team %}
  • {% trans 'Team number' %}: {{ user.team.net_number }}
  • +
  • {% trans 'Downloads' %}
  • {% endif %} {% if registration_open %} -
  • {% trans 'Edit' %}
  • +
  • {% trans 'Edit Team' %}
  • {% endif %} {% if user.is_staff %} diff --git a/src/ctf_gameserver/web/urls.py b/src/ctf_gameserver/web/urls.py index 19067c3..15976cf 100644 --- a/src/ctf_gameserver/web/urls.py +++ b/src/ctf_gameserver/web/urls.py @@ -92,6 +92,15 @@ name='service_status_json' ), + url(r'^downloads/$', + registration_views.list_team_downloads, + name='list_team_downloads' + ), + url(r'^downloads/(?P[^/]+)$', + registration_views.get_team_download, + name='get_team_download' + ), + url(r'^internal/mail-teams/$', registration_views.mail_teams, name='mail_teams' From 767c249f003a4651fd1f7d59f26d0ae78d1fbd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 20 May 2023 15:02:35 +0200 Subject: [PATCH 41/63] Web: Actually require login to get per-team downloads --- src/ctf_gameserver/web/registration/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ctf_gameserver/web/registration/views.py b/src/ctf_gameserver/web/registration/views.py index a6cb822..24b49c3 100644 --- a/src/ctf_gameserver/web/registration/views.py +++ b/src/ctf_gameserver/web/registration/views.py @@ -232,6 +232,7 @@ def list_team_downloads(request): return render(request, 'team_downloads.html', {'downloads': downloads}) +@login_required def get_team_download(request, filename): """ Delivers a single per-team download to the logged-in user. From 915e253afc9aab8eb5a8ed91402d5e060bd945e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 15 Jul 2023 12:50:26 +0200 Subject: [PATCH 42/63] Add new "VPN Status" component that writes VPN and (demo) Vulnbox checks to the database Web visualization is still missing. References: https://github.com/fausecteam/ctf-gameserver/issues/15 Co-authored-by: b3ny4 Co-authored-by: Simon Ruderich --- .devcontainer.json | 2 +- .github/workflows/ci.yml | 3 + Makefile | 2 +- conf/vpnstatus/ctf-vpnstatus.env | 10 + conf/vpnstatus/ctf-vpnstatus.service | 20 ++ debian/control | 1 + debian/install | 3 + debian/postinst | 3 + examples/vpnstatus/sudoers.d/ctf-vpnstatus | 2 + scripts/vpnstatus/ctf-vpnstatus | 9 + setup.py | 3 +- src/ctf_gameserver/vpnstatus/__init__.py | 1 + src/ctf_gameserver/vpnstatus/database.py | 34 ++ src/ctf_gameserver/vpnstatus/status.py | 312 +++++++++++++++++++ src/ctf_gameserver/web/base_settings.py | 3 +- src/ctf_gameserver/web/vpnstatus/__init__.py | 0 src/ctf_gameserver/web/vpnstatus/models.py | 22 ++ tests/vpnstatus/fixtures/status.json | 107 +++++++ tests/vpnstatus/test_status.py | 158 ++++++++++ 19 files changed, 691 insertions(+), 4 deletions(-) create mode 100644 conf/vpnstatus/ctf-vpnstatus.env create mode 100644 conf/vpnstatus/ctf-vpnstatus.service create mode 100644 examples/vpnstatus/sudoers.d/ctf-vpnstatus create mode 100755 scripts/vpnstatus/ctf-vpnstatus create mode 100644 src/ctf_gameserver/vpnstatus/__init__.py create mode 100644 src/ctf_gameserver/vpnstatus/database.py create mode 100644 src/ctf_gameserver/vpnstatus/status.py create mode 100644 src/ctf_gameserver/web/vpnstatus/__init__.py create mode 100644 src/ctf_gameserver/web/vpnstatus/models.py create mode 100644 tests/vpnstatus/fixtures/status.json create mode 100644 tests/vpnstatus/test_status.py diff --git a/.devcontainer.json b/.devcontainer.json index 8335b90..11859f7 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -1,6 +1,6 @@ { "image": "python:3.9-alpine", - "updateContentCommand": "apk --no-cache add git curl build-base jpeg-dev zlib-dev", + "updateContentCommand": "apk --no-cache add git curl build-base jpeg-dev zlib-dev iputils-ping", "postCreateCommand": "pip3 install --editable .[dev] && make dev", "extensions": ["ms-python.python"] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e025a9d..692c96b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: pip install tox # Make sure we have our dependencies, which are not required for Tox but for `make build` pip install -e . + # Ping is required for VPNStatusTest + apt-get --yes update + apt-get --yes install iputils-ping - run: make build - run: tox -e py311 -- --junitxml=.tox/py311/log/results.xml - name: Publish unit test results diff --git a/Makefile b/Makefile index ed70775..1ed580f 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ ext: $(EXT_DIR)/jquery.min.js $(EXT_DIR)/bootstrap $(WEB_DIR)/registration/count migrations: $(WEB_DIR)/registration/countries.csv - $(DEV_MANAGE) makemigrations templatetags registration scoring flatpages + $(DEV_MANAGE) makemigrations templatetags registration scoring flatpages vpnstatus $(WEB_DIR)/dev-db.sqlite3: migrations $(WEB_DIR)/registration/countries.csv $(DEV_MANAGE) migrate diff --git a/conf/vpnstatus/ctf-vpnstatus.env b/conf/vpnstatus/ctf-vpnstatus.env new file mode 100644 index 0000000..c64885c --- /dev/null +++ b/conf/vpnstatus/ctf-vpnstatus.env @@ -0,0 +1,10 @@ +CTF_DBNAME="DUMMY" +CTF_DBUSER="DUMMY" + +CTF_WIREGUARD_IFPATTERN="wg%d" + +CTF_GATEWAY_IPPATTERN="0.0.%s.1" +CTF_DEMO_IPPATTERN="0.0.%s.3" +CTF_DEMO_SERVICEPORT="80" +CTF_VULNBOX_IPPATTERN="0.0.%s.2" +CTF_VULNBOX_SERVICEPORT="80" diff --git a/conf/vpnstatus/ctf-vpnstatus.service b/conf/vpnstatus/ctf-vpnstatus.service new file mode 100644 index 0000000..82c5b66 --- /dev/null +++ b/conf/vpnstatus/ctf-vpnstatus.service @@ -0,0 +1,20 @@ +[Unit] +Description=CTF Gameserver Controller +After=postgresql.service + +[Service] +Type=notify +User=ctf-vpnstatus +EnvironmentFile=/etc/ctf-gameserver/vpnstatus.env +ExecStart=/usr/bin/ctf-vpnstatus +Restart=on-failure +RestartSec=5 + +# Security options, cannot use any which imply `NoNewPrivileges` because checks can get executed using sudo +PrivateTmp=yes +ProtectControlGroups=yes +ProtectHome=yes +ProtectSystem=strict + +[Install] +WantedBy=multi-user.target diff --git a/debian/control b/debian/control index df4ee86..26d2097 100644 --- a/debian/control +++ b/debian/control @@ -23,6 +23,7 @@ Section: python Depends: ${misc:Depends}, ${python3:Depends}, + iputils-ping, # ctf-gameserver brings its own JQuery, but python3-django requires Debian's JQUery (for the admin interface) # and it's only a "Recommend" there libjs-jquery, diff --git a/debian/install b/debian/install index 1555994..d478f41 100644 --- a/debian/install +++ b/debian/install @@ -8,4 +8,7 @@ conf/controller/ctf-controller.service lib/systemd/system conf/submission/submission.env etc/ctf-gameserver conf/submission/ctf-submission@.service lib/systemd/system +conf/vpnstatus/ctf-vpnstatus.env etc/ctf-gameserver +conf/vpnstatus/ctf-vpnstatus.service lib/systemd/system + conf/web/prod_settings.py etc/ctf-gameserver/web diff --git a/debian/postinst b/debian/postinst index f8914ce..265ad41 100755 --- a/debian/postinst +++ b/debian/postinst @@ -6,6 +6,9 @@ fi if ! getent passwd ctf-checkerrunner >/dev/null; then adduser --system --group --home /var/lib/ctf-checkerrunner --gecos 'CTF Gameserver Checker Script user,,,' ctf-checkerrunner fi +if ! getent passwd ctf-vpnstatus >/dev/null; then + adduser --system --group --home /var/lib/ctf-vpnstatus --gecos 'CTF Gameserver VPN Status Checker user,,,' ctf-vpnstatus +fi # No dh-systemd because we don't want to enable/start any services if test -x /bin/systemctl; then diff --git a/examples/vpnstatus/sudoers.d/ctf-vpnstatus b/examples/vpnstatus/sudoers.d/ctf-vpnstatus new file mode 100644 index 0000000..028f051 --- /dev/null +++ b/examples/vpnstatus/sudoers.d/ctf-vpnstatus @@ -0,0 +1,2 @@ +# Allow user "ctf-vpnstatus" to display WireGuard status without being prompted for a password +ctf-vpnstatus ALL = (root) NOPASSWD: wg show all latest-handshakes diff --git a/scripts/vpnstatus/ctf-vpnstatus b/scripts/vpnstatus/ctf-vpnstatus new file mode 100755 index 0000000..76feb67 --- /dev/null +++ b/scripts/vpnstatus/ctf-vpnstatus @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 + +import sys + +from ctf_gameserver.vpnstatus import main + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/setup.py b/setup.py index 46b13f9..61196d8 100755 --- a/setup.py +++ b/setup.py @@ -46,7 +46,8 @@ 'scripts/checker/ctf-checkermaster', 'scripts/checker/ctf-logviewer', 'scripts/controller/ctf-controller', - 'scripts/submission/ctf-submission' + 'scripts/submission/ctf-submission', + 'scripts/vpnstatus/ctf-vpnstatus' ], package_data = { 'ctf_gameserver.web': [ diff --git a/src/ctf_gameserver/vpnstatus/__init__.py b/src/ctf_gameserver/vpnstatus/__init__.py new file mode 100644 index 0000000..6e65eb0 --- /dev/null +++ b/src/ctf_gameserver/vpnstatus/__init__.py @@ -0,0 +1 @@ +from .status import main diff --git a/src/ctf_gameserver/vpnstatus/database.py b/src/ctf_gameserver/vpnstatus/database.py new file mode 100644 index 0000000..f3f33af --- /dev/null +++ b/src/ctf_gameserver/vpnstatus/database.py @@ -0,0 +1,34 @@ +from ctf_gameserver.lib.database import transaction_cursor + + +def get_active_teams(db_conn): + """ + Returns active teams as tuples of (user) ID and net number. + """ + + with transaction_cursor(db_conn) as cursor: + cursor.execute('SELECT auth_user.id, team.net_number FROM auth_user, registration_team team' + ' WHERE auth_user.id = team.user_id AND auth_user.is_active') + result = cursor.fetchall() + + return result + + +def add_results(db_conn, results_dict, prohibit_changes=False): + """ + Stores all check results for all teams in the database. Expects the results as a nested dict with team + IDs as outer keys and kinds of checks as inner keys. + """ + + with transaction_cursor(db_conn, prohibit_changes) as cursor: + rows = [] + for team_id, team_results in results_dict.items(): + rows.append((team_id, team_results['wireguard_handshake_time'], + team_results['gateway_ping_rtt_ms'], team_results['demo_ping_rtt_ms'], + team_results['demo_service_ok'], team_results['vulnbox_ping_rtt_ms'], + team_results['vulnbox_service_ok'])) + + cursor.executemany('INSERT INTO vpnstatus_vpnstatuscheck (team_id, wireguard_handshake_time,' + ' gateway_ping_rtt_ms, demo_ping_rtt_ms, demo_service_ok, vulnbox_ping_rtt_ms,' + ' vulnbox_service_ok, timestamp)' + 'VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())', rows) diff --git a/src/ctf_gameserver/vpnstatus/status.py b/src/ctf_gameserver/vpnstatus/status.py new file mode 100644 index 0000000..7abd126 --- /dev/null +++ b/src/ctf_gameserver/vpnstatus/status.py @@ -0,0 +1,312 @@ +import asyncio +from datetime import datetime +import logging +import os +import re +import time + +import prometheus_client +import psycopg2 +from psycopg2 import errorcodes as postgres_errors + +from ctf_gameserver.lib.args import get_arg_parser_with_db, parse_host_port +from ctf_gameserver.lib.database import transaction_cursor +from ctf_gameserver.lib.metrics import start_metrics_server + +from . import database + + +CHECK_INTERVAL = 60 +NETWORK_TIMEOUT = 10 + + +def main(): + + arg_parser = get_arg_parser_with_db('CTF Gameserver VPN Status Checker') + arg_parser.add_argument('--wireguard-ifpattern', type=str, help='Enable on-server Wireguard checks, ' + '(old-style) Python formatstring for building a team\'s Wireguard interface ' + 'from its net number') + arg_parser.add_argument('--gateway-ippattern', type=str, help='Enable pings to the teams\' gateways, ' + '(old-style) Python formatstring for building a team\'s gateway IP from its net ' + 'number') + arg_parser.add_argument('--demo-ippattern', type=str, help='Enable pings to the teams\' demo Vulnboxes, ' + 'formatstring like --gateway-ippattern') + arg_parser.add_argument('--demo-serviceport', type=int, help='Enable TCP connection checks to the ' + 'specified port on the teams\' demo Vulnboxes') + arg_parser.add_argument('--vulnbox-ippattern', type=str, help='Enable pings to the teams\' Vulnboxes, ' + 'formatstring like --gateway-ippattern') + arg_parser.add_argument('--vulnbox-serviceport', type=int, help='Enable TCP connection checks to the ' + 'specified port on the teams\' Vulnboxes') + arg_parser.add_argument('--net-numbers-filter-file', type=str, help='Only run checks for teams with ' + 'specific net numbers, file with one net number per line') + arg_parser.add_argument('--metrics-listen', help='Expose Prometheus metrics via HTTP (":")') + + args = arg_parser.parse_args() + + logging.basicConfig(format='[%(levelname)s] %(message)s') + numeric_loglevel = getattr(logging, args.loglevel.upper()) + logging.getLogger().setLevel(numeric_loglevel) + + try: + 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 database connection: %s', e) + return os.EX_UNAVAILABLE + logging.info('Established database connection') + + # Keep our mental model easy by always using (timezone-aware) UTC for dates and times + with transaction_cursor(db_conn) as cursor: + cursor.execute('SET TIME ZONE "UTC"') + + # Check database grants + try: + database.get_active_teams(db_conn) + dummy_results = {42: { + 'wireguard_handshake_time': None, + 'gateway_ping_rtt_ms': None, + 'demo_ping_rtt_ms': None, + 'demo_service_ok': False, + 'vulnbox_ping_rtt_ms': None, + 'vulnbox_service_ok': False + }} + database.add_results(db_conn, dummy_results, prohibit_changes=True) + 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 + logging.exception('Missing database permissions:') + return os.EX_NOPERM + else: + raise + + if args.metrics_listen is not None: + try: + metrics_host, metrics_port, metrics_family = parse_host_port(args.metrics_listen) + except ValueError: + logging.error('Metrics listen address needs to be specified as ":"') + return os.EX_USAGE + + start_metrics_server(metrics_host, metrics_port, metrics_family) + + metrics = make_metrics() + metrics['start_timestamp'].set_to_current_time() + + net_numbers = None + if args.net_numbers_filter_file: + with open(args.net_numbers_filter_file, encoding='ascii') as filter_file: + net_numbers = set(int(line) for line in filter_file) + + asyncio.run(main_loop(db_conn, metrics, args.wireguard_ifpattern, args.gateway_ippattern, + args.demo_ippattern, args.demo_serviceport, args.vulnbox_ippattern, + args.vulnbox_serviceport, net_numbers)) + + return os.EX_OK + + +def make_metrics(registry=prometheus_client.REGISTRY): + + metrics = {} + metric_prefix = 'ctf_vpnstatus_' + + gauges = [ + ('start_timestamp', '(Unix) timestamp when the process was started', []), + ('up_count', 'Teams with a successful ping/connection/handshake', ['kind']), + ('check_duration_seconds', 'Time spent running all latest checks for all teams', []) + ] + for name, doc, labels in gauges: + metrics[name] = prometheus_client.Gauge(metric_prefix+name, doc, labels, registry=registry) + + histograms = [ + ('ping_milliseconds', 'Ping RTT for all teams', ['target'], + (1, 10, 50, 100, 200, 500, 1000, float('inf'))) + ] + for name, doc, labels, buckets in histograms: + metrics[name] = prometheus_client.Histogram(metric_prefix+name, doc, labels, buckets=buckets, + registry=registry) + + return metrics + + +async def main_loop(db_conn, metrics, wireguard_if_pattern=None, gateway_ip_pattern=None, + demo_ip_pattern=None, demo_service_port=None, vulnbox_ip_pattern=None, + vulnbox_service_port=None, team_net_numbers=None): + """ + Continuously runs all different checks at the check interval and adds the results to the database. + """ + + while True: + start_time = time.monotonic() + + await loop_step(db_conn, metrics, wireguard_if_pattern, gateway_ip_pattern, demo_ip_pattern, + demo_service_port, vulnbox_ip_pattern, vulnbox_service_port, team_net_numbers) + + sleep_duration = CHECK_INTERVAL - (time.monotonic() - start_time) + if sleep_duration < 0: + logging.warning('Check interval exceeded') + else: + logging.info('Sleeping for %f seconds', sleep_duration) + await asyncio.sleep(sleep_duration) + + +async def loop_step(db_conn, metrics, wireguard_if_pattern=None, gateway_ip_pattern=None, + demo_ip_pattern=None, demo_service_port=None, vulnbox_ip_pattern=None, + vulnbox_service_port=None, team_net_numbers=None): + + start_time = time.monotonic() + teams = database.get_active_teams(db_conn) + + if team_net_numbers is not None: + teams = [t for t in teams if t[1] in team_net_numbers] + + checks = [] + if wireguard_if_pattern: + checks.append(check_wireguard(wireguard_if_pattern, teams)) + if gateway_ip_pattern: + checks.append(check_pings(gateway_ip_pattern, teams)) + if demo_ip_pattern: + checks.append(check_pings(demo_ip_pattern, teams)) + if demo_ip_pattern and demo_service_port: + checks.append(check_tcp_connects(demo_ip_pattern, demo_service_port, teams)) + if vulnbox_ip_pattern: + checks.append(check_pings(vulnbox_ip_pattern, teams)) + if vulnbox_ip_pattern and vulnbox_service_port: + checks.append(check_tcp_connects(vulnbox_ip_pattern, vulnbox_service_port, teams)) + + logging.info('Starting %d checks for %d teams', len(checks), len(teams)) + check_results = await asyncio.gather(*checks, return_exceptions=True) + + results = { + t[0]: { + 'wireguard_handshake_time': None, + 'gateway_ping_rtt_ms': None, + 'demo_ping_rtt_ms': None, + 'demo_service_ok': False, + 'vulnbox_ping_rtt_ms': None, + 'vulnbox_service_ok': False + } for t in teams + } + + def update_results(key): + nonlocal check_results + up_count = 0 + + if isinstance(check_results[0], Exception): + logging.error('Exception during status check:', exc_info=check_results[0]) + else: + for team_id, check_result in check_results[0].items(): + results[team_id][key] = check_result + + # We have to check for identity here because 0 is False but a valid "up" value + if check_result is not False and check_result is not None: + up_count += 1 + if key.endswith('_ping_rtt_ms') and check_result is not None: + metric_target = key.removesuffix('_ping_rtt_ms') + metrics['ping_milliseconds'].labels(metric_target).observe(check_result) + metric_kind = key.removesuffix('_time').removesuffix('_rtt_ms').removesuffix('_ok') + metrics['up_count'].labels(metric_kind).set(up_count) + + check_results = check_results[1:] + + if wireguard_if_pattern: + update_results('wireguard_handshake_time') + if gateway_ip_pattern: + update_results('gateway_ping_rtt_ms') + if demo_ip_pattern: + update_results('demo_ping_rtt_ms') + if demo_ip_pattern and demo_service_port: + update_results('demo_service_ok') + if vulnbox_ip_pattern: + update_results('vulnbox_ping_rtt_ms') + if vulnbox_ip_pattern and vulnbox_service_port: + update_results('vulnbox_service_ok') + + database.add_results(db_conn, results) + logging.info('Added results for %d teams to database', len(results)) + + run_duration = time.monotonic() - start_time + metrics['check_duration_seconds'].set(run_duration) + + +async def check_wireguard(if_pattern, teams): + + teams_map = {if_pattern % team[1]: team[0] for team in teams} + results = {} + + cmd = ['sudo', 'wg', 'show', 'all', 'latest-handshakes'] + proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=None) + + while data := await proc.stdout.readline(): + line = data.decode('ascii').rstrip() + interface, _, handshake_timestamp = line.split('\t') + + if interface not in teams_map: + continue + + if handshake_timestamp == '0': + # No handshake yet + handshake_time = None + else: + handshake_time = datetime.utcfromtimestamp(int(handshake_timestamp)) + results[teams_map[interface]] = handshake_time + + await proc.wait() + + if proc.returncode == os.EX_OK: + return results + else: + logging.error('"%s" call failed with exit code %d', ' '.join(cmd), proc.returncode) + return {} + + +async def check_tcp_connects(ip_pattern, port, teams): + + async def check_connect(ip, port): + async def connect(): + _, writer = await asyncio.open_connection(ip, port) + writer.close() + await writer.wait_closed() + return True + try: + return await asyncio.wait_for(connect(), timeout=NETWORK_TIMEOUT) + # TimeoutError is different from asyncio.TimeoutError in Python < 3.11 + except (OSError, TimeoutError, asyncio.TimeoutError): + return False + + checks = [check_connect(ip_pattern % team[1], port) for team in teams] + results = await asyncio.gather(*checks) + + return {team[0]: result for team, result in zip(teams, results)} + + +async def check_pings(ip_pattern, teams): + + async def ping(ip): + cmd = ['ping', '-W', str(NETWORK_TIMEOUT), '-c', '1', '-n', ip] + proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=None) + + rtt_re = re.compile(r'^rtt min/avg/max/mdev = ([0-9.]+)/[0-9.]+/[0-9.]+/[0-9.]+ ms$') + rtt = None + + while data := await proc.stdout.readline(): + line = data.decode('ascii').rstrip() + match = rtt_re.search(line) + if match: + rtt = round(float(match.group(1))) + + await proc.wait() + + if proc.returncode == 0: + if rtt is None: + logging.error('"%s" call returned 0, but could not parse result', ' '.join(cmd)) + return rtt + elif proc.returncode == 1: + # No reply received + return None + else: + logging.error('"%s" call failed with exit code %d', ' '.join(cmd), proc.returncode) + return None + + checks = [ping(ip_pattern % team[1]) for team in teams] + results = await asyncio.gather(*checks) + + return {team[0]: result for team, result in zip(teams, results)} diff --git a/src/ctf_gameserver/web/base_settings.py b/src/ctf_gameserver/web/base_settings.py index 76cf7a4..20a728a 100644 --- a/src/ctf_gameserver/web/base_settings.py +++ b/src/ctf_gameserver/web/base_settings.py @@ -29,7 +29,8 @@ 'ctf_gameserver.web.templatetags', 'ctf_gameserver.web.registration', 'ctf_gameserver.web.scoring', - 'ctf_gameserver.web.flatpages' + 'ctf_gameserver.web.flatpages', + 'ctf_gameserver.web.vpnstatus' ) # Ordering of the middlewares is important, see diff --git a/src/ctf_gameserver/web/vpnstatus/__init__.py b/src/ctf_gameserver/web/vpnstatus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ctf_gameserver/web/vpnstatus/models.py b/src/ctf_gameserver/web/vpnstatus/models.py new file mode 100644 index 0000000..160478f --- /dev/null +++ b/src/ctf_gameserver/web/vpnstatus/models.py @@ -0,0 +1,22 @@ +from django.db import models + +from ctf_gameserver.web.registration.models import Team + + +class VPNStatusCheck(models.Model): + """ + Database representation of one VPN status check, consisting of the different check results (VPN, ping, + etc.) for one team at one point in time. + """ + + team = models.ForeignKey(Team, on_delete=models.CASCADE) + wireguard_handshake_time = models.DateTimeField(null=True, blank=True) + gateway_ping_rtt_ms = models.PositiveIntegerField(null=True, blank=True) + demo_ping_rtt_ms = models.PositiveIntegerField(null=True, blank=True) + vulnbox_ping_rtt_ms = models.PositiveIntegerField(null=True, blank=True) + demo_service_ok = models.BooleanField() + vulnbox_service_ok = models.BooleanField() + timestamp = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return 'VPN status check {:d}'.format(self.id) diff --git a/tests/vpnstatus/fixtures/status.json b/tests/vpnstatus/fixtures/status.json new file mode 100644 index 0000000..9fa42ad --- /dev/null +++ b/tests/vpnstatus/fixtures/status.json @@ -0,0 +1,107 @@ +[ +{ + "model": "auth.user", + "pk": 2, + "fields": { + "password": "pbkdf2_sha256$36000$kHAF2GkRGCyG$qm+7EyJr0b8E9VbQWp3ZtfxaV0A5wIJSV/ABWEML6II=", + "last_login": null, + "is_superuser": false, + "username": "Team1", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2019-04-03T18:21:28.622Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 3, + "fields": { + "password": "pbkdf2_sha256$36000$xOKKVRJp772Y$YbUoJ0N2rDg3xndTwZv+jHKrIWcJ209dOUZij007eXg=", + "last_login": null, + "is_superuser": false, + "username": "Team2", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2019-04-03T18:21:46.918Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "auth.user", + "pk": 4, + "fields": { + "password": "pbkdf2_sha256$36000$UGfl968SDB0l$rnxu7pLZUqwvYXk14QBjwjddYFTBrpw99PcH2FxtaKo=", + "last_login": null, + "is_superuser": false, + "username": "NOP", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": false, + "is_active": true, + "date_joined": "2019-04-03T18:22:02Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "registration.team", + "pk": 2, + "fields": { + "informal_email": "team1@example.org", + "image": "", + "affiliation": "", + "country": "World", + "nop_team": false, + "net_number": 102 + } +}, +{ + "model": "registration.team", + "pk": 3, + "fields": { + "informal_email": "team2@example.org", + "image": "", + "affiliation": "", + "country": "World", + "nop_team": false, + "net_number": 103 + } +}, +{ + "model": "registration.team", + "pk": 4, + "fields": { + "informal_email": "nop@example.org", + "image": "", + "affiliation": "", + "country": "World", + "nop_team": true, + "net_number": 104 + } +}, +{ + "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": 6, + "flag_prefix": "FAUST_", + "registration_open": false + } +} +] diff --git a/tests/vpnstatus/test_status.py b/tests/vpnstatus/test_status.py new file mode 100644 index 0000000..8a3969f --- /dev/null +++ b/tests/vpnstatus/test_status.py @@ -0,0 +1,158 @@ +import asyncio +from collections import defaultdict +from datetime import datetime +import os +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import Mock, patch + +from ctf_gameserver.lib.database import transaction_cursor +from ctf_gameserver.lib.test_util import DatabaseTestCase +from ctf_gameserver.vpnstatus.status import loop_step + + +class VPNStatusTest(DatabaseTestCase): + + fixtures = ['tests/vpnstatus/fixtures/status.json'] + metrics = defaultdict(Mock) + + def test_wireguard(self): + with TemporaryDirectory(prefix='ctf-fake-path-') as fake_path_dir: + os.environ['PATH'] = fake_path_dir + ':' + os.environ['PATH'] + + with (Path(fake_path_dir) / 'sudo').open('w', encoding='utf-8') as sudo_file: + os.fchmod(sudo_file.fileno(), 0o755) + script_lines = [ + '#!/bin/sh', + 'if [ "$*" != "wg show all latest-handshakes" ]; then', + ' exit 1', + 'fi', + 'echo "wg_102\tZeymPBFvUbfWHyZSccoWaf5CKEO96YZkCH5lbv8rqU0=\t1689415702"', + 'echo "wg_103\toWaR/kbHAUBvrOMxFN5frtZZRgNZ5EAJdb56PrdFPX4=\t0"' + ] + sudo_file.write('\n'.join(script_lines) + '\n') + + asyncio.run(loop_step(self.connection, self.metrics, wireguard_if_pattern='wg_%d')) + + checks = self._fetch_checks() + self.assertEqual(len(checks), 3) + + self.assertEqual(checks[0]['team_id'], 2) + self.assertEqual(checks[0]['wireguard_handshake_time'], datetime.utcfromtimestamp(1689415702)) + + self.assertEqual(checks[1]['team_id'], 3) + self.assertIsNone(checks[1]['wireguard_handshake_time']) + + self.assertEqual(checks[2]['team_id'], 4) + self.assertIsNone(checks[2]['wireguard_handshake_time']) + + @patch('ctf_gameserver.vpnstatus.status.NETWORK_TIMEOUT', 1) + def test_ping(self): + asyncio.run(loop_step(self.connection, self.metrics, gateway_ip_pattern='127.0.%s.1', + demo_ip_pattern='127.0.%s.2', vulnbox_ip_pattern='169.254.%s.42')) + + checks = self._fetch_checks() + self.assertEqual(len(checks), 3) + self.assertEqual(checks[0]['team_id'], 2) + self.assertEqual(checks[1]['team_id'], 3) + self.assertEqual(checks[2]['team_id'], 4) + + for check in checks: + self.assertIsNone(check['wireguard_handshake_time']) + self.assertGreaterEqual(check['gateway_ping_rtt_ms'], 0) + self.assertLess(check['gateway_ping_rtt_ms'], 1000) + self.assertGreaterEqual(check['demo_ping_rtt_ms'], 0) + self.assertLess(check['demo_ping_rtt_ms'], 1000) + self.assertIsNone(check['vulnbox_ping_rtt_ms']) + self.assertFalse(check['demo_service_ok']) + self.assertFalse(check['vulnbox_service_ok']) + + @patch('ctf_gameserver.vpnstatus.status.NETWORK_TIMEOUT', 1) + def test_net_numbers(self): + asyncio.run(loop_step(self.connection, self.metrics, gateway_ip_pattern='127.0.%s.1', + team_net_numbers=set([103, 104]))) + + checks = self._fetch_checks() + self.assertEqual(len(checks), 2) + self.assertEqual(checks[0]['team_id'], 3) + self.assertEqual(checks[1]['team_id'], 4) + + for check in checks: + self.assertGreaterEqual(check['gateway_ping_rtt_ms'], 0) + self.assertLess(check['gateway_ping_rtt_ms'], 1000) + + @patch('ctf_gameserver.vpnstatus.status.NETWORK_TIMEOUT', 1) + def test_tcp(self): + async def handle_conn(_reader, writer): + writer.close() + await writer.wait_closed() + + async def tcp_server(host, port): + server = await asyncio.start_server(handle_conn, host, port) + async with server: + await server.serve_forever() + + async def coroutine(): + task = asyncio.create_task(tcp_server('127.0.102.2', 7777)) + + # Wait for the server to start up + for _ in range(50): + try: + _reader, writer = await asyncio.open_connection('127.0.102.2', 7777) + except OSError: + await asyncio.sleep(0.1) + else: + writer.close() + await writer.wait_closed() + break + + await loop_step(self.connection, self.metrics, + demo_ip_pattern='127.0.%s.2', demo_service_port=7777, + vulnbox_ip_pattern='169.254.%s.42', vulnbox_service_port=7777) + + task.cancel() + + asyncio.run(coroutine()) + + checks = self._fetch_checks() + self.assertEqual(len(checks), 3) + + self.assertEqual(checks[0]['team_id'], 2) + self.assertTrue(checks[0]['demo_service_ok']) + self.assertFalse(checks[0]['vulnbox_service_ok']) + + self.assertEqual(checks[1]['team_id'], 3) + self.assertFalse(checks[1]['demo_service_ok']) + self.assertFalse(checks[1]['vulnbox_service_ok']) + + def test_nothing(self): + asyncio.run(loop_step(self.connection, self.metrics)) + + checks = self._fetch_checks() + self.assertEqual(len(checks), 3) + + self.assertEqual(checks[0], { + 'team_id': 2, + 'wireguard_handshake_time': None, + 'gateway_ping_rtt_ms': None, + 'demo_ping_rtt_ms': None, + 'vulnbox_ping_rtt_ms': None, + 'demo_service_ok': False, + 'vulnbox_service_ok': False + }) + + def _fetch_checks(self): + with transaction_cursor(self.connection) as cursor: + cursor.execute('SELECT team_id, wireguard_handshake_time, gateway_ping_rtt_ms, demo_ping_rtt_ms,' + ' vulnbox_ping_rtt_ms, demo_service_ok, vulnbox_service_ok ' + 'FROM vpnstatus_vpnstatuscheck ORDER BY team_id') + checks = cursor.fetchall() + + return [{'team_id': c[0], + 'wireguard_handshake_time': c[1], + 'gateway_ping_rtt_ms': c[2], + 'demo_ping_rtt_ms': c[3], + 'vulnbox_ping_rtt_ms': c[4], + 'demo_service_ok': c[5], + 'vulnbox_service_ok': c[6] + } for c in checks] From 3c4684e6488abccbc8e3043b7b06d72ce77d4f33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Tue, 25 Jul 2023 21:02:40 +0200 Subject: [PATCH 43/63] Web: Add visualization of "VPN Status" checks Closes: https://github.com/fausecteam/ctf-gameserver/issues/15 Co-authored-by: Simon Ruderich --- .../web/templates/base-common.html | 1 + src/ctf_gameserver/web/urls.py | 6 ++ .../vpnstatus/templates/status_history.html | 101 ++++++++++++++++++ src/ctf_gameserver/web/vpnstatus/views.py | 54 ++++++++++ 4 files changed, 162 insertions(+) create mode 100644 src/ctf_gameserver/web/vpnstatus/templates/status_history.html create mode 100644 src/ctf_gameserver/web/vpnstatus/views.py diff --git a/src/ctf_gameserver/web/templates/base-common.html b/src/ctf_gameserver/web/templates/base-common.html index e2a95f2..5e227e3 100644 --- a/src/ctf_gameserver/web/templates/base-common.html +++ b/src/ctf_gameserver/web/templates/base-common.html @@ -99,6 +99,7 @@
  • {% trans 'Edit Team' %}
  • {% endif %} +
  • {% trans 'VPN Status History' %}
  • {% if user.is_staff %}
  • {% trans 'Administration' %}
  • {% trans 'Service History' %}
  • diff --git a/src/ctf_gameserver/web/urls.py b/src/ctf_gameserver/web/urls.py index 15976cf..bcfc330 100644 --- a/src/ctf_gameserver/web/urls.py +++ b/src/ctf_gameserver/web/urls.py @@ -6,6 +6,7 @@ from .registration import views as registration_views from .scoring import views as scoring_views from .flatpages import views as flatpages_views +from .vpnstatus import views as vpnstatus_views from .admin import admin_site from .forms import TeamAuthenticationForm, FormalPasswordResetForm @@ -92,6 +93,11 @@ name='service_status_json' ), + url(r'^vpn-status/$', + vpnstatus_views.status_history, + name='status_history' + ), + url(r'^downloads/$', registration_views.list_team_downloads, name='list_team_downloads' diff --git a/src/ctf_gameserver/web/vpnstatus/templates/status_history.html b/src/ctf_gameserver/web/vpnstatus/templates/status_history.html new file mode 100644 index 0000000..80fd1dd --- /dev/null +++ b/src/ctf_gameserver/web/vpnstatus/templates/status_history.html @@ -0,0 +1,101 @@ +{% extends 'base.html' %} +{% load i18n %} +{% load static %} + +{% block content %} + + +{% if allow_team_selection %} +
    + +
    + + + +
    + +
    +{% endif %} + +{% if check_results is not None %} +
    + + + + + + + + + + + + + + + {% for result in check_results %} + + + + {% if result.gateway_ping_rtt_ms is not None %} + + {% else %} + + {% endif %} + {% if result.demo_ping_rtt_ms is not None %} + + {% else %} + + {% endif %} + {% if result.demo_service_ok %} + + {% else %} + + {% endif %} + {% if result.vulnbox_ping_rtt_ms is not None %} + + {% else %} + + {% endif %} + {% if result.vulnbox_service_ok %} + + {% else %} + + {% endif %} + + {% endfor %} + +
    {% trans 'Time' %} ({{ server_timezone }}){% trans 'Last WireGuard Handshake' %}{% trans 'Gateway Ping' %}{% trans 'Demo Vulnbox Ping RTT' %}{% trans 'Demo Vulnbox Service' %}{% trans 'Vulnbox Ping RTT' %}{% trans 'Vulnbox Service' %}
    + {{ result.timestamp | date }} {{ result.timestamp | time }} + + {% if result.wireguard_handshake_time is not None %} + {{ result.wireguard_handshake_time | time }} + {% else %} + {% trans 'N/A' %} + {% endif %} + + {{ result.gateway_ping_rtt_ms }} ms + + {% trans 'down' %} + + {{ result.demo_ping_rtt_ms }} ms + + {% trans 'down' %} + + {% trans 'up' %} + + {% trans 'down' %} + + {{ result.vulnbox_ping_rtt_ms }} ms + + {% trans 'down' %} + + {% trans 'up' %} + + {% trans 'down' %} +
    +
    +{% endif %} +{% endblock %} diff --git a/src/ctf_gameserver/web/vpnstatus/views.py b/src/ctf_gameserver/web/vpnstatus/views.py new file mode 100644 index 0000000..2679b7e --- /dev/null +++ b/src/ctf_gameserver/web/vpnstatus/views.py @@ -0,0 +1,54 @@ +from datetime import timedelta + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import Http404 +from django.shortcuts import get_object_or_404, render + +from ctf_gameserver.web.registration.models import Team +from .models import VPNStatusCheck + + +@login_required +def status_history(request): + + if request.user.is_staff: + allow_team_selection = True + + net_number_param = request.GET.get('net-number') + if net_number_param is None: + return render(request, 'status_history.html', { + 'allow_team_selection': allow_team_selection, + 'net_number': None, + 'server_timezone': settings.TIME_ZONE, + 'check_results': None + }) + try: + net_number = int(net_number_param) + except ValueError as e: + # Cannot return status code 400 in the same easy way Β―\_(ツ)_/Β― + raise Http404('Invalid net number') from e + + team = get_object_or_404(Team, net_number=net_number) + else: + allow_team_selection = False + + try: + team = request.user.team + except Team.DoesNotExist as e: + raise Http404('User has no team') from e + + check_results = VPNStatusCheck.objects.filter(team=team).order_by('-timestamp')[:60].values() + for result in check_results: + if result['wireguard_handshake_time'] is None: + result['wireguard_ok'] = False + else: + age = result['timestamp'] - result['wireguard_handshake_time'] + result['wireguard_ok'] = age < timedelta(minutes=5) + + return render(request, 'status_history.html', { + 'allow_team_selection': allow_team_selection, + 'net_number': team.net_number, + 'server_timezone': settings.TIME_ZONE, + 'check_results': check_results + }) From b35b6cc7753f9968a8d8f9ca1ee6de064f8f23d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Tue, 25 Jul 2023 21:17:03 +0200 Subject: [PATCH 44/63] Checker: Fix typo in metrics description --- src/ctf_gameserver/checker/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/checker/metrics.py b/src/ctf_gameserver/checker/metrics.py index 467db20..6751988 100644 --- a/src/ctf_gameserver/checker/metrics.py +++ b/src/ctf_gameserver/checker/metrics.py @@ -74,7 +74,7 @@ def checker_metrics_factory(registry, service): metrics['completed_tasks'].labels(result.name, service) gauges = [ - ('start_timestamp', '(Unix timestamp when the process was started'), + ('start_timestamp', '(Unix) timestamp when the process was started'), ('interval_length_seconds', 'Configured launch interval length'), ('last_launch_timestamp', '(Unix) timestamp when tasks were launched the last time'), ('tasks_per_launch_count', 'Number of checks to start in one launch interval'), From 2335d541d53556322d25eefaefe645f477f73835 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Fri, 18 Aug 2023 08:16:55 +0200 Subject: [PATCH 45/63] VPN Status: Use absolute path to `wg` sudo requires an absolute path in sudoers. --- examples/vpnstatus/sudoers.d/ctf-vpnstatus | 2 +- src/ctf_gameserver/vpnstatus/status.py | 2 +- tests/vpnstatus/test_status.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/vpnstatus/sudoers.d/ctf-vpnstatus b/examples/vpnstatus/sudoers.d/ctf-vpnstatus index 028f051..b18520f 100644 --- a/examples/vpnstatus/sudoers.d/ctf-vpnstatus +++ b/examples/vpnstatus/sudoers.d/ctf-vpnstatus @@ -1,2 +1,2 @@ # Allow user "ctf-vpnstatus" to display WireGuard status without being prompted for a password -ctf-vpnstatus ALL = (root) NOPASSWD: wg show all latest-handshakes +ctf-vpnstatus ALL = (root) NOPASSWD: /usr/bin/wg show all latest-handshakes diff --git a/src/ctf_gameserver/vpnstatus/status.py b/src/ctf_gameserver/vpnstatus/status.py index 7abd126..47eedc4 100644 --- a/src/ctf_gameserver/vpnstatus/status.py +++ b/src/ctf_gameserver/vpnstatus/status.py @@ -232,7 +232,7 @@ async def check_wireguard(if_pattern, teams): teams_map = {if_pattern % team[1]: team[0] for team in teams} results = {} - cmd = ['sudo', 'wg', 'show', 'all', 'latest-handshakes'] + cmd = ['sudo', '/usr/bin/wg', 'show', 'all', 'latest-handshakes'] proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=None) while data := await proc.stdout.readline(): diff --git a/tests/vpnstatus/test_status.py b/tests/vpnstatus/test_status.py index 8a3969f..f08ae6d 100644 --- a/tests/vpnstatus/test_status.py +++ b/tests/vpnstatus/test_status.py @@ -24,7 +24,7 @@ def test_wireguard(self): os.fchmod(sudo_file.fileno(), 0o755) script_lines = [ '#!/bin/sh', - 'if [ "$*" != "wg show all latest-handshakes" ]; then', + 'if [ "$*" != "/usr/bin/wg show all latest-handshakes" ]; then', ' exit 1', 'fi', 'echo "wg_102\tZeymPBFvUbfWHyZSccoWaf5CKEO96YZkCH5lbv8rqU0=\t1689415702"', From 03e0d0377e9687c60dd0454c1c1dc0efb5151bff Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Fri, 18 Aug 2023 08:17:52 +0200 Subject: [PATCH 46/63] VPN Status: Add missing systemd ready notification --- src/ctf_gameserver/vpnstatus/status.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ctf_gameserver/vpnstatus/status.py b/src/ctf_gameserver/vpnstatus/status.py index 47eedc4..ebf8cc4 100644 --- a/src/ctf_gameserver/vpnstatus/status.py +++ b/src/ctf_gameserver/vpnstatus/status.py @@ -9,6 +9,7 @@ import psycopg2 from psycopg2 import errorcodes as postgres_errors +from ctf_gameserver.lib import daemon from ctf_gameserver.lib.args import get_arg_parser_with_db, parse_host_port from ctf_gameserver.lib.database import transaction_cursor from ctf_gameserver.lib.metrics import start_metrics_server @@ -96,6 +97,8 @@ def main(): with open(args.net_numbers_filter_file, encoding='ascii') as filter_file: net_numbers = set(int(line) for line in filter_file) + daemon.notify('READY=1') + asyncio.run(main_loop(db_conn, metrics, args.wireguard_ifpattern, args.gateway_ippattern, args.demo_ippattern, args.demo_serviceport, args.vulnbox_ippattern, args.vulnbox_serviceport, net_numbers)) From 54ded1fbd74d2ccb41c72ee4f7be619cfb74e9e1 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Thu, 31 Aug 2023 08:50:34 +0200 Subject: [PATCH 47/63] Checker: Assign boolean values to cancel_checks SQLite works fine with integers but Postgres only permits boolean values. Fixes: 739fc6f (Checker: Terminate Checker Scripts after competition has finished, 2023-04-22) --- src/ctf_gameserver/controller/database.py | 5 +++-- tests/checker/test_integration.py | 2 +- tests/controller/test_main_loop.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ctf_gameserver/controller/database.py b/src/ctf_gameserver/controller/database.py index 239ce06..f501749 100644 --- a/src/ctf_gameserver/controller/database.py +++ b/src/ctf_gameserver/controller/database.py @@ -27,7 +27,8 @@ def get_control_info(db_conn, prohibit_changes=False): def increase_tick(db_conn, prohibit_changes=False): with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('UPDATE scoring_gamecontrol SET current_tick = current_tick + 1, cancel_checks = 0') + cursor.execute('UPDATE scoring_gamecontrol SET current_tick = current_tick + 1,' + ' cancel_checks = false') # Create flags for every service and team in the new tick cursor.execute('INSERT INTO scoring_flag (service_id, protecting_team_id, tick)' ' SELECT service.id, team.user_id, control.current_tick' @@ -39,7 +40,7 @@ def increase_tick(db_conn, prohibit_changes=False): def cancel_checks(db_conn, prohibit_changes=False): with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks = 1') + cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks = true') def update_scoring(db_conn): diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index 36757ff..1493af6 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -274,7 +274,7 @@ def test_cancel_checks(self, monotonic_mock, warning_mock): os.kill(checkerscript_pid, 0) with transaction_cursor(self.connection) as cursor: - cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks=1') + cursor.execute('UPDATE scoring_gamecontrol SET cancel_checks=true') master_loop.supervisor.queue_timeout = 0.01 monotonic_mock.return_value = 190 diff --git a/tests/controller/test_main_loop.py b/tests/controller/test_main_loop.py index 273ff72..9657cf3 100644 --- a/tests/controller/test_main_loop.py +++ b/tests/controller/test_main_loop.py @@ -108,7 +108,7 @@ def test_next_tick_overdue(self, sleep_mock, _): with transaction_cursor(self.connection) as cursor: cursor.execute('UPDATE scoring_gamecontrol SET start=datetime("now", "-19 minutes"), ' ' end=datetime("now", "+1421 minutes"), ' - ' current_tick=5, cancel_checks=1') + ' current_tick=5, cancel_checks=true') controller.main_loop_step(self.connection, self.metrics, False) From 8dfe0430a199efd204fa421998dac535c53e921c Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 2 Sep 2023 16:32:54 +0200 Subject: [PATCH 48/63] Checker: cast numeric to float in get_check_duration() Postgresql 14 changed the result type for extract() from float to numeric. --- src/ctf_gameserver/checker/database.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ctf_gameserver/checker/database.py b/src/ctf_gameserver/checker/database.py index a493a0e..8cb7926 100644 --- a/src/ctf_gameserver/checker/database.py +++ b/src/ctf_gameserver/checker/database.py @@ -67,8 +67,8 @@ def get_check_duration(db_conn, service_id, std_dev_count, prohibit_changes=Fals """ with transaction_cursor(db_conn, prohibit_changes) as cursor: - cursor.execute('SELECT avg(extract(epoch from (placement_end - placement_start))) + %s *' - ' stddev_pop(extract(epoch from (placement_end - placement_start)))' + cursor.execute('SELECT (avg(extract(epoch from (placement_end - placement_start))) + %s *' + ' stddev_pop(extract(epoch from (placement_end - placement_start))))::float' ' FROM scoring_flag, scoring_gamecontrol' ' WHERE service_id = %s AND tick < current_tick', (std_dev_count, service_id)) result = cursor.fetchone() From ce8fe756e5e6d36dcbf5d42d107a82ae6445d322 Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Sat, 2 Sep 2023 17:47:31 +0200 Subject: [PATCH 49/63] Checker Lib: Check types of arguments in public API --- src/ctf_gameserver/checkerlib/lib.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ctf_gameserver/checkerlib/lib.py b/src/ctf_gameserver/checkerlib/lib.py index 934143b..b66b7f8 100644 --- a/src/ctf_gameserver/checkerlib/lib.py +++ b/src/ctf_gameserver/checkerlib/lib.py @@ -120,6 +120,9 @@ def get_flag(tick: int) -> str: current run. The returned flag can be used for both placement and checks. """ + if not isinstance(tick, int): + raise TypeError('tick must be of type int') + # Return dummy flag when launched locally if _launched_without_runner(): try: @@ -140,6 +143,9 @@ def set_flagid(data: str) -> None: Stores the Flag ID for the current team and tick. """ + if not isinstance(data, str): + raise TypeError('data must be of type str') + if not _launched_without_runner(): _send_ctrl_message({'action': 'FLAGID', 'param': data}) # Wait for acknowledgement @@ -154,6 +160,9 @@ def store_state(key: str, data: Any) -> None: service and team with the given key as an additional identifier. """ + if not isinstance(key, str): + raise TypeError('key must be of type str') + serialized_data = base64.b64encode(pickle.dumps(data)).decode('ascii') if not _launched_without_runner(): @@ -178,6 +187,9 @@ def load_state(key: str) -> Any: current service and team), None is returned. """ + if not isinstance(key, str): + raise TypeError('key must be of type str') + if not _launched_without_runner(): _send_ctrl_message({'action': 'LOAD', 'param': key}) result = _recv_ctrl_message() From 29f27adf09a7488086e7b4a7865832b6f8d7481a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 5 Aug 2023 10:27:21 +0200 Subject: [PATCH 50/63] Debian packaging: Do not install example sudoers file for Checker This should be an example only, deployment will be handled through other means such as our Ansible role. --- debian/install | 1 - 1 file changed, 1 deletion(-) diff --git a/debian/install b/debian/install index d478f41..f19b81b 100644 --- a/debian/install +++ b/debian/install @@ -1,6 +1,5 @@ conf/checker/checkermaster.env etc/ctf-gameserver 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/ctf-controller.service lib/systemd/system From 0cde55e11f2b16b366fd45faf1feca5b81d92c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Wed, 13 Sep 2023 16:32:56 +0200 Subject: [PATCH 51/63] Web: Update URL pattern for password reset --- src/ctf_gameserver/web/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/web/urls.py b/src/ctf_gameserver/web/urls.py index bcfc330..9026fd6 100644 --- a/src/ctf_gameserver/web/urls.py +++ b/src/ctf_gameserver/web/urls.py @@ -55,7 +55,7 @@ auth_views.PasswordResetDoneView.as_view(template_name='password_reset_done.html'), name='password_reset_done' ), - url(r'^auth/reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$', + url(r'^auth/reset/(?P[0-9A-Za-z_\-]+)/(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]+)/$', auth_views.PasswordResetConfirmView.as_view(template_name='password_reset_confirm.html'), name='password_reset_confirm' ), From 1526743b7d3e9fba76db47dc4f15071e6d67866e Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Fri, 15 Sep 2023 07:52:39 +0200 Subject: [PATCH 52/63] Call sudo with --non-interactive This option prevents any kind of prompts and causes sudo to exit with an error. Use it as safeguard against misconfigured setups which ask for a password. --- src/ctf_gameserver/checker/supervisor.py | 4 ++-- src/ctf_gameserver/vpnstatus/status.py | 2 +- tests/checker/test_integration.py | 4 ++-- tests/vpnstatus/test_status.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ctf_gameserver/checker/supervisor.py b/src/ctf_gameserver/checker/supervisor.py index f51969f..6d9c852 100644 --- a/src/ctf_gameserver/checker/supervisor.py +++ b/src/ctf_gameserver/checker/supervisor.py @@ -223,7 +223,7 @@ def dup_ctrl_fds(): if sudo_user is not None: args = ['sudo', '--user='+sudo_user, '--preserve-env=PATH,CTF_CHECKERSCRIPT,CHECKERSCRIPT_PIDFILE', - '--close-from=5', '--'] + args + '--close-from=5', '--non-interactive', '--'] + args env = {**os.environ, 'CTF_CHECKERSCRIPT': '1'} script_logger.info('[RUNNER] Executing Checker Script') @@ -252,7 +252,7 @@ def sigterm_handler(_, __): # 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 + kill_args = ['sudo', '--user='+sudo_user, '--non-interactive', '--'] + 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 diff --git a/src/ctf_gameserver/vpnstatus/status.py b/src/ctf_gameserver/vpnstatus/status.py index ebf8cc4..49754f1 100644 --- a/src/ctf_gameserver/vpnstatus/status.py +++ b/src/ctf_gameserver/vpnstatus/status.py @@ -235,7 +235,7 @@ async def check_wireguard(if_pattern, teams): teams_map = {if_pattern % team[1]: team[0] for team in teams} results = {} - cmd = ['sudo', '/usr/bin/wg', 'show', 'all', 'latest-handshakes'] + cmd = ['sudo', '--non-interactive', '--', '/usr/bin/wg', 'show', 'all', 'latest-handshakes'] proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=None) while data := await proc.stdout.readline(): diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index 1493af6..46a9a3d 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -565,8 +565,8 @@ def test_sudo_unfinished(self, monotonic_mock, warning_mock): checkerscript_pid = int(checkerscript_pidfile.read()) def signal_script(): - subprocess.check_call(['sudo', '--user=ctf-checkerrunner', '--', 'kill', '-0', - str(checkerscript_pid)]) + subprocess.check_call(['sudo', '--user=ctf-checkerrunner', '--non-interactive', '--', + 'kill', '-0', str(checkerscript_pid)]) # Ensure process is running by sending signal 0 signal_script() diff --git a/tests/vpnstatus/test_status.py b/tests/vpnstatus/test_status.py index f08ae6d..ba78dc7 100644 --- a/tests/vpnstatus/test_status.py +++ b/tests/vpnstatus/test_status.py @@ -24,7 +24,7 @@ def test_wireguard(self): os.fchmod(sudo_file.fileno(), 0o755) script_lines = [ '#!/bin/sh', - 'if [ "$*" != "/usr/bin/wg show all latest-handshakes" ]; then', + 'if [ "$*" != "--non-interactive -- /usr/bin/wg show all latest-handshakes" ]; then', ' exit 1', 'fi', 'echo "wg_102\tZeymPBFvUbfWHyZSccoWaf5CKEO96YZkCH5lbv8rqU0=\t1689415702"', From 38699cacb4774687956da8cc95565d87fc75371f Mon Sep 17 00:00:00 2001 From: Simon Ruderich Date: Fri, 15 Sep 2023 07:53:28 +0200 Subject: [PATCH 53/63] Checker: Add comment to test --- tests/checker/test_integration.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/checker/test_integration.py b/tests/checker/test_integration.py index 46a9a3d..79d5af4 100644 --- a/tests/checker/test_integration.py +++ b/tests/checker/test_integration.py @@ -540,6 +540,7 @@ def test_sudo_unfinished(self, monotonic_mock, warning_mock): checkerscript_path = os.path.join(os.path.dirname(__file__), 'integration_unfinished_checkerscript.py') + # NOTE: This needs `sysctl fs.protected_regular=0` if tempfile is created in /tmp checkerscript_pidfile = tempfile.NamedTemporaryFile() os.chmod(checkerscript_pidfile.name, 0o666) os.environ['CHECKERSCRIPT_PIDFILE'] = checkerscript_pidfile.name From 665ccb01ec67671f9d899eab450f26ee08292613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 30 Sep 2023 14:23:31 +0200 Subject: [PATCH 54/63] Web: More consistent table header in VPN Status history Co-authored-by: Simon Ruderich --- .../web/vpnstatus/templates/status_history.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ctf_gameserver/web/vpnstatus/templates/status_history.html b/src/ctf_gameserver/web/vpnstatus/templates/status_history.html index 80fd1dd..e404faa 100644 --- a/src/ctf_gameserver/web/vpnstatus/templates/status_history.html +++ b/src/ctf_gameserver/web/vpnstatus/templates/status_history.html @@ -26,9 +26,9 @@

    {% block title %}{% trans 'VPN Status History' %}{% endblock %}

    {% trans 'Time' %} ({{ server_timezone }}) {% trans 'Last WireGuard Handshake' %} - {% trans 'Gateway Ping' %} - {% trans 'Demo Vulnbox Ping RTT' %} - {% trans 'Demo Vulnbox Service' %} + {% trans 'Gateway Ping RTT' %} + {% trans 'Testing Vulnbox Ping RTT' %} + {% trans 'Testing Vulnbox Service' %} {% trans 'Vulnbox Ping RTT' %} {% trans 'Vulnbox Service' %} From bc0183e07a2399e4640ec71d8f7d6d134b053d73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 30 Sep 2023 15:00:13 +0200 Subject: [PATCH 55/63] Web: Display net number in user list in admin interface Co-authored-by: Simon Ruderich --- src/ctf_gameserver/web/admin.py | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/src/ctf_gameserver/web/admin.py b/src/ctf_gameserver/web/admin.py index 5f2ad6e..a18c00e 100644 --- a/src/ctf_gameserver/web/admin.py +++ b/src/ctf_gameserver/web/admin.py @@ -68,23 +68,14 @@ def queryset(self, request, queryset): else: return queryset - def user_has_team(self, user): - """ - Indicates if the given user is associated with a team or not. This is used as value generator for an - additional column in user lists. - """ + @admin.display(ordering='team__net_number', description='Net Number') + def team_net_number(self, user): try: - user.team # pylint: disable=pointless-statement - return True + return user.team.net_number except Team.DoesNotExist: - return False - - user_has_team.short_description = _('Associated team') - # Display on/off icons instead of strings for the user_has_team values - user_has_team.boolean = True - user_has_team.admin_order_field = 'team' + return None - list_display = ('username', 'is_active', 'is_staff', 'is_superuser', 'user_has_team', 'date_joined') + list_display = ('username', 'is_active', 'is_staff', 'is_superuser', 'team_net_number', 'date_joined') list_filter = ('is_active', 'is_staff', 'is_superuser', TeamListFilter, 'date_joined') search_fields = ('username', 'email', 'team__net_number', 'team__informal_email', 'team__affiliation', 'team__country') From 8ac0778fd918f4d6cc0dcab75b64acbd716fbc8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 30 Sep 2023 15:10:40 +0200 Subject: [PATCH 56/63] Web: Add admin interface for VPN status checks Co-authored-by: Simon Ruderich --- src/ctf_gameserver/web/vpnstatus/admin.py | 13 +++++++++++++ src/ctf_gameserver/web/vpnstatus/models.py | 3 +++ 2 files changed, 16 insertions(+) create mode 100644 src/ctf_gameserver/web/vpnstatus/admin.py diff --git a/src/ctf_gameserver/web/vpnstatus/admin.py b/src/ctf_gameserver/web/vpnstatus/admin.py new file mode 100644 index 0000000..3a6c842 --- /dev/null +++ b/src/ctf_gameserver/web/vpnstatus/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from ctf_gameserver.web.admin import admin_site +from . import models + + +@admin.register(models.VPNStatusCheck, site=admin_site) +class VPNStatusCheckAdmin(admin.ModelAdmin): + + list_display = ('team', 'timestamp', 'wireguard_handshake_time') + list_filter = ('team',) + search_fields = ('team__user__username' , 'team__net_number') + ordering = ('timestamp', 'team') diff --git a/src/ctf_gameserver/web/vpnstatus/models.py b/src/ctf_gameserver/web/vpnstatus/models.py index 160478f..876e9b8 100644 --- a/src/ctf_gameserver/web/vpnstatus/models.py +++ b/src/ctf_gameserver/web/vpnstatus/models.py @@ -18,5 +18,8 @@ class VPNStatusCheck(models.Model): vulnbox_service_ok = models.BooleanField() timestamp = models.DateTimeField(auto_now_add=True) + class Meta: + verbose_name = 'VPN status check' + def __str__(self): return 'VPN status check {:d}'.format(self.id) From 8de989af83803c6b91fb55fff7301095524ef93c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20Drei=C3=9Fig?= Date: Sat, 30 Sep 2023 15:17:52 +0200 Subject: [PATCH 57/63] Web: Fix linter error --- src/ctf_gameserver/web/vpnstatus/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ctf_gameserver/web/vpnstatus/admin.py b/src/ctf_gameserver/web/vpnstatus/admin.py index 3a6c842..2b7cbc6 100644 --- a/src/ctf_gameserver/web/vpnstatus/admin.py +++ b/src/ctf_gameserver/web/vpnstatus/admin.py @@ -9,5 +9,5 @@ class VPNStatusCheckAdmin(admin.ModelAdmin): list_display = ('team', 'timestamp', 'wireguard_handshake_time') list_filter = ('team',) - search_fields = ('team__user__username' , 'team__net_number') + search_fields = ('team__user__username', 'team__net_number') ordering = ('timestamp', 'team') From 195ea3bbfe23942ff8b359da6936751ad55b78a9 Mon Sep 17 00:00:00 2001 From: Felix Dreissig Date: Sat, 25 Nov 2023 18:27:30 +0100 Subject: [PATCH 58/63] Update copyright information MIT was never correct from the start. --- LICENSE.txt | 7 ++++--- README.md | 7 +++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/LICENSE.txt b/LICENSE.txt index 9cc17b5..20f3d40 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,7 @@ -Copyright (c) 2015 FAUST -- FAU Security Team -Copyright (c) 2015 Christoph Egger -Copyright (c) 2015 Felix Dreissig +Copyright (c) FAUST -- FAU Security Team +Copyright (c) Christoph Egger +Copyright (c) Felix Dreissig +Copyright (c) Simon Ruderich Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/README.md b/README.md index dd780a2..e258032 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,9 @@ some principles: * Scalability: We couldn't really estimate the load beforehand, nor could we easily do realistic load-testing. That's why the components are loosely coupled and can be run on different machines. -Licensing +Copyright --------- -The whole gameserver is released under the MIT (expat) license. Contributions are welcome! +The Gameserver was initially created by Christoph Egger and Felix Dreissig. It is currently maintained by +Felix Dreissig and Simon Ruderich with contributions from others. + +It is released under the ISC License. From 5082c76138f01c76878439f7b248a270346999df Mon Sep 17 00:00:00 2001 From: Felix Dreissig Date: Sat, 2 Dec 2023 12:38:50 +0100 Subject: [PATCH 59/63] Web: Improve help texts in admin interface Co-authored-by: Simon Ruderich --- src/ctf_gameserver/web/registration/models.py | 6 ++++-- src/ctf_gameserver/web/scoring/models.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ctf_gameserver/web/registration/models.py b/src/ctf_gameserver/web/registration/models.py index 20506b8..2f7ecd1 100644 --- a/src/ctf_gameserver/web/registration/models.py +++ b/src/ctf_gameserver/web/registration/models.py @@ -55,10 +55,12 @@ def __str__(self): class TeamDownload(models.Model): """ Database representation of a single type of per-team download. One file with the specified name can - be provided per team in the file system hierarchy below `settings.TEAM_DOWNLOADS_ROOT`. + be provided per team in the filesystem hierarchy below `settings.TEAM_DOWNLOADS_ROOT`. """ - filename = models.CharField(max_length=100, help_text=_('Name within the per-team filesystem hierarchy'), + filename = models.CharField(max_length=100, + help_text=_('Name within the per-team filesystem hierarchy, see ' + '"TEAM_DOWNLOADS_ROOT" setting'), validators=[RegexValidator(r'^[^/]+$', message=_('Must not contain slashes'))]) description = models.TextField() diff --git a/src/ctf_gameserver/web/scoring/models.py b/src/ctf_gameserver/web/scoring/models.py index e45d88a..8e433cb 100644 --- a/src/ctf_gameserver/web/scoring/models.py +++ b/src/ctf_gameserver/web/scoring/models.py @@ -12,7 +12,7 @@ class Service(models.Model): """ name = models.CharField(max_length=30, unique=True) - slug = models.SlugField(max_length=30, unique=True) + slug = models.SlugField(max_length=30, unique=True, help_text=_('Simplified name for use in paths')) def __str__(self): # pylint: disable=invalid-str-returned return self.name @@ -153,7 +153,8 @@ class GameControl(models.Model): # Tick duration in seconds 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) + # See https://github.com/fausecteam/ctf-gameserver/issues/84 + valid_ticks = models.PositiveSmallIntegerField(default=5, help_text=_('Currently unused')) current_tick = models.IntegerField(default=-1) # Instruct Checker Runners to cancel any running checks cancel_checks = models.BooleanField(default=False) From 209af339b1bf81518574b5c77ade129f9b4d17df Mon Sep 17 00:00:00 2001 From: Felix Dreissig Date: Sat, 2 Dec 2023 12:42:17 +0100 Subject: [PATCH 60/63] Update README file Co-authored-by: Simon Ruderich --- README.md | 87 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 45 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index e258032..aa8bb53 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,65 @@ CTF Gameserver ============== -This is a gameserver for [attack-defense (IT security) CTFs](https://ctftime.org/ctf-wtf/). It was originally -written for [FAUST CTF 2015](https://www.faustctf.net/2015/), but is designed to be re-usable for other +This is a Gameserver for [attack-defense (IT security) CTFs](https://ctftime.org/ctf-wtf/). It is used for +hosting [FAUST CTF](https://www.faustctf.net), but designed to be re-usable for other competitions. It is +scalable to large online CTFs, battle-tested in many editions of FAUST CTF, and customizable for other competitions. -What's included +For documentation on architecture, installation, etc., head to [ctf-gameserver.org](https://ctf-gameserver.org/). + +What's Included --------------- -The gameserver consists of multiple components. They may be deployed separately of each other as their only -means of communication is a shared database. +The Gameserver consists of multiple components: -* Web: A [Django](https://www.djangoproject.com/)-based web application for team registration and - scoreboards. It also contains the model files, which define the database structure. +* Web: A [Django](https://www.djangoproject.com/)-based web application for team registration, scoreboards, + and simple hosting of informational pages. It also contains the model files, which define the database + structure. * Controller: Coordinates the progress of the competition, e.g. the current tick and flags to be placed. -* Checker: Offers an interface for checker scripts, which place and retrieve flags and test the status of - services. +* Checker: Place and retrieve flags and test the service status on all teams' Vulnboxes. The Checker Master + launches Checker Scripts, which are individual to each service. +* Checkerlib: Libraries to assist in developing Checker Scripts. Currently, Python and Go are supported. * Submission: Server to submit captured flags to. -* Lib: Some code that is shared between the components. - -For deployment instructions and details on the implementations, see the `README`s of the individual -components. +* VPN Status: Optional helper that collects statistics about network connectivity to teams. -Related projects +Related Projects ---------------- -There are several alternatives out there, although none of them could really convince us. Your mileage may -vary at this point. +There are several alternatives out there, although none of them could really convince us when we started the +project in 2015. Your mileage may vary. -* ucsb-seclab/ictf-framework from the team behind iCTF, one of the most well-known - attack-defense CTFs. In addition to a gameserver, it includes utilities for VM creation and network setup. - We had trouble to get it running and documentation is generally rather rare. -* HackerDom/checksystem is the gameserver powering the RuCTF. The first impression wasn't too bad, but it - didn't look quite feature-complete to us. However, we didn't really grasp the Perl code, so we might have - overlooked something. -* isislab/CTFd appears to be de-facto standard for [jeopardy-based CTFs](https://ctftime.org/ctf-wtf/). It - is, however, not suitable for an attack-defense CTF. +* [ictf-framework](https://github.com/shellphish/ictf-framework) from the team behind iCTF, one of the most + well-known attack-defense CTFs. In addition to a gameserver, it includes utilities for VM creation and + network setup. We had trouble to get it running and documentation is generally rather scarce. +* [HackerDom checksystem](https://github.com/HackerDom/checksystem) is the Gameserver powering RuCTF. The + first impression wasn't too bad, but it didn't look quite feature-complete to us. However, we didn't really + grasp the Perl code, so we might have overlooked something. +* [saarctf-gameserver](https://github.com/MarkusBauer/saarctf-gameserver) from our friends at saarsec is + younger than our Gameserver. It contains a nice scoreboard and infrastructure for VPN/network setup. +* [EnoEngine](https://github.com/enowars/EnoEngine) by our other friends at ENOFLAG is also younger than + our solution. +* [CTFd](https://ctfd.io/) is the de-facto standard for [jeopardy-based CTFs](https://ctftime.org/ctf-wtf/). + It is, however, not suitable for an attack-defense CTF. Another factor for the creation of our own system was that we didn't want to build a large CTF on top of a system which we don't entirely understand. -Design principles ------------------ -The software will probably only be used once a year for severals hours, but it has to work reliably then. It -will hopefully continue to be used by future generations. These requirements led to the incorporation of -some principles: +Development +----------- +For a local development environment, set up a [Python venv](https://docs.python.org/3/library/venv.html) or +use our [dev container](https://code.visualstudio.com/docs/devcontainers/containers) from +`.devcontainer.json`. + +Then, run `make dev`. Tests can be executed through `make test` and a development instance of the Web +component can be launched with `make run_web`. + +We always aim to keep our Python dependencies compatible with the versions packaged in Debian stable. +Debian-based distributions are our primary target, but the Python code should generally be +platform-independent. -* Non-complex solutions: Keep the amount of code low and chose the less fancy path. That's why we use the - built-in Django admin interface instead of writing a custom admin dashboard – it'll be good enough for the - few people using it. -* Few external dependencies: Of course one shouldn't re-invent the wheel all over again, but every external - dependency means another moving part. Some libraries you always have to keep up with, others will become - unmaintained. We therefore focus on few, mature, well-chosen external dependencies. That's why we use a - plain old Makefile instead of [Bower](http://bower.io/) for JavaScript dependencies and Django's built-in - PBKDF2 instead of fancy [bcrypt](https://en.wikipedia.org/wiki/Bcrypt) for password hashing. -* Extensive documentation: This should be a no-brainer for any project, although it is easier said than done. -* Re-usability: The gameserver should be adjustable to your needs with some additional lines of code. An - example for such customizations can be found in the `faustctf-2015` branch of this repository. -* Scalability: We couldn't really estimate the load beforehand, nor could we easily do realistic - load-testing. That's why the components are loosely coupled and can be run on different machines. +Security +-------- +Should you encounter any security vulnerabilities in the Gameserver, please report them to use privately. +Use GitHub vulnerability reporting or contact Felix Dreissig or Simon Ruderich directly. Copyright --------- From c51614c93e7fb2dd0ab68446520aca490dd8326a Mon Sep 17 00:00:00 2001 From: Felix Dreissig Date: Sat, 2 Dec 2023 12:43:52 +0100 Subject: [PATCH 61/63] Major docs overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We finally have (hopefully) helpful docs! πŸŽ‰ Closes: https://github.com/fausecteam/ctf-gameserver/issues/5 Closes: https://github.com/fausecteam/ctf-gameserver/pull/78 Co-authored-by: Simon Ruderich --- Makefile | 5 +- {doc => conf}/controller/scoring.sql | 3 - doc/.gitignore | 1 - doc/Makefile | 193 ------------------ doc/flag.adoc | 38 ---- doc/source/checker.rst | 107 ---------- doc/source/conf.py | 289 --------------------------- doc/source/controller.rst | 9 - doc/source/flags.rst | 50 ----- doc/source/general.rst | 53 ----- doc/source/index.rst | 28 --- doc/source/intro.rst | 102 ---------- doc/source/setup.rst | 109 ---------- doc/source/web.rst | 11 - docs/architecture.md | 59 ++++++ docs/checkers/go-library.md | 16 +- docs/checkers/index.md | 51 +++-- docs/checkers/python-library.md | 24 +-- docs/index.md | 48 ++++- docs/installation.md | 176 ++++++++++++++++ docs/observability.md | 54 +++++ docs/submission.md | 6 +- mkdocs.yml | 3 + 23 files changed, 398 insertions(+), 1037 deletions(-) rename {doc => conf}/controller/scoring.sql (94%) delete mode 100644 doc/.gitignore delete mode 100644 doc/Makefile delete mode 100644 doc/flag.adoc delete mode 100644 doc/source/checker.rst delete mode 100644 doc/source/conf.py delete mode 100644 doc/source/controller.rst delete mode 100644 doc/source/flags.rst delete mode 100644 doc/source/general.rst delete mode 100644 doc/source/index.rst delete mode 100644 doc/source/intro.rst delete mode 100644 doc/source/setup.rst delete mode 100644 doc/source/web.rst create mode 100644 docs/architecture.md create mode 100644 docs/installation.md create mode 100644 docs/observability.md diff --git a/Makefile b/Makefile index 1ed580f..9a38b61 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ EXT_DIR ?= $(WEB_DIR)/static/ext DEV_MANAGE ?= src/dev_manage.py TESTS_DIR ?= tests -.PHONY: dev build ext migrations run_web test lint clean +.PHONY: dev build ext migrations run_web test lint run_docs clean .INTERMEDIATE: bootstrap.zip dev: $(WEB_DIR)/dev-db.sqlite3 ext @@ -49,6 +49,9 @@ lint: pycodestyle $(SOURCE_DIR) $(TESTS_DIR) bandit --ini bandit.ini -r $(SOURCE_DIR) +run_docs: + mkdocs serve + docs_site: mkdocs.yml $(wildcard docs/* docs/*/*) mkdocs build --strict diff --git a/doc/controller/scoring.sql b/conf/controller/scoring.sql similarity index 94% rename from doc/controller/scoring.sql rename to conf/controller/scoring.sql index 673adb7..7c1bd0c 100644 --- a/doc/controller/scoring.sql +++ b/conf/controller/scoring.sql @@ -74,6 +74,3 @@ NATURAL FULL OUTER JOIN defense NATURAL FULL OUTER JOIN sla NATURAL INNER JOIN fill ORDER BY team_id, service_id; - -ALTER MATERIALIZED VIEW scoring_scoreboard OWNER TO gameserver_controller; -GRANT SELECT on TABLE scoring_scoreboard TO gameserver_web; diff --git a/doc/.gitignore b/doc/.gitignore deleted file mode 100644 index c795b05..0000000 --- a/doc/.gitignore +++ /dev/null @@ -1 +0,0 @@ -build \ No newline at end of file diff --git a/doc/Makefile b/doc/Makefile deleted file mode 100644 index c9f0faa..0000000 --- a/doc/Makefile +++ /dev/null @@ -1,193 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = python3 /usr/bin/sphinx-build -#SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# User-friendly check for sphinx-build -ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) -$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) -endif - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - -clean: - rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/CTFGameserver.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/CTFGameserver.qhc" - -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/CTFGameserver" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/CTFGameserver" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." diff --git a/doc/flag.adoc b/doc/flag.adoc deleted file mode 100644 index 5b28d55..0000000 --- a/doc/flag.adoc +++ /dev/null @@ -1,38 +0,0 @@ -Flags -===== -Christoph Egger - -Format ------- - -Flags look like ++FAUST_VbDgYwwNs6w3AwAAAABEFEtvyHhdDRuN++. Separated -by ++_++ there is the prefix (++FAUST++) and base64 encoded data with -the following content: ++$$${timestamp}${teamid}${serviceid}${payload}${hmac}$$++ - -As a result, flags can be recreated (as long as no custom payload is -used or the payload is known) at any time. It is possible to check any -flag for validity without looking it up in some database as well as -verifying that it has not expired or is submitted by the owning team. - -.Fields -[width="60%",options="header",cols="5,>5,20"] -|=============================================================================== -| Field | Size (bits) | Description -| timestamp | 32 | Flag is valid until timestamp -| teamid | 8 | Team responsible of protecting this flag -| serviceid | 8 | Service this flag was submitted to -| payload | 64 | Custom data stored in the Flag, defaults to crc32 padded with zeros -| hmac | 80 | 80 bits from the keccak-100 sponge seeded with some secret -|=============================================================================== - -API ---- - -[source,python] --------------------------------------------------------------------------------- -generate(team, service, payload=None, timestamp=None): - return "FAUST_..." - -verify(flag): - return int(team), int(service), bytes(payload), int(timestamp) --------------------------------------------------------------------------------- diff --git a/doc/source/checker.rst b/doc/source/checker.rst deleted file mode 100644 index 13f9471..0000000 --- a/doc/source/checker.rst +++ /dev/null @@ -1,107 +0,0 @@ -Checkers --------- - -Purpose & Scope -=============== - -Checker scripts must fullfill two purposes: They place flags into the -team's services and check wether the service is fully functional or -not. They check for presence of all flags that are still valid to -submit. - -There is exactly one checker script per service. Several instances of -the script may be started in parallel. Each such process needs to take -care of exactly one team. - -Currently checkers are expected to be implemented as python modules -with one class inheriting -:py:class:`ctf_gameserver.checker.BaseChecker`. The :py:class:`BaseChecker` -inherits from the :py:class:`AbstractChecker` documented below. - -For service authors to locally test their checker a ``testrunner`` is -provided which can be called like this:: - - mytemp=`mktemp -d` - for i in {0..10} - do - ./ctf-testrunner --first 1437258032 --backend $mytemp --tick $i --ip $someip --team 1 --service 1 dummy:DummyChecker - done - -During the contest, checks are run by the ``checkermaster`` and -``checkerslave`` pair where the ``checkerslave`` starts the -checkermodule and comunicates with the ``checkermaster`` via -stdin/stdout. The ``checkermaster`` is responsible for monitoring the -individual checker processes recording their result and starting new -ones as needed. - -Contest services -================ - -The checker ships with a ``checkermaster@.service`` file. The checker -config files most be stored in ``/etc/ctf-gameserver`` and can then be -used with the ``checkermaster@dummy`` services. The python module -should go to the ``checker/`` subdirectory. The supplied setup has the -checkermaster logging to systemd's journal. Additionally to the full -journal for the individual checkermaster units (one per service to -check) you can also access the checkerscript's logging from the journal:: - - journalctl -u ctf-checkermaster@someservice.service SYSLOG_IDENTIFIER=team023-tick042 - -Writing a checker -================= - -Having robust checker scripts is essential for a fun -competition. Checkerscripts will encounter different kinds of half -broken services, slow network, unreachable hosts and lots of other -things. The checkerscript should therefore set reasonable timeouts for -all interactions and handle all exceptions for which it can properly -assign a return value. If an unexpected (and therefore unhandled) -exception occurs, the :py:class:`BaseChecker` will create an -appropriate logentry and not write any result into the database. - -The baseclass :py:class:`BaseChecker` provides a :py:mod:`logging` -logger which is set up properly to create well integrated logs with -all the relevant metainformation. All checkers should use it instead -of the global functions from the :py:mod:`logging` module. - -Checkers need to be able to recover from partial data loss on the -vulnboxes. They should not create login credentials on first run and -continue using them forever -- doing so would make it trivial to -distinguish the gameserver and will make the checker fail for the rest -of the competition if a vulnbox has been reset in the middle of the -game. - -Checker return codes -=================== - -The checker offers several return codes. They can all be imported from -:py:mod:`ctf_gameserver.checker.constants`. Return codes to be used by -individual checkerscripts are :py:data:`OK`, :py:data:`TIMEOUT`, -:py:data:`NOTWORKING` and :py:data:`NOTFOUND`. Additionally the -returncode :py:data:`RECOVERING` can be the result of a checker -invocation. - -* :py:data:`OK` is returned by the individual checker when the check - was sccessfull. -* :py:data:`TIMEOUT` is to be used whenever a timeout is reached. The - checker baseclass will correctly catch timeout excetions from both, - :py:mod:`requests` and :py:mod:`socket`. Individual checker scripts - may want to add additional timeouts and return this in case the - timeout is reached. -* :py:data:`NOTWORKING` is returned iff there is a general error with - the service (like requests to a website result in unexpected error - pages, the connection gets dropped, or similar things. -* :py:data:`NOTFOUND` is returned by :py:meth:`get_flag` when the flag - was not returned by the server. It should only be used iff - everything else works but the service could not find the right flag. - -* :py:data:`RECOVERING` is used internally. it is set iff the service - is working and the current flag could be placed and retrieved but - one or more of the older flags (within the checker window) are - :py:data:`NOTFOUND` - -API Baseclass -============= - -.. autoclass:: ctf_gameserver.checker.abstract.AbstractChecker - :members: diff --git a/doc/source/conf.py b/doc/source/conf.py deleted file mode 100644 index be0d84e..0000000 --- a/doc/source/conf.py +++ /dev/null @@ -1,289 +0,0 @@ -# CTF Gameserver documentation build configuration file, created by -# sphinx-quickstart on Sat Dec 26 10:37:08 2015. -# -# This file is execfile()d with the current directory set to its -# containing dir. -# -# Note that not all possible configuration values are present in this -# autogenerated file. -# -# All configuration values have a default; values that are commented out -# serve to show the default. - -import sys -import os -import shlex - -# If extensions (or modules to document with autodoc) are in another directory, -# add these directories to sys.path here. If the directory is relative to the -# documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('../src/')) -print(os.path.abspath('../../src/')) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.pngmath', - 'sphinx.ext.viewcode', -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -#source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'CTF Gameserver' -copyright = u'2015, Christoph Egger, Felix Dreissig' -author = u'Christoph Egger, Felix Dreissig' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = u'0.1' -# The full version, including alpha/beta/rc tags. -release = u'0.1' - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -#today = '' -# Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -#default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -#add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -#show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = True - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -html_theme = 'nature' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -#html_theme_options = {} - -# Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] - -# The name for this set of Sphinx documents. If None, it defaults to -# " v documentation". -#html_title = None - -# A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = None - -# The name of an image file (within the static path) to use as favicon of the -# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -#html_favicon = None - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -#html_extra_path = [] - -# If not '', a 'Last updated on:' timestamp is inserted at every page bottom, -# using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -#html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -#html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -#html_additional_pages = {} - -# If false, no module index is generated. -#html_domain_indices = True - -# If false, no index is generated. -#html_use_index = True - -# If true, the index is split into individual pages for each letter. -#html_split_index = False - -# If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -#html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' -#html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# Now only 'ja' uses this config value -#html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -#html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = 'CTFGameserverdoc' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', - -# Latex figure (float) alignment -#'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, 'CTFGameserver.tex', u'CTF Gameserver Documentation', - u'Christoph Egger, Felix Dreissig', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -#latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -#latex_use_parts = False - -# If true, show page references after internal links. -#latex_show_pagerefs = False - -# If true, show URL addresses after external links. -#latex_show_urls = False - -# Documents to append as an appendix to all manuals. -#latex_appendices = [] - -# If false, no module index is generated. -#latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, 'ctfgameserver', u'CTF Gameserver Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -#man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, 'CTFGameserver', u'CTF Gameserver Documentation', - author, 'CTFGameserver', 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -#texinfo_appendices = [] - -# If false, no module index is generated. -#texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False diff --git a/doc/source/controller.rst b/doc/source/controller.rst deleted file mode 100644 index a8626aa..0000000 --- a/doc/source/controller.rst +++ /dev/null @@ -1,9 +0,0 @@ -Controller ----------- - -Purpose & Scope -=============== - -Controller is a simple python script inteded to be used with a systemd -timer unit which is also included. The script advances the game by one -tick and creates fresh flags for the specific tick. diff --git a/doc/source/flags.rst b/doc/source/flags.rst deleted file mode 100644 index 902b33e..0000000 --- a/doc/source/flags.rst +++ /dev/null @@ -1,50 +0,0 @@ -Flags and Submission --------------------- - -Concept -======= - -Flags look like ``FAUST_VbDgYwwNs6w3AwAAAABEFEtvyHhdDRuN``. Separated -by ``_`` there is the prefix (``FAUST``) and base64 encoded data with -the following content: ``${timestamp}${teamid}${serviceid}${payload}${hmac}`` - -As a result, flags can be recreated (as long as no custom payload is -used or the payload is known) at any time. It is possible to check any -flag for validity without looking it up in some database as well as -verifying that it has not expired or is submitted by the owning team. - -Submission -========== - -Submission is a single-threaded python service accepting flags via a -network socket using non-blocking IO. Several instances can be run -either behind a nginx proxy or just via iptables loadbalancing. - -Flag Module -=========== - -.. autofunction:: ctf_gameserver.lib.flag.generate -.. autofunction:: ctf_gameserver.lib.flag.verify -.. autoexception:: ctf_gameserver.lib.flag.FlagVerificationError -.. autoexception:: ctf_gameserver.lib.flag.InvalidFlagFormat -.. autoexception:: ctf_gameserver.lib.flag.InvalidFlagMAC -.. autoexception:: ctf_gameserver.lib.flag.FlagExpired - -Bitstructure of the Data Part -============================= - -+-----------+-------------+-------------------------------------------+ -| Field | Size (bits) | Description | -+===========+=============+===========================================+ -| timestamp | 32 | Flag is valid until timestamp | -+-----------+-------------+-------------------------------------------+ -| teamid | 8 | Team responsible of protecting this flag | -+-----------+-------------+-------------------------------------------+ -| serviceid | 8 | Service this flag was submitted to | -+-----------+-------------+-------------------------------------------+ -| payload | 64 | Custom data stored in the Flag, defaults | -| | | to crc32 padded with zeros | -+-----------+-------------+-------------------------------------------+ -| hmac | 80 | 80 bits from the keccak-100 sponge seeded | -| | | with some secret | -+-----------+-------------+-------------------------------------------+ diff --git a/doc/source/general.rst b/doc/source/general.rst deleted file mode 100644 index a43d4e3..0000000 --- a/doc/source/general.rst +++ /dev/null @@ -1,53 +0,0 @@ -General -------- - -Database -======== - -Basically each component needs access to the database. However access -can be (somewhat) restricted. - -Checkermaster -^^^^^^^^^^^^^ - - - full access on ``scoring_checkerstate`` - - read on ``scoring_gamecontrol`` - - write on ``scoring_statuscheck`` - - write on ``scoring_statuscheck_id_seq`` - - update,read on ``scoring_flag`` - -Submission -^^^^^^^^^^ - - - Read on ``scoring_gamecontrol`` - - Read on ``scoring_flag`` - - Write on ``scoring_capture`` - - Write on ``scoring_capture_id_seq`` - -Controller / Scoring -^^^^^^^^^^^^^^^^^^^^ - - - Read on ``registration_team`` - - Read on ``scoring_service`` - - Write on ``scoring_gamecontrol`` - - Write on ``scoring_flag`` - - Write on ``scoring_flag_id_seq`` - - Owner on ``scoring_scoreboard`` - -Web -^^^ - -Configuration -============= - -All configuration files are stored in -``/etc/ctf-gameserver``. Individual components are started via systemd -units and are proper ``Type=notify`` services and/or timer units. The -website is special in this regard and runs from your wsgi daemon. - -Some settings currently still need code changes. The flag prefix is -hardcoded in the flag module and both checker and submission make -certain assumptions about the IP address layout: the checkermaster -assumes vulnboxes can be reached at ``10.66.$team.2`` and submission -uses the third component of the source IP to determine which team is -submitting the flag. diff --git a/doc/source/index.rst b/doc/source/index.rst deleted file mode 100644 index 068bfd7..0000000 --- a/doc/source/index.rst +++ /dev/null @@ -1,28 +0,0 @@ -.. CTF Gameserver documentation master file, created by - sphinx-quickstart on Sat Dec 26 10:37:08 2015. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. - -Welcome to CTF Gameserver's documentation! -========================================== - -Contents: - -.. toctree:: - :maxdepth: 2 - - intro - general - web - flags - checker - controller - setup - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - diff --git a/doc/source/intro.rst b/doc/source/intro.rst deleted file mode 100644 index 478d8b9..0000000 --- a/doc/source/intro.rst +++ /dev/null @@ -1,102 +0,0 @@ -Introduction -============ - -This is a gameserver for `attack-defense (IT security) -CTFs `_. It was originally written for -`FAUST CTF 2015 `_, but is designed to -be re-usable for other competitions. - -What's included ---------------- - -The gameserver consists of multiple components. They may be deployed -separately of each other as their only means of communication is a -shared database. - -* Web: A `Django `_-based web - application for team registration and scoreboards. It also contains - the model files, which define the database structure. -* Controller: Coordinates the progress of the competition, e.g. the - current tick and flags to be placed. -* Checker: Offers an interface for checker scripts, which place and - retrieve flags and test the status of services. -* Submission: Server to submit captured flags to. -* Lib: Some code that is shared between the components. - -For deployment instructions and details on the implementations, see -the ``README`` of the individual components. - -Related projects ----------------- - -There are several alternatives out there, although none of them could -really convince us. Your mileage may vary at this point. - -* ucsb-seclab/ictf-framework from the team behind iCTF, one of the - most well-known attack-defense CTFs. In addition to a gameserver, it - includes utilities for VM creation and network setup. We had - trouble to get it running and documentation is generally rather - rare. -* HackerDom/checksystem is the gameserver powering the RuCTF. The - first impression wasn't too bad, but it didn't look quite - feature-complete to us. However, we didn't really grasp the Perl - code, so we might have overlooked something. -* isislab/CTFd appears to be de-facto standard for `jeopardy-based - CTFs `_. It is, however, not suitable - for an attack-defense CTF. - -Another factor for the creation of our own system was that we didn't -want to build a large CTF on top of a system which we don't entirely -understand. - -Design principles ------------------ - -The software will probably only be used once a year for severals -hours, but it has to work reliably then. It will hopefully continue to -be used by future generations. These requirements led to the -incorporation of some principles: - -* Non-complex solutions: Keep the amount of code low and chose the - less fancy path. That's why we use the built-in Django admin - interface instead of writing a custom admin dashboard – it'll be - good enough for the few people using it. -* Few external dependencies: Of course one shouldn't re-invent the - wheel all over again, but every external dependency means another - moving part. Some libraries you always have to keep up with, others - will become unmaintained. We therefore focus on few, mature, - well-chosen external dependencies. That's why we use a plain old - Makefile instead of `Bower `_ for JavaScript - dependencies and Django's built-in PBKDF2 instead of fancy - `bcrypt `_ for password hashing. -* Extensive documentation: This should be a no-brainer for any - project, although it is easier said than done. -* Re-usability: The gameserver should be adjustable to your needs with - some additional lines of code. An example for such customizations - can be found in the ``faustctf-2015`` branch of this repository. -* Scalability: We couldn't really estimate the load beforehand, nor - could we easily do realistic load-testing. That's why the components - are loosely coupled and can be run on different machines. - -Packaged Version ----------------- - -For Debian/Ubuntu users a packaged version is available. For the -submission, checker and controller components, individual packages are -provided (named ``ctf-gameserver-$something``). - -Source Code ------------ - -Source Code is available on `Github -`_ including all the -usual github infrastructure. - -Licensing ---------- - -The whole gameserver is released under the ISC license. Contributions -are welcome! - -.. include:: ../../LICENSE.txt - :literal: diff --git a/doc/source/setup.rst b/doc/source/setup.rst deleted file mode 100644 index 921ce17..0000000 --- a/doc/source/setup.rst +++ /dev/null @@ -1,109 +0,0 @@ -Gameserver Setup -================ - -For the setup instructions we consider a clean debian stable install -on all machines. All components can be installed on separate machines -or all on one single node but should share a common (firewalled) -network. We further assume that postgresql has been installed on a -machine on that network. ctf-gameserver has been checked out and built -(dpkg-buildpackage -b). - -.. code-block:: bash - - createuser -P faustctf - createdb -O faustctf faustctf - -Website -------- - -The web part is a standard django webapplication. For the example -setup we use uwsgi and nginx to serve it and example configuration is -provided with the software. From the gameserver the package -``ctf-gameserver-web`` is needed. In the -``/etc/ctf-gameserver/web/prod_settings`` the following keys need -adaption: ``SECRET_KEY``, ``ALLOWED_HOSTS``, ``DATABASES`` and -``TIME_ZONE``. For the ``prod_manage.py`` utility add -``/usr/lib/ctf-gameserver/bin`` to your ``PATH``. - -.. code-block:: bash - - PYTHONPATH=/etc/ctf-gameserver/web django-admin migrate --settings prod_settings auth - PYTHONPATH=/etc/ctf-gameserver/web django-admin migrate --settings prod_settings - -.. note:: - - the default production settings also use memcached. Proper setup of - memcached should be covered here. For small CTFs it should be - enough to switch back to the dummycache from the development settings - -.. note:: - - setup.py does not install the external javascript dependencies. The - files needs to be downloaded. Please refer to ``web/Makefile`` in the - source tree for details. - -Submission and Controller -------------------------- - -Install the packages ``ctf-gameserver-controller`` and -``ctf-gameserver-submission``. The controller should now be working -(it's a systemd timer unit), the submission service needs explicit -activation with ``systemctl enable ctf-submission@1234`` where -``1234`` is the port submission should be listening on. One needs to -use a portnumber above 1000. One can easily run more than one -submission service and even use iptables to do some loadbalancing. The -submission server is using an event-based architecture and is -single-threaded. - -Checker -------- - -Put a service description into ``/etc/ctf-gameserver`` and the python -checker module into ``/etc/ctf-gameserver/checker`` for each service -in the ctf. After installing ``ctf-gameserver-controller`` you can -then enable the checkers with ``systemctl enable -ctf-checkermaster@exampleservice``. For advice on how the service -description is supposed to look like please refer to the provided -examples. - -Scoring -------- - -The gameserver comes with an example scoring function. If you want to -use it, apply the ``scoring.sql`` patch to the database. The -ctf-scoring unit will take care of periodically updating the -score. You can implement your own scoring either by adapting the SQL -for the materialized view in ``scoring.sql`` or by writing your -scoring code to the ``ctf-scoring`` programm. Scoring needs to create -a table or view with the schema produced below and contain a row for -every team. - -.. code-block:: sql - - CREATE TABLE "scoring_scoreboard" ( - "team_id" integer NOT NULL, - "attack" integer NOT NULL, - "bonus" integer NOT NULL, - "defense" double precision NOT NULL, - "sla" double precision NOT NULL, - "total" double precision NOT NULL, - PRIMARY KEY (team_id, service_id, identifier) - ); - -Networking ----------- - -This section will detail some suggestions for the network setup of the -CTF. - -* All Team members need to reach the submission system and the - submission system needs to observe the unmodified source ip from the - teams. If there is any NAT in place care must be taken to ensoure - the translated address still matches the Team network. -* Commonly all traffic reaching out to the vulnboxes are NAT'ed to - hide the real source-IP and thereby making it more difficult to - distinguish between checkers and attackers based on network - properties. -* All ``ctf-gameserver`` components need to reach the database. Noone - else does and it is a good idea to isolate the database from such - access. diff --git a/doc/source/web.rst b/doc/source/web.rst deleted file mode 100644 index c01e696..0000000 --- a/doc/source/web.rst +++ /dev/null @@ -1,11 +0,0 @@ -Website -------- - -Requirements -============ - -* Django<1.9 -* pytz -* Pillow -* Markdown - diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9223ee1 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,59 @@ +Architecture +============ + +Command & Control +----------------- +All integration between CTF Gameserver's components happens through a shared PostgreSQL database. +The **Controller** orchestrates all actions performed by the other components based on its system clock. +The system clock of the other components does not affect the competition's progress. + +Ticks +----- +The competition is divided into discrete time frames of a fixed duration. These are called **ticks** and +can be seen as rounds. Checking and scoring happen once per tick. Tick numbers start at zero. + +Anatomy of a Tick +----------------- +The **Controller** checks the clock and notices that it is time to start a new tick. It increments the +current tick and creates the flags for the new tick in the database. + +Each **Checker Master** belongs to one service. It regularly checks the database for flags to be placed. +For each flag (i.e. team), it launches the service's Checker Script as a separate process. If any Scripts +from previous ticks are still running, they get terminated. Checkers can be horizontally scaled by running +multiple Master instances per service. + +**Checker Scripts** have to be specifically created for the competition's individual services. Helper +libraries are currently provided for Python and Go, but any language can be used to implement the Checker +Script IPC protocol. For details, see the [documentation on Checkers](checkers/index.md). The status for the +tick (OK, down, etc.) is determined from the outcome of placing the new flag and retrieving flags from +previous ticks. The resulting status gets written to the database. + +At any time, teams can connect to the **Submission** server and submit flags they captured from other teams. +The submission protocol is described in the [Submission documentation](submission.md). For flags that are +valid and haven't been submitted by the same team before, a capture event is stored in the database. +Submission servers can be horizontally scaled by running multiple instances. For an example configuration +with multiple instances behind a single port, see the +[Submission installation docs](installation.md#submission). + +At the start of the next tick, the **Controller** updates the scoreboard in the database by calculating the +scores for the old tick based on status checks and capture events. + +Flags +----- +The string representation of a flag can always be generated from its database entry and the competition's +flag secret. It consists of a configurable static prefix, followed by the encoded flag data and a +[MAC](https://en.wikipedia.org/wiki/Message_authentication_code). + +Using a prefix of `FAUST_`, a valid flag could look like this: `FAUST_Q1RGLRml7uVTRVJBRXdsFhEI3jhxey9I` + +Team Numbers +------------ +Teams have two different numbers, ID and Net Number. + +The **Team ID** is the primary key of the team's database entry. It is usually assigned in ascending order by +registration time and only used internally. + +The **Team Net Number** is used to construct the team's IP address range (e.g. `10.66..0/24`). +It is assigned randomly and sometimes also just called "Team Number". It aims to prevent correlation between +the teams' registration order and address range, making it harder to target a specific team. This means teams +should only know their own assignment. diff --git a/docs/checkers/go-library.md b/docs/checkers/go-library.md index 57006e6..41680c9 100644 --- a/docs/checkers/go-library.md +++ b/docs/checkers/go-library.md @@ -12,7 +12,7 @@ It takes care of: * Setup of default timeouts for "net/http" * Handling of common connection errors and converting them to a DOWN result -This means that you do *not* have to handle timeout errors and can just let the library take care of them. +This means that you do **not** have to handle timeout errors and can just let the library take care of them. Installation ------------ @@ -29,10 +29,10 @@ To create a Checker Script, implement the `checkerlib.Checker` interface with th service health. * `CheckFlag(ip string, team int, tick int) (checkerlib.Result, error)`: Determine if the flag for the given tick can be retrieved. Use `checkerlib.GetFlag(tick, nil)` to get the flag to check for. Called multiple - times per Script execution, for the current and different preceding ticks. + times per Script execution, for the current and preceding ticks. In your `main()`, call `checkerlib.RunCheck()` with your implementation as argument. The library will take -care of calling your methods, merging the results and submitting them to the Checker Master. +care of calling your methods, merging the results, and submitting them to the Checker Master. ### Persistent State * `StoreState(key string, data interface{})`: Store data persistently across runs (serialized as JSON). @@ -79,17 +79,17 @@ func (c checker) CheckFlag(ip string, team int, tick int) (checkerlib.Result, er } ``` -For a complete, but still simple, Checker Script see "examples/checker/example_checker_go" in the [CTF +For a complete, but still simple, Checker Script see `examples/checker/example_checker_go` in the [CTF Gameserver repository](https://github.com/fausecteam/ctf-gameserver). Local Execution --------------- -When running your Checker Script locally, just pass your service IP, the tick to check and a dummy team ID -as command line arguments: +When running your Checker Script locally, just pass your service IP, the tick to check (starting from 0), +and a dummy team ID as command line arguments: ```sh -go build && ./checkerscript ::1 10 1 +go build && ./checkerscript ::1 10 0 ``` The library will print messages to stderr and generate dummy flags when launched without a Checker Master. -State stored in that case will be persisted in a file called "_state.json" in the current directory. +State stored will be persisted in a file called `_state.json` in the current directory in that case. diff --git a/docs/checkers/index.md b/docs/checkers/index.md index 3c09c49..b9229f1 100644 --- a/docs/checkers/index.md +++ b/docs/checkers/index.md @@ -6,25 +6,40 @@ place and retrieve flags. An individual **Checker Script** exists per service, w service-specific functionality. During the competition, Checker Scripts are launched by the **Checker Master**. +Having robust checker scripts is essential for a fun competition. Checker Scripts will encounter different +kinds of half-broken services, slow networks, unreachable hosts, and lots of other things. They should always +return a result and never exit unexpectedly (e.g. due to an uncaught exception). Make sure to test your +Checker Scripts under various conditions. + +Checker Scripts should behave like a regular user of the respective service and not be trivially +distinguishable from attackers. They also need to be able to recover from partial data loss on the Vulnboxes. + +Checker Script libraries +------------------------ +Libraries in the following languages are currently available to assist you in developing Checker Scripts: + +* [Python](python-library.md) +* [Go](go-library.md) + Execution Model --------------- For each service and team, one Checker Script gets executed per tick. The Checker Master launches the Scripts -as individual processes and communicates with them through an [IPC protcol](#ipc-protocol). This architecture -was chosen since Checker Scripts deal with a lot of untrusted input, and should therefore not have direct -access to the Gameserver database. +as individual processes and communicates with them through an [IPC protocol](#ipc-protocol). This +architecture was chosen since Checker Scripts deal with a lot of untrusted input, and should therefore not +have direct access to the Gameserver database. The same Script may run in parallel for different teams. There may be multiple Masters (on different hosts), so checks for the same team may be started by different Masters in different ticks. Therefore, [a -special API](#persistent-state) has to be used for keeping state across ticks. +special API](#persistent-state) has to be used to keep state across ticks. Checker Scripts should perform the following steps in each tick: 1. Place new flag for the current tick 2. Check general service availability -3. Retrieve flag of the current and 5 previous ticks +3. Retrieve flag of the current and five previous ticks -The Checker Script has to determine a single result from all of these steps. That means that if if any of -them fails, the service shall not be considered OK. If a step fails, the remaining ones need not to be +The Checker Script has to determine a single result from all of these steps. That means that if any of them +fails, the service shall not be considered OK. If a step fails, the remaining ones do not need to be performed. Arguments @@ -51,24 +66,27 @@ Each check reports one of the following results: library](#checker-script-libraries)) If a Checker Script exits without reporting a result (e.g. dying due to an exception), no results will be -stored for the tick (displayed as "Not checked" by the Gameserver frontend). The Script's exit code does -*not* influence the check result. +stored for the tick (displayed as "Not checked" by the Gameserver frontend). + +The Script's exit code does *not* influence the check result. Error Handling -------------- If errors occur while establishing a connection or sending requests, the service should be considered -DOWN. Theses errors have to be handled by Checker Scripts, but [libraries](#checker-script-libraries) +DOWN. These errors have to be handled by Checker Scripts, but [libraries](#checker-script-libraries) usually assist with that. Issues with the service itself (e.g. unexpected or missing output to requests) must be detected by Checker Scripts and lead to a FAULTY result. -This means that a proper Checker Script should never exit unexpectedly (with an exception, panic or similar). +This means that a proper Checker Script should never exit unexpectedly (with an exception, panic, or +similar). Logging ------- It is generally desirable to add *lots of* logging to Checker Scripts. For unified access to logs from -different Master instances, log messages get forwarded through the Master and stored centrally. +different Master instances, log messages get forwarded through the Master and stored centrally (see +[Checker logging docs](../observability.md#checkers)). Stdout and stderr from Checker Scripts are captured as well, but will lack metadata such as log level or source code line. @@ -83,7 +101,7 @@ one tick can be loaded in subsequent ones, regardless of the Master instances in Flag IDs -------- -In some cases, you want to provide teams with an identifier which helps retrieving an individual Flag. For +In some cases, you want to provide teams with an identifier which helps retrieve 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. @@ -100,10 +118,3 @@ request and return a result synchronously. When launching a Checker Script, the Master passes two Unix pipes as additional open file descriptors to the new process. Requests to the Master can be sent on file descriptor 4, responses can be read from file descriptor 3. Messages are JSON objects sent on a single line. - -Checker Script libraries ------------------------- -Libraries in the following languages are currently available to assist you in developing Checker Scripts: - -* [Python](python-library.md) -* [Go](go-library.md) diff --git a/docs/checkers/python-library.md b/docs/checkers/python-library.md index 4a3e9f5..7efeffe 100644 --- a/docs/checkers/python-library.md +++ b/docs/checkers/python-library.md @@ -10,11 +10,11 @@ It takes care of: * Starting check steps * Command line argument handling * Configuring logging to send messages to the Master -* Setup of default timeouts for Python sockets, [urllib3](https://urllib3.readthedocs.io) and +* Setup of default timeouts for Python sockets, [urllib3](https://urllib3.readthedocs.io), and [Requests](https://requests.readthedocs.io) * Handling of common connection exceptions and converting them to a DOWN result -This means that you do *not* have to catch timeout exceptions and can just let the library take care of +This means that you do **not** have to catch timeout exceptions and can just let the library take care of them. Installation @@ -23,8 +23,8 @@ To use the library, you must have the `ctf_gameserver.checkerlib` package availa installation. That package is self-contained and does not require any external dependencies. One option to do that would be to clone the [CTF Gameserver -repository](https://github.com/fausecteam/ctf-gameserver) and create a symlink called "ctf_gameserver" to -"src/ctf_gameserver". +repository](https://github.com/fausecteam/ctf-gameserver) and create a symlink called `ctf_gameserver` to +`src/ctf_gameserver`. Another option would be to install CTF Gameserver (preferably to a virtualenv) by running `pip install .` in the repository directory. @@ -39,10 +39,10 @@ To create a Checker Script, create a subclass of `checkerlib.BaseChecker` implem service health. * `check_flag(self, tick: int) -> checkerlib.CheckResult`: Determine if the flag for the given tick can be retrieved. Use `checkerlib.get_flag(tick)` to get the flag to check for. Called multiple times per Script - execution, for the current and different preceding ticks. + execution, for the current and preceding ticks. In your `__main__` code, call `checkerlib.run_check()` with your class as argument. The library will take -care of calling your methods, merging the results and submitting them to the Checker Master. +care of calling your methods, merging the results, and submitting them to the Checker Master. ### Functions * `get_flag(tick: int) -> str`: Get the flag for the given tick (for the checked team). @@ -54,7 +54,7 @@ care of calling your methods, merging the results and submitting them to the Che ### Classes * The `checkerlib.BaseChecker` class provides the following attributes: * `self.ip`: IP address of the checked team (may be IPv4 or IPv6, depending on your CTF) - * `self.team`: (Network) number of the checked team + * `self.team`: (Net) number of the checked team * `checkerlib.CheckResult` provides the following constants to express check results, [see general docs](index.md#check-results) for their semantics: * `CheckResult.OK` @@ -82,17 +82,17 @@ if __name__ == '__main__': checkerlib.run_check(MinimalChecker) ``` -For a complete, but still simple, Checker Script see "examples/checker/example_checker.py" in the +For a complete, but still simple, Checker Script see `examples/checker/example_checker.py` in the [CTF Gameserver repository](https://github.com/fausecteam/ctf-gameserver). Local Execution --------------- -When running your Checker Script locally, just pass your service IP, the tick to check and a dummy team ID -as command line arguments: +When running your Checker Script locally, just pass your service IP, the tick to check (starting from 0), +and a dummy team ID as command line arguments: ```sh -./checkerscript.py ::1 10 1 +./checkerscript.py ::1 10 0 ``` The library will print messages to stdout and generate dummy flags when launched without a Checker Master. -State stored in that case will be persisted in a file called "_state.json" in the current directory. +State stored will be persisted in a file called `_state.json` in the current directory in that case. diff --git a/docs/index.md b/docs/index.md index 9ccd82a..4a9e988 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,47 @@ -# CTF Gameserver +CTF Gameserver +============== -*Legacy documentation should be migrated here.* +FAUST's CTF Gameserver is a gameserver for [attack-defense (IT security) CTFs](https://ctftime.org/ctf-wtf/). +It is used for hosting [FAUST CTF](https://www.faustctf.net), but designed to be re-usable for other +competitions. It is scalable to large online CTFs, battle-tested in many editions of FAUST CTF, and +customizable for other competitions. + +Components +---------- +The Gameserver consists of multiple components. They may be deployed independently of each other as their +only means of communication is a shared database. + +* Web: A [Django](https://www.djangoproject.com/)-based web application for team registration, scoreboards, + and simple hosting of informational pages. It also contains the model files, which define the database + structure. +* Controller: Coordinates the progress of the competition, e.g. the current tick and flags to be placed. +* Checker: Place and retrieve flags and test the service status on all teams' Vulnboxes. The Checker Master + launches Checker Scripts, which are individual to each service. +* Checkerlib: Libraries to assist in developing Checker Scripts. Currently, Python and Go are supported. +* Submission: Server to submit captured flags to. +* VPN Status: Optional helper that collects statistics about network connectivity to teams. + +Environment +----------- +CTF Gameserver does **not** include facilities for network infrastructure, VPN setup, and Vulnbox creation. + +### Requirements +* Server(s) based on [Debian](https://www.debian.org/) or derivatives +* [PostgreSQL](https://www.postgresql.org/) database +* Web server and WSGI application server for the Web component + +### Network +It expects a network, completely local or VPN-based, with the following properties: + +* Teams need to be able to reach each other. +* Checkers have to reach the teams. +* Teams should not be able to distinguish between Checker and team traffic, i.e. at least applying a + masquerading NAT. +* Teams have to reach the Submission server. The Submission server needs to see real source addresses + (without NAT). +* All Gameserver components need to reach the database. Teams should not be able to talk to the database. +* Both IPv4 and IPv6 are supported. It must be possible to map the teams' network ranges to their + [team (net) number](architecture.md#team-numbers) based on string patterns. For example, use an addressing + scheme like `10.66..0/24`. +* One exception is displaying the latest handshake on the VPN Status History page, which is currently only + implemented for [WireGuard](https://www.wireguard.com/). diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..98652a7 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,176 @@ +Installation +============ + +For a small competition, it can be sufficient to deploy all CTF infrastructure on a single host. If your +competition is larger, you should distribute CTF Gameserver's components over several machines. + +Some recommendations on that with regard to CPU, memory, and disk requirements… + +… for external requirements: + +* From our experience, CTF Gameserver does not put a high load on the **Postgres database**. A small to + medium-sized machine should be sufficient as database server. +* **VPN/router machines** have rather high requirements. It can make sense to run multiple of them and shard + teams among them. + +… for CTF Gameserver components: + +* The **Controller** can reasonably be colocated on the database server, as its requirements are quite low. +* **Submission** has relatively low requirements. In small to medium-sized setups, colocating it with + database and Controller should suffice. In larger setups, one small machine should be enough, but multiple + machines are still possible. +* The **Web** component scales like a typical Django-based web app. If you get more requests, it makes sense + to use a separate, more powerful host. You could also run multiple application servers, but we never felt + the need to do so. +* The requirements of the **Checkers** drastically depend on the behavior of your individual Checker scripts. + It usually makes sense to scale Checkers by running them on multiple powerful machines. +* If you use the **VPN Status** component, the collection helper must run on your VPN/router machines. + +Package Build +------------- +The recommended installation method for CTF Gameserver is through a Debian package. We do not provide +pre-built packages, which gives you the option to apply all kinds of adjustments before building a package. + +To get a package, clone [CTF Gamesever's repository](https://github.com/fausecteam/ctf-gameserver) to a +Debian-based host. There are no releases, but the "master" branch should always be in a usable state. + +The build commands are: + + $ git clone git@github.com:fausecteam/ctf-gameserver.git + $ cd ctf-gameserver + # Do custom adjustments if desired + $ sudo apt install devscripts dpkg-dev equivs + $ mk-build-deps --install debian/control + $ dpkg-buildpackage --unsigned-changes --unsigned-buildinfo + +This should result in a package file called `ctf-gameserver_1.0_all.deb` **in the parent directory**. + +All components get installed from this same Debian package, but none of them are activated upon installation. +You control what gets run where, by starting the corresponding systemd units (or configuring a web +application server). + +Ansible +------- +The recommended way to install and configure CTF Gameserver is to use our Ansible roles provided in +[CTF Gameserver Ansible](https://github.com/fausecteam/ctf-gameserver-ansible). + +For instructions on how to use these roles, please refer to that repo's README file. + +Configuration +------------- +### General +Configuration for the components is either provided through command-line arguments or equivalent environment +variables. The Debian package already installs minimal environment files with dummy values to +`/etc/ctf-gameserver`, from where they get picked up by the systemd units. + +You can get help on the individual options by invoking CTF Gameserver's executables with the `--help` option +(`ctf-controller --help`, `ctf-submission --help`, etc.). + +When using the Ansible roles, options in the environment files get set from the respective Ansible variables. + +### Submission +The Submission server runs an event loop and is single-threaded. To make use of multiple CPU cores, you +need to run multiple instances. + +To support that need, the submission systemd service is an +[instantiated unit](https://0pointer.de/blog/projects/instances.html). +The instance name (the part after the '@') controls the name of an additional environment file +(`/etc/ctf-gameserver/submission-.env`). This can be used to run multiple instances on different ports. + +The Ansible role will already create one instance with an associated environment file per port listed in +`ctf_gameserver_submission_listen_ports`. + +To still provide a single submission port to teams, you may use iptables rules like these (assuming four instances): + + $ iptables -t nat -A PREROUTING -p tcp --dport 666 -m state --state NEW -m statistic --mode nth --every 4 --packet 0 - j DNAT --to-destination :6666 + $ iptables -t nat -A PREROUTING -p tcp --dport 666 -m state --state NEW -m statistic --mode nth --every 3 --packet 0 - j DNAT --to-destination :6667 + $ iptables -t nat -A PREROUTING -p tcp --dport 666 -m state --state NEW -m statistic --mode nth --every 2 --packet 0 - j DNAT --to-destination :6668 + $ iptables -t nat -A PREROUTING -p tcp --dport 666 -m state --state NEW -m statistic --mode nth --every 1 --packet 0 - j DNAT --to-destination :6669 + +### Checkers +Checkers use an instantiated systemd unit with a Checker Master instance per service. The Ansible role will +**not** configure or start these instances. + +A typical service-specific environment file in `/etc/ctf-gameserver/checker/.env` will look like +this: + + CTF_SERVICE="service-slug" + CTF_CHECKERSCRIPT="/path/to/service-script" + CTF_CHECKERCOUNT="1" + CTF_INTERVAL="10" + +There may be multiple Master instances for each service (usually on separate hosts). `CTF_CHECKERCOUNT` must +be set to the total number of Master instances for the service. `CTF_INTERVAL` is the time between launching +batches of Checker Scripts in seconds and should be considerably shorter than the tick length. + +You need to explicitly configure an output for Checker Scripts logs using either the `CTF_JOURNALD` or the +`CTF_GELF_SERVER` (Graylog) option. Larger setups should use Graylog as it can handle a larger volume of +log entries. See the [docs on Checker logging](observability.md#checkers) for details. + +### Web +#### Application Server +To run the Web component, you will need a WSGI application server such as Gunicorn or uWSGI. Please refer +to [Django's instructions on how to deploy with WSGI](https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/). + +The Web component uses Django's settings mechanism instead of the environment files described above. Its +config file is located at `/etc/ctf-gameserver/web/prod_settings.py`. When using the Ansible role, that +file gets generated from an Ansible template. + +The WSGI module for CTF Gameserver is called `ctf_gameserver.web.wsgi:application`. To pick up the +configuration file, `/etc/ctf-gameserver/web` must be on the PYTHONPATH and the environment variable +`DJANGO_SETTINGS_MODULE` must be set to `prod_settings`. + +Medium-sized or larger setups should utilize [Django’s cache framework](https://docs.djangoproject.com/en/4.2/topics/cache/). +We recommend [using Memcached](https://docs.djangoproject.com/en/4.2/topics/cache/#memcached) with +`django.core.cache.backends.memcached.PyMemcacheCache`. Memcached's memory limits should be increased by +setting `-m 256` and `-I 5m`. + +#### Static Files +In addition to the application server, some static files need to be delivered through a web server (e.g. one +that also acts as reverse proxy for the application). An example nginx config snippet would look like this: + + location /static/ { + alias /usr/lib/python3/dist-packages/ctf_gameserver/web/static/; + } + location /static/admin/ { + root /usr/lib/python3/dist-packages/django/contrib/admin/; + } + location /uploads/ { + alias /var/www/gameserver_uploads/; + } + location = /robots.txt { + alias /usr/lib/python3/dist-packages/ctf_gameserver/web/static/robots.txt; + } + +Manual Database Setup (without Ansible) +--------------------------------------- +If you are **not using our Ansible roles**, you need to manually set up the database. + +1. Create a Postgres user and a database owned by it. Add these parameters to + `/etc/ctf-gameserver/web/prod_settings.py`. +2. `PYTHONPATH=/etc/ctf-gameserver/web DJANGO_SETTINGS_MODULE=prod_settings django-admin migrate auth` +3. `PYTHONPATH=/etc/ctf-gameserver/web DJANGO_SETTINGS_MODULE=prod_settings django-admin migrate` +4. To create an initial admin user for the Web component, run: + `PYTHONPATH=/etc/ctf-gameserver/web DJANGO_SETTINGS_MODULE=prod_settings django-admin createsuperuser` +5. Create the Materialized View for scoring, apply [scoring.sql](https://github.com/fausecteam/ctf-gameserver/blob/master/conf/controller/scoring.sql): `psql < scoring.sql` + +If you want to restrict database access for the individual roles to what is actually required, create +additional Postgres users with the respective database grants. For details, see the [tasks from the +"db_epilog" Ansible role](https://github.com/fausecteam/ctf-gameserver-ansible/blob/master/roles/db_epilog/tasks/main.yml). + +Gameserver Setup +---------------- +After setting up the database and the web component, visit your website. It is expected that the start page +will show error 404, as no content has been created yet. Instead, visit the `/admin` path and log in with +your web credentials (Ansible vars `ctf_gameserver_web_admin_user` & `ctf_gameserver_web_admin_pass`). + +To configure the basic parameters of your competition, click "Game control" (under "SCORING"). Afterward, +add your services. There is no need to touch "Captures", "Flags", "Status checks", or "VPN status checks". + +You can use "Flatpages" to provide static web content. By default, each category will result in a dropdown +menu in the main navigation. For the home page, create a page with an empty title and no category. + +Personal files (e.g. VPN configs or other credentials) can be provided to teams confidentially through the +"Team Downloads" feature. To use it, set up `TEAM_DOWNLOADS_ROOT` in `prod_settings.py` and set up the +file names and descriptions through the admin interface. The per-team files then must be copied to the +filesystem hierarchy below `TEAM_DOWNLOADS_ROOT`, see the comment in `prod_settings.py` for details. diff --git a/docs/observability.md b/docs/observability.md new file mode 100644 index 0000000..f29ef8a --- /dev/null +++ b/docs/observability.md @@ -0,0 +1,54 @@ +Observability +============= + +This page describes **how to monitor the Gameserver with dashboards, metrics, and logs**. + +Built-In Dashboards +------------------- +CTF Gameserver's Web component includes several dashboards for service authors to observe the behavior of +their Checker Scripts: + +* **Service History** shows the check results for all teams over multiple ticks. It provides a nice overview + of the Checker Script's behavior at large. +* **Missing Checks** lists checks with the "Not checked" status. These are particularly interesting because + they point at crashes or timeouts. + +Access to all of these requires a user account with "Staff" status. If configured, links to the corresponding +logs in Graylog are automatically generated (setting `GRAYLOG_SEARCH_URL`). + +Users with "Staff" status can also view the **VPN Status History** dashboard of any selected team. + +Logging +------- +All components write logs to stdout, from where they are usually picked up by systemd. You can view them +through the regular journald facilities (`journalctl`). + +The only exception to this are Checker Script logs, which can be very verbose and should be accessible to +their individual authors. + +### Checkers +You must explicitly configure Checker Script logs to be sent to either journald or Graylog. + +[Graylog (Open)](https://graylog.org/products/source-available/) is the recommended option, especially for +larger competitions. It allows logs to be accessed through a web interface and filtered by service, team, +tick, etc. When `GRAYLOG_SEARCH_URL` is configured for the Web component, the built-in dashboards +automatically generate links to the respective logs. + +After installing Graylog, create a new "GELF UDP" input through the web interface with a large enough +`recv_buffer_size` (we use 2 MB, i.e. 2097152 bytes). The parameters of this input then get used in the +`CTF_GELF_SERVER` option. + +With journald-based Checker logging, you can filter log entries like this: + + journalctl -u ctf-checkermaster@service.service SYSLOG_IDENTIFIER=checker_service-team023-tick042 + +Additionally, the `ctf-logviewer` script is available. It is designed to be used as an SSH `ForceCommand` to +give service authors access to logs for a specific service. + +Metrics +------- +All components except Web can expose metrics in [Prometheus](https://prometheus.io/) format. Prometheus +enables both alerting and dashboarding with [Grafana](https://grafana.com/grafana/). + +To enable metrics, configure `CTF_METRICS_LISTEN` (the Ansible roles do that by default). For the available +metrics and their description, manually request the metrics via HTTP. diff --git a/docs/submission.md b/docs/submission.md index bf49de3..152da68 100644 --- a/docs/submission.md +++ b/docs/submission.md @@ -5,6 +5,10 @@ In order to score points for captured flags, the flags are submitted over a simp protocol. That protocol was agreed upon by the organizers of several A/D CTFs in [this GitHub discussion](https://github.com/enowars/specification/issues/14). +The following documentation describes the generic, agreed-upon protocol. CTF Gameserver itself uses a more +restricted flag format, it will for example never generate non-ASCII flags. For details on how CTF Gameserver +creates flags, see [flag architecture](architecture.md#flags). + Definitions ----------- * **Whitespace** consists of one or more space (ASCII `0x20`) and/or tab (ASCII `0x09`) characters. @@ -40,7 +44,7 @@ can be derived from the flag repetition in the response. Response Codes -------------- -* `OK`: The flag was valid, has been accepted by the server and will be considered for scoring. +* `OK`: The flag was valid, has been accepted by the server, and will be considered for scoring. * `DUP`: The flag was already submitted before (by the same team). * `OWN`: The flag belongs to (i.e. is supposed to be protected by) the submitting team. * `OLD`: The flag has expired and cannot be submitted anymore. diff --git a/mkdocs.yml b/mkdocs.yml index d985452..0051566 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,11 +14,14 @@ theme: nav: - Home: index.md + - Architecture: architecture.md + - Installation: installation.md - Checkers: - General: checkers/index.md - checkers/python-library.md - checkers/go-library.md - Submission: submission.md + - Observability: observability.md site_dir: docs_site markdown_extensions: From e39bba969e3cb918738082c21fde9554c10d480f Mon Sep 17 00:00:00 2001 From: Felix Dreissig Date: Sat, 2 Dec 2023 17:04:51 +0100 Subject: [PATCH 62/63] Docs: Add links to Privacy policy and Legal information --- mkdocs.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 0051566..dd26204 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,9 @@ nav: - Submission: submission.md - Observability: observability.md +# Use copyright field to add links to the footer +copyright: 'Privacy | Legal' + site_dir: docs_site markdown_extensions: - toc: From 98383a02c1f79f1dfe48546ddf565deae53ef315 Mon Sep 17 00:00:00 2001 From: Felix Dreissig Date: Sat, 2 Dec 2023 17:14:22 +0100 Subject: [PATCH 63/63] README: Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index aa8bb53..7ec33cd 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ platform-independent. Security -------- -Should you encounter any security vulnerabilities in the Gameserver, please report them to use privately. +Should you encounter any security vulnerabilities in the Gameserver, please report them to us privately. Use GitHub vulnerability reporting or contact Felix Dreissig or Simon Ruderich directly. Copyright