Split ctf_status.py into a tracker package, config from .env only
ctf_status.py is now just the command line; the work lives in tracker/. The rules that decide whether a challenge is believed are in tracker/checks.py, one function per rule, listed in README_CHECKS and REPO_CHECKS. Output is unchanged. Read the config from .env only (ctf_status.env.example -> .env.example), let single_run_lock() hold its own lock files, and rewrite the README as a short overview of what the repo does and how to run it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,198 +1,126 @@
|
||||
# ctf_status.py
|
||||
# CTF challenge status tracker
|
||||
|
||||
Reads the `README.md` of every repo in the GitLab challenges group (REST API, no
|
||||
clone) and shows which categories have challenges in which state, at which
|
||||
difficulty — in the terminal and as a HedgeDoc note. A status of Testing Ready
|
||||
or beyond is only believed if the repo backs it up (see
|
||||
[Checking the repo](#checking-the-repo)).
|
||||
Tracks the progress of the h4tum CTF challenges. It reads every repo in the
|
||||
GitLab challenges group, works out each challenge's status, category and
|
||||
difficulty from its `README.md`, and checks that challenges marked as ready
|
||||
really are. The result goes to:
|
||||
|
||||
Needs `requests` and `PyYAML` (`python-requests python-yaml` on Arch,
|
||||
`python3-requests python3-yaml` on Debian).
|
||||
- **a HedgeDoc note**: an overview of all challenges, refreshed every 30 minutes
|
||||
- **`challs.txt` in the deployment repo**: the clone URLs of every challenge
|
||||
ready for infra testing, updated daily
|
||||
- **the terminal**: the same overview, for a quick look
|
||||
|
||||
## Setup
|
||||
## Requirements
|
||||
|
||||
- Python 3.9+
|
||||
- [`requests`](https://pypi.org/project/requests/) and [`PyYAML`](https://pypi.org/project/PyYAML/)
|
||||
(`python-requests python-yaml` on Arch, `python3-requests python3-yaml` on Debian)
|
||||
|
||||
## Configuration
|
||||
|
||||
```sh
|
||||
cp ctf_status.env.example ctf_status.env && chmod 600 ctf_status.env
|
||||
$EDITOR ctf_status.env # GitLab group URL + token, HedgeDoc note
|
||||
cp .env.example .env && chmod 600 .env
|
||||
```
|
||||
|
||||
## Use
|
||||
|
||||
```sh
|
||||
./ctf_status.py # terminal summary
|
||||
./ctf_status.py -o overview.md # + HedgeDoc markdown to a file
|
||||
./ctf_status.py --stdout | wl-copy # straight to clipboard, paste into HedgeDoc
|
||||
./ctf_status.py --update # overwrite the note in HEDGEDOC_NOTE
|
||||
./ctf_status.py --update <note-url> # ... or an explicit one
|
||||
```
|
||||
|
||||
The token is a fine-grained personal access token on the challenges group with
|
||||
**Project: Read** (to list the repos) and **Repository: Read** (to download the
|
||||
READMEs and `description.yml`, and to list the repo's files). A plain `.env` with `GL_PAT=` / `GITLAB_URL=` is read as well.
|
||||
|
||||
## Keeping a HedgeDoc note up to date
|
||||
|
||||
`--update` overwrites an **existing** note in place, so the URL you share stays the
|
||||
same. Every run rewrites the whole body, starting with
|
||||
|
||||
```
|
||||
---
|
||||
tags: Logistics, h4tum CTF 2026
|
||||
---
|
||||
```
|
||||
|
||||
and a `**Last updated:** <date> <time> <tz>` line under the heading, so a stale note
|
||||
is obvious at a glance. Tags come from `HEDGEDOC_TAGS` (or `--tags`).
|
||||
|
||||
The note's permission has to be **freely** (Menu → Permission), because the run is
|
||||
anonymous. HedgeDoc 1.x has no REST API for editing a note, so the script talks the
|
||||
editor's own engine.io/socket.io + OT protocol over long-polling — it reads the
|
||||
current text, sends one "delete everything, insert this" operation, waits for the
|
||||
server's `ack`, and reads the note back to confirm. Anyone with the note open sees
|
||||
the change live. Plain `requests`, no websocket library.
|
||||
|
||||
`--hedgedoc <instance>` (POST a *new* note) is unrelated and only works where
|
||||
anonymous note creation is enabled — on `hedgedoc.cit.tum.de` it is not.
|
||||
|
||||
## challs.txt for the deployment repo
|
||||
|
||||
`--challs` commits the clone URL of every challenge at **Testing Ready or beyond**
|
||||
that is not blocked (see [Checking the repo](#checking-the-repo))
|
||||
to `CHALLS_PATH` (default `2026/challs.txt`) in the deployment repo
|
||||
`CHALLS_REPO_URL`, one per line, the same format as last year's:
|
||||
|
||||
```
|
||||
git@gitlab.lrz.de:h4tum-ctf/h4tum-ctf-2026/challenges/baby_rsa.git
|
||||
...
|
||||
```
|
||||
|
||||
It only commits when the list actually changed, with a message naming what was
|
||||
added and removed (`Update 2026/challs.txt from challenge status: +jwt -safe`).
|
||||
A challenge that falls back below Testing Ready drops out again. Preview without
|
||||
committing:
|
||||
|
||||
```sh
|
||||
./ctf_status.py -q --challs-out -
|
||||
```
|
||||
|
||||
The token additionally needs **Repository: Read** and **Repository: Write** on the
|
||||
deployment project. If the branch is protected, the token's user must be allowed
|
||||
to push to it. Without `CHALLS_BRANCH`, the default branch is looked up, which
|
||||
also needs **Project: Read** there.
|
||||
|
||||
## On a schedule, on a server
|
||||
|
||||
Two timers, independent of each other:
|
||||
|
||||
| timer | when | runs |
|
||||
| Key | Required | Meaning |
|
||||
|---|---|---|
|
||||
| `ctf-status.timer` | every 30 minutes | `--update` (HedgeDoc note) |
|
||||
| `ctf-challs.timer` | daily at 04:00 (server local time) | `--challs` (challs.txt commit) |
|
||||
| `GITLAB_GROUP_URL` | yes | GitLab group that holds one repo per challenge |
|
||||
| `GITLAB_TOKEN` | yes | Personal access token, see below |
|
||||
| `HEDGEDOC_NOTE` | for `--update` | URL of an existing note; its permission must be **freely** |
|
||||
| `HEDGEDOC_TAGS` | no | Tags written into the note's front matter |
|
||||
| `CHALLS_REPO_URL` | for `--challs` | Deployment repo that holds `challs.txt` |
|
||||
| `CHALLS_PATH` | no | Path of the file in that repo (default `2026/challs.txt`) |
|
||||
| `CHALLS_BRANCH` | no | Branch to commit to (default: the repo's default branch) |
|
||||
|
||||
Copy the repo to the server, create `ctf_status.env` or `.env` there (both are gitignored, so they
|
||||
do not travel with the code), then:
|
||||
The token needs **Project: Read** and **Repository: Read** on the challenges
|
||||
group. For `--challs` it also needs **Repository: Read + Write** on the deployment
|
||||
repo, and permission to push to the branch if it is protected.
|
||||
|
||||
Environment variables override the values in `.env`.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
./ctf_status.py # print the overview
|
||||
./ctf_status.py -o overview.md # also write the note's markdown to a file
|
||||
./ctf_status.py --update # overwrite the HedgeDoc note
|
||||
./ctf_status.py --challs # commit challs.txt, if it changed
|
||||
./ctf_status.py -q --challs-out - # preview challs.txt without committing
|
||||
```
|
||||
|
||||
`./ctf_status.py --help` lists all options. With `--quiet`, a run prints nothing
|
||||
unless something changed or failed.
|
||||
|
||||
## Deployment
|
||||
|
||||
On the server, with `.env` in place:
|
||||
|
||||
```sh
|
||||
./install-timer.sh
|
||||
```
|
||||
|
||||
It writes systemd **user** units into `~/.config/systemd/user/`, substituting
|
||||
whatever directory the repo actually sits in, enables both timers, turns on lingering
|
||||
so they keep firing when you are not logged in, and does one run of each to prove they work.
|
||||
Re-run it any time; it just overwrites the units.
|
||||
This installs and starts two systemd user timers:
|
||||
|
||||
| Timer | Runs | Does |
|
||||
|---|---|---|
|
||||
| `ctf-status.timer` | every 30 minutes | `--update`: refresh the HedgeDoc note |
|
||||
| `ctf-challs.timer` | daily at 04:00 | `--challs`: commit `challs.txt` |
|
||||
|
||||
Re-running the script is safe. To check on the timers:
|
||||
|
||||
```sh
|
||||
systemctl --user list-timers 'ctf-*' # when they next fire
|
||||
journalctl --user -u ctf-status.service -n 50 # what the HedgeDoc run did
|
||||
journalctl --user -u ctf-challs.service -n 50 # what the challs.txt run did
|
||||
systemctl --user start ctf-challs.service # commit challs.txt now
|
||||
systemctl --user disable --now ctf-challs.timer # stop the daily commit
|
||||
systemctl --user list-timers 'ctf-*'
|
||||
journalctl --user -u ctf-status.service -n 50
|
||||
journalctl --user -u ctf-challs.service -n 50
|
||||
```
|
||||
|
||||
The HedgeDoc timer fires at `:00` and `:30` with up to a minute of jitter; both
|
||||
timers have `Persistent=true`, so a run missed while the machine was down is caught up.
|
||||
Plain cron works too, if you would rather:
|
||||
## How challenges are evaluated
|
||||
|
||||
```
|
||||
*/30 * * * * /path/to/planner/ctf_status.py --update --quiet
|
||||
0 4 * * * /path/to/planner/ctf_status.py --challs --quiet
|
||||
```
|
||||
|
||||
`--quiet` keeps a successful run silent, so you only hear about it when something
|
||||
changed or broke. Overlapping runs of the same target are prevented by a lock file
|
||||
per target (the 04:00 challs run and the HedgeDoc run can overlap), and the
|
||||
HedgeDoc write is retried (`--retries`, default 3) before the run gives up with a
|
||||
one-line error and a non-zero exit.
|
||||
|
||||
## What the note shows
|
||||
|
||||
1. **✅ Ready for infra testing** — first thing in the note: every challenge at
|
||||
Testing Ready, Tested or Ready, linked to its repo, so infra knows what it
|
||||
can deploy and test against right now. Blocked ones are not in it.
|
||||
2. **⛔ Claims Testing Ready+, but the repo disagrees** — only shown when there
|
||||
are any: what the README claims, and why the repo says otherwise.
|
||||
3. **Status by category** — how many challenges each category has per stage
|
||||
(⚪ Planning → 🟠 PoC Draft → 🟡 Testing Ready → 🔵 Tested → 🟢 Ready).
|
||||
4. **Category × difficulty** — one dot per challenge, coloured by its status (⛔
|
||||
when blocked), so gaps and stragglers show up at a glance.
|
||||
5. **Challenges** — one table per category, linked to the repo, with event,
|
||||
author, tester and the repo's last activity.
|
||||
6. **Needs attention** — see below.
|
||||
|
||||
## Reading the README
|
||||
|
||||
Only the template's header block is parsed:
|
||||
Each challenge repo starts from a template whose README begins with:
|
||||
|
||||
```
|
||||
### Challenge Name
|
||||
- Category: crypto
|
||||
- Difficulty: medium
|
||||
- Event: TUM student CTF
|
||||
- Event: ...
|
||||
- Author: ...
|
||||
- Tester: ...
|
||||
- Status: Testing Ready
|
||||
```
|
||||
|
||||
People fill it in loosely, so values are normalised:
|
||||
The status is one of **Planning → PoC Draft → Testing Ready → Tested → Ready**.
|
||||
Loosely written values are normalised. If a status names several stages, the
|
||||
least advanced one counts.
|
||||
|
||||
- **Status** is matched against the five stages ("Tested with …" → Tested,
|
||||
"PoC" → PoC Draft). If it names several ("Planning to PoC Draft", a half-deleted
|
||||
option list) the *least* advanced one counts and it is flagged as unclear.
|
||||
- **Difficulty** ranges written as `medium-hard`, `medium/hard` or
|
||||
`easy to medium` become their own column; otherwise the first level named wins.
|
||||
- **Category**: the first one named counts ("rev, misc" → rev); the full value
|
||||
is shown next to the challenge. Categories outside the usual list (e.g. `ai`)
|
||||
get their own row.
|
||||
Two kinds of finding are reported:
|
||||
|
||||
## Checking the repo
|
||||
- **Problems** are missing or unclear README fields, such as no category, an
|
||||
unreadable status, or a Testing Ready challenge with no tester. They are
|
||||
listed under *Needs attention* and change nothing else.
|
||||
- **Blockers** are signs that a challenge claiming Testing Ready or beyond is not
|
||||
actually finished: `description.yml` is missing or still the template, its
|
||||
category or flag is wrong, a `deploy_dir` or its `Dockerfile` is missing, or
|
||||
there is no `solve.py`. A blocked challenge (⛔) is kept out of `challs.txt`.
|
||||
|
||||
Anyone can write `Status: Testing Ready`, so for every challenge whose README
|
||||
claims Testing Ready, Tested or Ready the repo itself is looked at too. Each of
|
||||
these makes it **blocked** (⛔): it keeps its claimed status in the counts, but
|
||||
it is left out of *Ready for infra testing* and `challs.txt`, and the reason is
|
||||
shown in the note.
|
||||
Passing every check does not prove that a challenge builds or solves. That is
|
||||
what testing is for.
|
||||
|
||||
- no `description.yml` at the top level, or one that does not parse
|
||||
- `description.yml` still has anything from the template it was copied from
|
||||
(`netcat-check-multi-container`): its id, name, author `jh`, its flag, or the
|
||||
`cont1`/`cont2` / `container-1`/`container-2` containers
|
||||
- its `category` is not one of the README's categories
|
||||
- the flag is not `h4tum{...}`
|
||||
- `dynamic: true`, but no container has a `flag:` block to receive it
|
||||
- a container's `deploy_dir` does not exist or has no `Dockerfile`
|
||||
- no `solve.py` anywhere in the repo
|
||||
All rules are in [`tracker/checks.py`](tracker/checks.py), one function each.
|
||||
|
||||
These are cheap signs that a challenge was never finished, not proof that it
|
||||
works: a repo can pass every check and still not build or solve. That is what
|
||||
actually testing it is for.
|
||||
## Project layout
|
||||
|
||||
## What it flags
|
||||
|
||||
- challenge at Testing Ready or beyond with no tester (`[Tester Name]`, `??`,
|
||||
`None`, `not found yet` all count as none)
|
||||
- status / difficulty / category missing or unclear
|
||||
- no challenge name in the README
|
||||
- repos whose README is missing or still the untouched template — these are left
|
||||
out of the counts
|
||||
|
||||
Repos GitLab has scheduled for deletion, and archived repos, are skipped.
|
||||
```
|
||||
ctf_status.py command line entry point
|
||||
tracker/
|
||||
checks.py the rules: README problems and repo blockers
|
||||
challenge.py reads a README header into a Challenge
|
||||
gitlab.py GitLab API access
|
||||
overview.py runs parsing and checks over all repos
|
||||
terminal.py terminal output
|
||||
markdown.py HedgeDoc note
|
||||
challs.py challs.txt
|
||||
hedgedoc.py writes the note to HedgeDoc
|
||||
config.py reads the configuration
|
||||
install-timer.sh installs the systemd units below
|
||||
ctf-*.service/.timer systemd unit templates
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user