From e5862e8229fcb977535f4bf37dea0962b5d42dd6 Mon Sep 17 00:00:00 2001 From: cato447 Date: Tue, 20 Oct 2026 16:57:06 +0200 Subject: [PATCH] add chall tracker to deployment --- README.md | 27 +++++++++++++- ctf-status.service | 4 +- ctf_status.env.example | 7 ++++ ctf_status.py | 85 ++++++++++++++++++++++++++++++++++++++---- install-timer.sh | 2 + 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 830f09b..c3439c2 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,31 @@ the change live. Still `requests`-only, no new dependencies. `--hedgedoc ` (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. + ## Every 30 minutes, on a server Copy the repo to the server, create `ctf_status.env` there (it is gitignored, so it @@ -75,7 +100,7 @@ The timer fires at `:00` and `:30` with up to a minute of jitter, and works too, if you would rather: ``` -*/30 * * * * /path/to/planner/ctf_status.py --update --quiet +*/30 * * * * /path/to/planner/ctf_status.py --update --challs --quiet ``` `--quiet` keeps a successful run silent, so you only hear about it when the note diff --git a/ctf-status.service b/ctf-status.service index ba1c07d..5940b26 100644 --- a/ctf-status.service +++ b/ctf-status.service @@ -1,5 +1,5 @@ [Unit] -Description=Push the CTF challenge status to HedgeDoc +Description=Push the CTF challenge status to HedgeDoc and challs.txt Documentation=file://__DIR__/README.md After=network-online.target Wants=network-online.target @@ -7,6 +7,6 @@ Wants=network-online.target [Service] Type=oneshot WorkingDirectory=__DIR__ -ExecStart=__DIR__/ctf_status.py --update --quiet +ExecStart=__DIR__/ctf_status.py --update --challs --quiet # GitLab walk + HedgeDoc write; never let one run squat on the next timer slot TimeoutStartSec=5min diff --git a/ctf_status.env.example b/ctf_status.env.example index 6155ea7..eeb2eb7 100644 --- a/ctf_status.env.example +++ b/ctf_status.env.example @@ -13,3 +13,10 @@ GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx HEDGEDOC_NOTE= # YAML front-matter tags written at the top of the note HEDGEDOC_TAGS=Logistics, h4tum CTF 2026 + +# --- deployment repo: --challs keeps in it up to date -------- +# one clone URL per line for every challenge at Testing Ready or beyond. The +# token additionally needs, on this project: Repository: Read + Repository: Write +CHALLS_REPO_URL=https://gitlab.lrz.de/h4tum-ctf/h4tum-ctf-deployment/ctf-deployment +CHALLS_PATH=2026/challs.txt +CHALLS_BRANCH=main diff --git a/ctf_status.py b/ctf_status.py index 7387bed..4f4a13d 100755 --- a/ctf_status.py +++ b/ctf_status.py @@ -11,6 +11,7 @@ Config: see ctf_status.env.example -> ctf_status.env (chmod 600) from __future__ import annotations import argparse +import base64 import fcntl import json import os @@ -31,11 +32,13 @@ import requests # ---------------------------------------------------------------- config ---- CFG_KEYS = ("GITLAB_GROUP_URL", "GITLAB_TOKEN") -OPT_KEYS = ("HEDGEDOC_NOTE", "HEDGEDOC_TAGS", "TITLE") +OPT_KEYS = ("HEDGEDOC_NOTE", "HEDGEDOC_TAGS", "TITLE", + "CHALLS_REPO_URL", "CHALLS_PATH", "CHALLS_BRANCH") # the names a plain `.env` tends to use for the same thing ALIASES = {"GL_PAT": "GITLAB_TOKEN", "GITLAB_URL": "GITLAB_GROUP_URL"} DEFAULT_TAGS = "Logistics, h4tum CTF 2026" DEFAULT_TITLE = "CTF challenge tracking" +DEFAULT_CHALLS_PATH = "2026/challs.txt" def load_env() -> dict: @@ -68,13 +71,15 @@ def load_env() -> dict: class Repo: path: str web_url: str + ssh_url: str last_activity: str # YYYY-MM-DD readme: str | None # None: the repo has no README at all class GitLab: - """Read-only access to one group. The token needs Project: Read (to list the - group) and Repository: Read (to download files).""" + """Access to one group, plus the one file we write elsewhere. The token needs + Project: Read (to list the group) and Repository: Read (to download files); + --challs additionally needs read + write on the deployment repo.""" def __init__(self, group_url: str, token: str, timeout: int = 20): parts = urlsplit(group_url) @@ -85,7 +90,12 @@ class GitLab: self.s.headers["PRIVATE-TOKEN"] = token def _get(self, url: str, **params) -> requests.Response: - r = self.s.get(url, params=params, timeout=self.timeout) + return self._req("GET", url, params=params) + + def _req(self, method: str, url: str, ok: tuple = (), **kw) -> requests.Response: + r = self.s.request(method, url, timeout=self.timeout, **kw) + if r.status_code in ok: + return r if r.status_code == 403: # fine-grained tokens say exactly which permission is missing try: @@ -121,12 +131,32 @@ class GitLab: def fetch(p: dict) -> Repo: return Repo(path=p["path"], web_url=p["web_url"], + ssh_url=p["ssh_url_to_repo"], last_activity=(p.get("last_activity_at") or "")[:10], readme=self.readme(p)) with ThreadPoolExecutor(max_workers=8) as pool: return list(pool.map(fetch, projects)) + def write_file(self, repo_url: str, path: str, content: str, branch: str | None, + message) -> bool: + """Commit `content` to `path` in the repo at `repo_url` (a web URL), creating + the file if needed; `message(old_content)` makes the commit message. + False if the file already said exactly this.""" + proj = quote(urlsplit(repo_url).path.strip("/").split("/-/", 1)[0], safe="") + base = f"{self.api}/projects/{proj}" + if not branch: + branch = self._get(base).json()["default_branch"] + url = f"{base}/repository/files/{quote(path, safe='')}" + r = self._req("GET", url, ok=(404,), params={"ref": branch}) + exists = r.status_code != 404 + old = base64.b64decode(r.json()["content"]).decode("utf-8", "replace") if exists else "" + if exists and old == content: + return False + self._req("PUT" if exists else "POST", url, + json={"branch": branch, "content": content, "commit_message": message(old)}) + return True + # --------------------------------------------------------------- parsing ---- @@ -383,6 +413,19 @@ def print_terminal(ov: Overview, c: bool) -> None: print() +def render_challs(ov: Overview) -> str: + """The deployment repo's challs.txt: one clone URL per line, for everything + that is Testing Ready or beyond.""" + return "".join(f"{c.repo.ssh_url}\n" for c in ov.testable) + + +def challs_message(old: str, new: str, path: str) -> str: + name = lambda u: u.rsplit("/", 1)[-1].removesuffix(".git") + was, now = set(old.split()), set(new.split()) + parts = [f"+{name(u)}" for u in sorted(now - was)] + [f"-{name(u)}" for u in sorted(was - now)] + return f"Update {path} from challenge status" + (f": {' '.join(parts)}" if parts else "") + + def md_cell(v: str) -> str: return v.replace("|", "\\|") @@ -668,6 +711,12 @@ def main() -> None: ap.add_argument("--update", metavar="NOTE_URL", nargs="?", const="", help="overwrite an existing HedgeDoc note in place; defaults to " "HEDGEDOC_NOTE from the config. This is the one to cron.") + ap.add_argument("--challs", action="store_true", + help="commit the clone URLs of every Testing Ready+ challenge to " + f"CHALLS_PATH (default {DEFAULT_CHALLS_PATH!r}) in the " + "deployment repo CHALLS_REPO_URL, if they changed") + ap.add_argument("--challs-out", metavar="FILE", + help="write that challs.txt to a local file instead ('-' for stdout)") ap.add_argument("--retries", type=int, default=3, help="attempts at the HedgeDoc write before giving up (default 3)") ap.add_argument("--tags", help="YAML front-matter tags for the note " @@ -691,6 +740,9 @@ def main() -> None: HedgeDocNote(note_url) # fail on a typo now, not after the GitLab walk except ValueError as e: sys.exit(str(e)) + if args.challs and not cfg.get("CHALLS_REPO_URL"): + sys.exit("--challs needs CHALLS_REPO_URL in the config") + if note_url or args.challs: lock = single_run_lock() # noqa: F841 (held for the lifetime of the process) gl = GitLab(cfg["GITLAB_GROUP_URL"], cfg["GITLAB_TOKEN"]) @@ -712,13 +764,32 @@ def main() -> None: print(f"markdown → {args.out}") if args.hedgedoc: print("hedgedoc → " + push_hedgedoc(args.hedgedoc, md)) + challs = render_challs(ov) + if args.challs_out == "-": + print(challs, end="") + elif args.challs_out: + Path(args.challs_out).write_text(challs) + + # one failing target must not keep the other from being updated + failed = [] if note_url: try: changed = update_hedgedoc(note_url, md, retries=args.retries, quiet=args.quiet) + if changed or not args.quiet: + print(("updated → " if changed else "unchanged → ") + note_url) except (requests.RequestException, RuntimeError, ValueError, KeyError) as e: - sys.exit(f"hedgedoc update of {note_url} failed: {e}") - if changed or not args.quiet: - print(("updated → " if changed else "unchanged → ") + note_url) + failed.append(f"hedgedoc update of {note_url} failed: {e}") + if args.challs: + repo, path = cfg["CHALLS_REPO_URL"], cfg.get("CHALLS_PATH") or DEFAULT_CHALLS_PATH + try: + changed = gl.write_file(repo, path, challs, cfg.get("CHALLS_BRANCH"), + lambda old: challs_message(old, challs, path)) + if changed or not args.quiet: + print(("committed → " if changed else "unchanged → ") + f"{repo} {path}") + except (requests.RequestException, RuntimeError, KeyError) as e: + failed.append(f"challs.txt update in {repo} failed: {e}") + if failed: + sys.exit("\n".join(failed)) if __name__ == "__main__": diff --git a/install-timer.sh b/install-timer.sh index daea77d..e14db94 100755 --- a/install-timer.sh +++ b/install-timer.sh @@ -12,6 +12,8 @@ units="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" echo "no $dir/ctf_status.env — copy ctf_status.env.example to it first" >&2; exit 1; } grep -q '^HEDGEDOC_NOTE=..*' "$dir/ctf_status.env" || { echo "set HEDGEDOC_NOTE= in $dir/ctf_status.env first" >&2; exit 1; } +grep -q '^CHALLS_REPO_URL=..*' "$dir/ctf_status.env" || { + echo "set CHALLS_REPO_URL= in $dir/ctf_status.env first" >&2; exit 1; } chmod 600 "$dir/ctf_status.env" mkdir -p "$units"