The Hack Fortress competition first debuted at Shmoocon 2011 after an initial attempt to debut at Defcon 2010 fell through due to funding issues.
It was and remains a hybrid Capture the Flag (CTF) and Gaming competition, combining a jeopardy style capture the flag competition with the Team Fortress 2 (TF2) team-based First Person Shooter (FPS).
Although the game format has evolved over the years, at its core remains the collaboration of gamers and hackers, working together to win the game.
In its current incarnation, as hackers solve challenges and submit flags, they get both points and coins.
Similarly, gamers playing TF2 will get points and generate coins as they play the game and get kills, point captures, etc.
The hackers can use the coins generated by their team to help the oppposite respective side of the team -- e.g., coins generated by hackers can help their TF2 team, and coins generated by gamers can be used to help hackers.
To facilitate this, the Hack Scoreboard
has a store system called the Hackonomy
that enables usage of the coins.
The game runs as a single-elimination bracket-style tournament, with it being run with eight (8) teams historically.
Given the number of teams, the brackets are broken up into QuarterFinals
, SemiFinals
, and Finals
, with each these being assigned a 'set' of puzzles.
The Hack Fortress architecture consists of three main pieces interconnected by a RabbitMQ message queue.
- The Hack Scoreboard (this repo)
- The Viewer
- The TF2 Interface
This repo contains the code and setup for the aforementioned Hack Scoreboard. Although, all three pieces are needed, this project contains the majority of the heavy lifting logic for most of the game's functionality, including providing a place for the hackers to get puzzles, solve challenges, access the store, etc.
The Hack Scoreboard actually consists of multiple services, all ruby and utilizing NGINX for serving up the application. One of the services is the main Rails + React application which the Hackers interact with. Another is a 'worker' that utilizes Ruby Kicks (formerly Sneakers) to continuously listen to RabbitMQ for TF2 events. The Rails app was originally written using Rails 3.0 with a JQuery javascript frontend and has been continously updated to where it is now, using Rails 7.1.x and React with Vite for the frontend. The final service is a small Sinatra app that is used for streaming events to the frontend using Server-sent Events via redis as an interconnect. This enables one-way communication to client to enable dynamic updates.
Both the Rails and Sinatra apps sit behind an NGINX proxy which combines both to be served from the same host.
The viewer project (currently unreleased) is a Python Flask + React App which listens to RabbitMQ for game events, from both the hack and tf2 sides and displays the game status.
This functionality was originally baked into the The Hack Scoreboard
project but was broken out in ~2016 for easier development and management.
The TF2 interface (currently unreleased) consists of Python instrumentation around TF2's API and log parser, enabling event-driven actions and custom modification of the games state based on outside factors.
The Hack Scoreboard requires three (3) backend dependencies to function. Other than the previously mentioned RabbitMQ server which is shared between all components, the scoreboard needs a redis server and MySQL database. The redis server is used for caching information and further is utilized for its streaming capabilities by the Sinatra app.
Setup and installation is simplified by using Ansible scripts to facilitate most of the installation and configuration of things that are needed for the system to work. Some manual configuration will still be required, though.
The ansible playbooks were written for and tested against Ubuntu 22.04. It may or may not work with other versions of Ubuntu. The code should work in any linux distro, but has only been run/tested in Ubuntu.
Run the setup_ansible.sh
script as root.
This will create a directory /opt/ansible
and create a virtual-env /opt/ansible/ansible-env
where it will install python dependencies needed by ansible.
After it runs, copy the ansible directory to /opt/ansible/
, e.g., /opt/ansible/ansible
.
Before running any playbooks, check the values in group_vars/all.yml
.
The development
variable, which defaults to false
determines if the playbook will setup a system user scoreboard
and if it can skip setting up RabbitMQ.
When set to true
, it will use the user specified as the user to own directories created.
If you are running this on a personal machine, set development to true
(or set the variable via cli).
If this is a shared system, keep it at false
, so it runs in production mode.
You can then switch user to the scoreboard
user via:
sudo su - scoreboard
If you want to run/install the scoreboard from a different directory, change the value of scoreboard_base
.
Before running any ansible commands you will need to be in the ansible venv:
cd /opt/ansible
. ansible-env/bin/activate
The preflight playbook sets up system-level components and directories. Run as follows:
cd /opt/ansible/ansible
ansible-playbook preflight.yml
After running the preflight playbook, copy over or pull the scoreboard (this repo) to the base directory (specified in the all.yml
file).
Next open up the scoreboard.yml
playbook and update the SQL passwords in the vars
section.
If this is a development environment, you can leave the passwords as is.
Now run the scoreboard playbook
cd /opt/ansible/ansible
ansible-playbook scoreboard.yml
If all goes well, it will have done most of the steps needed to get up and running. At this point only a few manual steps left.
With both Ansible playbooks run, all of the pieces are in place but some things need to be manually configured. These items are probably possible to automate via ansible in the future.
Rails manages credentials (and secrets) in an encrypted file. This encrypted file can be saved to a git repo but you will have to create your own for you own installation.
To get rails to create a file run:
EDITOR=vim rails credentials:edit
Replace the value of EDITOR
with your editor of choice.
This will create and open the file config/credentials.yml.enc
in vim for editing.
NOTE: If you are not familiar with vim and use it accidentally, type
q!
to exit.
It wil also create a config/master.key
file which will be ignored by git (entry in .gitignore
file).
Once the file is open for editing, take a look a the config/credentials.template.yml
file for
what values need to be added/updated.
The default file created by rails will not have stages, which is needed by this app.
With credentials setup, you now need to setup the Rails database, to do so run:
rails db:reset
This will setup the MySQL database with the app schema and seed the development database.
If you want to run this in production mode (which is faster but does not allow dynamic updates to the code), you can add RAILS_ENV=production
to any rails
command. E.g.,:
RAILS_ENV=production rails db:reset
Only necessary in production as development will auto-compile, we need to compile the assets and the frontend app:
rails assets:precompile
Finally, you can restart the services
sudo systemctl restart scoreboard
Navigate to http port 80 for the host where you installed the app and you should be greeted by the login prompt.
Enter admin
as the username and whatever default password you set when creating the credentials file.
The scoreboard utilizes NGINX for exposing the service and can be configured to work over SSL if desired. The installed configuration includes a commented out portion that redirects any http/port 80 traffic to https/port 443. After setting up certificates, comment out (or delete) the uncommented server section and then uncomment the commented portion.
The scoreboard uses roles to manage permissions, putting users into one of three:
- Admin
- Judge
- Contestant
The default admin
user is created by the scoreboard and is generally the only organizer role needed.
The Admin role allows access to all permissions and views. There is a marked difference between the number of pages and knobs available to an Admin versus other roles.
The Judge role allows a user to access interfaces needed to interact with the game. This includes capabilities such as managing puzzles, submitting challenges on behalf of contestants, etc. Historically all Judges used the same login as it simplifies management and the scoreboard is a short lived service.
The Contestant role grants locked down access to the scoreboard, limiting access if a team is currently not playing.
Using either an Admin or Judge Role grants access to the Puzzle Catalog
which enables the user to manage puzzles Categories
and Puzzles
within those categories.
To simplify matters we have historically managed puzzes in a spreadsheet, exported to CSV and then used the CSV Upload functionality in the Puzzle Catalog
to bulk import puzzles for an event. The following CSV fields are expected:
- Category - The puzzle category name
- Name - The puzzle name
- Set - The puzzle 'set', which should be 1, 2, or 3.
- Hints - Any hints that may be used by Judges to help contestants
- Points - The nmerical point value of the puzzle
- Description - A description of the puzzle that will be presented to the contestant
- Unlock - The number of minutes at which point the puzzle will be 'unlocked'
- Solution - The puzzle solution, if left blank, only a Judge can 'submit' a solution
- Location - A location description, or alternatively download path if
Download
is defined - Author - The author of the puzzle (makes it easier to find out who wrote the puzzle)
- Download - A modifier that changes how
Location
is interpreted- 'text_only' (default if not set) - handle as regular text
- 'gcloud' - handle as a file stored in GCP (see
config/gcloud.yml
for required config) - 'local' - handle as a file hosted in the app (see
config/localstorage.yml
for required config)
Beyond CSV, the scoreboard can export the puzzles in JSON format and provides the capability to upload puzzles from JSON.
As alluded to in the above section, puzzles are organized into 'sets'. This groups puzzles together enabling a set to be associated with a round (see below).
An above section covered users and roles, but didn't mention how teams work.
Basically, if a user is marked as a contestant, they are associated with a team.
Generally a contestant user shouldn't be created directly, instead a team should be created (via the Team
page).
Creating a team will automatically create an associated user and five (5) players associated with the team.
To manage the publicly displayed team name, go to the Team
page.
To manage the password for the team login, go the Users
page.
By default the scoreboard will create eight (8) rounds, consisting of four (4) quarter finals, two (2) semi finals and a final round.
It will further automatically assign puzzle sets to these rounds.
The Rounds
page enables an admin to manage the game, including setting which teams are playing and actually starting a game.
The entire Hack Fortress game runs on some level of event based automation. The TF2 side will send messages which include its information about the game such as who's playing and the game duration. At a high level it works like the following:
- Hack Scoreboard has a round set to
Ready
status - TF2 sends a "Game Prep" Message
- TF2 sends a "Game Start" message
- Hack Scoreboard gets the message and automatically sets the round that was set to
Ready
toLive
There are additional steps in betwee, but they main involve the Hack Fortress Viewer.
The Hackonomy store is managed via the Store Control
and Item Catalog
pages the latter of which is very similar to the above Puzzle Catalog
.
Since store items don't need to change as often, the majority of items are managed in db/seeds.rb
and seeded when running db:reset
and the store control page is mainly used for setting discounts.
The Item Catalog
allows further changes to be made to the base items.
Although the item catalog controls information about the store items, the Inventory
controls the stock levels per round.
Once you choose a Round
from the top drop-down list, it will you show the stock for each item separated by team color (red and blue).
Since inventory is set when a round is created, you must update the values here if you want to change the stock for an existing round.
When setup in development mode, Rails will automatically detect most changes to ruby code on the fly so your next call will utilize the latest code. Note that some changes, including in intializers may require a restart (see below).
To support frontend development, edit the /etc/scoreboard/env
file and define the RAILS_DEV_IP
variable.
If you have a hostname for your dev setup, also uncomment and define RAILS_DEV_HOSTNAME
.
Now restart the rails scoreboard:
sudo systemctl restart scoreboard
Further, Vite, specifically the vite rails integration provides an easy way to automatically reload the React frontend app when it detects a change. Just navigate to the scoreboard directory and run:
vite dev
This will start the vite development server and watch for changes. If you have already loaded the app in your browser, refresh and to have it refetch a debug version of the app.
The following is the message spec used for intra-communication. All messages are sent MessagePack'd
NOTE: Some of these message specs may be slightly out of date.
#TF2 Time Messages
#routing key: tf2.event.time
{
“event”: “game_prep”,
“game_id”: <unique game id number>,
“duration”: <duration>, # how many minutes is this game?
“red_team”: <name of red team>,
“blue_team”: <name of blue team>,
}
{
“event”: “game_start”,
“game_id”: <unique game id number>,
“timestamp”: <timestamp>,
}
{
“event”: “game_end”,
“game_id”: <unique game id number>
}
#TF2 Event Message
#routing key: tf2.event.score
{
"team": <"1" if red | "2" if blue>,
“game_id”: <game_id>,
"event": <"first_blood"|"domination"|"revenge"|"point_captured"|"kill"|"round_win">,
“value”: <event value, optional, defaults to internal hard-coded values>
}
#Hack Game/Time Messages
#routing key: hack.event.time
{
“event”: “game_prepped”,
“categories”: <list of game categories>
}
{
“event’: “game_started”
}
#Hack Event
#routing key: hack.event.score
{
"type": "hack",
"team": <"1" if red | "2" if blue>,
“category”: <category of puzzle if event == “hack”>,
"player": <player name>,
“name”: <puzzle name>,
"value": <event value>
}
{
"type": "bonus",
“bonus”: <"first_blood"|"domination"|"revenge"|"bonus">,
"team": <"1" if red | "2" if blue>”,
"value": <event value>
}
#Hack Effect Request
#routing key: purchase.effect.tf2
{
"from_team": <"1" if red | "2" if blue>, # Requesting team
"to_team": <"1" if red | "2" if blue>, # Affected team
"num_players": <value between 1 - 6>, # Number of player affected
"value": <effect value>,
"effect_name": <effect name>, # Pre-negotiated name of effect
# List in purchase.rb in hack scoreboard
# list in <??> in tf2 scoreboard
"delay": <amount of seconds to delay effect from occurring>
}
#Hack Effect Internal
#routing key: purchase.effect.hack
{
“effect_name”: <name of effect, e.g., store dos>
“from_team”: <”1” if red | “2” if blue> # Requesting team
“to_team”: <”1” if red | “2” if blue> # affected team
}
#Hack Status Effect
#routing key: status.effect
{
“team”: <”1” if red | “2” if blue> # affected team
“status_name”: <”cap_block” | “cap_delay”>
“timed”: “<true|false> # whether this is a timed status or not
“timer”: <number> # number of seconds this is timed for
“remove”: <true|false> #whether this is being requested to be removed>
}