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:
2026-10-20 19:40:24 +02:00
co-authored by Claude Opus 5.5
parent 4c6cb21cb8
commit e00f40eaf4
15 changed files with 1098 additions and 984 deletions
+91 -163
View File
@@ -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
```