Devlog #2 | Automation applied to an efficient operation will magnify efficiency

Last time we talked a lot about the HOW. This post is about realizing some parts of that plan into a real working PoC.

It only took us three development logs to start writing code. So fast! /s

gotta go fast

Side note: This time I’ve been working with my little brother Barak. His invaluable help (and willingness to work with me) is what will probably tip the scales in favor of this project actually making it to the finish line.

The first step was doing it manually, once 👨🏽‍🏭⚒

The first rule of any technology used in a business is that automation applied to an efficient operation will magnify the efficiency. The second is that automation applied to an inefficient operation will magnify the inefficiency.

-Bill Gates

With that quote in heart, I decided that I wanted to do everything that I needed to do to create a two-level challenge that works, but manually. This is my PoC, and it will be useful for two reasons: I didn’t want to waste time automating easy processes, and I wanted to make sure I have all the required knowledge to develop the challenge.

In an unsurprising (and pleseant) turn of events, I didn’t know everything I needed to ahead of time. Here are some lessons I’ve learned:

Writing a pre-receive hook

My previous git challenge was mostly performed through TravisCI. The player opened a PR and a Travis script validated what they’ve presented in their PR. This time, since the challenge should work completely offline and not be dependent on 3rd party services (here’s my reasoning why), I wanted to use git hooks.

Server side hooks in git are scripts that enable us to reject pushes with “error” messages. In the CTF, we’ll piggyback over this mechanism to validate the stages, print failure messages if the player didn’t solve the level correctly, or print the flags if the stage was solved. There are three available server-side hooks:

  • pre-receive which handles the push and may reject it (by exiting with a non-zero value). This is the hook that we’ll use.
  • update which is similar to pre-receive but runs once per branch.
  • post-receive which runs after the entire process is completed. This is mostly useful to update other services.

When writing the pre-receive hook, I stumbled onto a problem.

complicated

Looking at the push contents during the pre-receive execution

The pre-receive hook doesn’t have immediate access to the state of the working directory since it’s executed on a bare git repository. There is no working directory! However, since the hook needs to validate if the player solved the level correctly, it needs to look at the working directory.

For example, the first real level (start-here) has a very simple solve condition. 2 files, namely alice.txt and bob.txt should have been added to the repo in a single commit and pushed.

touch alice.txt bob.txt
git add alice.txt bob.txt
git commit -m "This is how you solve the first level. Not too hard."
git push

To test that the player solved the level correctly, we need to validate three things:

  1. The player only performed one commit to solve this level.
  2. No other files were changed/added/deleted.
  3. The files alice.txt and bob.txt were created in the root directory of the repo.

Let’s see how we validate condition #1 using only git and shell commands:

git fetch --tags --quiet  # get all the tags but don't show them to the player
# Check how many commits the player needed - should be two (the user commit + merge commit)!
commit_amount=$( git log start-here-tag..$new --oneline | wc -l )
if [ $commit_amount -ne 1 ];
    then reject-solution "The files should have been added in a single commit, but I've found ${commit_amount} commits in the log. To reset and try again, delete your local start-here branch, checkout the original start-here branch again and try again.";
fi

Condition #2 is validated using only git and shell as well:

# We know that there's only one commit in the changes - otherwise it would have failed before.
number_of_files_changed=$( git diff --stat $old $new | grep "files changed" | awk ' {print $1} ' )
if [[ $number_of_files_changed -ne 2 ]]
    then reject-solution "More than 2 files were changed! Only add alice.txt and bob.txt. Check out the original branch and try again.";
fi

The previous conditions don’t require us to actually look at the state of the working directory. The third condition - the existence of the files alice.txt and bob.txt - DOES require us to actually look at the directory and see if they’re there. If we had the working directory we could simply do this:

    # Check file existence.
    if [ ! -f alice.txt ];
        then reject-solution "Alice is missing! Try again.";
    fi

    if [ ! -f bob.txt ];
        then reject-solution "Bob is missing! Try again.";
    fi

But since we’re running on the server we don’t have the working directory deployed. After looking around I found the git archive command and came up with this:

Manually performing all the actions to deploy a game server

This was a very useful step, and I’m glad I did it. Writing down every little thing that I had to deal with while deploying the game made the actual development of CODE that will do it which I did later much more natural. It makes a lot more sense to automate something that was performed manually instead of doing it ahead of time. Here are the actions I performed from adding a new stage to deploying it:

  1. Create a solution checker script for the level.
  2. Add the level to the Levels repository.
  3. Add the level to the game-config.toml, which maps which flags it reveals, which branch it resides on, where the relevant solution checker script is located, and what the human-readable title is (e.g. merge-1).
  4. Update the “switchboard” pre-receive hook file. This file sees which branch was pushed and calls the appropriate solution checker. If the solution checker gives the green light, the hook will print the flags.
  5. Clone the make-git-better-levels repo with --bare.
  6. Create a player user.
  7. Create a git server (user, sshd, authorized_keys). See the relevant parts in the git book.
  8. Copy the solution checkers to the hooks directory in the game repo.
  9. Copy the pre-receive hook file to the hooks directory.
  10. Test. I only tested the first levels, which entailed cloning the repo, git checkout start-here, attempting to push a few wrong solutions, and then solving it correctly.

