Anyone can set the README status, and several challenges imported from
Nextcloud claimed Testing Ready with an untouched description.yml. For
every challenge claiming Testing Ready or beyond, now also read the repo
tree and description.yml, and block it if description.yml is missing or
still the template, its category disagrees with the README, the flag is
not h4tum{...}, a dynamic flag reaches no container, a deploy_dir or its
Dockerfile is missing, or there is no solve.py.
Blocked challenges keep their claimed status in the counts but are left
out of "Ready for infra testing" and challs.txt, and listed with the
reason in the terminal and the HedgeDoc note. Adds a PyYAML dependency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
8.1 KiB
Markdown
199 lines
8.1 KiB
Markdown
# ctf_status.py
|
||
|
||
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)).
|
||
|
||
Needs `requests` and `PyYAML` (`python-requests python-yaml` on Arch,
|
||
`python3-requests python3-yaml` on Debian).
|
||
|
||
## Setup
|
||
|
||
```sh
|
||
cp ctf_status.env.example ctf_status.env && chmod 600 ctf_status.env
|
||
$EDITOR ctf_status.env # GitLab group URL + token, HedgeDoc note
|
||
```
|
||
|
||
## 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 |
|
||
|---|---|---|
|
||
| `ctf-status.timer` | every 30 minutes | `--update` (HedgeDoc note) |
|
||
| `ctf-challs.timer` | daily at 04:00 (server local time) | `--challs` (challs.txt commit) |
|
||
|
||
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:
|
||
|
||
```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.
|
||
|
||
```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
|
||
```
|
||
|
||
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:
|
||
|
||
```
|
||
*/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:
|
||
|
||
```
|
||
### Challenge Name
|
||
- Category: crypto
|
||
- Difficulty: medium
|
||
- Event: TUM student CTF
|
||
- Author: ...
|
||
- Tester: ...
|
||
- Status: Testing Ready
|
||
```
|
||
|
||
People fill it in loosely, so values are normalised:
|
||
|
||
- **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.
|
||
|
||
## Checking the repo
|
||
|
||
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.
|
||
|
||
- 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
|
||
|
||
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.
|
||
|
||
## 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.
|