The 30-minute timer now only updates the HedgeDoc note; a new ctf-challs.timer runs --challs daily at 04:00. Locks are per target so the two runs can overlap without one skipping. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.5 KiB
Markdown
169 lines
6.5 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.
|
||
|
||
## 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). 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. Still `requests`-only, no new dependencies.
|
||
|
||
`--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**
|
||
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.
|
||
2. **Status by category** — how many challenges each category has per stage
|
||
(⚪ Planning → 🟠 PoC Draft → 🟡 Testing Ready → 🔵 Tested → 🟢 Ready).
|
||
3. **Category × difficulty** — one dot per challenge, coloured by its status, so
|
||
gaps and stragglers show up at a glance.
|
||
4. **Challenges** — one table per category, linked to the repo, with event,
|
||
author, tester and the repo's last activity.
|
||
5. **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.
|
||
|
||
## 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.
|