154 lines
6.7 KiB
Python
Executable File
154 lines
6.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""CTF challenge tracker.
|
|
|
|
Lists every repo in the GitLab challenges group, reads each repo's README.md
|
|
(the filled-out challenge template) and reports:
|
|
* a terminal summary: status per category, category x difficulty, open issues
|
|
* a HedgeDoc-ready markdown overview
|
|
|
|
A README status of Testing Ready or beyond is only believed if the repo backs it
|
|
up: description.yml is filled in (not the template), its flag and containers make
|
|
sense, and the deploy dirs and solve.py exist. Otherwise the challenge is shown
|
|
as blocked and kept out of challs.txt. The rules are in tracker/checks.py.
|
|
|
|
challenges.yml says which repos are part of this CTF; only those are counted, put
|
|
in the note and deployed. The terminal summary also lists the others.
|
|
|
|
Config: see .env.example -> .env (chmod 600)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import fcntl
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
from tracker.challs import challs_message, render_challs
|
|
from tracker.config import DEFAULT_CHALLS_PATH, DEFAULT_TAGS, DEFAULT_TITLE, load_env
|
|
from tracker.gitlab import GitLab
|
|
from tracker.hedgedoc import HedgeDocNote, push_hedgedoc, update_hedgedoc
|
|
from tracker.markdown import render_hedgedoc
|
|
from tracker.overview import collect
|
|
from tracker.selection import load_selection
|
|
from tracker.terminal import print_terminal
|
|
|
|
|
|
# the lock is released when its file is closed, so keep them open until we exit
|
|
_held_locks = []
|
|
|
|
|
|
def single_run_lock(target: str) -> None:
|
|
"""Keep a slow or hung cron run from colliding with the next one. One lock per
|
|
target, so the daily challs.txt run and the HedgeDoc refresh can overlap."""
|
|
fh = open(Path(tempfile.gettempdir()) / f"ctf_status-{target}.lock", "w")
|
|
try:
|
|
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError:
|
|
sys.exit(f"another ctf_status.py {target} run is still going; skipping this one")
|
|
_held_locks.append(fh)
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("-o", "--out", help="write the HedgeDoc markdown to this file")
|
|
ap.add_argument("--stdout", action="store_true", help="print the markdown instead of the summary")
|
|
ap.add_argument("--hedgedoc", metavar="URL",
|
|
help="POST the markdown to this HedgeDoc instance as a *new* note "
|
|
"(needs an instance that lets you create notes anonymously)")
|
|
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 included, unblocked 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 "
|
|
f"(default {DEFAULT_TAGS!r}, or HEDGEDOC_TAGS)")
|
|
ap.add_argument("--title", help=f"note heading (default {DEFAULT_TITLE!r}, or TITLE)")
|
|
ap.add_argument("-q", "--quiet", action="store_true",
|
|
help="say nothing unless something changed or broke (for cron)")
|
|
ap.add_argument("--no-color", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
cfg = load_env()
|
|
title = args.title or cfg.get("TITLE") or DEFAULT_TITLE
|
|
tags = args.tags if args.tags is not None else cfg.get("HEDGEDOC_TAGS", DEFAULT_TAGS)
|
|
|
|
note_url = None
|
|
if args.update is not None:
|
|
note_url = args.update or cfg.get("HEDGEDOC_NOTE", "")
|
|
if not note_url:
|
|
sys.exit("--update needs a note URL, or HEDGEDOC_NOTE in the config")
|
|
try:
|
|
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")
|
|
try:
|
|
selection = load_selection()
|
|
except ValueError as e:
|
|
sys.exit(str(e))
|
|
if note_url:
|
|
single_run_lock("hedgedoc")
|
|
if args.challs:
|
|
single_run_lock("challs")
|
|
|
|
gl = GitLab(cfg["GITLAB_GROUP_URL"], cfg["GITLAB_TOKEN"])
|
|
try:
|
|
ov = collect(gl, selection)
|
|
except (requests.RequestException, RuntimeError) as e:
|
|
sys.exit(f"reading {cfg['GITLAB_GROUP_URL']} failed: {e}")
|
|
if not ov.challenges and not ov.blank and not ov.excluded and not ov.undecided:
|
|
sys.exit(f"no repos found in {cfg['GITLAB_GROUP_URL']!r}")
|
|
|
|
md = render_hedgedoc(ov, title, tags)
|
|
if args.out:
|
|
Path(args.out).write_text(md)
|
|
if args.stdout:
|
|
print(md, end="")
|
|
elif not args.quiet:
|
|
print_terminal(ov, c=not args.no_color and sys.stdout.isatty())
|
|
if args.out:
|
|
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:
|
|
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__":
|
|
main()
|