-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmain.py
More file actions
1373 lines (1219 loc) · 54.4 KB
/
Copy pathmain.py
File metadata and controls
1373 lines (1219 loc) · 54.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright (c) 2017-2026 Joel Panther
# Licensed under the MIT License.
# Requires fastapi. Developed using v0.115.11-3
# Requires NodeJS. Developed using v20.18.1
# Requires puppeteer. Developed using v24.23.0
# Developed/tested using Python 3.13.3
# ===========================================================
# Regular challenges have the challenge_id range of 1 to 900.
# Secrets start with a challenge_id range of 901-999.
# Secrets between 901-949 are stand alone.
# Secrets between 950-999 require a PHP frontend.
# ===========================================================
from fastapi import FastAPI, WebSocket, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
from datetime import datetime
import asyncio
import docker
import re
import os
import mimetypes
import uvicorn
import random
import string
import shutil
import sys
import json
from pathlib import Path
from typing import Dict, Any, Optional
from DynamicDifficulty import DynamicDifficultyScaler
from puppet_manager import terminate_puppet
from challenge_generator import createChallenge
from timer_service import start_hostname_timer
from db_connect import *
# TODO:
# Merge the provision_secret and provision_challenge functions.
# =============================
# Class objects
# =============================
dds = DynamicDifficultyScaler() # Create dynamic difficulty scaler.
# =============================
# Game engine variables
# =============================
STARTING_CHALLENGES = 1 # Change to set number of challenges to generate when a new game starts.
ADMIN_PASSWORD = "pg49kNKexFbx" # You will want to change this before you go live.
clients = set() # HTTP clients for web interface.
game_state = "standby" # States include: running, win, lose.
secrets_exhausted = False # Switch to disable secret generation. False = build secrets, True = don't build secrets.
flagcounter = 0 # Used in testing.
# Control flags for deploy_secret_check().
worker_stop_event = asyncio.Event()
worker_task = None
# Web interface artefacts.
templates = Jinja2Templates(directory="templates")
mimetypes.add_type("application/javascript", ".mjs")
# =============================
# Optional: Custom flags
# =============================
# Custom flags are optional.
# Place custom flags in the 'CUSTOM_FLAG_LIST' list.
# Game will generate randomised flags if no custom flags in list or when list exhausted.
custom_flag_pool = [] # Initialise list. Leave empty.
CUSTOM_FLAG_LIST = [] # Place hard-coded custom flags here. Optional.
# =============================
# Global timer artefacts
# =============================
TIMER_START = 7200 # Default new game starting time on the clock (in seconds).
SAVE_INTERVAL = 60 # Modify to set autosave intervals (in seconds).
timer_task: asyncio.Task | None = None # Tracks instances of timed challenges.
clock_reset_event = asyncio.Event() # Shared event to signal a timer reset/stop.
timer_value = TIMER_START # Tracks current timer value. Updated by engine.
visible_total = TIMER_START # Total play time that has been visible to players. Updated by engine.
progress_value = 0 # 0-100%. Default is 0 (new game).
# =============================
# Filename + directory paths
# =============================
CHALLENGES_FILE = Path("deployed_challenges.json") # Where the current challenges are saved for reference.
SAVESTATE_FILE_PATH = Path("game_state.json") # Full game state save file.
PROGRESS_FILE_PATH = Path("progress.json") # Only tracks time and points.
# Duplicates anything written to console to a log file.
class Tee:
def __init__(self, filename, mode="a", stream=None):
self.stream = stream or sys.stdout
self.file = open(filename, mode, buffering=1, encoding="utf-8")
def write(self, message):
self.stream.write(message)
self.file.write(message)
def flush(self):
self.stream.flush()
self.file.flush()
def isatty(self):
return self.stream.isatty()
def close(self):
self.file.close()
def __getattr__(self, name):
return getattr(self.stream, name)
# Returns the current time.
def now():
# return datetime.now().strftime('%Y-%m-%d %H:%M:%S')
return datetime.now().strftime('%H:%M:%S')
# Returns custom flag, if applicable.
def get_custom_flag():
"""
If custom flags have been implemented, this function returns one from the custom_flag_pool list.
The flag is then removed from the list, so it is not used again.
"""
global custom_flag_pool
if not custom_flag_pool: # Check if list is empty
return ""
flag = str(custom_flag_pool[0])
del custom_flag_pool[0]
return str(flag)
# Check to see if engine needs to automatically provision a secret challenge.
async def deploy_secret_check():
"""
Checks to see if conditions have been met for a secret challenge to be provisioned.
If conditions are met, a secret challenge is provisioned.
If conditions not met, waits 5 minutes and checks again.
If all secret challenges have been provisioned, function stops checking.
"""
worker_stop_event.clear() # Clear the stop signal when starting.
while not worker_stop_event.is_set():
await asyncio.sleep(300) # 5 minutes.
time_reward = int(round(dds.calculate_time_reward()))
print(f"[{now()}][*] Deploy secret check? Time Reward is currently:",time_reward)
if time_reward >= 5:
print(f"[{now()}][*] Attempting to build new secret/bonus challenge...")
if secrets_exhausted == True:
print(f"[{now()}][!] No more secrets can be generated for this game, secrets exchuasted.")
print(f"[{now()}][*] Stopping 'deploy_secret_checks' task/thread...")
worker_stop_event.set()
else:
print(f"[{now()}][*] Building new secret/bonus challenge...")
values = provision_secret()
if values is not None:
domain, system_name, challenge_id, corporation, challenge_username, challenge_password = values
await broadcast_new_challenge(
system_name, challenge_id, domain, corporation,
challenge_username, challenge_password)
await asyncio.sleep(0) # Flush
print(f"[{now()}][*] Secret build complete.")
else:
print(f"[{now()}][!] WARNING: No secret challenge to build.")
# Creates the task for deploy_secret_check().
def start_secret_check_task():
global worker_task
print(f"[{now()}][*] Starting 'deploy_secret_checks' task/thread...")
if worker_task and not worker_task.done():
return # Already running.
# Create background asyncio task.
worker_task = asyncio.create_task(deploy_secret_check())
# Kills the task for deploy_secret_check().
def stop_secret_check_task():
global worker_task
print(f"[{now()}][*] Stopping 'deploy_secret_checks' task/thread...")
try:
worker_stop_event.set() # Tells the loop to stop.
except Exception as e:
print(f"[{now()}][!] WARNING: {e}") # Probably not running.
try:
if worker_task is None: # No worker has been started.
return
if worker_task.done(): # Worker exists but has already finished.
return
except Exception as e:
print(f"[{now()}][!] WARNING: {e}") # Probably not running.
# Remove challenge from challenge JSON file
def delete_system_by_challenge_id(challenge_id, backup: bool = True):
"""
Delete system entries matching 'challenge_id' from JSON file.
Args:
challenge_id: The challenge_id to remove (int or str).
backup: If True, writes a .bak copy before modifying.
"""
json_path = CHALLENGES_FILE
if not json_path.exists():
raise FileNotFoundError(f"No such file: {json_path}")
# Load JSON
with json_path.open("r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict) or "domains" not in data or not isinstance(data["domains"], list):
raise ValueError("Unexpected JSON structure: top-level 'domains' list is required")
# Normalise for comparison (handle int/str mismatches)
target = str(challenge_id)
removed_total = 0
for domain in data["domains"]:
systems = domain.get("systems")
if not isinstance(systems, list):
continue
kept = []
removed_here = 0
for s in systems:
# Only treat dicts with 'challenge_id' as candidates
cid = str(s.get("challenge_id")) if isinstance(s, dict) and "challenge_id" in s else None
if cid is not None and cid == target:
removed_here += 1
else:
kept.append(s)
if removed_here:
domain["systems"] = kept
removed_total += removed_here
# If nothing was removed, do not rewrite the file
if removed_total == 0:
print(f"[{now()}][*] ERROR: No challenge removed from JSON?")
#return 0
return
# Optional backup
if backup:
shutil.copy2(json_path, json_path.with_suffix(json_path.suffix + ".bak"))
# Write updated JSON (pretty-printed)
with json_path.open("w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
#return removed_total
return
# Removes challenge artefacts after it has been removed by the engine.
def challenge_cleanup(challenge_id):
print(f"[{now()}][*] Removing puppet, if applicable...")
terminate_puppet(challenge_id)
delete_system_by_challenge_id(challenge_id)
print(f"[{now()}][*] Removing database, if applicable...")
try:
conn = master_db()
cursor = conn.cursor()
cursor.execute(f"DROP DATABASE `{challenge_id}`")
conn.commit()
except mysql.connector.Error as err:
print(f"[{now()}][!] WARNING: Maybe no database used in this challenge?: {err}")
print(f"[{now()}][*] Removing users...")
try:
cursor.execute(f"DROP USER `readonly{challenge_id}`@``")
conn.commit()
except mysql.connector.Error as err:
print(f"[{now()}][!] ERROR: {err}")
finally:
if cursor:
cursor.close()
if conn:
conn.close()
print(f"[{now()}][*] Removing container...")
client = docker.from_env() # Connect to Docker.
name = f"challenge{challenge_id}"
# Remove container with matching "name".
try:
containers = client.containers.list(all=True, filters={"name": name})
if not containers:
print(f"[{now()}][*] No container found with name '{name}'")
for container in containers:
print(f"[{now()}][*] Stopping container: {container.name} ({container.id[:12]})")
try:
container.stop()
except docker.errors.APIError as e:
print(f"[{now()}][!] WARNING: Could not stop container: {e}")
print(f"[{now()}][*] Removing container: {container.name}")
container.remove(force=True)
except docker.errors.APIError as e:
print(f"[{now()}][!] Error finding/removing container: {e}")
print(f"[{now()}][*] Updating pool status...")
try:
conn = database_connection()
cursor = conn.cursor()
if int(challenge_id) >= 900: # If it has an ID that aligns with a secret...
sql = """
UPDATE `secret`
SET `deployed` = '0',
`complete` = '1',
`flag` = '',
`container` = ''
WHERE `id` = %s
"""
else:
sql = """
UPDATE `pool`
SET `deployed` = '0',
`complete` = '1',
`flag` = '',
`container` = ''
WHERE `id` = %s
"""
cursor.execute(sql, (challenge_id,))
conn.commit()
except mysql.connector.Error as err:
print(f"[{now()}][!] ERROR: {err}")
finally:
if cursor:
cursor.close()
if conn:
conn.close()
# Remove Docker image(s) with matching name.
try:
images = client.images.list(name=name)
if not images:
print(f"[{now()}][!] WARNING: No image found with name '{name}'")
for image in images:
tags = ", ".join(image.tags) if image.tags else image.short_id
print(f"[{now()}][*] Removing image: {tags}")
client.images.remove(image.id, force=True)
except docker.errors.APIError as e:
print(f"[{now()}][!] Error removing image: {e}")
return
except Exception as e:
print(f"[{now()}][!] ERROR: {e}")
return
print(f"[{now()}][*] Challenge has been removed.")
return
# Validate submitted code against known flags in challenge database.
def code_attempt(challenge_id, submitted_code, system_name):
conn = database_connection()
clean_code = re.sub(r'[^A-Za-z0-9]', '', submitted_code) # Removes unsafe/unused characters from flag submission.
print(f"[{now()}][*] Cleaned attempt:", clean_code)
if system_name == "data corruption detected": # Check to see if secret challenge.
print(f"[{now()}][*] Validating SECRET for", challenge_id)
query = "SELECT 1 FROM secret WHERE id = %s AND flag = %s LIMIT 1;"
else:
print(f"[{now()}][*] Validating code for", challenge_id)
query = "SELECT 1 FROM pool WHERE id = %s AND flag = %s LIMIT 1;"
try:
cur = conn.cursor()
cur.execute(query, (int(challenge_id), clean_code))
row = cur.fetchone()
cur.close()
return row is not None
except Exception as e:
print(f"[{now()}][!] ERROR:", e)
return False
finally:
if cur:
cur.close()
if conn:
conn.close()
# Append a new challenge to JSON file for distribution to clients.
def append_system(domain, name, folder, system_id, corporation, challenge_id, challenge_username, challenge_password):
"""
When a challenge is provisioned, its details are recorded in a JSON file.
This JSON file is distributed to clients that visit the main CTF web server to help populate web page.
This function appends a given system under its domain in a JSON file.
JSON structure:
{
"domains": [
{
"domain": "...",
"systems": [
{
"name": "...",
"folder": "...",
"system_id": "...",
"corporation": "...",
"challenge_id": "...",
"username": "...",
"password": "..."
}
]
},
...
]
}
"""
print(f"[{now()}][*] Data to save:", domain, name, folder, system_id, corporation, challenge_id, challenge_username, challenge_password)
# Load existing data or initialise new structure.
if CHALLENGES_FILE.exists():
try:
data = json.loads(CHALLENGES_FILE.read_text(encoding="utf-8"))
except json.JSONDecodeError:
print(f"[{now()}][*] File empty or corrupt, starting fresh.")
data = {"domains": []}
else:
data = {"domains": []}
# Ensure top-level shape.
if not isinstance(data, dict) or "domains" not in data or not isinstance(data["domains"], list):
data = {"domains": []}
# Find (or create) the domain entry.
domain_entry: Dict[str, Any] | None = next(
(d for d in data["domains"] if d.get("domain") == domain),
None
)
if domain_entry is None:
domain_entry = {"domain": domain, "systems": []}
data["domains"].append(domain_entry)
# Build the challenge record.
system_record = {
"name": name,
"folder": folder,
"system_id": system_id,
"corporation": corporation,
"challenge_id": challenge_id,
"username": challenge_username,
"password": challenge_password,
}
print(f"[{now()}][*] Record to save:", system_record)
# Append the new challenge.
domain_entry["systems"].append(system_record)
# Write back.
tmp_file = CHALLENGES_FILE.with_suffix(".tmp")
tmp_file.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp_file.replace(CHALLENGES_FILE)
print(f"[{now()}][*] Challenge data saved to JSON.")
return
# Select a new secret/special/bonus challenge.
def provision_secret(challenge_type="secret"):
"""
The challenge_type="secret" is for the challenge generator,
so it knows what type of challenge it needs to build.
This function forces the creation of a secret challenge.
"""
global secrets_exhausted
if secrets_exhausted == True:
print(f"[{now()}][!] WARNING: Building a secret challenge was attempted, but secrets are exchausted.")
return
conn = database_connection()
cursor = conn.cursor()
print(f"[{now()}][*] Building a secret challenge...")
cursor.execute("""
SELECT id, type, flag
FROM secret
WHERE deployed = 0 AND complete = 0
ORDER BY RAND()
LIMIT 1
""")
row = cursor.fetchone() # Fetch secret challenge scenario from pool.
if row:
challenge_id, secret_type, flag_value, = row
print(f"[{now()}][*] Secret ID:", challenge_id)
print(f"[{now()}][*] Secret Type:", secret_type)
else:
cursor.close()
conn.close()
print(f"[{now()}][!] WARNING: No more secrets available.")
secrets_exhausted = True # Switch to stop provisioning more secret challenges.
return
# Checks for valid custom flags.
if flag_value == "":
flag_value = str(get_custom_flag())
# Some secrets have hard-coded flags. For others, will need to generate flag.
if flag_value == "":
# Generate the flag, only using ASCII letters and digits.
characters = string.ascii_letters + string.digits # A-Z, a-z, 0-9
flag_value = ''.join(random.choices(characters, k=20))
print(f"[{now()}][*] {challenge_id} assigned flag: {flag_value}")
# Grab a random corporation.
query = "SELECT name FROM corporations ORDER BY RAND() LIMIT 1"
cursor.execute(query)
namerow = cursor.fetchone()
corporation = str(namerow[0])
# Pre-filled secret variables for challenge generator.
domain = "secret"
system_name = "secret"
folder = "secret"
system_id = "0"
# Create the new challenge.
container_id, challenge_username, challenge_password = createChallenge(
domain, system_name, folder, system_id, corporation, challenge_id, flag_value, cursor, challenge_type)
# Update database to record flag, container_id, and deployment status of secret challenge.
cursor.execute("""
UPDATE secret
SET deployed = 1, flag = %s, container = %s
WHERE id = %s
""", (flag_value, container_id, challenge_id))
conn.commit()
print(f"[{now()}][*] {challenge_id} container saved to database.")
cursor.close()
conn.close()
print(f"[{now()}][*] Saving secret challenge to JSON for persistence.")
append_system(domain, system_name, folder, system_id, corporation, challenge_id, challenge_username, challenge_password)
print(f"[{now()}][*] SUCCESS: Secret challenge build completed!")
return domain, system_name, challenge_id, secret_type, challenge_username, challenge_password
# Select a new challenge from the available pool.
def provision_challenge(challenge_type="none"):
"""
Function determines which challenge to build and passes variables to the challenge_generator class.
The challenge_type parameter is used when calling the challenge generator via createChallenge(...).
Supported values:
- none (the challenge generator will randomly select a challenge type to make)
- web (web application challenge)
- ssh (challenge with an entry point via SSH)
- secret (secret/bonus challenge)
"""
flag_value = ""
conn = database_connection()
cursor = conn.cursor()
print(f"[{now()}][*] Building a challenge...")
cursor.execute("""
SELECT id, corporation, system
FROM pool
WHERE deployed = 0
ORDER BY RAND()
LIMIT 1
""")
row = cursor.fetchone() # Fetch challenge scenario from pool.
if row:
challenge_id, corporation, system_id = row
# print(f"[{now()}][*] Challenge ID:", challenge_id)
# print(f"[{now()}][*] Corporation:", corporation)
# print(f"[{now()}][*] System ID:", system_id)
else:
print(f"[{now()}][!] WARNING: Problem building challenge. No available challenges.")
cursor.close()
conn.close()
return
cursor.execute("""
SELECT system, folder, domain
FROM systems
WHERE id = %s
LIMIT 1
""", (system_id,))
q2 = cursor.fetchone() # Fetch details on the challenge.
if q2:
system_name, folder, domain = q2
# print(f"[{now()}][*] System Name:", system_name)
# print(f"[{now()}][*] System Folder:", folder)
# print(f"[{now()}][*] Domain:", domain)
else:
print(f"[{now()}][!] ERROR: Problem locating information for System ID:", system_id)
raise
# Checks for a valid custom flag.
if flag_value == "":
flag_value = str(get_custom_flag())
# Generate the flag, using only ASCII letters and digits.
if flag_value == "":
characters = string.ascii_letters + string.digits # A-Z, a-z, 0-9
flag_value = ''.join(random.choices(characters, k=20))
print(f"[{now()}][*] {challenge_id} assigned flag: {flag_value}")
# Create the new challenge.
container_id, challenge_username, challenge_password = createChallenge(
domain, system_name, folder, system_id, corporation, challenge_id, flag_value, cursor, challenge_type)
# Update database to record flag, container_id, and deployment status.
cursor.execute("""
UPDATE pool
SET deployed = 1, flag = %s, container = %s
WHERE id = %s
""", (flag_value, container_id, challenge_id))
conn.commit()
print(f"[{now()}][*] {challenge_id} container saved to database.")
cursor.close()
conn.close()
print(f"[{now()}][*] Saving challenge to JSON for persistence...")
append_system(domain, system_name, folder, system_id, corporation, challenge_id, challenge_username, challenge_password)
print(f"[{now()}][*] SUCCESS: Challenge build completed!")
return domain, system_name, challenge_id, corporation, challenge_username, challenge_password
# Save the current timer and progress/points values to a JSON file.
def save_progress():
"""
The dds.save_state(...) function likely replaces this function,
but its kept here until its verified there are no consequences to
it being removed. Only saves current timer value and points.
Can possibly retire this function.
"""
global timer_value, visible_total, progress_value
data = {
"timer_value": timer_value,
"visible_total": visible_total,
"progress_value": progress_value
}
with open(PROGRESS_FILE_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, indent=4)
# Load current game's timer and progress/points from JSON file.
def load_progress():
"""
The dds.load_state(...) function likely replaces this function,
but its kept here until its verified there are no consequences to
it being removed. Only saves current timer value and points.
Can possibly retire this function.
"""
global timer_value, visible_total, progress_value
print(f"[{now()}][*] Attempting to load previous game state from JSON file...")
if not os.path.exists(PROGRESS_FILE_PATH):
print(f"[{now()}][*] File '{PROGRESS_FILE_PATH}' not found. Skipping load.")
return False
try:
with open(PROGRESS_FILE_PATH, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
print(f"[{now()}][!] ERROR: Error reading '{PROGRESS_FILE_PATH}': {e}")
return False
# Extract variables from JSON.
timer_value = data.get("timer_value")
visible_total = data.get("visible_total")
progress_value = data.get("progress_value")
return True
# Changes format of time from an int to a string.
def format_time(seconds: int) -> str:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
secs = seconds % 60
return f"{hours:02}:{minutes:02}:{secs:02}"
# Starts countdown timer and transmits time to clients.
async def countdown_timer():
"""
This function controls the countdown timer and updates game state
and clients based on time remaining on the clock.
Server is the authority on time.
Every second, this function checks the global 'timer_value' variable.
- If timer_value <= 0, the "lose" state is triggered.
- While timer_value > 0, clients are sent current time.
This function also triggers the auto-save function when
last_save_counter >= SAVE_INTERVAL.
"""
print(f"[{now()}][*] Countdown timer starting...")
global timer_value, visible_total
last_save_counter = 0
clock_reset_event.clear() # Ensure it starts.
try:
while not clock_reset_event.is_set():
await asyncio.sleep(1)
if timer_value <= 0:
await win_or_lose("lose")
clock_reset_event.clear()
return
if timer_value > 0:
timer_value -= 1
visible_total += 1
dds.set_visible_time(remaining=timer_value/60, total=visible_total/60)
last_save_counter += 1
timer_percent = int((TIMER_START - timer_value) / TIMER_START * 100)
await broadcast_to_clients({"type": "containment_banner", "value": timer_percent}) # Aesthetic function.
if last_save_counter >= SAVE_INTERVAL:
dds.save_state()
save_progress()
last_save_counter = 0 # Reset counter for auto saving.
await broadcast_current_timer_value() # Send current time to clients.
except asyncio.CancelledError:
raise
finally:
clock_reset_event.clear() # Ensure the next start begins.
print(f"[{now()}][*] Countdown timer loop exited.")
# Broadcasts current countdown timer value to clients. Server is authority on time.
async def broadcast_current_timer_value():
time_str = format_time(timer_value)
timer_percent = int((TIMER_START - timer_value) / TIMER_START * 100)
await broadcast_to_clients({
"type": "tick",
"time": time_str,
"timer_percent": timer_percent,
"progress": progress_value
})
# When a new challenge is created, broadcast notify clients.
async def broadcast_new_challenge(system_name, challenge_id, domain, corporation, challenge_username, challenge_password):
print(f"[{now()}][*] Broadcasting new challenge...")
await broadcast_to_clients({
"type": "newchallenge",
"system_name": system_name,
"challenge_id": challenge_id,
"domain": domain,
"corporation": corporation,
"username": challenge_username,
"password": challenge_password
})
# When a challenge is removed, broadcast notify clients.
async def broadcast_remove_challenge(challenge_id, system_name, corporation):
print(f"[{now()}][*] Broadcsting challenge removal...")
await broadcast_to_clients({
"type": "remove",
"challenge_id": challenge_id,
"system_name": system_name,
"corporation": corporation
})
# Broadcast a JSON payload to clients.
async def broadcast_to_clients(payload: dict):
for client in clients.copy():
try:
await client.send_json(payload)
except:
clients.remove(client)
# Prepare contents of CHALLENGES_FILE into a payload to be sent to new clients, if applicable.
def current_init_payload():
try:
with open(CHALLENGES_FILE, "r", encoding="utf-8") as f:
payload = json.load(f) # Parse the JSON into a Python dict.
except FileNotFoundError:
print(f"[{now()}][!] WARNING: Prior game JSON file not found: {CHALLENGES_FILE}")
payload = {}
except json.JSONDecodeError as e:
print(f"[{now()}][!] ERROR: Invalid JSON in {CHALLENGES_FILE}: {e}")
payload = {}
payload.setdefault("type", "init")
return payload
# Deletes all old artefacts and resets clock + timer + progress.
def force_new_game():
"""
Function resets game back to a default state.
It performs the following:
- Deletes the progress file.
- Deletes the save state file.
- Deletes the challenges file.
- Removes all challenge databases from DBMS.
- Removes all challenge database users from DBMS.
- Deletes all challenge containers from Docker.
- Deletes all challenge container images from Docker.
- Resets timer_value, progress_value, visible_total, secrets_exhausted, and flagcounter.
- Resets engine database to default state.
This function intentionally does NOT modify the "game_state" global variable.
"""
print(f"[{now()}][*] Forcing new game, deleting/resetting all artefacts...")
# Delete progress file.
try:
os.remove(PROGRESS_FILE_PATH)
print(f"[{now()}][*] File '{PROGRESS_FILE_PATH}' deleted successfully.")
except FileNotFoundError:
print(f"[{now()}][!] ERROR: File '{PROGRESS_FILE_PATH}' not found.")
except PermissionError:
print(f"[{now()}][!] ERROR: Permission denied: Cannot delete '{PROGRESS_FILE_PATH}'.")
except OSError as e:
print(f"[{now()}][!] ERROR: Problem deleting file '{PROGRESS_FILE_PATH}': {e}")
# Delete savestate file.
try:
os.remove(SAVESTATE_FILE_PATH)
print(f"[{now()}][*] File '{SAVESTATE_FILE_PATH}' deleted successfully.")
except FileNotFoundError:
print(f"[{now()}][!] ERROR: File '{SAVESTATE_FILE_PATH}' not found.")
except PermissionError:
print(f"[{now()}][!] ERROR: Permission denied: Cannot delete '{SAVESTATE_FILE_PATH}'.")
except OSError as e:
print(f"[{now()}][!] ERROR: Problem deleting file '{SAVESTATE_FILE_PATH}': {e}")
# Delete challenges file.
try:
os.remove(CHALLENGES_FILE)
print(f"[{now()}][*] File '{CHALLENGES_FILE}' deleted successfully.")
except FileNotFoundError:
print(f"[{now()}][!] ERROR: File '{CHALLENGES_FILE}' not found.")
except PermissionError:
print(f"[{now()}][!] ERROR: Permission denied: Cannot delete '{CHALLENGES_FILE}'.")
except OSError as e:
print(f"[{now()}][!] ERROR: Problem deleting file '{CHALLENGES_FILE}': {e}")
# Purge challenge databases from DBMS.
pattern = re.compile(r"^\d{1,3}")
print(f"[{now()}][*] Removing challenge databases...")
try:
conn = master_db()
cursor = conn.cursor()
cursor.execute("SHOW DATABASES") # Fetch all databases.
databases = cursor.fetchall()
for (db_name,) in databases:
# Skip system and engine databases.
if db_name in ["labrandor", "information_schema", "mysql", "performance_schema", "sys"]:
continue
# Check if database starts with 1–3 digits (1-999).
if pattern.match(db_name):
print(f"[{now()}][*] Deleting database: {db_name}")
cursor.execute(f"DROP DATABASE `{db_name}`")
conn.commit()
except mysql.connector.Error as err:
print(f"[{now()}][!] SQL ERROR: {err}")
except Exception as err:
print(f"[{now()}][!] ERROR: {err}")
# Purge challenge databases users from DBMS.
print(f"[{now()}][*] Removing challenge database users...")
try:
cursor.execute("SELECT user, host FROM mysql.user")
users = cursor.fetchall()
for row in users:
username, host = row
username = username.decode("utf-8", errors="ignore") if isinstance(username, (bytes, bytearray)) else str(username)
host = host.decode("utf-8", errors="ignore") if isinstance(host, (bytes, bytearray)) else str(host)
if not username:
continue
# If a challenge creates a table with its own user, add the table's base username here.
if (username.startswith("readonly")) or (username.endswith("employee")) or (username.endswith("blog")) or (username.endswith("product")):
print(f"[{now()}][*] Dropping user: '{username}'@'{host}'")
cursor.execute(f"DROP USER `{username}`@`{host}`")
conn.commit()
except mysql.connector.Error as err:
print(f"[{now()}][!] SQL ERROR: {err}")
except Exception as err:
print(f"[{now()}][!] ERROR: {err}")
finally:
if cursor:
cursor.close()
if conn:
conn.close()
# Remove Docker containers and images.
print(f"[{now()}][*] Removing challenge Docker containers...")
client = docker.from_env() # Connect to Docker.
try:
# client.ping() # Connectivity check.
# Remove containers whose *name* starts with "challenge".
for c in client.containers.list(all=True, filters={"name": "challenge"}):
if c.name and c.name.startswith("challenge"):
try:
print(f"[{now()}][*] Removing container: {c.name}")
c.remove(force=True) # Stops if running.
except NotFound:
pass
except APIError as e:
print(f"[{now()}][!] ERROR: Container remove error ({c.name}): {getattr(e, 'explanation', e)}")
# Remove Docker images whose repository starts with "challenge".
for img in client.images.list(all=True):
tags = img.tags or []
if any(tag.split(":", 1)[0].startswith("challenge") for tag in tags):
try:
print(f"[{now()}][*] Removing image: {img.id[:12]} {tags}")
client.images.remove(image=img.id, force=True, noprune=False)
except NotFound:
pass
except APIError as e:
print(f"[{now()}][!] WARNING: Image removal error ({img.id[:12]}): {getattr(e, 'explanation', e)}")
except APIError as e:
print(f"[{now()}][!] ERROR: Docker API Error: {getattr(e, 'explanation', err)}")
except Exception as e:
print(f"[{now()}][!] ERROR: {e}")
# Resetting engine's database back to the default state.
print(f"[{now()}][*] Resetting the pool of challenges...")
sql = """
UPDATE `pool`
SET `deployed` = 0,
`complete` = 0,
`flag` = "",
`container` = ""
WHERE 1
"""
sql2 = """
UPDATE `secret`
SET `deployed` = 0,
`complete` = 0,
`flag` = "",
`container` = ""
WHERE 1
"""
# Certain secret challenges have hard-coded flags.
secret901 = """
UPDATE `secret`
SET `deployed` = 0,
`complete` = 0,
`flag` = "HAL9000",
`container` = ""
WHERE `id` = "901"
"""
secret902 = """
UPDATE `secret`
SET `deployed` = 0,
`complete` = 0,
`flag` = "MOTHER",
`container` = ""
WHERE `id` = "902"
"""
# Disabling this secret until it is fixed. Can remove this query after challenge has been fixed.
secret955 = """
UPDATE `secret`
SET `deployed` = 1,
`complete` = 1,
`flag` = "",
`container` = ""
WHERE `id` = "955"
"""
try:
conn = database_connection()
cursor = conn.cursor()
cursor.execute(sql) # Reset "pool" table.
cursor.execute(sql2) # Reset "secret" table.
cursor.execute(secret901) # Certain secret challenges have hard-coded flags.
cursor.execute(secret902) # Certain secret challenges have hard-coded flags.
cursor.execute(secret955) # If this secret is fixed, remove this line.
conn.commit()
except Exception as e:
print(f"[{now()}][!] ERROR: Failed to update 'pool' or 'secret' table. Details: {e}")
finally:
cursor.close()
conn.close()
# Reset timers, counters, and progress.
global timer_value, progress_value, visible_total, secrets_exhausted, flagcounter
timer_value = TIMER_START
visible_total = TIMER_START
progress_value = 0
secrets_exhausted = False
flagcounter = 0 # Used in testing.
stop_secret_check_task() # Stop task from previous game.
print(f"[{now()}][*] Done.")
return
# Starts the new game or resumes the previously saved game.
def start_game():
global timer_task, game_state, timer_value, visible_total, progress_value
print(f"[{now()}][*] Reset clock, if applicable...")
try:
clock_reset_event.set()
except Exception as err:
print(f"[{now()}][!] ERROR: {err}")
print(f"[{now()}][*] Updating DDS...")
# Update DDS with times and progress.
try:
dds.set_visible_time_remaining(int(timer_value/60))
dds.set_visible_total_time(int(visible_total/60))
dds.set_progress(int(progress_value))
except Exception as err:
print(f"[{now()}][!] ERROR: {err}")
print(f"[{now()}][*] Starting the countdown timer...")
# Cancel any stuck task immediately.
if timer_task and not timer_task.done():
print(f"[{now()}][*] Killing old timer task...")
timer_task.cancel()
timer_task = asyncio.create_task(countdown_timer()) # Start fresh timer.
start_secret_check_task() # Start fresh secret check task.
game_state = "running"
return