diff --git a/README.md b/README.md
index c167201..42d127b 100644
--- a/README.md
+++ b/README.md
@@ -29,10 +29,16 @@ for details.
Getting Started Developing
-------------------------------
- $ git clone $your_puzzles_repo puzzles
+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
-Then point a web browser at http://localhost:8080/
+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
diff --git a/example-puzzles/example/1/puzzle.moth b/example-puzzles/example/1/puzzle.moth
index 294c99c..3b9fc1b 100644
--- a/example-puzzles/example/1/puzzle.moth
+++ b/example-puzzles/example/1/puzzle.moth
@@ -13,17 +13,17 @@ Puzzle categories are laid out on the filesystem:
├─3
│ └─puzzle.py
├─10
- │ └─puzzle.py
+ │ └─puzzle.moth
└─100
- └─puzzle.moth
+ └─puzzle.py
In this example,
there are puzzles with point values 1, 2, 3, 10, and 100.
-Puzzles 1, 2, and 100 are "static" puzzles:
+Puzzles 1, 2, and 10 are "static" puzzles:
their content was written by hand.
-Puzzles 3 and 10 are "dynamic" puzzles:
+Puzzles 3 and 100 are "dynamic" puzzles:
they are generated from a Python module.
To create a static puzzle, all you must have is a
diff --git a/example-puzzles/example/10/puzzle.moth b/example-puzzles/example/10/puzzle.moth
new file mode 100644
index 0000000..3d0e943
--- /dev/null
+++ b/example-puzzles/example/10/puzzle.moth
@@ -0,0 +1,90 @@
+Author: neale
+Summary: Making excellent puzzles
+Answer: moo
+
+Making Excellent Puzzles
+====================
+
+We (the dirtbags) use some core principles.
+If you're writing puzzles for our events,
+we expect you to bear these in mind.
+
+If you're doing your own event,
+you might still be interested in these:
+it's the result of years of doing these events
+and thinking hard about how to make the best possible experience for participants.
+
+
+Respect The Player
+---------------------------
+
+Start with the assumption that the person playing your puzzle is intelligent,
+can learn,
+and can figure out whatever you ask of them.
+If you observe in trial runs that they can't figure it out,
+that indicates a problem on your end,
+not theirs.
+
+
+Expect Creative Mayhem
+------------------------------------
+
+This is a hacking contest.
+People are going to be on the lookout for "ways around" your puzzles.
+This is something we want them to do:
+as a defender, they need to be always searching for holes in the defense.
+
+Here are some examples of "ways around".
+All of these have happened at prior events:
+
+* Logging into your appliance and changing the access password
+* Flooding your service off the network
+* Brute-force attacking your 2-letter answer
+* Photographing your printed list of answers while a team-mate chats with you
+* Writing a script to create hundreds of new accounts faster than you can remove them
+
+It's important to remember that they are here precisely to cause mayhem.
+If you set up a playground where this sort of mayhem ruins the experience for other players,
+we (the people running the event) can't penalize participants for doing what we've asked them to.
+Instead, we have to devalue points in your category to the point where nobody wants to bother playing it any more.
+
+
+Make The Work Easier than Guessing The Answer
+--------------------------------------------------------
+
+An important corrolary to expecting creative mayhem is that people are going to
+work hard to come up with creative ways to get points without doing the work you ask.
+
+It's okay to make your work about the same level of difficulty as guessing the answer.
+For instance, we use a few 4-byte answers.
+It would take about four hours to try submitting every possible answer,
+and if people want to spend their time coding up a brute-force attack,
+that is a constructive use of their event time and we're okay with it.
+Typically people give up on this line of attack when they realize
+how much time it's going to take
+(figuring this out is also a productive use of time),
+but once in a while people will go ahead.
+
+This means your answers need to be resistant to guessing and brute force attacks.
+True/false questions are, needless to say, going to result in us laughing at you.
+
+
+There's More Than One Way To Do It
+----------------------------------------------------
+
+Don't make puzzles that require a specific solution strategy.
+For instance,
+we're not going to allow puzzles that require a certain technology
+(such as "what is the third item in the File menu on product X's interface?").
+
+Rather, if your goal is to highlight a technology,
+you should create puzzles that show off how great the product is for this application,
+compared to alternatives.
+Something like "mount this file system from 1982 and paste the contents of the file named `foo`"
+might be trivial in a certain product and take hours by other means.
+That's fine.
+That's what we want to see.
+
+----
+
+The answer for this page is `moo`
\ No newline at end of file
diff --git a/example-puzzles/example/100/puzzle.py b/example-puzzles/example/100/puzzle.py
new file mode 100755
index 0000000..6b29a25
--- /dev/null
+++ b/example-puzzles/example/100/puzzle.py
@@ -0,0 +1,25 @@
+#!/usr/bin/python3
+
+import io
+
+def make(puzzle):
+ puzzle.author = 'neale'
+ puzzle.summary = 'crazy stuff you can do with puzzle generation'
+
+ puzzle.body.write("## Crazy Things You Can Do With Puzzle Generation\n")
+ puzzle.body.write("\n")
+ puzzle.body.write("The source to this puzzle has some advanced examples of stuff you can do in Python.\n")
+ puzzle.body.write("\n")
+
+ # You can use any file-like object; even your own class that generates output.
+ f = io.BytesIO("This is some text! Isn't that fantastic?".encode('utf-8'))
+ puzzle.add_stream(f)
+
+ # We have debug logging
+ puzzle.log("You don't have to disable puzzle.log calls to move to production; the debug log is just ignored at build-time.")
+ puzzle.log("HTML is escaped, so you don't have to worry about that!")
+
+ puzzle.answers.append('coffee')
+ answer = puzzle.make_answer() # Generates a random answer, appending it to puzzle.answers too
+ puzzle.log("Answers: {}".format(puzzle.answers))
+
diff --git a/example-puzzles/example/2/puzzle.moth b/example-puzzles/example/2/puzzle.moth
index 61b63ec..d909c6c 100644
--- a/example-puzzles/example/2/puzzle.moth
+++ b/example-puzzles/example/2/puzzle.moth
@@ -1,5 +1,6 @@
Author: neale
Summary: Static puzzle resource files
+File: salad.jpg
Answer: salad
You can include additional resources in a static puzzle,
diff --git a/example-puzzles/example/3/puzzle.py b/example-puzzles/example/3/puzzle.py
index 39636f7..a5e93df 100644
--- a/example-puzzles/example/3/puzzle.py
+++ b/example-puzzles/example/3/puzzle.py
@@ -14,7 +14,14 @@ def make(puzzle):
puzzle.body.write("(Participants don't like it when puzzles and answers change.)\n")
puzzle.body.write("\n")
+ puzzle.add_file('salad.jpg')
+ puzzle.body.write("Here are some more pictures of salad:\n")
+ puzzle.body.write("")
+ puzzle.body.write("![salad](salad.jpg)")
+ puzzle.body.write("\n\n")
+
number = puzzle.rand.randint(20, 500)
puzzle.log("One is the loneliest number, but {} is the saddest number.".format(number))
- puzzle.body.write("The answer for this page is {}.\n".format(answer))
+ puzzle.body.write("The answer for this page is `{}`.\n".format(answer))
+
diff --git a/example-puzzles/example/3/salad.jpg b/example-puzzles/example/3/salad.jpg
new file mode 100644
index 0000000..cf2239e
Binary files /dev/null and b/example-puzzles/example/3/salad.jpg differ
diff --git a/install b/install
deleted file mode 100755
index 8b5f77b..0000000
--- a/install
+++ /dev/null
@@ -1,75 +0,0 @@
-#! /bin/sh
-
-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 () {
- target=$DESTDIR/$1
- if older $target $1; then
- echo "COPY $1"
- mkdir -p $(dirname $target)
- cp $1 $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
-}
-
-
-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 ls-files | while read fn; do
- case "$fn" in
- install|.*)
- ;;
- doc/*)
- ;;
- www/*)
- copy $fn
- ;;
- bin/*)
- copy $fn
- ;;
- *)
- echo "??? $fn"
- ;;
- esac
-done
diff --git a/package-puzzles b/package-puzzles
deleted file mode 100755
index f730a17..0000000
--- a/package-puzzles
+++ /dev/null
@@ -1,127 +0,0 @@
-#!/usr/bin/env python3
-
-import argparse
-import binascii
-import glob
-import hashlib
-import io
-import json
-import os
-import shutil
-import sys
-import zipfile
-
-import puzzles
-
-TMPFILE = "%s.tmp"
-
-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%s" % (key, val, os.linesep))
- else:
- filehandle.write("%s: %s%s" % (key, kv[key], os.linesep))
- filehandle.seek(0)
- ziphandle.writestr(filename, filehandle.read())
-
-if __name__ == '__main__':
- parser = argparse.ArgumentParser(description='Build a category package')
- parser.add_argument('categorydirs', nargs='+', help='Directory of category source')
- parser.add_argument('outdir', help='Output directory')
- args = parser.parse_args()
-
- for categorydir in args.categorydirs:
- puzzles_dict = {}
- secrets = {}
-
- categoryname = os.path.basename(categorydir.strip(os.sep))
-
- # create zipfile
- zipfilename = os.path.join(args.outdir, "%s.zip" % categoryname)
- if os.path.exists(zipfilename):
- # open and gather some state
- zf = zipfile.ZipFile(zipfilename, 'r')
- try:
- category_seed = zf.open(os.path.join(categoryname, "category_seed.txt")).read().strip()
- except:
- pass
- zf.close()
-
- zf = zipfile.ZipFile(TMPFILE % zipfilename, 'w')
- if 'category_seed' not in locals():
- category_seed = binascii.b2a_hex(os.urandom(20))
-
- # read in category details (will be pflarr in future)
- for categorypath in glob.glob(os.path.join(categorydir, "*", "puzzle.moth")):
- points = categorypath.split(os.sep)[-2] # directory before '/puzzle.moth'
- categorypath = os.path.dirname(categorypath)
- print(categorypath)
- try:
- points = int(points)
- except:
- print("Failed to identify points on: %s" % categorypath, file=sys.stderr)
- continue
- puzzle = puzzles.Puzzle(category_seed, categorypath)
- puzzles_dict[points] = puzzle
-
- # build mapping, answers, and summary
- mapping = {}
- answers = {}
- summary = {}
- for points in sorted(puzzles_dict):
- puzzle = puzzles_dict[points]
- hashmap = hashlib.sha1(category_seed)
- hashmap.update(str(points).encode('utf-8'))
- mapping[points] = hashmap.hexdigest()
- answers[points] = puzzle['answer']
- if len(puzzle['summary']) > 0:
- summary[points] = puzzle['summary']
-
- # write mapping, answers, summary, category_seed
- write_kv_pairs(zf, os.path.join(categoryname, 'map.txt'), mapping)
- write_kv_pairs(zf, os.path.join(categoryname, 'answers.txt'), answers)
- write_kv_pairs(zf, os.path.join(categoryname, 'summary.txt'), summary)
- zf.writestr(os.path.join(categoryname, "category_seed.txt"), category_seed)
-
- # write out puzzles
- for points in sorted(puzzles_dict):
- puzzle = puzzles_dict[points]
- puzzledir = os.path.join(categoryname, 'content', mapping[points])
- puzzledict = {
- 'author': puzzle.author,
- 'hashes': puzzle.hashes(),
- 'files': [f.name for f in puzzle.files if f.visible],
- 'body': puzzle.html_body(),
- }
- secretsdict = {
- 'summary': puzzle.summary,
- 'answers': puzzle.answers,
- }
-
- # write associated files
- assoc_files = []
- for fobj in puzzle['files']:
- if fobj.visible == True:
- assoc_files.append(fobj.name)
- zf.writestr(os.path.join(puzzledir, fobj.name), \
- fobj.handle.read())
-
- if len(assoc_files) > 0:
- puzzlejson["associated_files"] = assoc_files
-
- # non-optimal writing of file-like objects, but it works
- zf.writestr(os.path.join(puzzledir, 'puzzle.json'), \
- json.dumps(puzzlejson))
-
- # clean up
- zf.close()
- shutil.move(TMPFILE % zipfilename, zipfilename)
-#vim:py
diff --git a/tools/build-puzzles.py b/tools/build-puzzles.py
deleted file mode 100755
index 8138e83..0000000
--- a/tools/build-puzzles.py
+++ /dev/null
@@ -1,84 +0,0 @@
-#!/usr/bin/python3
-
-import hmac
-import base64
-import argparse
-import glob
-import json
-import os
-import markdown
-import random
-
-messageChars = b'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
-
-def djb2hash(buf):
- h = 5381
- for c in buf:
- h = ((h * 33) + c) & 0xffffffff
- return h
-
-class Puzzle:
- def __init__(self, stream):
- self.message = bytes(random.choice(messageChars) for i in range(20))
- self.fields = {}
- self.answers = []
- self.hashes = []
-
- body = []
- header = True
- for line in stream:
- if header:
- line = line.strip()
- if not line.strip():
- header = False
- continue
- key, val = line.split(':', 1)
- key = key.lower()
- val = val.strip()
- self._add_field(key, val)
- else:
- body.append(line)
- self.body = ''.join(body)
-
- def _add_field(self, key, val):
- if key == 'answer':
- h = djb2hash(val.encode('utf8'))
- self.answers.append(val)
- self.hashes.append(h)
- else:
- self.fields[key] = val
-
- def publish(self):
- obj = {
- 'author': self.fields['author'],
- 'hashes': self.hashes,
- 'body': markdown.markdown(self.body),
- }
- return obj
-
- def secrets(self):
- obj = {
- 'answers': self.answers,
- 'summary': self.fields['summary'],
- }
- return obj
-
-
-parser = argparse.ArgumentParser(description='Build a puzzle category')
-parser.add_argument('puzzledir', nargs='+', help='Directory of puzzle source')
-args = parser.parse_args()
-
-for puzzledir in args.puzzledir:
- puzzles = {}
- secrets = {}
- for puzzlePath in glob.glob(os.path.join(puzzledir, "*.moth")):
- filename = os.path.basename(puzzlePath)
- points, ext = os.path.splitext(filename)
- points = int(points)
- puzzle = Puzzle(open(puzzlePath))
- puzzles[points] = puzzle
-
- for points in sorted(puzzles):
- puzzle = puzzles[points]
- print(puzzle.secrets())
-
diff --git a/tools/devel-server.py b/tools/devel-server.py
index 5c7d879..365980a 100755
--- a/tools/devel-server.py
+++ b/tools/devel-server.py
@@ -1,5 +1,10 @@
#!/usr/bin/env python3
+# To pick up any changes to this file without restarting anything:
+# while true; do ./tools/devel-server.py --once; done
+# It's kludgy, but it gets the job done.
+# Feel free to make it suck less, for example using the `tcpserver` program.
+
import glob
import html
import http.server
@@ -26,7 +31,6 @@ sys.dont_write_bytecode = True
# XXX: This will eventually cause a problem. Do something more clever here.
seed = 1
-
def page(title, body):
return """
@@ -58,6 +62,8 @@ class ThreadingServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
class MothHandler(http.server.SimpleHTTPRequestHandler):
+ puzzles_dir = "puzzles"
+
def handle_one_request(self):
try:
super().handle_one_request()
@@ -122,7 +128,7 @@ you are a fool.
puzzle = None
try:
- fpath = os.path.join("puzzles", parts[2])
+ fpath = os.path.join(self.puzzles_dir, parts[2])
points = int(parts[3])
except:
pass
@@ -135,15 +141,16 @@ you are a fool.
if not cat:
title = "Puzzle Categories"
body.write("