diff --git a/Dockerfile.moth b/Dockerfile.moth index 0e8b7e9..43e55e1 100644 --- a/Dockerfile.moth +++ b/Dockerfile.moth @@ -1,14 +1,8 @@ -FROM neale/eris - -RUN apk --no-cache add lua5.1 lua5.2 lua5.3 -RUN ln -s lua5.2 /usr/bin/lua - -# Install MOTH. This could be less obtuse. -COPY www /moth/www/ -COPY bin /moth/bin/ -COPY src/moth-init /moth/init -RUN ln -s ../state/puzzles.json /moth/www/puzzles.json && \ - ln -s ../state/points.json /moth/www/points.json - -CMD ["/moth/init"] +FROM alpine AS builder +RUN apk --no-cache add go libc-dev +COPY src /src +RUN go build -o /mothd /src/*.go +FROM alpine +COPY --from=builder /mothd /mothd +ENTRYPOINT [ "/mothd" ] diff --git a/Dockerfile.moth-compile b/Dockerfile.moth-compile deleted file mode 100644 index cd1331f..0000000 --- a/Dockerfile.moth-compile +++ /dev/null @@ -1,7 +0,0 @@ -FROM alpine - -RUN apk --no-cache add python3 py3-pillow - -COPY . /moth/ - -ENTRYPOINT ["python3", "/moth/tools/mothballer.py"] diff --git a/Dockerfile.moth-devel b/Dockerfile.moth-devel index 8058a5b..ba6a4d8 100644 --- a/Dockerfile.moth-devel +++ b/Dockerfile.moth-devel @@ -7,4 +7,4 @@ COPY . /moth/ COPY example-puzzles /puzzles/ WORKDIR /moth/ -ENTRYPOINT ["python3", "/moth/tools/devel-server.py", "--bind", ":8080", "--puzzles", "/puzzles"] +ENTRYPOINT ["python3", "/moth/devel/devel-server.py", "--bind", ":8080", "--puzzles", "/puzzles"] diff --git a/Dockerfile.package-puzzles b/Dockerfile.package-puzzles deleted file mode 100644 index f9f152f..0000000 --- a/Dockerfile.package-puzzles +++ /dev/null @@ -1,10 +0,0 @@ -FROM alpine - -RUN apk --no-cache add python3 py3-pillow - -COPY tools/package-puzzles.py /pp/ -COPY tools/moth.py /pp/ -COPY tools/mistune.py /pp/ -COPY tools/answer_words.txt /pp/ - -ENTRYPOINT ["python3", "/pp/package-puzzles.py"] diff --git a/README.md b/README.md index b3c9fd6..2d73d75 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ which in the past has been called "HACK", "Queen Of The Hill", "Cyber Spark", -and "Cyber Fire". +"Cyber Fire", +"Cyber Fire Puzzles", +and "Cyber Fire Foundry". Information about these events is at http://dirtbags.net/contest/ @@ -18,96 +20,125 @@ It also tracks scores, and comes with a JavaScript-based scoreboard to display team rankings. +Running a Development Server +============================ + + docker run --rm -it -p 8080:8080 dirtbags/moth-devel + +And point a browser to http://localhost:8080/ (or whatever host is running the server). + +When you're ready to create your own puzzles, +read [the devel server documentation](docs/devel-server.md). + +Click the `[mb]` link by a puzzle category to compile and download a mothball that the production server can read. + + +Running a Production Server +=========================== + + docker run --rm -it -p 8080:8080 -v /path/to/moth:/moth dirtbags/moth + +You can be more fine-grained about directories, if you like. +Inside the container, you need the following paths: + +* `/moth/state` (rw) Where state is stored. Read [the overview](docs/overview.md) to learn what's what in here. +* `/moth/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server. +* `/moth/resources` (ro) Overrides for built-in HTML/CSS resources. + + + + + Getting Started Developing ------------------------------- -You'll want to start out with the Development Server. +If you don't have a `puzzles` directory, +you can copy the example puzzles as a starting point: + + $ cp -r example-puzzles puzzles + +Then launch the development server: + + $ python3 tools/devel-server.py + +Point a web browser at http://localhost:8080/ +and start hacking on things in your `puzzles` directory. More on how the devel sever works in [the devel server documentation](docs/devel-server.md) -How everything works ---------------------------- - -This section wound up being pretty long. -Please check out [the overview](docs/overview.md) -for details. - - Running A Production Server ==================== -Please submit a merge request to improve this section ;) +Run `dirtbags/moth` (Docker) or `mothd` (native). + +`mothd` assumes you're running a contest out of `/moth`. +For Docker, you'll need to bind-mount your actual directories +(`state`, `mothballs`, and optionally `resources`) into +`/moth/`. + +You can override any path with an option, +run `mothd -help` for usage. -How to install it --------------------- - -It's made to be virtualized, -so you can run multiple contests at once if you want. -If you were to want to run it out of `/srv/moth`, -do the following: - - $ mothinst=/srv/moth/mycontest - $ mkdir -p $mothinst - $ install.sh $mothinst - - Yay, you've got it installed. - -How to run a contest ------------------------- - -`mothd` runs through every contest on your server every few seconds, -and does housekeeping tasks that make the contest "run". -If you stop `mothd`, people can still play the contest, -but their points won't show up on the scoreboard. - -A handy side-effect here is that if you need to meddle with the points log, -you can just kill `mothd`, -do you work, -then bring `mothd` back up. - - $ cp src/mothd /srv/moth - $ /srv/moth/mothd - -You're also going to need a web server if you want people to be able to play. +State Directory +=============== -How to run a web server ------------------------------ +Pausing scoring +------------------- -Your web server needs to serve up files for you contest out of -`$mothinst/www`. +Create the file `state/disabled` +to pause scoring, +and remove it to resume. +You can use the Unix `touch` command to create the file: -If you don't want to fuss around with setting up a full-featured web server, -you can use `tcpserver` and `eris`, -which is what we use to run our contests. + touch state/disabled -`tcpserver` is part of the `uscpi-tcp` package in Ubuntu. -You can also use busybox's `tcpsvd` (my preference, but a PITA on Ubuntu). - -`eris` can be obtained at https://github.com/nealey/eris - - $ mothinst=/srv/moth/mycontest - $ $mothinst/bin/httpd +When scoring is paused, +participants can still submit answers, +and the system will tell them whether the answer is correct. +As soon as you unpause, +all correctly-submitted answers will be scored. -Installing Puzzle Categories ------------------------------------- +Resetting an instance +------------------- -Puzzle categories are distributed in a different way than the server. -After setting up (see above), just run +Remove the file `state/initialized`, +and the server will zap everything. - $ /srv/koth/mycontest/bin/install-category /path/to/my/category - -Permissions ----------------- +Setting up custom team IDs +------------------- + +The file `state/teamids.txt` has all the team IDs, +one per line. +This defaults to all 4-digit natural numbers. +You can edit it to be whatever strings you like. + +We sometimes to set `teamids.txt` to a bunch of random 8-digit hex values: + + for i in $(seq 50); do od -x /dev/urandom | awk '{print $2 $3; exit;}'; done + +Remember that team IDs are essentially passwords. + + +Mothball Directory +================== + +Installing puzzle categories +------------------- + +The development server will provide you with a `.mb` (mothball) file, +when you click the `[mb]` link next to a category. + +Just drop that file into the `mothballs` directory, +and the server will pick it up. + +If you remove a mothball, +the category will vanish, +but points scored in that category won't! -It's up to you not to be a bonehead about permissions. -Install sets it so the web user on your system can write to the files it needs to, -but if you're using Apache, -it plays games with user IDs when running CGI. -You're going to have to figure out how to configure your preferred web server. diff --git a/bin/httpd b/bin/httpd deleted file mode 100755 index edeacbc..0000000 --- a/bin/httpd +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -e - -# Starts a standalone server using tcpsvd and eris - -echo "Figuring out web user..." -for www in www www-data http tc _ _www; do - id $www && break -done -if [ $www = _ ]; then - echo "Unable to determine httpd user on this system. Dying." - exit 1 -fi - -cd $(dirname $0)/../www -tcpserver -RHI localhost -u $www -g $www 0 80 eris -c -. - diff --git a/bin/install-category b/bin/install-category deleted file mode 100755 index 060524e..0000000 --- a/bin/install-category +++ /dev/null @@ -1,26 +0,0 @@ -#! /bin/sh -e - -package=$1 -if ! [ -n "$package" -a -f $package ]; then - echo "Usage: $0 PACKAGE" - exit 1 -fi -shift - - -cat=$(basename $package .zip) -outdir=$(dirname $(dirname $0))/packages/$cat - -echo "Extracting to $outdir..." -mkdir -p $outdir -unzip -o -d $outdir $package - -echo "Fixing permissions..." -chmod a+rx $outdir/*/ $outdir/*/* -chmod -R a+r $outdir -find $outdir/content -name \*.cgi -exec chmod a+rx {} \; - -if [ ! -h $outdir/../../www/$cat ]; then - echo "Linking into web space..." - ln -sf ../packages/$cat/content $outdir/../../www/$cat -fi diff --git a/bin/new b/bin/new deleted file mode 100755 index e765e88..0000000 --- a/bin/new +++ /dev/null @@ -1,37 +0,0 @@ -#! /bin/sh - -newdir=$1 -if [ -z "$newdir" ]; then - echo "Usage: $0 NEWDIR" - exit 1 -fi - -KOTH_BASE=$(cd $(dirname $0)/.. && pwd) - -echo "Figuring out web user..." -for www in www-data http _; do - id $www && break -done - -if [ $www = _ ]; then - echo "Unable to determine httpd user on this system. Dying." - exit 1 -fi - -mkdir -p $newdir -cd $newdir - -for i in points.new points.tmp teams; do - mkdir -p state/$i - setfacl -m ${www}:rwx state/$i -done - ->> state/points.log - -if ! [ -f assigned.txt ]; then - hd < /dev/urandom | awk '{print $3 $4 $5 $6;}' | head -n 100 > assigned.txt -fi - -mkdir -p www -cp -r $KOTH_BASE/html/* www/ -cp $KOTH_BASE/bin/*.cgi www/ diff --git a/bin/once b/bin/once deleted file mode 100755 index 388ba21..0000000 --- a/bin/once +++ /dev/null @@ -1,95 +0,0 @@ -#! /bin/sh - -if [ -n "$1" ]; then - cd $1 -else - cd $(dirname $0)/.. -fi -basedir=$(pwd) - -log () { - echo "moth: $@" 1>&2 -} - -# Do nothing if `disabled` is present -if [ -f state/disabled ]; then - log "Instance disabled; doing nothing" - exit -fi - -# Are we stopping at a certain time? -if [ -f state/until ]; then - read -r until < state/until - when=$(date -d "$until" +%s) - now=$(date +%s) - if [ $now -ge $when ]; then - log "End time reached; doing nothing" - exit - fi -fi - -# Reset to initial state? -if [ ! -f state/initialized ]; then - log "Resetting contest state" - - rm -rf state/teams state/points.new state/points.tmp - mkdir -p state/teams state/points.new state/points.tmp - chown www:www state/teams state/points.new state/points.tmp # Needs root. Use Docker. - : > state/points.log - echo 'Remove this file to obliterate teams and points' > state/initialized -fi - -# Create some team names if needed -if [ ! -f state/assigned.txt ]; then - log "Generating team names" - hd state/assigned.txt -fi - -# Install new categories -for pkg in puzzles/*; do - cat=$(basename $pkg .zip) - if [ ! -f packages/$cat/installed ] || [ $pkg -nt packages/$cat/installed ]; then - log "Installing $pkg" - bin/install-category $pkg - : >packages/$cat/installed - fi -done - -# Helpful error message -if [ $(ls packages | wc -l) -eq 0 ]; then - log "error: No packages installed" - exit -fi - -# Create a list of currently-active categories -: > state/categories.txt.new -for dn in packages/*; do - cat=${dn##packages/} - echo "$cat" >> state/categories.txt.new -done -mv state/categories.txt.new state/categories.txt - -# Collect new points -find state/points.new -type f | while read fn; do - # Skip files opened by another process - lsof $fn | grep -q $fn && continue - - # Skip partially written files - [ $(wc -l < $fn) -gt 0 ] || continue - - # filter the file for unique awards - sort -k 4 $fn | uniq -f 1 | sort -n >> state/points.log - - # Now kill the file - rm -f $fn -done - -# Generate new puzzles.json -if bin/puzzles $basedir > state/puzzles.json.new; then - mv state/puzzles.json.new state/puzzles.json -fi - -# Generate new points.json -if bin/points $basedir > state/points.json.new; then - mv state/points.json.new state/points.json -fi diff --git a/bin/points b/bin/points deleted file mode 100755 index 79a4e5d..0000000 --- a/bin/points +++ /dev/null @@ -1,45 +0,0 @@ -#! /usr/bin/lua5.3 - -local basedir = arg[1] -local statedir = basedir .. "/state" - -io.write('{\n "points": [\n') -local teams = {} -local teamnames = {} -local nteams = 0 -local NR = 0 - -for line in io.lines(statedir .. "/points.log") do - local ts, hash, cat, points = line:match("(%d+) (%g+) (%g+) (%d+)") - local teamno = teams[hash] - - if not teamno then - teamno = nteams - teams[hash] = teamno - nteams = nteams + 1 - - teamnames[hash] = io.lines(statedir .. "/teams/" .. hash)() - end - - if NR > 0 then - -- JSON sucks, barfs if you have a comma with nothing after it - io.write(",\n") - end - NR = NR + 1 - - io.write(' [' .. ts .. ', "' .. teamno .. '", "' .. cat .. '", ' .. points .. ']') -end - -io.write('\n],\n "teams": {\n') - -NR = 0 -for hash,teamname in pairs(teamnames) do - if NR > 0 then - io.write(",\n") - end - NR = NR + 1 - - teamno = teams[hash] - io.write(' "' .. teamno .. '": "' .. teamname .. '"') -end -io.write('\n }\n}\n') diff --git a/bin/puzzles b/bin/puzzles deleted file mode 100755 index 0259174..0000000 --- a/bin/puzzles +++ /dev/null @@ -1,53 +0,0 @@ -#! /usr/bin/lua5.3 - -local basedir = arg[1] -local statedir = basedir .. "/state" - -local max_by_cat = {} -for cat in io.lines(statedir .. "/categories.txt") do - max_by_cat[cat] = 0 -end - -for line in io.lines(statedir .. "/points.log") do - local ts, team, cat, points = line:match("^(%d+) (%g+) (%g+) (%d+)") - points = tonumber(points) or 0 - - -- Skip scores for removed categories - if (max_by_cat[cat] ~= nil) then - max_by_cat[cat] = math.max(max_by_cat[cat], points) - end -end - - -local i = 0 -io.write('{\n') -for cat, biggest in pairs(max_by_cat) do - local points, dirname - local j = 0 - - if i > 0 then - io.write(',\n') - end - i = i + 1 - - io.write(' "' .. cat .. '": [\n') - for line in io.lines(basedir .. "/packages/" .. cat .. "/map.txt") do - points, dirname = line:match("^(%d+) (.*)") - points = tonumber(points) - - if j > 0 then - io.write(',\n') - end - j = j + 1 - io.write(' [' .. points .. ', "' .. dirname .. '"]') - if (points > biggest) then - break - end - end - if (points == biggest) then - io.write(',\n') - io.write(' [0, ""]') - end - io.write('\n ]') -end -io.write('\n}\n') diff --git a/bin/server-start b/bin/server-start deleted file mode 100755 index a60fc73..0000000 --- a/bin/server-start +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh - -# Starts a standalone server using tcpsvd and eris - -tcpserver diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..d0cec51 --- /dev/null +++ b/build.sh @@ -0,0 +1,11 @@ +#! /bin/sh + +set -e + +version=$(date +%Y%m%d%H%M) + +for img in moth moth-devel; do + echo "==== $img" + sudo docker build --build-arg http_proxy=$http_proxy --build-arg https_proxy=$https_proxy --tag dirtbags/$img --tag dirtbags/$img:$version -f Dockerfile.$img . + [ "$1" = "-push" ] && docker push dirtbags/$img:$version && docker push dirtbags/$img +done diff --git a/bin/award b/contrib/award similarity index 100% rename from bin/award rename to contrib/award diff --git a/bin/mktokens b/contrib/mktokens similarity index 100% rename from bin/mktokens rename to contrib/mktokens diff --git a/tools/answer_words.txt b/devel/answer_words.txt similarity index 100% rename from tools/answer_words.txt rename to devel/answer_words.txt diff --git a/tools/devel-server.py b/devel/devel-server.py similarity index 85% rename from tools/devel-server.py rename to devel/devel-server.py index 830eb9f..354f899 100755 --- a/tools/devel-server.py +++ b/devel/devel-server.py @@ -1,7 +1,6 @@ #!/usr/bin/python3 import asyncio -import cgitb import glob import html from aiohttp import web @@ -16,12 +15,11 @@ import shutil import socketserver import sys import traceback -import mothballer sys.dont_write_bytecode = True # Don't write .pyc files def mkseed(): - return bytes(random.choice(b'abcdef0123456789') for i in range(40)).decode('ascii') + return bytes(random.choice(b'abcdef0123456789') for i in range(40)) class Page: def __init__(self, title, depth=0): @@ -74,17 +72,11 @@ async def handle_front(request): return p.response(request) async def handle_puzzlelist(request): - seed = request.query.get("seed", mkseed()) p = Page("Puzzle Categories", 1) - p.write("