Even this scary task list still doesn’t take into consideration the web content creation which is a BIG part.

Starting to automate 🤖

After performing all these tasks manually, I mapped how they will be automated in the future. I want to automate anything that’s not strictly level content creation and validation. But since I have to begin somewhere, I chose to automate task #4 with a helper script and tasks #5 to #9 with Docker.

Generating the pre-receive hook automatically 🤖

I guess I’m a Rustacean now 🦀

This is the first rustlang script I’ve ever written, and I have to say…

rusty spoons

Developing in Rust (after getting over the initial hurdles) was a very rewarding experience. I can see why it’s voted as the most loved language in StackOverflow’s Developer Survey for the fourth year in a row. My setup was as simple as it gets - vim with some rust plugins. It was easy to jump into it with my C++ experience, and the compiler messages blew me away.

This is obviously an amateur’s foray into the language. It’s missing a lot of stuff which I hope to add in the future, mainly Unit Testing, and I’m sure there are some Rust “best practices” that I’ve missed.

Requirements: What does the script need to do

The script itself had a fairly simple job to perform - create the pre-receive hook based on the game’s configuration. For each level, make sure that the correct solution checker is executed, and if it passes, print the relevant flags. This way the checker script doesn’t “know” what level it’s in, doesn’t need to know the flags, and can be reused. The configuration looks like this:

[[levels]]
        title = "start-here"
        branch = "start-here"
        solution_checker = "hooks/checkers/start-here.sh"
        flags = ["merge-1"]

[[levels]]
        title = "merge-1"
        branch = "fizzling-vulture-pedial"
        solution_checker = "hooks/checkers/merge-1.sh"
        flags = ["merge-2", "log-1"]
# So on and so forth for all the levels...

So the end result should look like this:

#!/bin/bash

read old new ref < /dev/stdin

branch_name=$(echo $ref | awk 'BEGIN { FS = "/" } ; { print $NF }')

case $branch_name in
start-here)
    echo $old $new $ref | hooks/checkers/start-here.sh && print_flags fizzling-vulture-pedial
    ;;
fizzling-vulture-pedial)
    echo $old $new $ref | hooks/checkers/merge-1.sh && print_flags first-flag-name second-flag-name  # <- notice the two flags here
    ;;
# So on and so forth for all the levels...
esac

Implementation: Breaking the problem into smaller problems, and solving them one by one

I’m omitting the “boilerplate” stuff like parsing CLI arguments and logging from this analysis. You can read all the code on GitHub if you want to see all the details.

Parsing game-config.toml

This was pretty straight-forward. First, I defined the data structs for the configuration:

#[derive(Debug, Deserialize, Serialize)]
struct GameConfig {
    levels: Vec<Level>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
struct Level {
    title: String,
    branch: String,
    solution_checker: String,
    flags: Vec<String>,
}

And then read the configuration file into a GameConfig struct:

let game_config_file_contents = fs::read_to_string(args.game_config_path).unwrap();
let mut game_config: GameConfig = toml::from_str(&game_config_file_contents).unwrap();
Replacing level titles with their branches 🌿

The branch names are nonsense, to prevent players from looking at branch names for solution hints. To make the configuration more readable, flags are the next level titles rather than branch names. Compare:

[[levels]]
        title = "start-here"
        branch = "start-here"
        flags = ["merge-1"]  # Could have been flags = ["fizzling-vulture-pedial"]

[[levels]]
        title = "merge-1"
        branch = "fizzling-vulture-pedial"
# ...

However, the actual flag that we need to give the player is the branch they need to check out next in order to advance. So the script needs to replace all the flags with the branches - if possible. This took a while to implement, and it was the first time I actually needed to think about Rust’s ownership model:

fn replace_flags_with_branch_names(game_config: &mut GameConfig) {
    // This has to be cloned! Can't iterate over this while changing it. Thanks, rustc :)
    let levels_info = game_config.levels.clone();

    for mut level in &mut game_config.levels {
        let mut new_flags = Vec::new();
        for flag in &level.flags {
            debug!("level {} flag {}", level.title, flag);
            let mut levels_iterator = levels_info.iter();
            let found = levels_iterator.find(|&x| &x.title == flag);
            match found {
                Some(x) => {
                    debug!("replacing {} with {}", flag, x.branch);
                    new_flags.push(String::from(&x.branch));
                }
                None => {
                    debug!("flag {} is final", flag);
                    new_flags.push(flag.to_string());
                }
            }
        }
        level.flags = new_flags;
    }
}

fn main() {
  // [...]
  let mut game_config: GameConfig = toml::from_str(&game_config_file_contents).unwrap();
  // [...]
  replace_flags_with_branch_names(&mut game_config);
}
Output the result into a working pre-receive hook file

This was done using tinytemplate. The template’s interesting part looks like this:

case $branch_name in
{{ for level in levels }}{level.branch})
    echo $old $new $ref | {level.solution_checker} && print_flags{{ for levelflag in level.flags }} {levelflag}{{ endfor }}
    ;;
{{ endfor }}esac

