Remove dev server, update documentation.

This commit is contained in:
Neale Pickett 2020-09-11 20:16:58 -06:00
parent 522e5dbf6c
commit 4ec9666a7c
30 changed files with 412 additions and 23080 deletions

152
README.md
View File

@ -9,8 +9,8 @@ Devel:
![](https://github.com/dirtbags/moth/workflows/Mothd%20Docker%20build/badge.svg?branch=devel)
![](https://github.com/dirtbags/moth/workflows/moth-devel%20Docker%20build/badge.svg?branch=devel)
This is a set of thingies to run our Monarch-Of-The-Hill contest,
which in the past has been called
Monarch Of The Hill (MOTH) is a puzzle server.
We (the authors) have used it for instructional and contest events called
"Tracer FIRE",
"Project 2",
"HACK",
@ -23,152 +23,38 @@ and "Cyber Fire Foundry".
Information about these events is at
http://dirtbags.net/contest/
This software serves up puzzles in a manner similar to Jeopardy.
It also tracks scores,
and comes with a JavaScript-based scoreboard to display team rankings.
A few things make MOTH different than other Capture The Flag server projects:
* Once any team opens a puzzle, all teams can work on it (high fives to DC949/Orange County for this idea)
* No penalties for wrong answers
* No time-based point deductions (if you're faster, you get to answer more puzzles)
* No internal notion of ranking or score: it only stores an event log, and scoreboards parse it however they want
* All puzzles must be compiled to static content before it can be served up
* The server does very little: most functionality is in client-side JavaScript
You can read more about why we made these decisions in [philosophy](doc/philosophy.md).
Running a Development Server
============================
To use example puzzles
docker run --rm -it -p 8080:8080 dirtbags/moth-devel
or, to use your own puzzles
docker run --rm -it -p 8080:8080 -v /path/to/puzzles:/puzzles:ro dirtbags/moth-devel
And point a browser to http://localhost:8080/ (or whatever host is running the server).
The development server includes a number of Python libraries that we have found useful in writing puzzles.
When you're ready to create your own puzzles,
read [the devel server documentation](doc/devel-server.md).
Click the `[mb]` link by a puzzle category to compile and download a mothball that the production server can read.
Documentation
==========
* [Development](doc/development.md): The development server lets you create and test categories, and compile mothballs.
* [Getting Started](doc/getting-started.md): This guide will get you started with a production server.
* [Administration](doc/administration.md): How to set hours, and change setup.
Running a Production Server
===========================
docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/balls:/mothballs:ro dirtbags/moth
docker run --rm -it -p 8080:8080 -v /path/to/moth/state:/state -v /path/to/moth/mothballs:/mothballs:ro dirtbags/moth
You can be more fine-grained about directories, if you like.
Inside the container, you need the following paths:
* `/state` (rw) Where state is stored. Read [the overview](doc/overview.md) to learn what's what in here.
* `/mothballs` (ro) Mothballs (puzzle bundles) as provided by the development server.
* `/resources` (ro) Overrides for built-in HTML/CSS resources.
* `/theme` (ro) Overrides for the built-in theme.
Getting Started Developing
-------------------------------
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 devel/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](doc/devel-server.md)
Running A Production Server
====================
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.
State Directory
===============
Pausing scoring
-------------------
Create the file `state/disabled`
to pause scoring,
and remove it to resume.
You can use the Unix `touch` command to create the file:
touch state/disabled
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.
Resetting an instance
-------------------
Remove the file `state/initialized`,
and the server will zap everything.
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.
Enabling offline/PWA mode
-------------------
If the file `state/export_manifest` is found, the server will expose the
endpoint `/current_manifest.json?id=<teamId>`. This endpoint will return
a list of all files, including static theme content and JSON and content
for currently-unlocked puzzles. This is used by the native PWA
implementation and `Cache` button on the index page to cache all of the
content necessary to display currently-open puzzles while offline.
Grading will be unavailable while offline. Some puzzles may not function
as expected while offline. A valid team ID must be provided.
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!
Contributing to MOTH
==================

View File

@ -24,7 +24,7 @@ func NewTestServer() *MothServer {
afero.WriteFile(theme.Fs, "/index.html", []byte("index.html"), 0644)
go theme.Maintain(TestMaintenanceInterval)
return NewMothServer(Configuration{Devel: true}, theme, state, puzzles)
return NewMothServer(Configuration{}, theme, state, puzzles)
}
func TestServer(t *testing.T) {
@ -47,7 +47,7 @@ func TestServer(t *testing.T) {
es := handler.ExportState()
if es.Config.Devel {
t.Error("Marked as development server")
t.Error("Marked as development server", es.Config)
}
if len(es.Puzzles) != 1 {
t.Error("Puzzle categories wrong length")

View File

@ -17,7 +17,11 @@ import (
// DistinguishableChars are visually unambiguous glyphs.
// People with mediocre handwriting could write these down unambiguously,
// and they can be entered without holding down shift.
const DistinguishableChars = "234678abcdefhikmnpqrtwxyz="
const DistinguishableChars = "34678abcdefhikmnpqrtwxy="
// RFC3339Space is a time layout which replaces 'T' with a space.
// This is also a valid RFC3339 format.
const RFC3339Space = "2006-01-02 15:04:05Z07:00"
// State defines the current state of a MOTH instance.
// We use the filesystem for synchronization between threads.
@ -50,11 +54,11 @@ func NewState(fs afero.Fs) *State {
// updateEnabled checks a few things to see if this state directory is "enabled".
func (s *State) updateEnabled() {
nextEnabled := true
why := "`state/enabled` present, `state/hours` missing"
why := "`state/enabled` present, `state/hours.txt` missing"
if untilFile, err := s.Open("hours"); err == nil {
if untilFile, err := s.Open("hours.txt"); err == nil {
defer untilFile.Close()
why = "`state/hours` present"
why = "`state/hours.txt` present"
scanner := bufio.NewScanner(untilFile)
for scanner.Scan() {
@ -74,10 +78,13 @@ func (s *State) updateEnabled() {
case '#':
continue
default:
log.Println("Misformatted line in hours file")
log.Println("Misformatted line in hours.txt file")
}
line = strings.TrimSpace(line)
until, err := time.Parse(time.RFC3339, line)
if err != nil {
until, err = time.Parse(RFC3339Space, line)
}
if err != nil {
log.Println("Suspended: Unparseable until date:", line)
continue
@ -283,7 +290,7 @@ func (s *State) maybeInitialize() {
// Remove any extant control and state files
s.Remove("enabled")
s.Remove("hours")
s.Remove("hours.txt")
s.Remove("points.log")
s.Remove("messages.html")
s.Remove("mothd.log")
@ -327,8 +334,8 @@ func (s *State) maybeInitialize() {
f.Close()
}
if f, err := s.Create("hours"); err == nil {
fmt.Fprintln(f, "# hours: when the contest is enabled")
if f, err := s.Create("hours.txt"); err == nil {
fmt.Fprintln(f, "# hours.txt: when the contest is enabled")
fmt.Fprintln(f, "#")
fmt.Fprintln(f, "# Enable: + timestamp")
fmt.Fprintln(f, "# Disable: - timestamp")

View File

@ -34,7 +34,7 @@ func TestState(t *testing.T) {
mustExist("initialized")
mustExist("enabled")
mustExist("hours")
mustExist("hours.txt")
teamIDsBuf, err := afero.ReadFile(s.Fs, "teamids.txt")
if err != nil {
@ -133,7 +133,7 @@ func TestStateDisabled(t *testing.T) {
t.Error("Brand new state is disabled")
}
hoursFile, err := s.Create("hours")
hoursFile, err := s.Create("hours.txt")
if err != nil {
t.Error(err)
}
@ -146,7 +146,7 @@ func TestStateDisabled(t *testing.T) {
t.Error("Disabling 1970-01-01")
}
fmt.Fprintln(hoursFile, "+ 1970-01-01T01:01:01+05:00")
fmt.Fprintln(hoursFile, "+ 1970-01-01 01:01:01+05:00")
hoursFile.Sync()
s.refresh()
if !s.Enabled {
@ -175,12 +175,12 @@ func TestStateDisabled(t *testing.T) {
t.Error("Disabling 1980-01-01")
}
if err := s.Remove("hours"); err != nil {
if err := s.Remove("hours.txt"); err != nil {
t.Error(err)
}
s.refresh()
if !s.Enabled {
t.Error("Removing `hours` disabled event")
t.Error("Removing `hours.txt` disabled event")
}
if err := s.Remove("enabled"); err != nil {

File diff suppressed because it is too large Load Diff

View File

@ -1,296 +0,0 @@
#!/usr/bin/python3
import cgitb
import html
import cgi
import http.server
import io
import json
import mimetypes
import moth
import logging
import os
import pathlib
import random
import shutil
import socketserver
import sys
import traceback
import mothballer
import parse
import urllib.parse
import posixpath
from http import HTTPStatus
sys.dont_write_bytecode = True # Don't write .pyc files
class MothServer(socketserver.ForkingMixIn, http.server.HTTPServer):
def __init__(self, server_address, RequestHandlerClass):
super().__init__(server_address, RequestHandlerClass)
self.args = {}
class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
endpoints = []
def __init__(self, request, client_address, server):
self.directory = str(server.args["theme_dir"])
try:
super().__init__(request, client_address, server, directory=server.args["theme_dir"])
except TypeError:
super().__init__(request, client_address, server)
# Why isn't this the default?!
def guess_type(self, path):
mtype, encoding = mimetypes.guess_type(path)
if encoding:
return "%s; encoding=%s" % (mtype, encoding)
else:
return mtype
# Backport from Python 3.7
def translate_path(self, path):
# I guess we just hope that some other thread doesn't call getcwd
getcwd = os.getcwd
os.getcwd = lambda: self.directory
ret = super().translate_path(path)
os.getcwd = getcwd
return ret
def get_puzzle(self):
category = self.req.get("cat")
points = int(self.req.get("points"))
catpath = str(self.server.args["puzzles_dir"].joinpath(category))
cat = moth.Category(catpath, self.seed)
puzzle = cat.puzzle(points)
return puzzle
def send_json(self, obj):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(obj).encode("utf-8"))
def handle_register(self):
# Everybody eats when they come to my house
ret = {
"status": "success",
"data": {
"short": "You win",
"description": "Welcome to the development server, you wily hacker you"
}
}
self.send_json(ret)
endpoints.append(('/{seed}/register', handle_register))
def handle_answer(self):
for f in ("cat", "points", "answer"):
self.req[f] = self.fields.getfirst(f)
puzzle = self.get_puzzle()
ret = {
"status": "success",
"data": {
"short": "",
"description": "%r was not in list of answers" % self.req.get("answer")
},
}
if self.req.get("answer") in puzzle.answers:
ret["data"]["description"] = "Answer is correct"
self.send_json(ret)
endpoints.append(('/{seed}/answer', handle_answer))
def puzzlelist(self):
puzzles = {}
for p in self.server.args["puzzles_dir"].glob("*"):
if not p.is_dir() or p.match(".*"):
continue
catName = p.parts[-1]
cat = moth.Category(str(p), self.seed)
puzzles[catName] = [[i, str(i)] for i in cat.pointvals()]
puzzles[catName].append([0, ""])
if len(puzzles) <= 1:
logging.warning("No directories found matching {}/*".format(self.server.args["puzzles_dir"]))
return puzzles
# XXX: Remove this (redundant) when we've upgraded the bundled theme (probably v3.6 and beyond)
def handle_puzzlelist(self):
self.send_json(self.puzzlelist())
endpoints.append(('/{seed}/puzzles.json', handle_puzzlelist))
def handle_state(self):
resp = {
"Config": {
"Devel": True,
},
"Puzzles": self.puzzlelist(),
"Messages": "<p><b>[MOTH Development Server]</b> Participant broadcast messages would go here.</p>",
}
self.send_json(resp)
endpoints.append(('/{seed}/state', handle_state))
def handle_puzzle(self):
puzzle = self.get_puzzle()
obj = puzzle.package()
obj["answers"] = puzzle.answers
obj["hint"] = puzzle.hint
obj["summary"] = puzzle.summary
obj["logs"] = puzzle.logs
obj["format"] = puzzle._source_format
self.send_json(obj)
endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle))
def handle_puzzlefile(self):
puzzle = self.get_puzzle()
try:
file = puzzle.files[self.req["filename"]]
except KeyError:
self.send_error(
HTTPStatus.NOT_FOUND,
"File Not Found: %s" % self.req["filename"],
)
return
self.send_response(200)
self.send_header("Content-Type", mimetypes.guess_type(file.name))
self.end_headers()
shutil.copyfileobj(file.stream, self.wfile)
endpoints.append(("/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile))
def handle_mothballer(self):
category = self.req.get("cat")
try:
catdir = self.server.args["puzzles_dir"].joinpath(category)
mb = mothballer.package(category, str(catdir), self.seed)
except Exception as ex:
logging.exception(ex)
self.send_response(500)
self.send_header("Content-Type", "text/html; charset=\"utf-8\"")
self.end_headers()
self.wfile.write(cgitb.html(sys.exc_info()).encode("utf-8"))
return
self.send_response(200)
self.send_header("Content-Type", "application/octet_stream")
self.end_headers()
shutil.copyfileobj(mb, self.wfile)
endpoints.append(("/{seed}/mothballer/{cat}.mb", handle_mothballer))
def handle_index(self):
seed = random.getrandbits(32)
self.send_response(307)
self.send_header("Location", "%s/" % seed)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"Your browser was supposed to redirect you to <a href=\"%i/\">here</a>." % seed)
endpoints.append((r"/", handle_index))
def handle_theme_file(self):
self.path = "/" + self.req.get("path", "")
super().do_GET()
endpoints.append(("/{seed}/", handle_theme_file))
endpoints.append(("/{seed}/{path}", handle_theme_file))
def do_GET(self):
self.fields = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={
"REQUEST_METHOD": self.command,
"CONTENT_TYPE": self.headers["Content-Type"],
},
)
url = urllib.parse.urlparse(self.path)
for pattern, function in self.endpoints:
result = parse.parse(pattern, url.path)
if result:
self.req = result.named
seed = self.req.get("seed", "random")
if seed == "random":
self.seed = random.getrandbits(32)
else:
self.seed = int(seed)
return function(self)
super().do_GET()
def do_POST(self):
self.do_GET()
def do_HEAD(self):
self.send_error(
HTTPStatus.NOT_IMPLEMENTED,
"Unsupported method (%r)" % self.command,
)
# I don't fully understand why you can't do this inside the class definition.
MothRequestHandler.extensions_map[".mjs"] = "application/ecmascript"
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="MOTH puzzle development server")
parser.add_argument(
'--puzzles', default='puzzles',
help="Directory containing your puzzles"
)
parser.add_argument(
'--theme', default='theme',
help="Directory containing theme files")
parser.add_argument(
'--bind', default=":8080",
help="Bind to ip:port"
)
parser.add_argument(
'--base', default="",
help="Base URL to this server, for reverse proxy setup"
)
parser.add_argument(
"-v", "--verbose",
action="count",
default=1, # Leave at 1, for now, to maintain current default behavior
help="Include more verbose logging. Use multiple flags to increase level",
)
args = parser.parse_args()
parts = args.bind.split(":")
addr = parts[0]
port = int(parts[1])
if args.verbose >= 2:
log_level = logging.DEBUG
elif args.verbose == 1:
log_level = logging.INFO
else:
log_level = logging.WARNING
logging.basicConfig(level=log_level)
mimetypes.add_type("application/javascript", ".mjs")
server = MothServer((addr, port), MothRequestHandler)
server.args["base_url"] = args.base
server.args["puzzles_dir"] = pathlib.Path(args.puzzles)
server.args["theme_dir"] = args.theme
logging.info("Listening on %s:%d", addr, port)
server.serve_forever()

File diff suppressed because it is too large Load Diff

View File

@ -1,508 +0,0 @@
#!/usr/bin/python3
import argparse
import contextlib
import copy
import glob
import hashlib
import html
import io
import importlib.machinery
import logging
import mistune
import os
import random
import string
import sys
import tempfile
import shlex
import yaml
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
LOGGER = logging.getLogger(__name__)
def sha256hash(str):
return hashlib.sha256(str.encode("utf-8")).hexdigest()
@contextlib.contextmanager
def pushd(newdir):
newdir = str(newdir)
curdir = os.getcwd()
LOGGER.debug("Attempting to chdir from %s to %s" % (curdir, newdir))
os.chdir(newdir)
# Force a copy of the old path, instead of just a reference
old_path = list(sys.path)
old_modules = copy.copy(sys.modules)
sys.path.append(newdir)
try:
yield
finally:
# Restore the old path
to_remove = []
for module in sys.modules:
if module not in old_modules:
to_remove.append(module)
for module in to_remove:
del(sys.modules[module])
sys.path = old_path
LOGGER.debug("Changing directory back from %s to %s" % (newdir, curdir))
os.chdir(curdir)
def loadmod(name, path):
abspath = os.path.abspath(path)
loader = importlib.machinery.SourceFileLoader(name, abspath)
return loader.load_module()
# Get a big list of clean words for our answer file.
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
'answer_words.txt'))]
class PuzzleFile:
"""A file associated with a puzzle.
path: The path to the original input file. May be None (when this is created from a file handle
and there is no original input.
handle: A File-like object set to read the file from. You should be able to read straight
from it without having to seek to the beginning of the file.
name: The name of the output file.
visible: A boolean indicating whether this file should visible to the user. If False,
the file is still expected to be accessible, but it's path must be known
(or figured out) to retrieve it."""
def __init__(self, stream, name, visible=True):
self.stream = stream
self.name = name
self.visible = visible
class PuzzleSuccess(dict):
"""Puzzle success objectives
:param acceptable: Learning outcome from acceptable knowledge of the subject matter
:param mastery: Learning outcome from mastery of the subject matter
"""
valid_fields = ["acceptable", "mastery"]
def __init__(self, **kwargs):
super(PuzzleSuccess, self).__init__()
for key in self.valid_fields:
self[key] = None
for key, value in kwargs.items():
if key in self.valid_fields:
self[key] = value
def __getattr__(self, attr):
if attr in self.valid_fields:
return self[attr]
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr))
def __setattr__(self, attr, value):
if attr in self.valid_fields:
self[attr] = value
else:
raise AttributeError("'%s' object has no attribute '%s'" % (type(self).__name__, attr))
class Puzzle:
def __init__(self, category_seed, points):
"""A MOTH Puzzle.
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
It is combined with the puzzle points.
:param points: The point value of the puzzle.
"""
super().__init__()
self._source_format = "py"
self.points = points
self.summary = None
self.authors = []
self.answers = []
self.xAnchors = {"begin", "end"}
self.scripts = []
self.pattern = None
self.hint = None
self.files = {}
self.body = io.StringIO()
# NIST NICE objective content
self.objective = None # Text describing the expected learning outcome from solving this puzzle, *why* are you solving this puzzle
self.success = PuzzleSuccess() # Text describing criteria for different levels of success, e.g. {"Acceptable": "Did OK", "Mastery": "Did even better"}
self.solution = None # Text describing how to solve the puzzle
self.ksas = [] # A list of references to related NICE KSAs (e.g. K0058, . . .)
self.logs = []
self.randseed = category_seed * self.points
self.rand = random.Random(self.randseed)
def log(self, *vals):
"""Add a new log message to this puzzle."""
msg = ' '.join(str(v) for v in vals)
self.logs.append(msg)
def read_stream(self, stream):
header = True
line = ""
if stream.read(3) == "---":
header = "yaml"
self._source_format = "yaml"
else:
header = "moth"
self._source_format = "moth"
stream.seek(0)
if header == "yaml":
LOGGER.info("Puzzle is YAML-formatted")
self.read_yaml_header(stream)
elif header == "moth":
LOGGER.info("Puzzle is MOTH-formatted")
self.read_moth_header(stream)
for line in stream:
self.body.write(line)
def read_yaml_header(self, stream):
contents = ""
header = False
for line in stream:
if line.strip() == "---" and header: # Handle last line
break
elif line.strip() == "---": # Handle first line
header = True
continue
else:
contents += line
config = yaml.safe_load(contents)
for key, value in config.items():
key = key.lower()
self.handle_header_key(key, value)
def read_moth_header(self, stream):
for line in stream:
line = line.strip()
if not line:
break
key, val = line.split(':', 1)
key = key.lower()
val = val.strip()
self.handle_header_key(key, val)
def handle_header_key(self, key, val):
LOGGER.debug("Handling key: %s, value: %s", key, val)
if key == 'author':
self.authors.append(val)
elif key == 'authors':
if not isinstance(val, list):
raise ValueError("Authors must be a list, got %s, instead" & (type(val),))
self.authors = list(val)
elif key == 'summary':
self.summary = val
elif key == 'answer':
if not isinstance(val, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
self.answers.append(val)
elif key == 'x-answer-pattern':
a = val.strip("*")
assert "*" not in a, "Patterns may only have * at the beginning and end"
assert "?" not in a, "Patterns do not currently support ? characters"
assert "[" not in a, "Patterns do not currently support character ranges"
self.answers.append(a)
if val.startswith("*"):
self.xAnchors.discard("begin")
if val.endswith("*"):
self.xAnchors.discard("end")
elif key == "answers":
for answer in val:
if not isinstance(answer, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(answer),))
self.answers.append(answer)
elif key == 'pattern':
self.pattern = val
elif key == 'hint':
self.hint = val
elif key == 'name':
pass
elif key == 'file':
parts = shlex.split(val)
name = parts[0]
hidden = False
LOGGER.debug("Attempting to open %s", os.path.abspath(name))
stream = open(name, 'rb')
try:
name = parts[1]
hidden = (parts[2].lower() == "hidden")
except IndexError:
pass
self.files[name] = PuzzleFile(stream, name, not hidden)
elif key == 'files' and isinstance(val, dict):
for filename, options in val.items():
if not options:
options = {}
source = options.get("source", filename)
hidden = options.get("hidden", False)
stream = open(source, "rb")
self.files[filename] = PuzzleFile(stream, filename, not hidden)
elif key == 'files' and isinstance(val, list):
for filename in val:
stream = open(filename, "rb")
self.files[filename] = PuzzleFile(stream, filename)
elif key == 'script':
stream = open(val, 'rb')
self.add_script_stream(stream, val)
elif key == "scripts" and isinstance(val, list):
for script in val:
stream = open(script, "rb")
self.add_script_stream(stream, script)
elif key == "objective":
self.objective = val
elif key == "success":
# Force success dictionary keys to be lower-case
self.success = dict((x.lower(), y) for x,y in val.items())
elif key == "success.acceptable":
self.success.acceptable = val
elif key == "success.mastery":
self.success.mastery = val
elif key == "solution":
self.solution = val
elif key == "ksas":
if not isinstance(val, list):
raise ValueError("KSAs must be a list, got %s, instead" & (type(val),))
self.ksas = val
elif key == "ksa":
self.ksas.append(val)
else:
raise ValueError("Unrecognized header field: {}".format(key))
def read_directory(self, path):
try:
puzzle_mod = loadmod("puzzle", os.path.join(path, "puzzle.py"))
except FileNotFoundError:
puzzle_mod = None
with pushd(path):
if puzzle_mod:
puzzle_mod.make(self)
elif os.path.exists('puzzle.moth'):
with open('puzzle.moth') as f:
self.read_stream(f)
else:
self.authors = ["boggarts"]
self.body.write("This puzzle is broken! It has no puzzle.py or puzzle.moth.")
def random_hash(self):
"""Create a file basename (no extension) with our number generator."""
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
def make_temp_file(self, name=None, visible=True):
"""Get a file object for adding dynamically generated data to the puzzle. When you're
done with this file, flush it, but don't close it.
:param name: The name of the file for links within the puzzle. If this is None, a name
will be generated for you.
:param visible: Whether or not the file will be visible to the user.
:return: A file object for writing
"""
stream = tempfile.TemporaryFile()
self.add_stream(stream, name, visible)
return stream
def add_script_stream(self, stream, name):
# Make sure this shows up in the header block of the HTML output.
self.files[name] = PuzzleFile(stream, name, visible=False)
self.scripts.append(name)
def add_stream(self, stream, name=None, visible=True):
if name is None:
name = self.random_hash()
self.files[name] = PuzzleFile(stream, name, visible)
def create_stream(self, name=None, visible=True):
stream = io.BytesIO()
self.add_stream(stream, name, visible)
return stream
def add_file(self, filename, visible=True):
fd = open(filename, 'rb')
name = os.path.basename(filename)
self.add_stream(fd, name=name, visible=visible)
def randword(self):
"""Return a randomly-chosen word"""
return self.rand.choice(ANSWER_WORDS)
def make_answer(self, word_count=4, sep=' '):
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
:param int word_count: The number of words to include in the answer.
:param str|bytes sep: The word separator.
:returns: The answer string
"""
words = [self.randword() for i in range(word_count)]
answer = sep.join(words)
self.answers.append(answer)
return answer
hexdump_stdch = stdch = (
'················'
'················'
' !"#$%&\'()*+,-./'
'0123456789:;<=>?'
'@ABCDEFGHIJKLMNO'
'PQRSTUVWXYZ[\]^_'
'`abcdefghijklmno'
'pqrstuvwxyz{|}~·'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
)
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '')):
hexes, chars = [], []
out = []
for b in buf:
if len(chars) == 16:
out.append((hexes, chars))
hexes, chars = [], []
if b is None:
h, c = gap
else:
h = '{:02x}'.format(b)
c = charset[b]
chars.append(c)
hexes.append(h)
out.append((hexes, chars))
offset = 0
elided = False
lastchars = None
self.body.write('<pre>')
for hexes, chars in out:
if chars == lastchars:
offset += len(chars)
if not elided:
self.body.write('*\n')
elided = True
continue
lastchars = chars[:]
elided = False
pad = 16 - len(chars)
hexes += [' '] * pad
self.body.write('{:08x} '.format(offset))
self.body.write(' '.join(hexes[:8]))
self.body.write(' ')
self.body.write(' '.join(hexes[8:]))
self.body.write(' |')
self.body.write(html.escape(''.join(chars)))
self.body.write('|\n')
offset += len(chars)
self.body.write('{:08x}\n'.format(offset))
self.body.write('</pre>')
def get_authors(self):
if len(self.authors) > 0:
return self.authors
elif hasattr(self, "author"):
return [self.author]
else:
return []
def get_body(self):
return self.body.getvalue()
def html_body(self):
"""Format and return the markdown for the puzzle body."""
return mistune.markdown(self.get_body(), escape=False)
def package(self, answers=False):
"""Return a dict packaging of the puzzle."""
files = sorted([fn for fn,f in self.files.items() if f.visible])
hidden = [fn for fn,f in self.files.items() if not f.visible]
return {
'authors': self.get_authors(),
'hashes': self.hashes(),
'files': files,
'hidden': hidden,
'scripts': self.scripts,
'pattern': self.pattern,
'body': self.html_body(),
'objective': self.objective,
'success': self.success,
'solution': self.solution,
'ksas': self.ksas,
'xAnchors': list(self.xAnchors),
}
def hashes(self):
"Return a list of answer hashes"
return [sha256hash(a) for a in self.answers]
class Category:
def __init__(self, path, seed):
self.path = path
self.seed = seed
self.catmod = None
try:
self.catmod = loadmod('category', os.path.join(path, 'category.py'))
except FileNotFoundError:
self.catmod = None
def pointvals(self):
if self.catmod:
with pushd(self.path):
pointvals = self.catmod.pointvals()
else:
pointvals = []
for fpath in glob.glob(os.path.join(self.path, "[0-9]*")):
pn = os.path.basename(fpath)
points = int(pn)
pointvals.append(points)
return sorted(pointvals)
def puzzle(self, points):
puzzle = Puzzle(self.seed, points)
path = os.path.join(self.path, str(points))
if self.catmod:
with pushd(self.path):
self.catmod.make(points, puzzle)
else:
puzzle.read_directory(path)
return puzzle
def __iter__(self):
for points in self.pointvals():
yield self.puzzle(points)

View File

@ -1,132 +0,0 @@
#!/usr/bin/env python3
import argparse
import binascii
import datetime
import hashlib
import io
import json
import logging
import moth
import os
import platform
import shutil
import tempfile
import zipfile
import random
SEEDFN = "SEED"
def write_kv_pairs(ziphandle, filename, kv):
""" Write out a sorted map to file
:param ziphandle: a zipfile object
:param filename: The filename to write within the zipfile object
:param kv: the map to write out
:return:
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
if isinstance(kv[key], list):
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def build_category(categorydir, outdir):
category_seed = random.getrandbits(32)
categoryname = os.path.basename(categorydir.strip(os.sep))
zipfilename = os.path.join(outdir, "%s.mb" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
if os.path.exists(zipfilename):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
category_seed = int(existing.open(SEEDFN).read().strip())
except Exception:
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)
def write_metadata(ziphandle, category):
metadata = {"platform": {}, "moth": {}, "category": {}}
try:
with open("../VERSION", "r") as infile:
version = infile.read().strip()
metadata["moth"]["version"] = version
except IOError:
pass
metadata["category"]["build_time"] = datetime.datetime.now().strftime("%c")
metadata["category"]["type"] = "catmod" if category.catmod is not None else "traditional"
metadata["platform"]["arch"] = platform.machine()
metadata["platform"]["os"] = platform.system()
metadata["platform"]["version"] = platform.platform()
metadata["platform"]["python_version"] = platform.python_version()
ziphandle.writestr("meta.json", json.dumps(metadata))
# 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", str(seed))
cat = moth.Category(categorydir, seed)
answers = {}
summary = {}
puzzles = []
for puzzle in cat:
logging.info("Processing point value {}".format(puzzle.points))
puzzles.append(puzzle.points)
answers[puzzle.points] = puzzle.answers
summary[puzzle.points] = puzzle.summary
puzzledir = os.path.join("content", str(puzzle.points))
for fn, f in puzzle.files.items():
payload = f.stream.read()
zf.writestr(os.path.join(puzzledir, fn), payload)
obj = puzzle.package()
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), json.dumps(obj))
zf.writestr("puzzles.txt", "\n".join(str(p) for p in puzzles) + "\n")
write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary)
write_metadata(zf, cat)
# clean up
zf.close()
zfraw.seek(0)
return zfraw
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG)
outdir = os.path.abspath(args.outdir)
for categorydir in args.categorydirs:
categorydir = os.path.abspath(categorydir)
build_category(categorydir, outdir)

View File

@ -1,18 +0,0 @@
# To install:
# sudo cp mothd.service /etc/systemd/system/moth.service
# sudo systemctl enable mothd
# sudo systemctl start mothd
[Unit]
Description=Monarch Of The Hill server
After=network.target auditd.service
[Service]
WorkingDirectory=/srv/moth
User=www-data
ExecStart=/srv/moth/mothd
KillMode=process
Restart=on-failure
[Install]
WantedBy=multi-user.target

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
[flake8]
# flake8 is an automated code formatting pedant.
# Use it, please.
#
# python3 -m flake8 .
#
ignore = E501
exclude = .git

View File

@ -1,19 +0,0 @@
#!/bin/sh
set +e
url='https://rawgit.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt'
getter="curl -sL"
fn="answer_words.txt"
filterer() {
grep '......*'
}
if ! curl -h >/dev/null 2>/dev/null; then
getter="wget -q -O -"
elif ! wget -h >/dev/null 2>/dev/null; then
echo "[!] I don't know how to download. I need curl or wget."
fi
$getter "${url}" | filterer > ${fn}.tmp \
&& mv -f ${fn}.tmp ${fn}

View File

@ -1,229 +0,0 @@
#!/usr/bin/python3
"""A validator for MOTH puzzles"""
import logging
import os
import os.path
import re
import moth
# pylint: disable=len-as-condition, line-too-long
DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"]
LOGGER = logging.getLogger(__name__)
class MothValidationError(Exception):
"""An exception for encapsulating MOTH puzzle validation errors"""
class MothValidator:
"""A class which validates MOTH categories"""
def __init__(self, fields):
self.required_fields = fields
self.results = {"category": {}, "checks": []}
def validate(self, categorydir, only_errors=False):
"""Run validation checks against a category"""
LOGGER.debug("Loading category from %s", categorydir)
try:
category = moth.Category(categorydir, 0)
except NotADirectoryError:
return
LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir)
self.results["category"][categorydir] = {
"puzzles": {},
"name": os.path.basename(categorydir.strip(os.sep)),
}
curr_category = self.results["category"][categorydir]
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
if check_function_name not in self.results["checks"]:
self.results["checks"].append(check_function_name)
for puzzle in category:
LOGGER.info("Processing %s: %s", categorydir, puzzle.points)
curr_category["puzzles"][puzzle.points] = {}
curr_puzzle = curr_category["puzzles"][puzzle.points]
curr_puzzle["failures"] = []
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
check_function = getattr(self, check_function_name)
LOGGER.debug("Running %s on %d", check_function_name, puzzle.points)
try:
check_function(puzzle)
except MothValidationError as ex:
curr_puzzle["failures"].append(str(ex))
if only_errors and len(curr_puzzle["failures"]) == 0:
del curr_category["puzzles"][puzzle.points]
def check_fields(self, puzzle):
"""Check if the puzzle has the requested fields"""
for field in self.required_fields:
if not hasattr(puzzle, field) or \
getattr(puzzle,field) is None or \
getattr(puzzle,field) == "":
raise MothValidationError("Missing field %s" % (field,))
@staticmethod
def check_has_answers(puzzle):
"""Check if the puzle has answers defined"""
if len(puzzle.answers) == 0:
raise MothValidationError("No answers provided")
@staticmethod
def check_unique_answers(puzzle):
"""Check if puzzle answers are unique"""
known_answers = []
duplicate_answers = []
for answer in puzzle.answers:
if answer not in known_answers:
known_answers.append(answer)
else:
duplicate_answers.append(answer)
if len(duplicate_answers) > 0:
raise MothValidationError("Duplicate answer(s) %s" % ", ".join(duplicate_answers))
@staticmethod
def check_has_authors(puzzle):
"""Check if the puzzle has authors defined"""
if len(puzzle.authors) == 0:
raise MothValidationError("No authors provided")
@staticmethod
def check_unique_authors(puzzle):
"""Check if puzzle authors are unique"""
known_authors = []
duplicate_authors = []
for author in puzzle.authors:
if author not in known_authors:
known_authors.append(author)
else:
duplicate_authors.append(author)
if len(duplicate_authors) > 0:
raise MothValidationError("Duplicate author(s) %s" % ", ".join(duplicate_authors))
@staticmethod
def check_has_summary(puzzle):
"""Check if the puzzle has a summary"""
if puzzle.summary is None:
raise MothValidationError("Summary has not been provided")
@staticmethod
def check_has_body(puzzle):
"""Check if the puzzle has a body defined"""
old_pos = puzzle.body.tell()
puzzle.body.seek(0)
if len(puzzle.body.read()) == 0:
puzzle.body.seek(old_pos)
raise MothValidationError("No body provided")
puzzle.body.seek(old_pos)
@staticmethod
def check_ksa_format(puzzle):
"""Check if KSAs are properly formatted"""
ksa_re = re.compile("^[KSA]\d{4}$")
if hasattr(puzzle, "ksa"):
for ksa in puzzle.ksa:
if ksa_re.match(ksa) is None:
raise MothValidationError("Unrecognized KSA format (%s)" % (ksa,))
@staticmethod
def check_success(puzzle):
"""Check if success criteria are defined"""
if not hasattr(puzzle, "success"):
raise MothValidationError("Success not defined")
criteria = ["acceptable", "mastery"]
missing_criteria = []
for criterion in criteria:
if criterion not in puzzle.success.keys() or \
puzzle.success[criterion] is None or \
len(puzzle.success[criterion]) == 0:
missing_criteria.append(criterion)
if len(missing_criteria) > 0:
raise MothValidationError("Missing success criteria (%s)" % (", ".join(missing_criteria)))
def output_json(data):
"""Output results in JSON format"""
import json
print(json.dumps(data))
def output_text(data):
"""Output results in a text-based tabular format"""
longest_category = max([len(y["name"]) for x, y in data["category"].items()])
longest_category = max([longest_category, len("Category")])
longest_failure = len("Failures")
for category_data in data["category"].values():
for points, puzzle_data in category_data["puzzles"].items():
longest_failure = max([longest_failure, len(", ".join(puzzle_data["failures"]))])
formatstr = "| %%%ds | %%6s | %%%ds |" % (longest_category, longest_failure)
headerfmt = formatstr % ("Category", "Points", "Failures")
print(headerfmt)
for cat_data in data["category"].values():
for points, puzzle_data in sorted(cat_data["puzzles"].items()):
print(formatstr % (cat_data["name"], points, ", ".join([str(x) for x in puzzle_data["failures"]])))
def main():
"""Main function"""
# pylint: disable=invalid-name
import argparse
LOGGER.addHandler(logging.StreamHandler())
parser = argparse.ArgumentParser(description="Validate MOTH puzzle field compliance")
parser.add_argument("category", nargs="+", help="Categories to validate")
parser.add_argument("-f", "--fields", help="Comma-separated list of fields to check for", default=",".join(DEFAULT_REQUIRED_FIELDS))
parser.add_argument("-o", "--output-format", choices=["text", "json"], default="text", help="Output format (default: text)")
parser.add_argument("-e", "--only-errors", action="store_true", default=False, help="Only output errors")
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity of output, repeat to increase")
args = parser.parse_args()
if args.verbose == 1:
LOGGER.setLevel("INFO")
elif args.verbose > 1:
LOGGER.setLevel("DEBUG")
LOGGER.debug(args)
validator = MothValidator(args.fields.split(","))
for category in args.category:
LOGGER.info("Validating %s", category)
validator.validate(category, only_errors=args.only_errors)
if args.output_format == "text":
output_text(validator.results)
elif args.output_format == "json":
output_json(validator.results)
if __name__ == "__main__":
main()

125
doc/administration.md Normal file
View File

@ -0,0 +1,125 @@
Administration
=========
Everything you need to do happens through the filesystem.
Usually, in `/srv/moth/state`.
The server doesn't cache anything in memory,
so the `state` directory always contains the current state.
Backing up current state
---------------------------
tar czf backup.tar.gz /srv/moth/state # Full backup
curl http://localhost:8080/state > state.json # Pull anonymized event log and team names (scoreboard)
Pausing/resuming scoring
-------------------
rm /srv/moth/state/enabled # Pause scoring
touch /srv/moth/state/enabled # Resume scoring
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.
Scheduling an automatic pause and resume
-----------------------------------
printf '-'; date --rfc-3339=s -d '10:00 PM' >> /srv/moth/state/hours.txt # Schedule suspend at 10:00 PM
printf '+'; date --rfc-3339=s -d '08:00 tomorrow' >> /srv/moth/state/hours.txt # Schedule resume at 08:00 tomorrow
You might prefer to open `/srv/moth/state/hours.txt` in a text editor.
I do.
Re-initalize
-------------------
rm /srv/moth/state/initialized
This will reset the following:
* team registrations
* points log
Team tokens stick around, though.
Setting up custom team IDs
-------------------
echo > /srv/moth/state/teamids.txt # Teams must be registered manually
seq 9999 > /srv/moth/state/teamids.txt # Allow all 4-digit numbers
`teamids.txt` is a list of acceptable team IDs,
one per line.
You can make it anything you want.
New instances will initialize this with some hex values.
Remember that team IDs are essentially passwords.
Adjusting scores
------------------
rm /srv/moth/state/enabled # Suspend scoring
nano /srv/moth/state/points.log
touch /srv/moth/state/enabled # Resume scoring
We don't warn participants before we do this:
any points scored while scoring is suspended are queued up and processed as soon as scoreing is resumed.
It's very important to suspend scoring before mucking around with the points log.
The maintenance loop assumes it is the only thing writing to this file,
and any edits you make could blow aware points scored.
No, I don't use nano.
None of us use nano.
Changing a team name
----------------------
grep . /srv/moth/state/teams/* # Show all team IDs and names
echo 'exciting new team name' > /srv/moth/state/teams/$teamid
Please remember, you have to replace `$teamid` with the actual team ID that you want to edit.
Dealing with puzzles
===========
Checking on an answer
----------------------
Mothballs are just zip files.
If you need to check something about a running category,
just unzip the mothball for that category.
mkdir /tmp/category
cd /tmp/category
unzip /srv/moth/mothballs/category.zip
cat answers.txt # Show all valid answers for all puzzles. Watch your shoulder!
Installing new categories
-------------------
Just drop a new mothball in the `mothballs' directory.
cp new-category.mb /srv/moth/mothballs
Taking a category offline
-------------------------
rm /srv/moth/mothballs/old-category.mb
Removing a category won't remove points that have been scored in it!

View File

@ -1,63 +0,0 @@
Using the MOTH Development Server
======================
To make puzzle development easier,
MOTH comes with a standalone web server written in Python,
which will show you how your puzzles are going to look without making you compile or package anything.
It even works in Windows,
because that is what my career has become.
Getting It Going
----------------
### With Docker
If you can use docker, you are in luck:
docker run --rm -t -p 8080:8080 dirtbags/moth-devel
Gets you a development puzzle server running on port 8080,
with the sample puzzle directory set up.
### Without Docker
If you can't use docker,
try this:
apt install python3
pip3 install scapy pillow PyYAML
git clone https://github.com/dirtbags/moth/
cd moth
python3 devel/devel-server.py --puzzles example-puzzles
Installing New Puzzles
-----------------------------
The development server wants to see category directories under `puzzles`,
like this:
$ find puzzles -type d
puzzles/
puzzles/category1/
puzzles/category1/10/
puzzles/category1/20/
puzzles/category1/30/
puzzles/category2/
puzzles/category2/100/
puzzles/category2/200/
puzzles/category2/300/
### With Docker
docker run --rm -t -v /path/to/my/puzzles:/puzzles:ro -p 8080:8080 dirtbags/moth-devel
### Without Docker
You can use the `--puzzles` argument to `devel-server.py`
to specify a path to your puzzles directory.

105
doc/development.md Normal file
View File

@ -0,0 +1,105 @@
Developing Content
============================
The development server shows debugging for each puzzle,
and will compile puzzles on the fly.
Use it along with a text editor and shell to create new puzzles and categories.
Set up some example puzzles
---------
If you don't have puzzles of your own to start with,
you can copy the example puzzles that come with the source:
cp -r /path/to/src/moth/example-puzzles /srv/moth/puzzles
Run the server in development mode
---------------
These recipes run the server in the foreground,
so you can watch the access log and any error messages.
### Podman
podman run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth -puzzles /puzzles
### Docker
docker run --rm -it -p 8080:8080 -v /srv/moth/puzzles:/puzzles:ro dirtbags/moth -puzzles /puzzles
### Native
I assume you've built and installed the `moth` command from the source tree.
If you don't know how to build Go packages,
please consider using Podman or Docker.
Building Go software is not a skill related to running MOTH or puzzle events,
unless you plan on hacking on the source code.
mkdir -p /srv/moth/state
cp -r /path/to/src/moth/theme /srv/moth/theme
cd /srv/moth
moth -puzzles puzzles
Log In
-----
Point a browser to http://localhost:8080/ (or whatever host is running the server).
You will be logged in automatically.
Browse the example puzzles
------------
The example puzzles are written to demonstrate various features of MOTH,
and serve as documentation of the puzzle format.
Make your own puzzle category
-------------------------
cp -r /srv/moth/puzzles/example /srv/moth/puzzles/my-category
Edit the one point puzzle
--------
nano /srv/moth/puzzles/my-category/1/puzzle.md
I don't use nano, personally,
but if you're advanced enough to have an opinion about nano,
you're advanced enough to know how to use a different editor.
Read our advice
---------------
The [Writing Puzzles](writing-puzzles.md) document
has some tips on how we approach puzzle writing.
There may be something in here that will help you out!
Stop the server
-------
You can hit Control-C in the terminal where you started the server,
and it will exit.
Mothballs
=======
In the list of puzzle categories and puzzles,
there will be a button to download a mothball.
Once your category is set up the way you like it,
download a mothball for it,
and you're ready to [get started](getting-started.md)
with the production server.

77
doc/getting-started.md Normal file
View File

@ -0,0 +1,77 @@
Getting Started
===============
Compile Mothballs
--------------------
Mothballs are compiled, static-content versions of a puzzle category.
You need a mothball for every category you want to run.
To get some mothballs, you'll need to run a development server, which includes the category compiler.
See [development](development.md) for details.
Set up directories
--------------------
mkdir -p /srv/moth/state
mkdir -p /srv/moth/mothballs
cp -r /path/to/src/moth/theme /srv/moth/theme # Skip if using Docker/Podman/Kubernetes
MOTH needs three directories. We recommend putting them all in `/srv/moth`.
* `/srv/moth/state`: (read-write) an empty directory for the server to record its state
* `/srv/moth/mothballs`: (read-only) drop your mothballs here
* `/srv/moth/theme`: (read-only) The HTML5 MOTH client: static content served to web browsers
Run the server
----------------
We're going to assume you put everything in `/srv/moth`, like we suggested.
### Podman
podman run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
### Docker
docker run --name=moth -d -v /srv/moth/mothballs:/mothballs:ro -v /srv/moth/state:/state dirtbags/moth
### Native
cd /srv/moth
moth
Copy in some mothballs
-------------------------
cp category1.mb category2.mb /srv/moth/mothballs
You can add and remove mothballs at any time while the server is running.
Get a list of valid team tokens
-----------------------
cat /srv/moth/state/tokens.txt
You can edit or replace this file if you want to use different tokens than the pre-generated ones.
Connect to the server
------------------------
Open http://localhost:8080/
Substitute the hostname appropriately if you're a fancypants with a cloud.
Yay!
-------
You should be all set now!
See [administration](administration.md) for how to keep your new MOTH server running the way you want.

View File

@ -1,7 +1,10 @@
Philosophy
==========
This is just some scattered thoughts by the architect, Neale.
Some scattered thoughts by the architect, Neale.
Hardening
-----------
People are going to try to break this thing.
It needs to be bulletproof.
@ -10,21 +13,23 @@ This pretty much set the entire design:
* As much as possible is done client-side
* Participants can attack their own web browsers as much as they feel like
* Also reduces server load
* We will help you create brute-force attacks!
* We even made a puzzle category to walk people through creating brute-force attacks!
* Your laptop is faster than our server
* We give you the carrot of hashed answers and the hashing function
* This removes one incentive to DoS the server
* Generate static content whenever possible
* Puzzles are statically compiled before the event even starts
* `points.json` and `puzzles.json` are generated and cached by a maintenance loop
* Puzzles must be statically compiled before the event even starts
* As much content as possible is generated by a maintenance loop
* Minimize dynamic handling
* There are only two (2) dynamic handlers
* There are only three (3) dynamic handlers
* team registration
* answer validation
* server state (open puzzles + event log)
* You can disable team registration if you want, just remove `teamids.txt`
* I even removed token handling once I realized we replicate the user experience with the `answer` handler and some client-side JavaScript
* As much as possible is read-only
* The only rw directory is `state`
* The only read-write directory is `state`
* This plays very well with Docker, which didn't exist when we designed MOTH
* Server code should be as tiny as possible
* Server should provide highly limited functionality
* It should be easy to remember in your head everything it does
@ -35,3 +40,21 @@ This pretty much set the entire design:
* Want to provide a time bonus for quick answers? I don't, but if you do, you can just modify the scoreboard to do so.
* Maybe you want to show a graph of team rankings over time: just replay the event log.
* Want to do some analysis of what puzzles take the longest to answer? It's all there.
Fairness
---------
We spend a lot of time thinking about whether new content is going to feel fair.
Or, more importantly, if there's a possibility for it to be viewed as unfair.
It's possible to run fun events that don't focus so much on fairness,
but those aren't the type of events we run.
* People generally don't mind discovering that they could improve
* People can get furious if they feel like some system is unfairly targeting them
* Every team that does the same amount of work should have the same score
* No time bonuses / decaying points
* No penalties for trying things that don't work out
* No one should ever feel like it's impossible to catch up
* Achievements ("cheevos") work well here
* Time-based awards (flags) don't mesh with this idea

View File

@ -1,12 +1,42 @@
Tokens
======
Tokens are good for a single point in a single category. They are
formed by prepending the category and a colon to the bubblebabble digest
of 3 random octets. A token for the "merfing" category might look like
this:
We used to use tokens extensively for categories outside of MOTH
(like scavenger hunts, Dirtbags Tanks, and other standalone stuff).
merfing:xunap-motex
We still occasionally pull out tokens to deal with oddball categories
that we want to score alongside MOTH categories.
Here's how they work.
Description
------------
Tokens are a 3-tuple:
> (category, points, nonce)
We build a mothball with nothing but `answers.txt`,
and a special 1-point puzzle that uses JavaScript to parse and submit tokens.
Generally, tokens use colon separators, so they look like this:
category:12:xunap-motex
Uniqueness
--------
Because they work just like normal categories,
you can't have two distinct tokens worth the same number of points.
When we need two or more tokens worth the same amount,
we make the point values very high,
so the least significant digit doesn't have much impact on the overall value.
For instance:
category:1000001:xylep-nanox
category:1000002:xenod-relix
category:1000003:xoter-darox
Entropy

File diff suppressed because it is too large Load Diff

View File

@ -1,287 +0,0 @@
#!/usr/bin/python3
import asyncio
import cgitb
import html
import cgi
import http.server
import io
import json
import mimetypes
import moth
import logging
import os
import pathlib
import random
import shutil
import sys
import traceback
import mothballer
import parse
import urllib.parse
import posixpath
from http import HTTPStatus
sys.dont_write_bytecode = True # Don't write .pyc files
try:
ThreadingHTTPServer = http.server.ThreadingHTTPServer
except AttributeError:
import socketserver
class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
class MothServer(ThreadingHTTPServer):
def __init__(self, server_address, RequestHandlerClass):
super().__init__(server_address, RequestHandlerClass)
self.args = {}
class MothRequestHandler(http.server.SimpleHTTPRequestHandler):
endpoints = []
def __init__(self, request, client_address, server):
self.directory = str(server.args["theme_dir"])
try:
super().__init__(request, client_address, server, directory=server.args["theme_dir"])
except TypeError:
super().__init__(request, client_address, server)
# Backport from Python 3.7
def translate_path(self, path):
# I guess we just hope that some other thread doesn't call getcwd
getcwd = os.getcwd
os.getcwd = lambda: self.directory
ret = super().translate_path(path)
os.getcwd = getcwd
return ret
def get_puzzle(self):
category = self.req.get("cat")
points = int(self.req.get("points"))
catpath = str(self.server.args["puzzles_dir"].joinpath(category))
cat = moth.Category(catpath, self.seed)
puzzle = cat.puzzle(points)
return puzzle
def handle_answer(self):
for f in ("cat", "points", "answer"):
self.req[f] = self.fields.getfirst(f)
puzzle = self.get_puzzle()
ret = {
"status": "success",
"data": {
"short": "",
"description": "Provided answer was not in list of answers"
},
}
if self.req.get("answer") in puzzle.answers:
ret["data"]["description"] = "Answer is correct"
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(ret).encode("utf-8"))
endpoints.append(('/{seed}/answer', handle_answer))
def handle_puzzlelist(self):
puzzles = {
"__devel__": [[0, ""]],
}
for p in self.server.args["puzzles_dir"].glob("*"):
if not p.is_dir() or p.match(".*"):
continue
catName = p.parts[-1]
cat = moth.Category(str(p), self.seed)
puzzles[catName] = [[i, str(i)] for i in cat.pointvals()]
puzzles[catName].append([0, ""])
if len(puzzles) <= 1:
logging.warning("No directories found matching {}/*".format(self.server.args["puzzles_dir"]))
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(puzzles).encode("utf-8"))
endpoints.append(('/{seed}/puzzles.json', handle_puzzlelist))
def handle_puzzle(self):
puzzle = self.get_puzzle()
obj = puzzle.package()
obj["answers"] = puzzle.answers
obj["hint"] = puzzle.hint
obj["summary"] = puzzle.summary
obj["logs"] = puzzle.logs
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(obj).encode("utf-8"))
endpoints.append(('/{seed}/content/{cat}/{points}/puzzle.json', handle_puzzle))
def handle_puzzlefile(self):
puzzle = self.get_puzzle()
try:
file = puzzle.files[self.req["filename"]]
except KeyError:
self.send_error(
HTTPStatus.NOT_FOUND,
"File Not Found",
)
return
self.send_response(200)
self.send_header("Content-Type", mimetypes.guess_type(file.name))
self.end_headers()
shutil.copyfileobj(file.stream, self.wfile)
endpoints.append(("/{seed}/content/{cat}/{points}/{filename}", handle_puzzlefile))
def handle_mothballer(self):
category = self.req.get("cat")
try:
catdir = self.server.args["puzzles_dir"].joinpath(category)
mb = mothballer.package(category, catdir, self.seed)
except Exception as ex:
logging.exception(ex)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=\"utf-8\"")
self.end_headers()
self.wfile.write(cgitb.html(sys.exc_info()))
return
self.send_response(200)
self.send_header("Content-Type", "application/octet_stream")
self.end_headers()
shutil.copyfileobj(mb, self.wfile)
endpoints.append(("/{seed}/mothballer/{cat}.mb", handle_mothballer))
def handle_index(self):
seed = random.getrandbits(32)
body = """<!DOCTYPE html>
<html>
<head>
<title>Dev Server</title>
<script>
// Skip trying to log in
sessionStorage.setItem("id", "devel-server")
</script>
</head>
<body>
<h1>Dev Server</h1>
<p>
Pick a seed:
</p>
<ul>
<li><a href="{seed}/">{seed}</a>: a special seed I made just for you!</li>
<li><a href="random/">random</a>: will use a different seed every time you load a page (could be useful for debugging)</li>
<li>You can also hack your own seed into the URL, if you want to.</li>
</ul>
<p>
Puzzles can be generated from Python code: these puzzles can use a random number generator if they want.
The seed is used to create these random numbers.
</p>
<p>
We like to make a new seed for every contest,
and re-use that seed whenever we regenerate a category during an event
(say to fix a bug).
By using the same seed,
we make sure that all the dynamically-generated puzzles have the same values
in any new packages we build.
</p>
</body>
</html>
""".format(seed=seed)
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode('utf-8'))
endpoints.append((r"/", handle_index))
endpoints.append((r"/{ignored}", handle_index))
def handle_theme_file(self):
self.path = "/" + self.req.get("path", "")
super().do_GET()
endpoints.append(("/{seed}/", handle_theme_file))
endpoints.append(("/{seed}/{path}", handle_theme_file))
def do_GET(self):
self.fields = cgi.FieldStorage(
fp=self.rfile,
headers=self.headers,
environ={
"REQUEST_METHOD": self.command,
"CONTENT_TYPE": self.headers["Content-Type"],
},
)
for pattern, function in self.endpoints:
result = parse.parse(pattern, self.path)
if result:
self.req = result.named
seed = self.req.get("seed", "random")
if seed == "random":
self.seed = random.getrandbits(32)
else:
self.seed = int(seed)
return function(self)
super().do_GET()
def do_POST(self):
self.do_GET()
def do_HEAD(self):
self.send_error(
HTTPStatus.NOT_IMPLEMENTED,
"Unsupported method (%r)" % self.command,
)
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser(description="MOTH puzzle development server")
parser.add_argument(
'--puzzles', default='puzzles',
help="Directory containing your puzzles"
)
parser.add_argument(
'--theme', default='theme',
help="Directory containing theme files")
parser.add_argument(
'--bind', default="127.0.0.1:8080",
help="Bind to ip:port"
)
parser.add_argument(
'--base', default="",
help="Base URL to this server, for reverse proxy setup"
)
args = parser.parse_args()
parts = args.bind.split(":")
addr = parts[0] or "0.0.0.0"
port = int(parts[1])
logging.basicConfig(level=logging.INFO)
server = MothServer((addr, port), MothRequestHandler)
server.args["base_url"] = args.base
server.args["puzzles_dir"] = pathlib.Path(args.puzzles)
server.args["theme_dir"] = args.theme
logging.info("Listening on %s:%d", addr, port)
server.serve_forever()

File diff suppressed because it is too large Load Diff

View File

@ -1,368 +0,0 @@
#!/usr/bin/python3
import argparse
import contextlib
import glob
import hashlib
import html
import io
import importlib.machinery
import mistune
import os
import random
import string
import tempfile
import shlex
import yaml
messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
def djb2hash(str):
h = 5381
for c in str.encode("utf-8"):
h = ((h * 33) + c) & 0xffffffff
return h
@contextlib.contextmanager
def pushd(newdir):
curdir = os.getcwd()
os.chdir(newdir)
try:
yield
finally:
os.chdir(curdir)
def loadmod(name, path):
abspath = os.path.abspath(path)
loader = importlib.machinery.SourceFileLoader(name, abspath)
return loader.load_module()
# Get a big list of clean words for our answer file.
ANSWER_WORDS = [w.strip() for w in open(os.path.join(os.path.dirname(__file__),
'answer_words.txt'))]
class PuzzleFile:
"""A file associated with a puzzle.
path: The path to the original input file. May be None (when this is created from a file handle
and there is no original input.
handle: A File-like object set to read the file from. You should be able to read straight
from it without having to seek to the beginning of the file.
name: The name of the output file.
visible: A boolean indicating whether this file should visible to the user. If False,
the file is still expected to be accessible, but it's path must be known
(or figured out) to retrieve it."""
def __init__(self, stream, name, visible=True):
self.stream = stream
self.name = name
self.visible = visible
class Puzzle:
def __init__(self, category_seed, points):
"""A MOTH Puzzle.
:param category_seed: A byte string to use as a seed for random numbers for this puzzle.
It is combined with the puzzle points.
:param points: The point value of the puzzle.
"""
super().__init__()
self.points = points
self.summary = None
self.authors = []
self.answers = []
self.scripts = []
self.pattern = None
self.hint = None
self.files = {}
self.body = io.StringIO()
self.logs = []
self.randseed = category_seed * self.points
self.rand = random.Random(self.randseed)
def log(self, *vals):
"""Add a new log message to this puzzle."""
msg = ' '.join(str(v) for v in vals)
self.logs.append(msg)
def read_stream(self, stream):
header = True
line = ""
if stream.read(3) == "---":
header = "yaml"
else:
header = "moth"
stream.seek(0)
if header == "yaml":
self.read_yaml_header(stream)
elif header == "moth":
self.read_moth_header(stream)
for line in stream:
self.body.write(line)
def read_yaml_header(self, stream):
contents = ""
header = False
for line in stream:
if line.strip() == "---" and header: # Handle last line
break
elif line.strip() == "---": # Handle first line
header = True
continue
else:
contents += line
config = yaml.safe_load(contents)
for key, value in config.items():
key = key.lower()
self.handle_header_key(key, value)
def read_moth_header(self, stream):
for line in stream:
line = line.strip()
if not line:
break
key, val = line.split(':', 1)
key = key.lower()
val = val.strip()
self.handle_header_key(key, val)
def handle_header_key(self, key, val):
if key == 'author':
self.authors.append(val)
elif key == 'summary':
self.summary = val
elif key == 'answer':
if not isinstance(val, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(val),))
self.answers.append(val)
elif key == "answers":
for answer in val:
if not isinstance(answer, str):
raise ValueError("Answers must be strings, got %s, instead" % (type(answer),))
self.answers.append(answer)
elif key == 'pattern':
self.pattern = val
elif key == 'hint':
self.hint = val
elif key == 'name':
pass
elif key == 'file':
parts = shlex.split(val)
name = parts[0]
hidden = False
stream = open(name, 'rb')
try:
name = parts[1]
hidden = (parts[2].lower() == "hidden")
except IndexError:
pass
self.files[name] = PuzzleFile(stream, name, not hidden)
elif key == 'script':
stream = open(val, 'rb')
# Make sure this shows up in the header block of the HTML output.
self.files[val] = PuzzleFile(stream, val, visible=False)
self.scripts.append(val)
else:
raise ValueError("Unrecognized header field: {}".format(key))
def read_directory(self, path):
try:
puzzle_mod = loadmod("puzzle", os.path.join(path, "puzzle.py"))
except FileNotFoundError:
puzzle_mod = None
with pushd(path):
if puzzle_mod:
puzzle_mod.make(self)
else:
with open('puzzle.moth') as f:
self.read_stream(f)
def random_hash(self):
"""Create a file basename (no extension) with our number generator."""
return ''.join(self.rand.choice(string.ascii_lowercase) for i in range(8))
def make_temp_file(self, name=None, visible=True):
"""Get a file object for adding dynamically generated data to the puzzle. When you're
done with this file, flush it, but don't close it.
:param name: The name of the file for links within the puzzle. If this is None, a name
will be generated for you.
:param visible: Whether or not the file will be visible to the user.
:return: A file object for writing
"""
stream = tempfile.TemporaryFile()
self.add_stream(stream, name, visible)
return stream
def add_stream(self, stream, name=None, visible=True):
if name is None:
name = self.random_hash()
self.files[name] = PuzzleFile(stream, name, visible)
def add_file(self, filename, visible=True):
fd = open(filename, 'rb')
name = os.path.basename(filename)
self.add_stream(fd, name=name, visible=visible)
def randword(self):
"""Return a randomly-chosen word"""
return self.rand.choice(ANSWER_WORDS)
def make_answer(self, word_count=4, sep=' '):
"""Generate and return a new answer. It's automatically added to the puzzle answer list.
:param int word_count: The number of words to include in the answer.
:param str|bytes sep: The word separator.
:returns: The answer string
"""
words = [self.randword() for i in range(word_count)]
answer = sep.join(words)
self.answers.append(answer)
return answer
hexdump_stdch = stdch = (
'················'
'················'
' !"#$%&\'()*+,-./'
'0123456789:;<=>?'
'@ABCDEFGHIJKLMNO'
'PQRSTUVWXYZ[\]^_'
'`abcdefghijklmno'
'pqrstuvwxyz{|}~·'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
'················'
)
def hexdump(self, buf, charset=hexdump_stdch, gap=('<EFBFBD>', '')):
hexes, chars = [], []
out = []
for b in buf:
if len(chars) == 16:
out.append((hexes, chars))
hexes, chars = [], []
if b is None:
h, c = gap
else:
h = '{:02x}'.format(b)
c = charset[b]
chars.append(c)
hexes.append(h)
out.append((hexes, chars))
offset = 0
elided = False
lastchars = None
self.body.write('<pre>')
for hexes, chars in out:
if chars == lastchars:
offset += len(chars)
if not elided:
self.body.write('*\n')
elided = True
continue
lastchars = chars[:]
elided = False
pad = 16 - len(chars)
hexes += [' '] * pad
self.body.write('{:08x} '.format(offset))
self.body.write(' '.join(hexes[:8]))
self.body.write(' ')
self.body.write(' '.join(hexes[8:]))
self.body.write(' |')
self.body.write(html.escape(''.join(chars)))
self.body.write('|\n')
offset += len(chars)
self.body.write('{:08x}\n'.format(offset))
self.body.write('</pre>')
def get_authors(self):
return self.authors or [self.author]
def get_body(self):
return self.body.getvalue()
def html_body(self):
"""Format and return the markdown for the puzzle body."""
return mistune.markdown(self.get_body(), escape=False)
def package(self, answers=False):
"""Return a dict packaging of the puzzle."""
files = [fn for fn,f in self.files.items() if f.visible]
return {
'authors': self.authors,
'hashes': self.hashes(),
'files': files,
'scripts': self.scripts,
'pattern': self.pattern,
'body': self.html_body(),
}
def hashes(self):
"Return a list of answer hashes"
return [djb2hash(a) for a in self.answers]
class Category:
def __init__(self, path, seed):
self.path = path
self.seed = seed
self.catmod = None
try:
self.catmod = loadmod('category', os.path.join(path, 'category.py'))
except FileNotFoundError:
self.catmod = None
def pointvals(self):
if self.catmod:
with pushd(self.path):
pointvals = self.catmod.pointvals()
else:
pointvals = []
for fpath in glob.glob(os.path.join(self.path, "[0-9]*")):
pn = os.path.basename(fpath)
points = int(pn)
pointvals.append(points)
return sorted(pointvals)
def puzzle(self, points):
puzzle = Puzzle(self.seed, points)
path = os.path.join(self.path, str(points))
if self.catmod:
with pushd(self.path):
self.catmod.make(points, puzzle)
else:
puzzle.read_directory(path)
return puzzle
def __iter__(self):
for points in self.pointvals():
yield self.puzzle(points)

View File

@ -1,115 +0,0 @@
#!/usr/bin/env python3
import argparse
import binascii
import hashlib
import io
import json
import logging
import moth
import os
import shutil
import tempfile
import zipfile
import random
SEEDFN = "SEED"
def write_kv_pairs(ziphandle, filename, kv):
""" Write out a sorted map to file
:param ziphandle: a zipfile object
:param filename: The filename to write within the zipfile object
:param kv: the map to write out
:return:
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
if isinstance(kv[key], list):
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def build_category(categorydir, outdir):
category_seed = random.getrandbits(32)
categoryname = os.path.basename(categorydir.strip(os.sep))
zipfilename = os.path.join(outdir, "%s.mb" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
if os.path.exists(zipfilename):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
category_seed = int(existing.open(SEEDFN).read().strip())
except Exception:
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)
# 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", str(seed))
cat = moth.Category(categorydir, seed)
mapping = {}
answers = {}
summary = {}
for puzzle in cat:
logging.info("Processing point value {}".format(puzzle.points))
hashmap = hashlib.sha1(str(seed).encode('utf-8'))
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
puzzledir = os.path.join("content", puzzlehash)
for fn, f in puzzle.files.items():
payload = f.stream.read()
zf.writestr(os.path.join(puzzledir, fn), payload)
obj = puzzle.package()
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), json.dumps(obj))
write_kv_pairs(zf, 'map.txt', mapping)
write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary)
# clean up
zf.close()
zfraw.seek(0)
return zfraw
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG)
outdir = os.path.abspath(args.outdir)
for categorydir in args.categorydirs:
categorydir = os.path.abspath(categorydir)
build_category(categorydir, outdir)

View File

@ -1,169 +0,0 @@
#!/usr/bin/env python3
import argparse
import binascii
import glob
import hashlib
import io
import json
import logging
import moth
import os
import shutil
import string
import sys
import tempfile
import zipfile
def write_kv_pairs(ziphandle, filename, kv):
""" Write out a sorted map to file
:param ziphandle: a zipfile object
:param filename: The filename to write within the zipfile object
:param kv: the map to write out
:return:
"""
filehandle = io.StringIO()
for key in sorted(kv.keys()):
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('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
def generate_html(ziphandle, puzzle, puzzledir, category, points, authors, files):
html_content = io.StringIO()
file_content = io.StringIO()
if files:
file_content.write(
''' <section id="files">
<h2>Associated files:</h2>
<ul>
''')
for fn in files:
file_content.write(' <li><a href="{fn}">{efn}</a></li>\n'.format(fn=fn, efn=escape(fn)))
file_content.write(
''' </ul>
</section>
''')
scripts = ['<script src="{}"></script>'.format(s) for s in puzzle.scripts]
html_content.write(
'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width">
<title>{category} {points}</title>
<link rel="stylesheet" href="../../style.css">
{scripts}
</head>
<body>
<h1>{category} for {points} points</h1>
<section id="readme">
{body} </section>
{file_content} <section id="form">
<form id="puzzler" action="../../cgi-bin/puzzler.cgi" method="get" accept-charset="utf-8" autocomplete="off">
<input type="hidden" name="c" value="{category}">
<input type="hidden" name="p" value="{points}">
<div>Team hash:<input name="t" size="8"></div>
<div>Answer:<input name="a" id="answer" size="20"></div>
<input type="submit" value="submit">
</form>
</section>
<address>Puzzle by <span class="authors" data-handle="{authors}">{authors}</span></address>
</body>
</html>'''.format(
category=category,
points=points,
body=puzzle.html_body(),
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.mb" % categoryname)
logging.info("Building {} from {}".format(zipfilename, categorydir))
if os.path.exists(zipfilename):
# open and gather some state
existing = zipfile.ZipFile(zipfilename, 'r')
try:
category_seed = existing.open(seedfn).read().strip()
except:
pass
existing.close()
logging.debug("Using PRNG seed {}".format(category_seed))
zf.writestr(seedfn, category_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(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
puzzledir = os.path.join('content', puzzlehash)
files = []
for fn, f in puzzle.files.items():
if f.visible:
files.append(fn)
payload = f.stream.read()
zf.writestr(os.path.join(puzzledir, fn), payload)
puzzledict = {
'authors': puzzle.authors,
'hashes': puzzle.hashes(),
'files': files,
'body': puzzle.html_body(),
}
puzzlejson = json.dumps(puzzledict)
zf.writestr(os.path.join(puzzledir, 'puzzle.json'), puzzlejson)
generate_html(zf, puzzle, puzzledir, categoryname, puzzle.points, puzzle.get_authors(), files)
write_kv_pairs(zf, 'map.txt', mapping)
write_kv_pairs(zf, 'answers.txt', answers)
write_kv_pairs(zf, 'summaries.txt', summary)
# clean up
zf.close()
shutil.move(zipfileraw.name, zipfilename)
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Build a category package')
parser.add_argument('outdir', help='Output directory')
parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
args = parser.parse_args()
logging.basicConfig(level=logging.DEBUG)
for categorydir in args.categorydirs:
build_category(categorydir, args.outdir)

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +0,0 @@
[flake8]
# flake8 is an automated code formatting pedant.
# Use it, please.
#
# python3 -m flake8 .
#
ignore = E501
exclude = .git

View File

@ -1,19 +0,0 @@
#!/bin/sh
set +e
url='https://rawgit.com/first20hours/google-10000-english/master/google-10000-english-no-swears.txt'
getter="curl -sL"
fn="answer_words.txt"
filterer() {
grep '......*'
}
if ! curl -h >/dev/null 2>/dev/null; then
getter="wget -q -O -"
elif ! wget -h >/dev/null 2>/dev/null; then
echo "[!] I don't know how to download. I need curl or wget."
fi
$getter "${url}" | filterer > ${fn}.tmp \
&& mv -f ${fn}.tmp ${fn}

View File

@ -1,206 +0,0 @@
#!/usr/bin/python3
"""A validator for MOTH puzzles"""
import logging
import os
import os.path
import moth
# pylint: disable=len-as-condition, line-too-long
DEFAULT_REQUIRED_FIELDS = ["answers", "authors", "summary"]
LOGGER = logging.getLogger(__name__)
class MothValidationError(Exception):
"""An exception for encapsulating MOTH puzzle validation errors"""
class MothValidator:
"""A class which validates MOTH categories"""
def __init__(self, fields):
self.required_fields = fields
self.results = {"category": {}, "checks": []}
def validate(self, categorydir, only_errors=False):
"""Run validation checks against a category"""
LOGGER.debug("Loading category from %s", categorydir)
try:
category = moth.Category(categorydir, 0)
except NotADirectoryError:
return
LOGGER.debug("Found %d puzzles in %s", len(category.pointvals()), categorydir)
self.results["category"][categorydir] = {
"puzzles": {},
"name": os.path.basename(categorydir.strip(os.sep)),
}
curr_category = self.results["category"][categorydir]
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
if check_function_name not in self.results["checks"]:
self.results["checks"].append(check_function_name)
for puzzle in category:
LOGGER.info("Processing %s: %s", categorydir, puzzle.points)
curr_category["puzzles"][puzzle.points] = {}
curr_puzzle = curr_category["puzzles"][puzzle.points]
curr_puzzle["failures"] = []
for check_function_name in [x for x in dir(self) if x.startswith("check_") and callable(getattr(self, x))]:
check_function = getattr(self, check_function_name)
LOGGER.debug("Running %s on %d", check_function_name, puzzle.points)
try:
check_function(puzzle)
except MothValidationError as ex:
curr_puzzle["failures"].append(str(ex))
if only_errors and len(curr_puzzle["failures"]) == 0:
del curr_category["puzzles"][puzzle.points]
def check_fields(self, puzzle):
"""Check if the puzzle has the requested fields"""
for field in self.required_fields:
if not hasattr(puzzle, field):
raise MothValidationError("Missing field %s" % (field,))
@staticmethod
def check_has_answers(puzzle):
"""Check if the puzle has answers defined"""
if len(puzzle.answers) == 0:
raise MothValidationError("No answers provided")
@staticmethod
def check_unique_answers(puzzle):
"""Check if puzzle answers are unique"""
known_answers = []
duplicate_answers = []
for answer in puzzle.answers:
if answer not in known_answers:
known_answers.append(answer)
else:
duplicate_answers.append(answer)
if len(duplicate_answers) > 0:
raise MothValidationError("Duplicate answer(s) %s" % ", ".join(duplicate_answers))
@staticmethod
def check_has_authors(puzzle):
"""Check if the puzzle has authors defined"""
if len(puzzle.authors) == 0:
raise MothValidationError("No authors provided")
@staticmethod
def check_unique_authors(puzzle):
"""Check if puzzle authors are unique"""
known_authors = []
duplicate_authors = []
for author in puzzle.authors:
if author not in known_authors:
known_authors.append(author)
else:
duplicate_authors.append(author)
if len(duplicate_authors) > 0:
raise MothValidationError("Duplicate author(s) %s" % ", ".join(duplicate_authors))
@staticmethod
def check_has_summary(puzzle):
"""Check if the puzzle has a summary"""
if puzzle.summary is None:
raise MothValidationError("Summary has not been provided")
@staticmethod
def check_has_body(puzzle):
"""Check if the puzzle has a body defined"""
old_pos = puzzle.body.tell()
puzzle.body.seek(0)
if len(puzzle.body.read()) == 0:
puzzle.body.seek(old_pos)
raise MothValidationError("No body provided")
puzzle.body.seek(old_pos)
# Leaving this as a placeholder until KSAs are formally supported
@staticmethod
def check_ksa_format(puzzle):
"""Check if KSAs are properly formatted"""
if hasattr(puzzle, "ksa"):
for ksa in puzzle.ksa:
if not ksa.startswith("K"):
raise MothValidationError("Unrecognized KSA format")
def output_json(data):
"""Output results in JSON format"""
import json
print(json.dumps(data))
def output_text(data):
"""Output results in a text-based tabular format"""
longest_category = max([len(y["name"]) for x, y in data["category"].items()])
longest_category = max([longest_category, len("Category")])
longest_failure = len("Failures")
for category_data in data["category"].values():
for points, puzzle_data in category_data["puzzles"].items():
longest_failure = max([longest_failure, len(", ".join(puzzle_data["failures"]))])
formatstr = "| %%%ds | %%6s | %%%ds |" % (longest_category, longest_failure)
headerfmt = formatstr % ("Category", "Points", "Failures")
print(headerfmt)
for cat_data in data["category"].values():
for points, puzzle_data in sorted(cat_data["puzzles"].items()):
print(formatstr % (cat_data["name"], points, ", ".join([str(x) for x in puzzle_data["failures"]])))
def main():
"""Main function"""
# pylint: disable=invalid-name
import argparse
LOGGER.addHandler(logging.StreamHandler())
parser = argparse.ArgumentParser(description="Validate MOTH puzzle field compliance")
parser.add_argument("category", nargs="+", help="Categories to validate")
parser.add_argument("-f", "--fields", help="Comma-separated list of fields to check for", default=",".join(DEFAULT_REQUIRED_FIELDS))
parser.add_argument("-o", "--output-format", choices=["text", "json"], default="text", help="Output format (default: text)")
parser.add_argument("-e", "--only-errors", action="store_true", default=False, help="Only output errors")
parser.add_argument("-v", "--verbose", action="count", default=0, help="Increase verbosity of output, repeat to increase")
args = parser.parse_args()
if args.verbose == 1:
LOGGER.setLevel("INFO")
elif args.verbose > 1:
LOGGER.setLevel("DEBUG")
LOGGER.debug(args)
validator = MothValidator(args.fields.split(","))
for category in args.category:
LOGGER.info("Validating %s", category)
validator.validate(category, only_errors=args.only_errors)
if args.output_format == "text":
output_text(validator.results)
elif args.output_format == "json":
output_json(validator.results)
if __name__ == "__main__":
main()