seed = {}

".format(seed)) p.write("") return p.response(request) @@ -145,7 +137,7 @@ async def handle_puzzle(request): return p.response(request) async def handle_puzzlefile(request): - seed = request.query.get("seed", mkseed()).encode('ascii') + seed = request.query.get("seed", mkseed()) category = request.match_info.get("category") points = int(request.match_info.get("points")) filename = request.match_info.get("filename") @@ -166,25 +158,6 @@ async def handle_puzzlefile(request): resp.body = file.stream.read() return resp -async def handle_mothballer(request): - seed = request.query.get("seed", mkseed()) - category = request.match_info.get("category") - - try: - catdir = os.path.join(request.app["puzzles_dir"], category) - mb = mothballer.package(category, catdir, seed) - except: - body = cgitb.html(sys.exc_info()) - resp = web.Response(text=body, content_type="text/html") - return resp - - mb_buf = mb.read() - resp = web.Response( - body=mb_buf, - headers={"Content-Disposition": "attachment; filename={}.zip".format(category)}, - content_type="application/octet_stream", - ) - return resp if __name__ == '__main__': import argparse @@ -219,6 +192,5 @@ if __name__ == '__main__': app.router.add_route("GET", "/puzzles/{category}/", handle_category) app.router.add_route("GET", "/puzzles/{category}/{points}/", handle_puzzle) app.router.add_route("GET", "/puzzles/{category}/{points}/{filename}", handle_puzzlefile) - app.router.add_route("GET", "/mothballer/{category}", handle_mothballer) app.router.add_static("/files/", mydir, show_index=True) web.run_app(app, host=addr, port=port) diff --git a/tools/mistune.py b/devel/mistune.py similarity index 100% rename from tools/mistune.py rename to devel/mistune.py diff --git a/tools/moth.py b/devel/moth.py similarity index 100% rename from tools/moth.py rename to devel/moth.py diff --git a/tools/mothd.service b/devel/mothd.service similarity index 100% rename from tools/mothd.service rename to devel/mothd.service diff --git a/tools/mothballer.py b/devel/package-puzzles.py similarity index 88% rename from tools/mothballer.py rename to devel/package-puzzles.py index 5b368ab..4a8da80 100755 --- a/tools/mothballer.py +++ b/devel/package-puzzles.py @@ -2,6 +2,7 @@ import argparse import binascii +import glob import hashlib import io import json @@ -9,12 +10,11 @@ import logging import moth import os import shutil +import string +import sys import tempfile import zipfile -SEEDFN = "SEED" - - def write_kv_pairs(ziphandle, filename, kv): """ Write out a sorted map to file :param ziphandle: a zipfile object @@ -24,19 +24,17 @@ def write_kv_pairs(ziphandle, filename, kv): """ filehandle = io.StringIO() for key in sorted(kv.keys()): - if isinstance(kv[key], list): + if type(kv[key]) == type([]): for val in kv[key]: filehandle.write("%s %s\n" % (key, val)) else: filehandle.write("%s %s\n" % (key, kv[key])) filehandle.seek(0) ziphandle.writestr(filename, filehandle.read()) - - + def escape(s): return s.replace('&', '&').replace('<', '<').replace('>', '>') - - + def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files): html_content = io.StringIO() file_content = io.StringIO() @@ -53,7 +51,7 @@ def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files ''') scripts = [''.format(s) for s in puzzle.scripts] - + html_content.write( ''' @@ -86,15 +84,20 @@ def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files file_content=file_content.getvalue(), authors=', '.join(authors), scripts='\n'.join(scripts), - ) + ) ) ziphandle.writestr(os.path.join(puzzledir, 'index.html'), html_content.getvalue()) - def build_category(categorydir, outdir): + zipfileraw = tempfile.NamedTemporaryFile(delete=False) + zf = zipfile.ZipFile(zipfileraw, 'x') + category_seed = binascii.b2a_hex(os.urandom(20)) + puzzles_dict = {} + secrets = {} categoryname = os.path.basename(categorydir.strip(os.sep)) + seedfn = os.path.join("category_seed.txt") zipfilename = os.path.join(outdir, "%s.zip" % categoryname) logging.info("Building {} from {}".format(zipfilename, categorydir)) @@ -102,36 +105,25 @@ def build_category(categorydir, outdir): # open and gather some state existing = zipfile.ZipFile(zipfilename, 'r') try: - category_seed = existing.open(SEEDFN).read().strip() - except Exception: + category_seed = existing.open(seedfn).read().strip() + except: pass existing.close() logging.debug("Using PRNG seed {}".format(category_seed)) - zipfileraw = tempfile.NamedTemporaryFile(delete=False) - mothball = package(categoryname, categorydir, category_seed) - shutil.copyfileobj(mothball, zipfileraw) - zipfileraw.close() - shutil.move(zipfileraw.name, zipfilename) + zf.writestr(seedfn, category_seed) - -# Returns a file-like object containing the contents of the new zip file -def package(categoryname, categorydir, seed): - zfraw = io.BytesIO() - zf = zipfile.ZipFile(zfraw, 'x') - zf.writestr("category_seed.txt", seed) - - cat = moth.Category(categorydir, seed) + cat = moth.Category(categorydir, category_seed) mapping = {} answers = {} summary = {} for puzzle in cat: logging.info("Processing point value {}".format(puzzle.points)) - hashmap = hashlib.sha1(seed) + hashmap = hashlib.sha1(category_seed) hashmap.update(str(puzzle.points).encode('utf-8')) puzzlehash = hashmap.hexdigest() - + mapping[puzzle.points] = puzzlehash answers[puzzle.points] = puzzle.answers summary[puzzle.points] = puzzle.summary @@ -160,10 +152,10 @@ def package(categoryname, categorydir, seed): # clean up zf.close() - zfraw.seek(0) - return zfraw - + shutil.move(zipfileraw.name, zipfilename) + + if __name__ == '__main__': parser = argparse.ArgumentParser(description='Build a category package') parser.add_argument('outdir', help='Output directory') @@ -174,3 +166,4 @@ if __name__ == '__main__': for categorydir in args.categorydirs: build_category(categorydir, args.outdir) + diff --git a/setup.cfg b/devel/setup.cfg similarity index 100% rename from setup.cfg rename to devel/setup.cfg diff --git a/tools/update-words.sh b/devel/update-words.sh similarity index 100% rename from tools/update-words.sh rename to devel/update-words.sh diff --git a/docs/overview.md b/docs/overview.md index 94d2885..5242aed 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -16,8 +16,142 @@ indicating score within each category, and overall ranking. -How Scores are Calculated -------------------------- +State Directory +=============== + +The state directory is written to by the server to preserve state. +At no point is anything only in memory: +if it's not on the filesystem, +mothd doesn't think it exists. + +The state directory is also used to communicate actions to mothd. + + +`initialized` +------------- + +Remove this file to reset the state. This will blow away team assignments and the points log. + + +`disabled` +---------- + +Create this file to pause collection of points and other maintenance. +Contestants can still submit answers, +but they won't show up on the scoreboard until you remove this file. + +This file does not normally exist. + + +`until` +------- + +Put an RFC3337 date/time stamp in here to have the server pause itself at a given time. +Remember that time zones exist! +I recommend always using Zulu time. + +This file does not normally exist. + + +`teamids.txt` +------------- + +A list of valid Team IDs, one per line. +It defaults to all 4-digit natural numbers, +but you can put whatever you want in here. + + +`points.log` +------------ + +The log of awarded points: + + EpochTime TeamId Category Points + +Do not write to this file, unless you have disabled the contest. You will lose points! + + +`points.tmp` +------------ + +Drop points logs here. +Filenames can be anything. + +When the file is complete and written out, +move it into `points.new`, +where a non-disabled event's maintenance loop will eventually move it into the main log. + +`points.new` +------------ + +Complete points logs should be atomically moved here. +This is to avoid needing locks. +[Read about Maildir](https://en.wikipedia.org/wiki/Maildir) +if you care about the technical reasons we do things this way. + + +Mothball Directory +================== + +Put a mothball in this directory to open that category. +Remove a mothball to disable that category. + +Overwriting a mothball with a newer version will be noticed by the server within one maintenance interval +(20 seconds by default). +Be sure to use the same compilation seed in the development server if you compile a new version! + +Removing a category does not remove points that have been scored in the category. + + +Resources Directory +=================== + + +Making it look better +------------------- + +`mothd` provides some built-in HTML for rendering a complete contest, +but it's rather bland. +You can override everything by dropping a new file into the `resources` directory: + +* `basic.css` is used by the default HTML to pretty things up +* `index.html` is the landing page, which asks to register a team +* `puzzle.html` renders a puzzle from JSON +* `puzzle-list.html` renders the list of active puzzles from JSON +* `scoreboard.html` renders the current scoreboard from JSON +* Any other file in the `resources` directory will be served up, too. + +If you don't want to read through the source code, I don't blame you. +Run a `mothd` server and pull the various static resources into your `resources` directory, +and then you can start hacking away at them. + + +Making it look totally different +--------------------- + +Every handler can serve its answers up in JSON format, +just add `application/json` to the `Accept` header of your request. + +This means you could completely ignore the file structure in the previous section, +and write something like a web app that only loads static resources at startup. + + +Changing scoring +-------------- + +Scoring is determined client-side in the scoreboard, +from the points log. +You can hack in whatever algorithm you like, +and provide your own scoreboard(s). + +If you do hack in a new algorithm, +please be a dear and email it to us. +We'd love to see it! + + + +How Scores are Calculated by Default +------------------------------------ The per-category score for team `t` is computed as: @@ -38,138 +172,3 @@ Because we don't award extra points for quick responses, teams always feel like they have the possibility to catch up if they are skilled enough. -Requirements -------------- - -MOTH was written to run on a wide range of Linux systems. -We are very careful not to require exotic extensions: -you can run MOTH equally well on OpenWRT and Ubuntu Server. -It might even run on BSD: if you've tried this, please email us! - -Its architecture also limits permissions, -to make it easier to lock things down very tight. -Since it writes to the filesystem slowly and atomically, -it can be run from a USB flash drive formatted with VFAT. - - -On the server, it requires: - -* Bourne shell (POSIX 1003.2: BASH is okay but not required) -* Awk (POSIX 1003.2: gawk is okay but not required) -* Lua 5.1 - - -On the client, it requires: - -* A modern web browser with JavaScript -* Categories might add other requirements (like domain-specific tools to solve the puzzles) - - -Filesystem Layout -================= - -The system is set up to make it simple to run one or more contests on a single machine. - -I like to use `/srv/moth` as the base directory for all instances. -So if I were running an instance called "hack", -the instance directory would be `/srv/moth/hack`. - -There are five entries in each instance directory, described in detail below: - - /srv/moth/hack # (r-x) Instance directory - /srv/moth/hack/assigned.txt # (r--) List of assigned team tokens - /srv/moth/hack/bin/ # (r-x) Per-instance binaries - /srv/moth/hack/categories/ # (r-x) Installed categories - /srv/moth/hack/state/ # (rwx) Contest state - /srv/moth/hack/www/ # (r-x) Web server documentroot - - - -`state/assigned.txt` ----------------- - -This is just a list of tokens that have been assigned. -One token per line, and tokens can be anything you want. - -For my middle school events, I make tokens all possible 4-digit numbers, -and tell kids to use any number they want: it makes it quicker to start. -For more advanced events, -this doesn't work as well because people start guessing other teams' numbers to confuse each other. -So I use hex representations of random 32-bit ints. -But you could use anything you want in here (for specifics on allowed characters, read the registration CGI). - -The registration CGI checks this list to see if a token has already assigned to a team name. -Teams enter points by token, -which lets them use any text they want for a team name. -Since we don't read their team name anywhere else than the registration and scoreboard generator, -it allows some assumptions about what kind of strings tokens can be, -resulting in simpler code. - - -`categories/` --------------- - -`categories/` contains read-only category packages. -Within each subdirectory there is: - -* `map.txt` mapping point values to directory names -* `answers.txt` a list of answers for each point value -* `salt` used to generate directory names (so people can't guess them to skip ahead) -* `summary.txt` a compliation of `00summary.txt` files for puzzles, to give you a quick reference point when someone says "I need help on js 40". -* `puzzles` is all the HTML that needs to be served up for the category - - -`bin/` ------- - -Contains all the binaries you'll need to run an event. -These are probably just copies from the `base` package (where this README lives). -They're copied over in case you need to hack on them during an event. - -`bin/once` is of particular interest: -it gets run periodically to do everything, including: - -* Gather points from `points.new` and append them to the points log. -* Generate a new `puzzles.html` listing all open puzzles. -* Generate a new `points.json` for the scoreboard - -### Pausing `once` - -You can pause everything `bin/once` does by touching a file in the root directory -called `disabled`. -This doesn't stop the game: -it just stops points collection and generation of the files listed above. - -This is extremely helpful when, inevitably, -you need to hack the points log, -or do other maintenance tasks. -Most times you don't even need to announce that you're doing anything: -people can keep playing the game and their points keep collecting, -ready to be appended to the log when you're done and you re-enable `once`. - - -`www/` ------------ - -HTML root for an event. -It is possible to make this read-only, -after you've set up your packages. -You will need to symlink a few things into the `state` directory, though. - - -`state/` ---------- - -Where all game state is stored. -This is the only part of the contest directory setup that needs to be writable, -and tarring it up preserves exactly the entire contest. - -Notable, it contains the mapping from team hash to name, -and the points log. - -`points.log` is replayed by the scoreboard generator to calculate the current score for each team. - -New points are written to `points.new`, and picked up by `bin/once` to append to `points.log`. -When `once` is disabled (by touching a file called `disabled` at the top level for a game), -the various points-awarding things can keep writing files into `points.new`, -with no need for locking or "bringing down the game for maintenance". diff --git a/install.sh b/install.sh deleted file mode 100755 index f9f9d91..0000000 --- a/install.sh +++ /dev/null @@ -1,82 +0,0 @@ -#! /bin/sh -e - -DESTDIR=$1 - -if [ -z "$DESTDIR" ]; then - echo "Usage: $0 DESTDIR" - exit -fi - -cd $(dirname $0) - -older () { - [ -z "$1" ] && return 1 - target=$1; shift - [ -f $target ] || return 0 - for i in "$@"; do - [ $i -nt $target ] && return 0 - done - return 1 -} - -copy () { - src=$1 - target=$2/$src - targetdir=$(dirname $target) - if older $target $src; then - echo "COPY $src" - mkdir -p $targetdir - cp $src $target - fi -} - -setup() { - [ -d $DESTDIR/state ] && return - echo "SETUP" - for i in points.new points.tmp teams; do - dir=$DESTDIR/state/$i - mkdir -p $dir - setfacl -m ${www}:rwx $dir - done - mkdir -p $DESTDIR/packages - >> $DESTDIR/state/points.log - if ! [ -f $DESTDIR/assigned.txt ]; then - hd $DESTDIR/assigned.txt - fi - - mkdir -p $DESTDIR/www - ln -sf ../state/points.json $DESTDIR/www - ln -sf ../state/puzzles.json $DESTDIR/www -} - - -echo "Figuring out web user..." -for www in www-data http tc _ _www; do - id $www && break -done -if [ $www = _ ]; then - echo "Unable to determine httpd user on this system. Dying." - exit 1 -fi - -mkdir -p $DESTDIR || exit 1 - -setup -git $SRCDIR ls-files | while read fn; do - case "$fn" in - example-puzzles/*|tools/*|docs/*|install.sh|setup.cfg|README.md|.gitignore|src/mothd) - true # skip - ;; - www/*) - copy $fn $DESTDIR/ - ;; - bin/*) - copy $fn $DESTDIR/ - ;; - *) - echo "??? $fn" - ;; - esac -done - -echo "All done installing." diff --git a/res/basic.css b/res/basic.css new file mode 100644 index 0000000..4e9bd88 --- /dev/null +++ b/res/basic.css @@ -0,0 +1,79 @@ +/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ +body { + font-family: sans-serif; + max-width: 40em; + background: #282a33; + color: #f6efdc; +} +a:any-link { + color: #8b969a; +} +h1 { + background: #5e576b; + color: #9e98a8; +} +.Fail, .Error { + background: #3a3119; + color: #ffcc98; +} +.Fail:before { + content: "Fail: "; +} +.Error:before { + content: "Error: "; +} +p { + margin: 1em 0em; +} +form, pre { + margin: 1em; +} +input { + padding: 0.6em; + margin: 0.2em; +} +nav { + border: solid black 2px; +} +nav ul, .category ul { + padding: 1em; +} +nav li, .category li { + display: inline; + margin: 1em; +} +iframe#body { + border: inherit; + width: 100%; +} +img { + max-width: 100%; +} +#scoreboard { + width: 100%; + position: relative; +} + +#scoreboard span { + font-size: 75%; + display: inline-block; + overflow: hidden; + height: 1.7em; +} +#scoreboard span.teamname { + font-size: inherit; + color: white; + text-shadow: 0 0 3px black; + opacity: 0.8; + position: absolute; + right: 0.2em; +} +#scoreboard div * {white-space: nowrap;} +.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} +.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} +.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;} +.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;} +.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;} +.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;} +.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} +.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} diff --git a/res/index.html b/res/index.html new file mode 100644 index 0000000..803cd7f --- /dev/null +++ b/res/index.html @@ -0,0 +1,34 @@ + + + + Welcome + + + + + +

Welcome

+
+

Register your team

+ +
+ Team ID:
+ Team name: + +
+ +

+ If someone on your team has already registered, + proceed to the + puzzles overview. +

+
+ + + diff --git a/res/puzzle-list.html b/res/puzzle-list.html new file mode 100644 index 0000000..32f79dc --- /dev/null +++ b/res/puzzle-list.html @@ -0,0 +1,81 @@ + + + + Open Puzzles + + + + + + + +

Open Puzzles

+
+
+
+ + + diff --git a/res/puzzle.html b/res/puzzle.html new file mode 100644 index 0000000..87723b8 --- /dev/null +++ b/res/puzzle.html @@ -0,0 +1,92 @@ + + + + Puzzle + + + + + + + +

Puzzle

+
+
Loading...
+ +

Puzzle by

+
+
+ + + Team ID:
+ Answer:
+ +
+ + + diff --git a/res/scoreboard.html b/res/scoreboard.html new file mode 100644 index 0000000..a50d9f1 --- /dev/null +++ b/res/scoreboard.html @@ -0,0 +1,148 @@ + + + + Scoreboard + + + + + + + +

Scoreboard

+
+
+
+ + + diff --git a/src/award.go b/src/award.go new file mode 100644 index 0000000..4a8ba75 --- /dev/null +++ b/src/award.go @@ -0,0 +1,72 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +type Award struct { + When time.Time + TeamId string + Category string + Points int +} + +func ParseAward(s string) (*Award, error) { + ret := Award{} + + s = strings.TrimSpace(s) + + var whenEpoch int64 + + n, err := fmt.Sscanf(s, "%d %s %s %d", &whenEpoch, &ret.TeamId, &ret.Category, &ret.Points) + if err != nil { + return nil, err + } else if n != 4 { + return nil, fmt.Errorf("Malformed award string: only parsed %d fields", n) + } + + ret.When = time.Unix(whenEpoch, 0) + + return &ret, nil +} + +func (a *Award) String() string { + return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) +} + +func (a *Award) MarshalJSON() ([]byte, error) { + if a == nil { + return []byte("null"), nil + } + jTeamId, err := json.Marshal(a.TeamId) + if err != nil { + return nil, err + } + jCategory, err := json.Marshal(a.Category) + if err != nil { + return nil, err + } + ret := fmt.Sprintf( + "[%d,%s,%s,%d]", + a.When.Unix(), + jTeamId, + jCategory, + a.Points, + ) + return []byte(ret), nil +} + +func (a *Award) Same(o *Award) bool { + switch { + case a.TeamId != o.TeamId: + return false + case a.Category != o.Category: + return false + case a.Points != o.Points: + return false + } + return true +} diff --git a/src/award_test.go b/src/award_test.go new file mode 100644 index 0000000..2875557 --- /dev/null +++ b/src/award_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "testing" +) + +func TestAward(t *testing.T) { + entry := "1536958399 1a2b3c4d counting 1" + a, err := ParseAward(entry) + if err != nil { + t.Error(err) + return + } + if a.TeamId != "1a2b3c4d" { + t.Error("TeamID parsed wrong") + } + if a.Category != "counting" { + t.Error("Category parsed wrong") + } + if a.Points != 1 { + t.Error("Points parsed wrong") + } + + if a.String() != entry { + t.Error("String conversion wonky") + } + + if _, err := ParseAward("bad bad bad 1"); err == nil { + t.Error("Not throwing error on bad timestamp") + } + if _, err := ParseAward("1 bad bad bad"); err == nil { + t.Error("Not throwing error on bad points") + } +} diff --git a/src/handlers.go b/src/handlers.go new file mode 100644 index 0000000..b5e6590 --- /dev/null +++ b/src/handlers.go @@ -0,0 +1,345 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strconv" + "strings" +) + +type JSend struct { + Status string `json:"status"` + Data JSendData `json:"data"` +} +type JSendData struct { + Short string `json:"short"` + Description string `json:"description"` +} + +// ShowJSend renders a JSend response to w +func ShowJSend(w http.ResponseWriter, status Status, short string, description string) { + + resp := JSend{ + Status: "success", + Data: JSendData{ + Short: short, + Description: description, + }, + } + switch status { + case Success: + resp.Status = "success" + case Fail: + resp.Status = "fail" + default: + resp.Status = "error" + } + + respBytes, err := json.Marshal(resp) + if (err != nil) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) // RFC2616 makes it pretty clear that 4xx codes are for the user-agent + w.Write(respBytes) +} + +// ShowHtml delevers an HTML response to w +func ShowHtml(w http.ResponseWriter, status Status, title string, body string) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + statusStr := "" + switch status { + case Success: + statusStr = "Success" + case Fail: + statusStr = "Fail" + default: + statusStr = "Error" + } + + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "%s", title) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") + fmt.Fprintf(w, "

%s

", statusStr, title) + fmt.Fprintf(w, "
%s
", body) + fmt.Fprintf(w, "") + fmt.Fprintf(w, "") +} + +func respond(w http.ResponseWriter, req *http.Request, status Status, short string, format string, a ...interface{}) { + long := fmt.Sprintf(format, a...) + // This is a kludge. Do proper parsing when this causes problems. + accept := req.Header.Get("Accept") + if strings.Contains(accept, "application/json") { + ShowJSend(w, status, short, long) + } else { + ShowHtml(w, status, short, long) + } +} + +// hasLine returns true if line appears in r. +// The entire line must match. +func hasLine(r io.Reader, line string) bool { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + if scanner.Text() == line { + return true + } + } + return false +} + +func (ctx *Instance) registerHandler(w http.ResponseWriter, req *http.Request) { + teamname := req.FormValue("name") + teamid := req.FormValue("id") + + // Keep foolish operators from shooting themselves in the foot + // You would have to add a pathname to your list of Team IDs to open this vulnerability, + // but I have learned not to overestimate people. + if strings.Contains(teamid, "../") { + teamid = "rodney" + } + + if (teamid == "") || (teamname == "") { + respond( + w, req, Fail, + "Invalid Entry", + "Either `id` or `name` was missing from this request.", + ) + return + } + + teamids, err := os.Open(ctx.StatePath("teamids.txt")) + if err != nil { + respond( + w, req, Fail, + "Cannot read valid team IDs", + "An error was encountered trying to read valid teams IDs: %v", err, + ) + return + } + defer teamids.Close() + if !hasLine(teamids, teamid) { + respond( + w, req, Fail, + "Invalid Team ID", + "I don't have a record of that team ID. Maybe you used capital letters accidentally?", + ) + return + } + + f, err := os.OpenFile(ctx.StatePath("teams", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err != nil { + log.Print(err) + respond( + w, req, Fail, + "Registration failed", + "Unable to register. Perhaps a teammate has already registered?", + ) + return + } + defer f.Close() + fmt.Fprintln(f, teamname) + respond( + w, req, Success, + "Team registered", + "Okay, your team has been named and you may begin using your team ID!", + ) +} + +func (ctx *Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { + teamid := req.FormValue("id") + token := req.FormValue("token") + + var category string + var points int + var fluff string + + stoken := strings.Replace(token, ":", " ", 2) + n, err := fmt.Sscanf(stoken, "%s %d %s", &category, &points, &fluff) + if err != nil || n != 3 { + respond( + w, req, Fail, + "Malformed token", + "That doesn't look like a token: %v.", err, + ) + return + } + + if (category == "") || (points <= 0) { + respond( + w, req, Fail, + "Weird token", + "That token doesn't make any sense.", + ) + return + } + + f, err := ctx.OpenCategoryFile(category, "tokens.txt") + if err != nil { + respond( + w, req, Fail, + "Cannot list valid tokens", + err.Error(), + ) + return + } + defer f.Close() + + // Make sure the token is in the list + if !hasLine(f, token) { + respond( + w, req, Fail, + "Unrecognized token", + "I don't recognize that token. Did you type in the whole thing?", + ) + return + } + + if err := ctx.AwardPoints(teamid, category, points); err != nil { + respond( + w, req, Fail, + "Error awarding points", + err.Error(), + ) + return + } + respond( + w, req, Success, + "Points awarded", + "%d points for %s!", points, teamid, + ) +} + +func (ctx *Instance) answerHandler(w http.ResponseWriter, req *http.Request) { + teamid := req.FormValue("id") + category := req.FormValue("cat") + pointstr := req.FormValue("points") + answer := req.FormValue("answer") + + points, err := strconv.Atoi(pointstr) + if err != nil { + respond( + w, req, Fail, + "Cannot parse point value", + "This doesn't look like an integer: %s", pointstr, + ) + return + } + + haystack, err := ctx.OpenCategoryFile(category, "answers.txt") + if err != nil { + respond( + w, req, Fail, + "Cannot list answers", + "Unable to read the list of answers for this category.", + ) + return + } + defer haystack.Close() + + // Look for the answer + needle := fmt.Sprintf("%d %s", points, answer) + if !hasLine(haystack, needle) { + respond( + w, req, Fail, + "Wrong answer", + "That is not the correct answer for %s %d.", category, points, + ) + return + } + + if err := ctx.AwardPoints(teamid, category, points); err != nil { + respond( + w, req, Error, + "Cannot award points", + "The answer is correct, but there was an error awarding points: %v", err.Error(), + ) + return + } + respond( + w, req, Success, + "Points awarded", + fmt.Sprintf("%d points for %s!", points, teamid), + ) +} + +func (ctx *Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(ctx.jPuzzleList) +} + +func (ctx *Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(ctx.jPointsLog) +} + +func (ctx *Instance) contentHandler(w http.ResponseWriter, req *http.Request) { + // Prevent directory traversal + if strings.Contains(req.URL.Path, "/.") { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + // Be clever: use only the last three parts of the path. This may prove to be a bad idea. + parts := strings.Split(req.URL.Path, "/") + if len(parts) < 3 { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + fileName := parts[len(parts)-1] + puzzleId := parts[len(parts)-2] + categoryName := parts[len(parts)-3] + + mb, ok := ctx.Categories[categoryName] + if !ok { + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + mbFilename := fmt.Sprintf("content/%s/%s", puzzleId, fileName) + mf, err := mb.Open(mbFilename) + if err != nil { + log.Print(err) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + defer mf.Close() + + http.ServeContent(w, req, fileName, mf.ModTime(), mf) +} + +func (ctx *Instance) staticHandler(w http.ResponseWriter, req *http.Request) { + ServeStatic(w, req, ctx.ResourcesDir) +} + +func (ctx *Instance) BindHandlers(mux *http.ServeMux) { + mux.HandleFunc(ctx.Base+"/", ctx.staticHandler) + mux.HandleFunc(ctx.Base+"/register", ctx.registerHandler) + mux.HandleFunc(ctx.Base+"/token", ctx.tokenHandler) + mux.HandleFunc(ctx.Base+"/answer", ctx.answerHandler) + mux.HandleFunc(ctx.Base+"/content/", ctx.contentHandler) + mux.HandleFunc(ctx.Base+"/puzzles.json", ctx.puzzlesHandler) + mux.HandleFunc(ctx.Base+"/points.json", ctx.pointsHandler) +} diff --git a/src/instance.go b/src/instance.go new file mode 100644 index 0000000..4a73e03 --- /dev/null +++ b/src/instance.go @@ -0,0 +1,177 @@ +package main + +import ( + "bufio" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "path" + "strings" + "time" +) + +type Instance struct { + Base string + MothballDir string + StateDir string + ResourcesDir string + Categories map[string]*Mothball + update chan bool + jPuzzleList []byte + jPointsLog []byte +} + +func NewInstance(base, mothballDir, stateDir, resourcesDir string) (*Instance, error) { + ctx := &Instance{ + Base: strings.TrimRight(base, "/"), + MothballDir: mothballDir, + StateDir: stateDir, + ResourcesDir: resourcesDir, + Categories: map[string]*Mothball{}, + update: make(chan bool, 10), + } + + // Roll over and die if directories aren't even set up + if _, err := os.Stat(mothballDir); err != nil { + return nil, err + } + if _, err := os.Stat(stateDir); err != nil { + return nil, err + } + + ctx.MaybeInitialize() + + return ctx, nil +} + +func (ctx *Instance) MaybeInitialize() { + // Only do this if it hasn't already been done + if _, err := os.Stat(ctx.StatePath("initialized")); err == nil { + return + } + log.Print("initialized file missing, re-initializing") + + // Remove any extant control and state files + os.Remove(ctx.StatePath("until")) + os.Remove(ctx.StatePath("disabled")) + os.Remove(ctx.StatePath("points.log")) + os.RemoveAll(ctx.StatePath("points.tmp")) + os.RemoveAll(ctx.StatePath("points.new")) + os.RemoveAll(ctx.StatePath("teams")) + + // Make sure various subdirectories exist + os.Mkdir(ctx.StatePath("points.tmp"), 0755) + os.Mkdir(ctx.StatePath("points.new"), 0755) + os.Mkdir(ctx.StatePath("teams"), 0755) + + // Preseed available team ids if file doesn't exist + if f, err := os.OpenFile(ctx.StatePath("teamids.txt"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644); err == nil { + defer f.Close() + for i := 0; i <= 9999; i += 1 { + fmt.Fprintf(f, "%04d\n", i) + } + } + + // Create initialized file that signals whether we're set up + f, err := os.OpenFile(ctx.StatePath("initialized"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + log.Print(err) + } + defer f.Close() + fmt.Fprintln(f, "Remove this file to reinitialize the contest") +} + +func (ctx Instance) MothballPath(parts ...string) string { + tail := path.Join(parts...) + return path.Join(ctx.MothballDir, tail) +} + +func (ctx *Instance) StatePath(parts ...string) string { + tail := path.Join(parts...) + return path.Join(ctx.StateDir, tail) +} + +func (ctx *Instance) PointsLog() []*Award { + var ret []*Award + + fn := ctx.StatePath("points.log") + f, err := os.Open(fn) + if err != nil { + log.Printf("Unable to open %s: %s", fn, err) + return ret + } + defer f.Close() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + cur, err := ParseAward(line) + if err != nil { + log.Printf("Skipping malformed award line %s: %s", line, err) + continue + } + ret = append(ret, cur) + } + + return ret +} + +// awardPoints gives points to teamid in category. +// It first checks to make sure these are not duplicate points. +// This is not a perfect check, you can trigger a race condition here. +// It's just a courtesy to the user. +// The maintenance task makes sure we never have duplicate points in the log. +func (ctx *Instance) AwardPoints(teamid, category string, points int) error { + a := Award{ + When: time.Now(), + TeamId: teamid, + Category: category, + Points: points, + } + + teamName, err := ctx.TeamName(teamid) + if err != nil { + return fmt.Errorf("No registered team with this hash") + } + + for _, e := range ctx.PointsLog() { + if a.Same(e) { + return fmt.Errorf("Points already awarded to this team in this category") + } + } + + fn := fmt.Sprintf("%s-%s-%d", teamid, category, points) + tmpfn := ctx.StatePath("points.tmp", fn) + newfn := ctx.StatePath("points.new", fn) + + if err := ioutil.WriteFile(tmpfn, []byte(a.String()), 0644); err != nil { + return err + } + + if err := os.Rename(tmpfn, newfn); err != nil { + return err + } + + ctx.update <- true + log.Printf("Award %s %s %d", teamName, category, points) + return nil +} + +func (ctx *Instance) OpenCategoryFile(category string, parts ...string) (io.ReadCloser, error) { + mb, ok := ctx.Categories[category] + if !ok { + return nil, fmt.Errorf("No such category: %s", category) + } + + filename := path.Join(parts...) + f, err := mb.Open(filename) + return f, err +} + +func (ctx *Instance) TeamName(teamId string) (string, error) { + teamNameBytes, err := ioutil.ReadFile(ctx.StatePath("teams", teamId)) + teamName := strings.TrimSpace(string(teamNameBytes)) + return teamName, err +} diff --git a/src/instance_test.go b/src/instance_test.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/src/instance_test.go @@ -0,0 +1 @@ +package main diff --git a/src/maintenance.go b/src/maintenance.go new file mode 100644 index 0000000..0879d68 --- /dev/null +++ b/src/maintenance.go @@ -0,0 +1,245 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "os" + "strconv" + "strings" + "time" +) + +type PuzzleMap struct { + Points int + Path string +} + +func (pm *PuzzleMap) MarshalJSON() ([]byte, error) { + if pm == nil { + return []byte("null"), nil + } + + jPath, err := json.Marshal(pm.Path) + if err != nil { + return nil, err + } + + ret := fmt.Sprintf("[%d,%s]", pm.Points, string(jPath)) + return []byte(ret), nil +} + +func (ctx *Instance) generatePuzzleList() error { + maxByCategory := map[string]int{} + for _, a := range ctx.PointsLog() { + if a.Points > maxByCategory[a.Category] { + maxByCategory[a.Category] = a.Points + } + } + + ret := map[string][]PuzzleMap{} + for catName, mb := range ctx.Categories { + mf, err := mb.Open("map.txt") + if err != nil { + return err + } + defer mf.Close() + + pm := make([]PuzzleMap, 0, 30) + completed := true + scanner := bufio.NewScanner(mf) + for scanner.Scan() { + line := scanner.Text() + + var pointval int + var dir string + + n, err := fmt.Sscanf(line, "%d %s", &pointval, &dir) + if err != nil { + return err + } else if n != 2 { + return fmt.Errorf("Parsing map for %s: short read", catName) + } + + pm = append(pm, PuzzleMap{pointval, dir}) + + if pointval > maxByCategory[catName] { + completed = false + break + } + } + if completed { + pm = append(pm, PuzzleMap{0, ""}) + } + + ret[catName] = pm + } + + jpl, err := json.Marshal(ret) + if err == nil { + ctx.jPuzzleList = jpl + } + return err +} + +func (ctx *Instance) generatePointsLog() error { + var ret struct { + Teams map[string]string `json:"teams"` + Points []*Award `json:"points"` + } + ret.Teams = map[string]string{} + ret.Points = ctx.PointsLog() + + teamNumbersById := map[string]int{} + for nr, a := range ret.Points { + teamNumber, ok := teamNumbersById[a.TeamId] + if !ok { + teamName, err := ctx.TeamName(a.TeamId) + if err != nil { + teamName = "[unregistered]" + } + teamNumber = nr + teamNumbersById[a.TeamId] = teamNumber + ret.Teams[strconv.FormatInt(int64(teamNumber), 16)] = teamName + } + a.TeamId = strconv.FormatInt(int64(teamNumber), 16) + } + + jpl, err := json.Marshal(ret) + if err == nil { + ctx.jPointsLog = jpl + } + return err +} + +// maintenance runs +func (ctx *Instance) tidy() { + // Do they want to reset everything? + ctx.MaybeInitialize() + + // Refresh all current categories + for categoryName, mb := range ctx.Categories { + if err := mb.Refresh(); err != nil { + // Backing file vanished: remove this category + log.Printf("Removing category: %s: %s", categoryName, err) + mb.Close() + delete(ctx.Categories, categoryName) + } + } + + // Any new categories? + files, err := ioutil.ReadDir(ctx.MothballPath()) + if err != nil { + log.Printf("Error listing mothballs: %s", err) + } + for _, f := range files { + filename := f.Name() + filepath := ctx.MothballPath(filename) + if !strings.HasSuffix(filename, ".mb") { + continue + } + categoryName := strings.TrimSuffix(filename, ".mb") + + if _, ok := ctx.Categories[categoryName]; !ok { + mb, err := OpenMothball(filepath) + if err != nil { + log.Printf("Error opening %s: %s", filepath, err) + continue + } + log.Printf("New category: %s", filename) + ctx.Categories[categoryName] = mb + } + } +} + +// collectPoints gathers up files in points.new/ and appends their contents to points.log, +// removing each points.new/ file as it goes. +func (ctx *Instance) collectPoints() { + logf, err := os.OpenFile(ctx.StatePath("points.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Printf("Can't append to points log: %s", err) + return + } + defer logf.Close() + + files, err := ioutil.ReadDir(ctx.StatePath("points.new")) + if err != nil { + log.Printf("Error reading packages: %s", err) + } + for _, f := range files { + filename := ctx.StatePath("points.new", f.Name()) + s, err := ioutil.ReadFile(filename) + if err != nil { + log.Printf("Can't read points file %s: %s", filename, err) + continue + } + award, err := ParseAward(string(s)) + if err != nil { + log.Printf("Can't parse award file %s: %s", filename, err) + continue + } + + duplicate := false + for _, e := range ctx.PointsLog() { + if award.Same(e) { + duplicate = true + break + } + } + + if duplicate { + log.Printf("Skipping duplicate points: %s", award.String()) + } else { + fmt.Fprintf(logf, "%s\n", award.String()) + } + + logf.Sync() + if err := os.Remove(filename); err != nil { + log.Printf("Unable to remove %s: %s", filename, err) + } + } +} + +func (ctx *Instance) isEnabled() bool { + // Skip if we've been disabled + if _, err := os.Stat(ctx.StatePath("disabled")); err == nil { + log.Print("Suspended: disabled file found") + return false + } + + untilspec, err := ioutil.ReadFile(ctx.StatePath("until")) + if err == nil { + untilspecs := strings.TrimSpace(string(untilspec)) + until, err := time.Parse(time.RFC3339, untilspecs) + if err != nil { + log.Printf("Suspended: Unparseable until date: %s", untilspec) + return false + } + if until.Before(time.Now()) { + log.Print("Suspended: until time reached, suspending maintenance") + return false + } + } + + return true +} + +// maintenance is the goroutine that runs a periodic maintenance task +func (ctx *Instance) Maintenance(maintenanceInterval time.Duration) { + for { + if ctx.isEnabled() { + ctx.tidy() + ctx.collectPoints() + ctx.generatePuzzleList() + ctx.generatePointsLog() + } + select { + case <-ctx.update: + // log.Print("Forced update") + case <-time.After(maintenanceInterval): + // log.Print("Housekeeping...") + } + } +} diff --git a/src/moth-init b/src/moth-init deleted file mode 100755 index e0cd4d1..0000000 --- a/src/moth-init +++ /dev/null @@ -1,9 +0,0 @@ -#! /bin/sh - -while true; do - /moth/bin/once - sleep 20 -done & - -cd /moth/www -s6-tcpserver -u $(id -u www) -g $(id -g www) 0.0.0.0 80 /usr/bin/eris -c -d -. diff --git a/src/mothball.go b/src/mothball.go new file mode 100644 index 0000000..149dbf5 --- /dev/null +++ b/src/mothball.go @@ -0,0 +1,191 @@ +package main + +import ( + "archive/zip" + "fmt" + "io" + "io/ioutil" + "os" + "strings" + "time" +) + +type Mothball struct { + zf *zip.ReadCloser + filename string + mtime time.Time +} + +type MothballFile struct { + f io.ReadCloser + pos int64 + zf *zip.File + io.Reader + io.Seeker + io.Closer +} + +func NewMothballFile(zf *zip.File) (*MothballFile, error) { + mf := &MothballFile{ + zf: zf, + pos: 0, + f: nil, + } + if err := mf.reopen(); err != nil { + return nil, err + } + return mf, nil +} + +func (mf *MothballFile) reopen() error { + if mf.f != nil { + if err := mf.f.Close(); err != nil { + return err + } + } + f, err := mf.zf.Open() + if err != nil { + return err + } + mf.f = f + mf.pos = 0 + return nil +} + +func (mf *MothballFile) ModTime() time.Time { + return mf.zf.Modified +} + +func (mf *MothballFile) Read(p []byte) (int, error) { + n, err := mf.f.Read(p) + mf.pos += int64(n) + return n, err +} + +func (mf *MothballFile) Seek(offset int64, whence int) (int64, error) { + var pos int64 + switch whence { + case io.SeekStart: + pos = offset + case io.SeekCurrent: + pos = mf.pos + int64(offset) + case io.SeekEnd: + pos = int64(mf.zf.UncompressedSize64) - int64(offset) + } + + if pos < 0 { + return mf.pos, fmt.Errorf("Tried to seek %d before start of file", pos) + } + if pos >= int64(mf.zf.UncompressedSize64) { + // We don't need to decompress anything, we're at the end of the file + mf.f.Close() + mf.f = ioutil.NopCloser(strings.NewReader("")) + mf.pos = int64(mf.zf.UncompressedSize64) + return mf.pos, nil + } + if pos < mf.pos { + if err := mf.reopen(); err != nil { + return mf.pos, err + } + } + + buf := make([]byte, 32*1024) + for pos > mf.pos { + l := pos - mf.pos + if l > int64(cap(buf)) { + l = int64(cap(buf)) - 1 + } + p := buf[0:int(l)] + n, err := mf.Read(p) + if err != nil { + return mf.pos, err + } else if n <= 0 { + return mf.pos, fmt.Errorf("Short read (%d bytes)", n) + } + } + + return mf.pos, nil +} + +func (mf *MothballFile) Close() error { + return mf.f.Close() +} + +func OpenMothball(filename string) (*Mothball, error) { + var m Mothball + + m.filename = filename + + err := m.Refresh() + if err != nil { + return nil, err + } + + return &m, nil +} + +func (m *Mothball) Close() error { + return m.zf.Close() +} + +func (m *Mothball) Refresh() error { + info, err := os.Stat(m.filename) + if err != nil { + return err + } + mtime := info.ModTime() + + if !mtime.After(m.mtime) { + return nil + } + + zf, err := zip.OpenReader(m.filename) + if err != nil { + return err + } + + if m.zf != nil { + m.zf.Close() + } + m.zf = zf + m.mtime = mtime + + return nil +} + +func (m *Mothball) get(filename string) (*zip.File, error) { + for _, f := range m.zf.File { + if filename == f.Name { + return f, nil + } + } + return nil, fmt.Errorf("File not found: %s %s", m.filename, filename) +} + +func (m *Mothball) Header(filename string) (*zip.FileHeader, error) { + f, err := m.get(filename) + if err != nil { + return nil, err + } + return &f.FileHeader, nil +} + +func (m *Mothball) Open(filename string) (*MothballFile, error) { + f, err := m.get(filename) + if err != nil { + return nil, err + } + mf, err := NewMothballFile(f) + return mf, err +} + +func (m *Mothball) ReadFile(filename string) ([]byte, error) { + f, err := m.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + bytes, err := ioutil.ReadAll(f) + return bytes, err +} diff --git a/src/mothball_test.go b/src/mothball_test.go new file mode 100644 index 0000000..8115809 --- /dev/null +++ b/src/mothball_test.go @@ -0,0 +1,63 @@ +package main + +import ( + "archive/zip" + "fmt" + "io" + "io/ioutil" + "os" + "testing" +) + +func TestMothball(t *testing.T) { + tf, err := ioutil.TempFile("", "mothball") + if err != nil { + t.Error(err) + return + } + defer os.Remove(tf.Name()) + + w := zip.NewWriter(tf) + f, err := w.Create("moo.txt") + if err != nil { + t.Error(err) + return + } + // no Close method + + _, err = fmt.Fprintln(f, "The cow goes moo") + //.Write([]byte("The cow goes moo")) + if err != nil { + t.Error(err) + return + } + w.Close() + tf.Close() + + // Now read it in + mb, err := OpenMothball(tf.Name()) + if err != nil { + t.Error(err) + return + } + + cow, err := mb.Open("moo.txt") + if err != nil { + t.Error(err) + return + } + + line := make([]byte, 200) + n, err := cow.Read(line) + if (err != nil) && (err != io.EOF) { + t.Error(err) + return + } + + if string(line[:n]) != "The cow goes moo\n" { + t.Log(line) + t.Error("Contents didn't match") + return + } + +} diff --git a/src/mothd b/src/mothd deleted file mode 100755 index ffad247..0000000 --- a/src/mothd +++ /dev/null @@ -1,14 +0,0 @@ -#! /bin/sh - -cd ${1:-$(dirname $0)} -KOTH_BASE=$(pwd) - -echo "Running koth instances in $KOTH_BASE" - -while true; do - for i in $KOTH_BASE/*/assigned.txt; do - dir=${i%/*} - $dir/bin/once - done - sleep 5 -done diff --git a/src/mothd.go b/src/mothd.go new file mode 100644 index 0000000..acfda71 --- /dev/null +++ b/src/mothd.go @@ -0,0 +1,74 @@ +package main + +import ( + "flag" + "log" + "mime" + "net/http" + "time" +) + +func logRequest(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("HTTP %s %s %s\n", r.RemoteAddr, r.Method, r.URL) + handler.ServeHTTP(w, r) + }) +} + +func setup() error { + return nil +} + +func main() { + base := flag.String( + "base", + "/", + "Base URL of this instance", + ) + mothballDir := flag.String( + "mothballs", + "/moth/mothballs", + "Path to read mothballs", + ) + stateDir := flag.String( + "state", + "/moth/state", + "Path to write state", + ) + resourcesDir := flag.String( + "resources", + "/moth/resources", + "Path to static resources (HTML, images, css, ...)", + ) + maintenanceInterval := flag.Duration( + "maint", + 20*time.Second, + "Maintenance interval", + ) + listen := flag.String( + "listen", + ":8080", + "[host]:port to bind and listen", + ) + flag.Parse() + + if err := setup(); err != nil { + log.Fatal(err) + } + + ctx, err := NewInstance(*base, *mothballDir, *stateDir, *resourcesDir) + if err != nil { + log.Fatal(err) + } + ctx.BindHandlers(http.DefaultServeMux) + + // Add some MIME extensions + // Doing this avoids decompressing a mothball entry twice per request + mime.AddExtensionType(".json", "application/json") + mime.AddExtensionType(".zip", "application/zip") + + go ctx.Maintenance(*maintenanceInterval) + + log.Printf("Listening on %s", *listen) + log.Fatal(http.ListenAndServe(*listen, logRequest(http.DefaultServeMux))) +} diff --git a/src/static.go b/src/static.go new file mode 100644 index 0000000..41a60bc --- /dev/null +++ b/src/static.go @@ -0,0 +1,435 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strings" +) + +type Status int + +const ( + Success = iota + Fail + Error +) + +// staticStylesheet serves up a basic stylesheet. +// This is designed to be usable on small touchscreens (like mobile phones) +func staticStylesheet(w http.ResponseWriter) { + w.Header().Set("Content-Type", "text/css") + w.WriteHeader(http.StatusOK) + + fmt.Fprint( + w, + ` +/* http://paletton.com/#uid=63T0u0k7O9o3ouT6LjHih7ltq4c */ +body { + font-family: sans-serif; + max-width: 40em; + background: #282a33; + color: #f6efdc; +} +a:any-link { + color: #8b969a; +} +h1 { + background: #5e576b; + color: #9e98a8; +} +.Fail, .Error { + background: #3a3119; + color: #ffcc98; +} +.Fail:before { + content: "Fail: "; +} +.Error:before { + content: "Error: "; +} +p { + margin: 1em 0em; +} +form, pre { + margin: 1em; +} +input { + padding: 0.6em; + margin: 0.2em; +} +nav { + border: solid black 2px; +} +nav ul, .category ul { + padding: 1em; +} +nav li, .category li { + display: inline; + margin: 1em; +} +iframe#body { + border: inherit; + width: 100%; +} +img { + max-width: 100%; +} +#scoreboard { + width: 100%; + position: relative; +} + +#scoreboard span { + font-size: 75%; + display: inline-block; + overflow: hidden; + height: 1.7em; +} +#scoreboard span.teamname { + font-size: inherit; + color: white; + text-shadow: 0 0 3px black; + opacity: 0.8; + position: absolute; + right: 0.2em; +} +#scoreboard div * {white-space: nowrap;} +.cat0, .cat8, .cat16 {background-color: #a6cee3; color: black;} +.cat1, .cat9, .cat17 {background-color: #1f78b4; color: white;} +.cat2, .cat10, .cat18 {background-color: #b2df8a; color: black;} +.cat3, .cat11, .cat19 {background-color: #33a02c; color: white;} +.cat4, .cat12, .cat20 {background-color: #fb9a99; color: black;} +.cat5, .cat13, .cat21 {background-color: #e31a1c; color: white;} +.cat6, .cat14, .cat22 {background-color: #fdbf6f; color: black;} +.cat7, .cat15, .cat23 {background-color: #ff7f00; color: black;} + `, + ) +} + +// staticIndex serves up a basic landing page +func staticIndex(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Welcome", + ` +

Register your team

+ +
+ Team ID:
+ Team name: + +
+ +

+ If someone on your team has already registered, + proceed to the + puzzles overview. +

+ `, + ) +} + +func staticScoreboard(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Scoreboard", + ` +
+ + `, + ) +} + +func staticPuzzleList(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Open Puzzles", + ` +
+
+
+ + `, + ) +} + +func staticPuzzle(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Open Puzzles", + ` +
+
Loading...
+
+
+ + + Team ID:
+ Answer:
+ +
+ + `, + ) +} + +func tryServeFile(w http.ResponseWriter, req *http.Request, path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + return false + } + + http.ServeContent(w, req, path, d.ModTime(), f) + return true +} + +func ServeStatic(w http.ResponseWriter, req *http.Request, resourcesDir string) { + path := req.URL.Path + if strings.Contains(path, "..") { + http.Error(w, "Invalid URL path", http.StatusBadRequest) + return + } + if path == "/" { + path = "/index.html" + } + + fpath := filepath.Join(resourcesDir, path) + if tryServeFile(w, req, fpath) { + return + } + + switch path { + case "/basic.css": + staticStylesheet(w) + case "/index.html": + staticIndex(w) + case "/scoreboard.html": + staticScoreboard(w) + case "/puzzle-list.html": + staticPuzzleList(w) + case "/puzzle.html": + staticPuzzle(w) + default: + http.NotFound(w, req) + } +} diff --git a/tools/fetch.py b/tools/fetch.py deleted file mode 100755 index 5ff4198..0000000 --- a/tools/fetch.py +++ /dev/null @@ -1,32 +0,0 @@ -#! /usr/bin/python3 - -import requests -import zipfile - -instance = "foundry" - -url = "https://puzzles.cyberfire.training/{}/".format(instance) -url = url.rstrip("/") - -r = requests.get(url + "/puzzles.json") -puzzles = r.json() - -zf = zipfile.ZipFile("/tmp/{}.zip".format(instance), "w") -for cat, entries in puzzles.items(): - if cat == "wopr": - continue - - for points, dn in entries: - if points == 0: - continue - u = "{}/{}/{}/puzzle.json".format(url, cat, dn) - - print(u, points, dn) - obj = requests.get(u).json() - files = obj.get("files") + ["index.html"] - - for fn in files: - path = "{}/{}/{}".format(cat, points, fn) - furl="{}/{}/{}/{}".format(url, cat, dn, fn) - data = requests.get(furl).content - zf.writestr(path, data) diff --git a/tools/maint.md b/tools/maint.md deleted file mode 100644 index 723100b..0000000 --- a/tools/maint.md +++ /dev/null @@ -1,4 +0,0 @@ -# Maintainer notes - -Update `answer_words.txt` using `./update-words.sh`. - diff --git a/tools/mothd b/tools/mothd deleted file mode 100755 index ffad247..0000000 --- a/tools/mothd +++ /dev/null @@ -1,14 +0,0 @@ -#! /bin/sh - -cd ${1:-$(dirname $0)} -KOTH_BASE=$(pwd) - -echo "Running koth instances in $KOTH_BASE" - -while true; do - for i in $KOTH_BASE/*/assigned.txt; do - dir=${i%/*} - $dir/bin/once - done - sleep 5 -done diff --git a/www/cgi-bin/cgi.lua b/www/cgi-bin/cgi.lua deleted file mode 100644 index b37d322..0000000 --- a/www/cgi-bin/cgi.lua +++ /dev/null @@ -1,109 +0,0 @@ -#! /usr/bin/env lua - -local cgi = {} - -cgi.POST_MAX = 512 - -local CL = tonumber(os.getenv("CONTENT_LENGTH")) or 0 -function getc_post() - if (CL > 0) then - CL = CL - 1 - return io.read(1) - else - return nil - end -end - -local query = os.getenv("QUERY_STRING") or "" -local query_len = query:len() -local query_pos = 0 -function getc_get() - if (query_pos < query_len) then - query_pos = query_pos + 1 - return string.sub(query, query_pos, query_pos) - else - return nil - end -end - -function read_hex() - local a = getc() or 0 - local b = getc() or 0 - - return string.char(tonumber(a, 16)*16 + tonumber(b, 16)) -end - -function item() - local val = "" - - while (true) do - local c = getc() - if ((c == nil) or (c == "=") or (c == "&")) then - return val - elseif (c == "%") then - c = read_hex() - elseif (c == "+") then - c = " " - end - val = val .. c - end -end - -function init() - method = os.getenv("REQUEST_METHOD") - if (method == "POST") then - if (os.getenv("HTTP_CONTENT_TYPE") ~= "application/x-www-form-urlencoded") then - cgi.http_error(415, "Unsupported content-type", "You are sending me data in a format I can't process") - end - - if (CL > cgi.POST_MAX) then - cgi.http_error(413, "Post Data Too Long", "You are sending me more data than I'm prepared to handle") - end - - getc = getc_post - elseif (method == "GET") then - local query = os.getenv("QUERY_STRING") or "" - local query_pos = 0 - local query_len = string.len(query) - if (query_len > cgi.POST_MAX) then - cgi.http_error(413, "Query Data Too Long", "You are sending me more data than I'm prepared to handle") - end - - getc = getc_get - else - cgi.http_error(405, "Method not allowed", "I only do GET and POST.") - end - - cgi.fields = {} - while (true) do - local k = item() - local v = item() - - if (k == "") then - break - end - cgi.fields[k] = v - end -end - - -function cgi.http_error(code, name, info) - print(code .. " " .. name) - print("Allow: GET POST") - print("Content-type: text/html") - print() - print("

" .. code .. " " .. name .. "

") - print("

" .. info .. "

") - os.exit(0) -end - -function cgi.escape(s) - s = string.gsub(s, "&", "&") - s = string.gsub(s, "<", "<") - s = string.gsub(s, ">", ">") - return s -end - -init() - -return cgi diff --git a/www/cgi-bin/koth.lua b/www/cgi-bin/koth.lua deleted file mode 100644 index df23523..0000000 --- a/www/cgi-bin/koth.lua +++ /dev/null @@ -1,119 +0,0 @@ -#! /usr/bin/env lua - -local koth = {} - --- cut -d$ANCHOR -f2- | grep -Fx "$NEEDLE" -function koth.anchored_search(haystack, needle, anchor) - local f, err = io.open(haystack) - if (not f) then - return false, err - end - - for line in f:lines() do - if (anchor) then - pos = line:find(anchor) - if (pos) then - line = line:sub(pos+1) - end - end - - if (line == needle) then - f:close() - return true - end - end - - f:close() - return false -end - -function koth.page(title, body) - if (os.getenv("REQUEST_METHOD")) then - print("Content-type: text/html") - print() - end - print("") - print("" .. title .. "") - print("

" .. title .. "

") - if (body) then - print("
") - print(body) - print("
") - end - - print('') - - print('
') - print('') - print('') - print('') - print('') - print('
') - print("") - os.exit(0) -end - --- --- We're going to rely on `bin/once` only processing files with the right number of lines. --- -function koth.award_points(team, category, points, comment) - team = team:gsub("[^0-9a-f]", "-") - if (team == "") then - team = "-" - end - - local filename = team .. "." .. category .. "." .. points - local entry = team .. " " .. category .. " " .. points - - if (comment) then - entry = entry .. " " .. comment - end - - local f = io.open(koth.path("state/teams/" .. team)) - if (f) then - f:close() - else - return false, "No such team" - end - - local ok = koth.anchored_search(koth.path("state/points.log"), entry, " ") - if (ok) then - return false, "Points already awarded" - end - - local f = io.open(koth.path("state/points.new/" .. filename), "a") - if (not f) then - return false, "Unable to write to points file" - end - - f:write(os.time(), " ", entry, "\n") - f:close() - - return true -end - --- Most web servers cd to the directory containing the CGI. --- Not uhttpd. - -koth.base = "" -function koth.path(p) - return koth.base .. p -end - --- Traverse up to find assigned.txt -for i = 0, 5 do - local f = io.open(koth.path("state/assigned.txt")) - if (f) then - f:close() - break - end - koth.base = koth.base .. "../" -end - -return koth diff --git a/www/cgi-bin/puzzler.cgi b/www/cgi-bin/puzzler.cgi deleted file mode 100755 index 3b51c70..0000000 --- a/www/cgi-bin/puzzler.cgi +++ /dev/null @@ -1,34 +0,0 @@ -#! /usr/bin/env lua - -package.path = "?.lua;cgi-bin/?.lua;www/cgi-bin/?.lua" - -local cgi = require "cgi" -local koth = require "koth" - -local team = cgi.fields['t'] or "" -local category = cgi.fields['c'] or "" -local points = cgi.fields['p'] or "" -local answer = cgi.fields['a'] or "" - --- Defang category name; prevent directory traversal -category = category:gsub("[^A-Za-z0-9_]", "-") - --- Check answer -local needle = points .. " " .. answer -local haystack = koth.path("packages/" .. category .. "/answers.txt") -local found, err = koth.anchored_search(haystack, needle) - -if (not found) then - koth.page("Wrong answer", err) -end - -local ok, err = koth.award_points(team, category, points) -if (not ok) then - koth.page("Error awarding points", - "

You got the right answer, but there was a problem trying to give you points:

" .. - "

" .. err .. "

") -end - -koth.page("Points awarded", - "

" .. points .. " points for " .. team .. "!

" .. - "

Back to puzzles

") diff --git a/www/cgi-bin/register.cgi b/www/cgi-bin/register.cgi deleted file mode 100755 index 7017016..0000000 --- a/www/cgi-bin/register.cgi +++ /dev/null @@ -1,33 +0,0 @@ -#! /usr/bin/env lua - -package.path = "?.lua;cgi-bin/?.lua;www/cgi-bin/?.lua" - - -local cgi = require "cgi" -local koth = require "koth" - -local team = cgi.fields["n"] or "" -local hash = cgi.fields["h"] or "" - -hash = hash:match("[0-9a-f]*") - -if ((hash == "") or (team == "")) then - koth.page("Invalid Entry", "Oops! Are you sure you got that right?") -elseif (not koth.anchored_search(koth.path("state/assigned.txt"), hash)) then - koth.page("Invalid Hash", "Oops! I don't have a record of that hash. Did you maybe use capital letters accidentally?") -end - -local f = io.open(koth.path("state/teams/" .. hash)) -if (f) then - f:close() - koth.page("Already Exists", "Your team has already been named! Maybe somebody on your team beat you to it.") -end - -local f, err = io.open(koth.path("state/teams/" .. hash), "w+") -if (not f) then - koth.page("Kersplode", err) -end -f:write(team) -f:close() - -koth.page("Success", "Okay, your team has been named and you may begin using your hash!") diff --git a/www/cgi-bin/token.cgi b/www/cgi-bin/token.cgi deleted file mode 100755 index 6e03abe..0000000 --- a/www/cgi-bin/token.cgi +++ /dev/null @@ -1,38 +0,0 @@ -#! /usr/bin/env lua - -package.path = "?.lua;cgi-bin/?.lua;www/cgi-bin/?.lua" - -local cgi = require "cgi" -local koth = require "koth" - -local team = cgi.fields['t'] or "" -local token = cgi.fields['k'] or "" - --- Check answer -local needle = token -local haystack = koth.path("state/tokens.txt") -local found, err = koth.anchored_search(haystack, needle) - -if (not found) then - koth.page("Unrecognized token", err) -end - -local category, points = token:match("^(.*):(.*):") -if ((category == nil) or (points == nil)) then - koth.page("Unrecognized token", "Something doesn't look right about that token") -end -points = tonumber(points) - --- Defang category name; prevent directory traversal -category = category:gsub("[^A-Za-z0-9]", "-") - -local ok, err = koth.award_points(team, category, points, token) -if (not ok) then - koth.page("Error awarding points", - "

You entered a valid token, but there was a problem trying to give you points:

" .. - "

" .. err .. "

") -end - -koth.page("Points awarded", - "

" .. points .. " points for " .. team .. "!

" .. - "

Back to puzzles

") diff --git a/www/credits.html b/www/credits.html index b6be5ec..ec3367f 100644 --- a/www/credits.html +++ b/www/credits.html @@ -92,6 +92,7 @@ window.addEventListener("load", init); + diff --git a/www/images/logo4.png b/www/images/logo4.png new file mode 100644 index 0000000..0c4add8 Binary files /dev/null and b/www/images/logo4.png differ diff --git a/www/index.html b/www/index.html index 7662935..ad9f3db 100644 --- a/www/index.html +++ b/www/index.html @@ -71,6 +71,7 @@ + diff --git a/www/register.html b/www/register.html index b4e07b6..07fe93a 100644 --- a/www/register.html +++ b/www/register.html @@ -46,6 +46,7 @@ + diff --git a/www/scoring.html b/www/scoring.html index 8c0dc60..e85dea4 100644 --- a/www/scoring.html +++ b/www/scoring.html @@ -118,6 +118,7 @@ +