We then rendered the template with our updated GameConfig struct, and wrote it to a file:

    let mut tt = TinyTemplate::new();
    let template_name = "switch_case";
    tt.add_template(template_name, &template_file_contents)
        .unwrap();
    let rendered = tt.render(template_name, &game_config).unwrap();

    let mut output_dir = args.output_path.clone();
    output_dir.pop();
    fs::create_dir_all(&output_dir).expect("Failed to create parent dir");
    let mut output_file = fs::File::create(&args.output_path).expect("Couldn't create file!");
    output_file.write_all(&rendered.as_bytes()).unwrap();

Setting up the game inside a Docker container

docker

I managed to get it to work. I’m not sure it’s perfect by any means, but it’s good enough to move forward to other tasks!

Requirements: What should the Dockerfile do

You can compare the Dockerfile’s content to the manual tasks I performed as listed earlier.

  1. Setting up the container and installing dependencies.
  2. Creating the users, and doing their basic setup.
  3. Setting up the git server and the levels repo.
  4. Setting up the actual “game” part with the hooks.

Implementation: What does the Dockerfile actually do

Here’s the initial version that worked for me. The comments should highlight what’s happening in the file:

from ubuntu:latest

# Install dependencies.
RUN apt update -y
RUN DEBIAN_FRONTEND="noninteractive" apt install -y tzdata
RUN apt install -y \
        git-all \
        vim \
        nano \
        whois \
        openssh-server \
        curl \
        apt-utils \
        iputils-ping \
        zsh \
        tmux

# Create the required users. The game master is the `git` account, and the player is the user's account
RUN useradd --comment "GameMaster account" --create-home --password $(mkpasswd -m sha-512 ...) gamemaster
RUN useradd --comment "Player account" --create-home --password $(mkpasswd -m sha-512 player) --shell /bin/zsh player

# player_entrypoint.sh sets up the player's SSH keys, copies the public key to /tmp, and sets up the shell (zsh with git plugin).
COPY build/player_entrypoint.sh /home/player
RUN chown player:player /home/player/player_entrypoint.sh
RUN chmod 770 /home/player/player_entrypoint.sh
RUN su -c "/home/player/player_entrypoint.sh" - player

# SSH server configuraion
RUN mkdir /var/run/sshd
RUN echo 'ClientAliveInterval 60' >> /etc/ssh/sshd_config
RUN echo 'ClientAliveCountMax 10' >>  /etc/ssh/sshd_config
COPY build/ssh_banner.txt /etc/banner
RUN echo 'Banner /etc/banner' >> /etc/ssh/sshd_config

# Set up the git server so that the player can run git clone gamemaster@localhost:~/game-repo
RUN git clone --bare https://github.com/ShayNehmad/make-git-better-levels.git /home/gamemaster/game-repo
# gamemaster_entrypoint.sh adds the player's ssh public key from /tmp to the authorized_keys
COPY build/gamemaster_entrypoint.sh /home/gamemaster
RUN chown gamemaster:gamemaster /home/gamemaster/gamemaster_entrypoint.sh
RUN chmod 770 /home/gamemaster/gamemaster_entrypoint.sh
RUN su -c "/home/gamemaster/gamemaster_entrypoint.sh" - gamemaster

# Set up the hooks for the actual gameplay in the repo
COPY levels/checkers /home/gamemaster/game-repo/hooks/checkers
COPY scripts/generate-pre-receive-hook/output/pre-receive /home/gamemaster/game-repo/hooks
# Make sure that gamemaster owns all of their files
RUN chown -R gamemaster:gamemaster /home/gamemaster

# Now that we're done with gamemaster's setup, we can change the shell to git-shell
RUN chsh gamemaster -s $(which git-shell)

# Cleanup
RUN rm -rf /tmp/*
RUN rm -rf /home/player/player_entrypoint.sh

EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]

What can be improved?

The build time is REALLY LONG, mostly due to apt-update taking forever. The final docker image is pretty large as well. I’m considering moving to alpine as a base image to fix this, but I’m not sure all of the dependencies will work there. I’m sticking with Ubuntu as a base for now because it’s easy and predictable.

What’s next?

The plan is seeing how the web content side of this will work, automating level creation, and moving to creating a TON of levels.

The deadline is drawing near…

Attribution: Background images created by stories @freepik