diff --git a/README.md b/README.md index 3c73a6d..9b62c5a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ which in the past has been called "HACK", "Queen Of The Hill", "Cyber Spark", -and "Cyber Fire". +"Cyber Fire", +"Cyber Fire Puzzles", +and "Cyber Fire Foundry". Information about these events is at http://dirtbags.net/contest/ @@ -48,75 +50,110 @@ More on how the devel sever works in Running A Production Server ==================== -XXX: Update this +Run `dirtbags/moth` (Docker) or `mothd` (native). -How to install it --------------------- +`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/`. -It's made to be virtualized, -so you can run multiple contests at once if you want. -If you were to want to run it out of `/srv/moth`, -do the following: - - $ mothinst=/srv/moth/mycontest - $ mkdir -p $mothinst - $ install.sh $mothinst - - Yay, you've got it installed. - -How to run a contest ------------------------- - -`mothd` runs through every contest on your server every few seconds, -and does housekeeping tasks that make the contest "run". -If you stop `mothd`, people can still play the contest, -but their points won't show up on the scoreboard. - -A handy side-effect here is that if you need to meddle with the points log, -you can just kill `mothd`, -do you work, -then bring `mothd` back up. - - $ cp src/mothd /srv/moth - $ /srv/moth/mothd - -You're also going to need a web server if you want people to be able to play. +You can override any path with an option, +run `mothd -help` for usage. -How to run a web server ------------------------------ - -Your web server needs to serve up files for you contest out of -`$mothinst/www`. - -If you don't want to fuss around with setting up a full-featured web server, -you can use `tcpserver` and `eris`, -which is what we use to run our contests. - -`tcpserver` is part of the `uscpi-tcp` package in Ubuntu. -You can also use busybox's `tcpsvd` (my preference, but a PITA on Ubuntu). - -`eris` can be obtained at https://woozle.org/neale/g.cgi/net/eris/about/ - - $ mothinst=/srv/moth/mycontest - $ $mothinst/bin/httpd +State Directory +=============== -Installing Puzzle Categories ------------------------------------- +Pausing scoring +------------------- -Puzzle categories are distributed in a different way than the server. -After setting up (see above), just run +Create the file `state/disabled` +to pause scoring, +and remove it to resume. +You can use the Unix `touch` command to create the file: - $ /srv/koth/mycontest/bin/install-category /path/to/my/category - + touch state/disabled -Permissions ----------------- +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. -It's up to you not to be a bonehead about permissions. -Install sets it so the web user on your system can write to the files it needs to, -but if you're using Apache, -it plays games with user IDs when running CGI. -You're going to have to figure out how to configure your preferred web server. +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. + + +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! + + + +Resources Directory +=================== + + +Making it look better +------------------- + +`mothd` provides some built-in HTML for rendering a complete contest, +but it's rather bland. +You can override everything by dropping a new file into the `resources` directory: + +* `basic.css` is used by the default HTML to pretty things up +* `index.html` is the landing page, which asks to register a team +* `puzzle.html` and `puzzle.js` render a puzzle from JSON +* `puzzle-list.html` and `puzzle-list.js` render the list of active puzzles from JSON +* `scoreboard.html` and `scoreboard.js` render the current scoreboard from JSON +* Any other file in the `resources` directory will be served up, too. + +If you don't want to read through the source code, I don't blame you. +Run a `mothd` server and pull the various static resources into your `resources` directory, +and then you can start hacking away at them. + + +Changing scoring +-------------- + +Believe it or not, +scoring is determined client-side in the scoreboard, +from the points log. +You can hack in whatever algorithm you like. + +If you do hack in a new algorithm, +please be a dear and email it to us. +We'd love to see it! + diff --git a/src/award.go b/src/award.go index e975599..f8cf653 100644 --- a/src/award.go +++ b/src/award.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "strconv" "strings" @@ -8,39 +9,62 @@ import ( ) type Award struct { - When time.Time - TeamId string + When time.Time + TeamId string Category string - Points int + Points int } func (a *Award) String() string { return fmt.Sprintf("%d %s %s %d", a.When.Unix(), a.TeamId, a.Category, a.Points) } +func (a *Award) MarshalJSON() ([]byte, error) { + if a == nil { + return []byte("null"), nil + } + jTeamId, err := json.Marshal(a.TeamId) + if err != nil { + return nil, err + } + jCategory, err := json.Marshal(a.Category) + if err != nil { + return nil, err + } + ret := fmt.Sprintf( + "[%d,%s,%s,%d]", + a.When.Unix(), + jTeamId, + jCategory, + a.Points, + ) + return []byte(ret), nil +} + func ParseAward(s string) (*Award, error) { ret := Award{} - + + s = strings.Trim(s, " \t\n") + parts := strings.SplitN(s, " ", 5) if len(parts) < 4 { return nil, fmt.Errorf("Malformed award string") } - + whenEpoch, err := strconv.ParseInt(parts[0], 10, 64) - if (err != nil) { + if err != nil { return nil, fmt.Errorf("Malformed timestamp: %s", parts[0]) } ret.When = time.Unix(whenEpoch, 0) - + ret.TeamId = parts[1] ret.Category = parts[2] - + points, err := strconv.Atoi(parts[3]) - if (err != nil) { - return nil, fmt.Errorf("Malformed Points: %s", parts[3]) + if err != nil { + return nil, fmt.Errorf("Malformed Points: %s: %v", parts[3], err) } ret.Points = points return &ret, nil } - diff --git a/src/award_test.go b/src/award_test.go index ba3ff06..2875557 100644 --- a/src/award_test.go +++ b/src/award_test.go @@ -20,11 +20,11 @@ func TestAward(t *testing.T) { if a.Points != 1 { t.Error("Points parsed wrong") } - + if a.String() != entry { t.Error("String conversion wonky") } - + if _, err := ParseAward("bad bad bad 1"); err == nil { t.Error("Not throwing error on bad timestamp") } diff --git a/src/handlers.go b/src/handlers.go index f3b60ea..fa508c2 100644 --- a/src/handlers.go +++ b/src/handlers.go @@ -1,65 +1,61 @@ package main import ( + "bufio" + "encoding/json" "fmt" + "log" "net/http" "os" "regexp" - "strings" "strconv" - "io" - "log" - "bufio" + "strings" ) -// anchoredSearch looks for needle in r, -// skipping the first skip space-delimited words -func anchoredSearch(r io.Reader, needle string, skip int) bool { - scanner := bufio.NewScanner(r) - for scanner.Scan() { - line := scanner.Text() - parts := strings.SplitN(line, " ", skip+1) - if (len(parts) > skip) && (parts[skip] == needle) { - return true - } +func respond(w http.ResponseWriter, req *http.Request, status Status, short string, description string) { + // This is a kludge. Do proper parsing when this causes problems. + accept := req.Header.Get("Accept") + if strings.Contains(accept, "application/json") { + ShowJSend(w, status, short, description) + } else { + ShowHtml(w, status, short, description) } - - return false } -func anchoredSearchFile(filename string, needle string, skip int) bool { - r, err := os.Open(filename) - if err != nil { - return false - } - defer r.Close() - - return anchoredSearch(r, needle, skip) -} - - func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { - teamname := req.FormValue("n") - teamid := req.FormValue("h") - - if matched, _ := regexp.MatchString("[^0-9a-z]", teamid); matched { - teamid = "" + teamname := req.FormValue("name") + teamid := req.FormValue("id") + + // Keep foolish operators from shooting themselves in the foot + // You would have to add a pathname to your list of Team IDs to open this vulnerability, + // but I have learned not to overestimate people. + if strings.Contains(teamid, "../") { + teamid = "rodney" } - + if (teamid == "") || (teamname == "") { - showPage(w, "Invalid Entry", "Oops! Are you sure you got that right?") + respond( + w, req, Fail, + "Invalid Entry", + "Either `id` or `name` was missing from this request.", + ) return } - - if ! anchoredSearchFile(ctx.StatePath("teamids.txt"), teamid, 0) { - showPage(w, "Invalid Team ID", "I don't have a record of that team ID. Maybe you used capital letters accidentally?") + + if !anchoredSearchFile(ctx.StatePath("teamids.txt"), teamid, 0) { + respond( + w, req, Fail, + "Invalid Team ID", + "I don't have a record of that team ID. Maybe you used capital letters accidentally?", + ) return } - - f, err := os.OpenFile(ctx.StatePath(teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + + f, err := os.OpenFile(ctx.StatePath("teams", teamid), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) if err != nil { - showPage( - w, + log.Print(err) + respond( + w, req, Fail, "Registration failed", "Unable to register. Perhaps a teammate has already registered?", ) @@ -67,7 +63,11 @@ func (ctx Instance) registerHandler(w http.ResponseWriter, req *http.Request) { } defer f.Close() fmt.Fprintln(f, teamname) - showPage(w, "Success", "Okay, your team has been named and you may begin using your team ID!") + respond( + w, req, Success, + "Team registered", + "Okay, your team has been named and you may begin using your team ID!", + ) } func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { @@ -75,8 +75,12 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { token := req.FormValue("k") // Check answer - if ! anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 0) { - showPage(w, "Unrecognized token", "I don't recognize that token. Did you type in the whole thing?") + if !anchoredSearchFile(ctx.StatePath("tokens.txt"), token, 0) { + respond( + w, req, Fail, + "Unrecognized token", + "I don't recognize that token. Did you type in the whole thing?", + ) return } @@ -95,17 +99,29 @@ func (ctx Instance) tokenHandler(w http.ResponseWriter, req *http.Request) { if matched, _ := regexp.MatchString("^[A-Za-z0-9_-]", category); matched { category = "" } - + if (category == "") || (points == 0) { - showPage(w, "Unrecognized token", "Something doesn't look right about that token") + respond( + w, req, Fail, + "Unrecognized token", + "Something doesn't look right about that token", + ) return } - + if err := ctx.AwardPoints(teamid, category, points); err != nil { - showPage(w, "Error awarding points", err.Error()) + respond( + w, req, Fail, + "Error awarding points", + err.Error(), + ) return } - showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) + respond( + w, req, Success, + "Points awarded", + fmt.Sprintf("%d points for %s!", points, teamid), + ) } func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { @@ -118,83 +134,121 @@ func (ctx Instance) answerHandler(w http.ResponseWriter, req *http.Request) { if err != nil { points = 0 } - + catmb, ok := ctx.Categories[category] - if ! ok { - showPage(w, "Category does not exist", "The specified category does not exist. Sorry!") + if !ok { + respond( + w, req, Fail, + "Category does not exist", + "The requested category does not exist. Sorry!", + ) return } // Get the answers haystack, err := catmb.Open("answers.txt") if err != nil { - showPage(w, "Answers do not exist", - "Please tell the contest people that the mothball for this category has no answers.txt in it!") + respond( + w, req, Error, + "Answers do not exist", + "Please tell the contest people that the mothball for this category has no answers.txt in it!", + ) return } defer haystack.Close() - + // Look for the answer needle := fmt.Sprintf("%d %s", points, answer) - if ! anchoredSearch(haystack, needle, 0) { - showPage(w, "Wrong answer", err.Error()) + if !anchoredSearch(haystack, needle, 0) { + respond( + w, req, Fail, + "Wrong answer", + err.Error(), + ) return } if err := ctx.AwardPoints(teamid, category, points); err != nil { - showPage(w, "Error awarding points", err.Error()) - return - } - showPage(w, "Points awarded", fmt.Sprintf("%d points for %s!", points, teamid)) -} - -func (ctx Instance) puzzlesHandler(w http.ResponseWriter, req *http.Request) { - puzzles := map[string][]interface{}{} - // v := map[string][]interface{}{"Moo": {1, "0177f85ae895a33e2e7c5030c3dc484e8173e55c"}} - // j, _ := json.Marshal(v) - - for _, category := range ctx.Categories { - log.Print(puzzles, category) - } -} - -func (ctx Instance) pointsHandler(w http.ResponseWriter, req *http.Request) { - -} - -// staticHandler serves up static files. -func (ctx Instance) rootHandler(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/" { - showPage( - w, - "Welcome", - ` -
+ If someone on your team has already registered, + proceed to the + puzzles overview. +
+ `, + ) +} + +func staticScoreboard(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Scoreboard", + "XXX: This would be the scoreboard", + ) +} + +func staticPuzzles(w http.ResponseWriter) { + ShowHtml( + w, Success, + "Puzzles", + "XXX: This would be the puzzles overview", + ) +} + +func tryServeFile(w http.ResponseWriter, req *http.Request, path string) bool { + f, err := os.Open(path) + if err != nil { + return false + } + defer f.Close() + + d, err := f.Stat() + if err != nil { + return false + } + + http.ServeContent(w, req, path, d.ModTime(), f) + return true +} + +func ServeStatic(w http.ResponseWriter, req *http.Request, resourcesDir string) { + path := req.URL.Path + if strings.Contains(path, "..") { + http.Error(w, "Invalid URL path", http.StatusBadRequest) + return + } + if path == "/" { + path = "/index.html" + } + + fpath := filepath.Join(resourcesDir, path) + if tryServeFile(w, req, fpath) { + return + } + + switch path { + case "/basic.css": + staticStylesheet(w) + case "/index.html": + staticIndex(w) + case "/scoreboard.html": + staticScoreboard(w) + case "/puzzles.html": + staticPuzzles(w) + default: + http.NotFound(w, req) + } +}