|
| 1 | +// Package ipieces allows users to create IP address-based Geocaching puzzles. |
| 2 | +// |
| 3 | +// To create a puzzle, you need to populate a [Puzzle] struct and call [Run] on it. |
| 4 | +// For example: |
| 5 | +// |
| 6 | +// package main |
| 7 | +// |
| 8 | +// import ( |
| 9 | +// "github.com/bitlux/caches/ipieces" |
| 10 | +// "github.com/bitlux/vpnapi" |
| 11 | +// ) |
| 12 | +// |
| 13 | +// func main() { |
| 14 | +// p := ipieces.Puzzle{ |
| 15 | +// Final: []ipieces.Digit{ |
| 16 | +// ipieces.Digit{Value: "3", Status: ipieces.VISIBLE} |
| 17 | +// ipieces.Digit{Value: "7", Status: ipieces.VISIBLE} |
| 18 | +// ipieces.Digit{Value: "2", Status: ipieces.VISIBLE} |
| 19 | +// ipieces.Digit{Value: "4", Status: ipieces.HIDDEN} |
| 20 | +// ipieces.Digit{Value: "0", Status: ipieces.HIDDEN} |
| 21 | +// ipieces.Digit{Value: "0", Status: ipieces.HIDDEN} |
| 22 | +// ipieces.Digit{Value: "0", Status: ipieces.HIDDEN} |
| 23 | +// ipieces.Digit{Value: "1", Status: ipieces.VISIBLE} |
| 24 | +// ipieces.Digit{Value: "2", Status: ipieces.VISIBLE} |
| 25 | +// ipieces.Digit{Value: "2", Status: ipieces.VISIBLE} |
| 26 | +// ipieces.Digit{Value: "0", Status: ipieces.VISIBLE} |
| 27 | +// ipieces.Digit{Value: "4", Status: ipieces.HIDDEN} |
| 28 | +// ipieces.Digit{Value: "0", Status: ipieces.HIDDEN} |
| 29 | +// ipieces.Digit{Value: "0", Status: ipieces.HIDDEN} |
| 30 | +// ipieces.Digit{Value: "0", Status: ipieces.HIDDEN} |
| 31 | +// }, |
| 32 | +// IndexFunc: func(b [sha256.Size]byte) int { |
| 33 | +// return int(b[sha256.Size-1]) % 8 |
| 34 | +// }, |
| 35 | +// // Setting Client is optional. |
| 36 | +// Client: vpnapi.New("YOUR-API-KEY-HERE"), |
| 37 | +// Backdoor: "topsecret", |
| 38 | +// GCCode: "GCB2PKC", |
| 39 | +// } |
| 40 | +// p.Run() |
| 41 | +// } |
| 42 | +// |
| 43 | +// [Run] creates two handlers: |
| 44 | +// - a text endpoint at `/text` which responds with a short plaintext page with the client's IP, |
| 45 | +// the computed index into the final coordinates, and the revealed coordinate, and |
| 46 | +// - a default endpoint, which serves any path other than `/text`, and responds with an HTML page. |
| 47 | +package ipieces |
| 48 | + |
| 49 | +import ( |
| 50 | + "crypto/sha256" |
| 51 | + "encoding/hex" |
| 52 | + "fmt" |
| 53 | + "html/template" |
| 54 | + "io" |
| 55 | + "log" |
| 56 | + "net/http" |
| 57 | + "net/netip" |
| 58 | + "os" |
| 59 | + "slices" |
| 60 | + |
| 61 | + "github.com/bitlux/vpnapi" |
| 62 | +) |
| 63 | + |
| 64 | +// TODO: All logging is printed to stdout. This is fine for Google Cloud Run, |
| 65 | +// but consider using log/slog. |
| 66 | + |
| 67 | +func init() { |
| 68 | + hostname, _ := os.Hostname() |
| 69 | + fmt.Printf("STARTING on %s, pid %d\n", hostname, os.Getpid()) |
| 70 | +} |
| 71 | + |
| 72 | +// VPNChecker determines whether a client's IP address belongs to a VPN or proxy. Its concrete |
| 73 | +// implementation is *github.com/bitlux/vpnapi.Client. In order to use vpnapi.Client, you must |
| 74 | +// first obtain an API key from http://vpnapi.io. |
| 75 | +type VPNChecker interface { |
| 76 | + Query(string) (*vpnapi.Response, error) |
| 77 | +} |
| 78 | + |
| 79 | +// Display controls how a Digit is displayed. |
| 80 | +type Display int |
| 81 | + |
| 82 | +const ( |
| 83 | + // Visible by default |
| 84 | + VISIBLE Display = iota |
| 85 | + // Hidden by default |
| 86 | + HIDDEN |
| 87 | + // Hidden by default, revealed in this rendering |
| 88 | + REVEALED |
| 89 | +) |
| 90 | + |
| 91 | +// A Digit is one of the digits that make up the coordinates of the final. |
| 92 | +type Digit struct { |
| 93 | + // Value is a single digit, in string form. |
| 94 | + Value string |
| 95 | + // Status is how this digit should be displayed: visible, hidden, or revealed. |
| 96 | + Status Display |
| 97 | +} |
| 98 | + |
| 99 | +func (d Digit) format() template.HTML { |
| 100 | + switch d.Status { |
| 101 | + case VISIBLE: |
| 102 | + return template.HTML(d.Value) |
| 103 | + case HIDDEN: |
| 104 | + return template.HTML(`<span class="underline"> </span>`) |
| 105 | + case REVEALED: |
| 106 | + return template.HTML(fmt.Sprintf(`<span class="red">%s</span>`, d.Value)) |
| 107 | + default: |
| 108 | + return template.HTML("ERROR") |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +type Puzzle struct { |
| 113 | + // Final is the full final coordinates of the puzzle. |
| 114 | + Final []Digit |
| 115 | + |
| 116 | + // IndexFunc determines which digit of Final is revealed. The return value must be less than the number |
| 117 | + // of hidden Digits, because it is used as an index into the hidden Digits in Final. |
| 118 | + IndexFunc func([sha256.Size]byte) int |
| 119 | + |
| 120 | + // Client determines how to handle requests from IP addresses that belong to VPNs or proxies. |
| 121 | + // If Client is nil, no VPN checking will be done. In order to do VPN checking, first obtain an |
| 122 | + // API key from http://vpnapi.io. Pass that key to github.com/bitlux/vpnapi.Client.New to create |
| 123 | + // a *vpnapi.Client, and set Client to that value. |
| 124 | + Client VPNChecker |
| 125 | + |
| 126 | + // Backdoor allows you to test how the server handles a specific IP address. Backdoor will be accepted |
| 127 | + // as an HTTP header name. The server will read the header value as the client's IP address. For |
| 128 | + // example, if Backdoor is "geocache" and the server is running on localhost:8080, the following |
| 129 | + // command will tell the server that the request is coming from IP 1.2.3.4: |
| 130 | + // curl -H "geocache: 1.2.3.4" localhost:8080 |
| 131 | + // Any string is a valid . |
| 132 | + Backdoor string |
| 133 | + |
| 134 | + // GCCode is used to link back to the puzzle on geocaching.com. |
| 135 | + GCCode string |
| 136 | + |
| 137 | + hiddenCount int |
| 138 | +} |
| 139 | + |
| 140 | +type data struct { |
| 141 | + IP string |
| 142 | + Hash string |
| 143 | + Digits []Digit |
| 144 | + Index int |
| 145 | + Revealed string |
| 146 | + GCCode string |
| 147 | +} |
| 148 | + |
| 149 | +func ipFromHeaders(h http.Header, backdoor string) string { |
| 150 | + if vals := h.Values(("X-Forwarded-For")); len(vals) > 1 { |
| 151 | + fmt.Println("X-Forwarded-For:", vals) |
| 152 | + } |
| 153 | + if ip := h.Get(backdoor); ip != "" { |
| 154 | + fmt.Println("Setting IP to", ip, "via header") |
| 155 | + return ip |
| 156 | + } |
| 157 | + return h.Get("X-Forwarded-For") |
| 158 | +} |
| 159 | + |
| 160 | +func (p Puzzle) dataFromReq(req *http.Request) (*data, error) { |
| 161 | + ip := ipFromHeaders(req.Header, p.Backdoor) |
| 162 | + if ip == "" { |
| 163 | + ap, err := netip.ParseAddrPort(req.RemoteAddr) |
| 164 | + if err != nil { |
| 165 | + return nil, fmt.Errorf("IP error: ParseAddrPort(%s) returned %v", req.RemoteAddr, err) |
| 166 | + } |
| 167 | + ip = ap.Addr().String() |
| 168 | + } |
| 169 | + |
| 170 | + sha := sha256.Sum256([]byte(ip)) |
| 171 | + d := &data{ |
| 172 | + IP: ip, |
| 173 | + Hash: hex.EncodeToString(sha[:]), |
| 174 | + Digits: slices.Clone(p.Final), |
| 175 | + Index: p.IndexFunc(sha), |
| 176 | + GCCode: p.GCCode, |
| 177 | + } |
| 178 | + d.flip() |
| 179 | + return d, nil |
| 180 | +} |
| 181 | + |
| 182 | +func (d *data) flip() { |
| 183 | + count := 0 |
| 184 | + for i := range d.Digits { |
| 185 | + if d.Digits[i].Status == HIDDEN { |
| 186 | + if count == d.Index { |
| 187 | + d.Digits[i].Status = REVEALED |
| 188 | + d.Revealed = d.Digits[i].Value |
| 189 | + return |
| 190 | + } |
| 191 | + count++ |
| 192 | + } |
| 193 | + } |
| 194 | +} |
| 195 | + |
| 196 | +func writeResponse(w http.ResponseWriter, code int, body string, format string, args ...any) { |
| 197 | + fmt.Printf(format, args...) |
| 198 | + w.WriteHeader(code) |
| 199 | + if _, err := io.WriteString(w, body); err != nil { |
| 200 | + fmt.Println("WriteString failed:", err) |
| 201 | + } |
| 202 | +} |
| 203 | + |
| 204 | +func (p Puzzle) handle(w http.ResponseWriter, req *http.Request, tmpl *template.Template) { |
| 205 | + d, err := p.dataFromReq(req) |
| 206 | + fmt.Printf("IP %s index %d\n", d.IP, d.Index) |
| 207 | + if err != nil { |
| 208 | + writeResponse(w, http.StatusInternalServerError, errorPage, "dataFromReq failed: %v\n", err) |
| 209 | + return |
| 210 | + } |
| 211 | + |
| 212 | + if p.Client != nil { |
| 213 | + resp, err := p.Client.Query(d.IP) |
| 214 | + if err != nil { |
| 215 | + if err == vpnapi.ErrRateLimited { |
| 216 | + writeResponse(w, http.StatusTooManyRequests, rateLimitPage, "rate limited: %v\n", err) |
| 217 | + return |
| 218 | + } |
| 219 | + // TODO: This fails closed. This may be too strict, especially if vpnapi.io is unreliable. |
| 220 | + writeResponse(w, http.StatusInternalServerError, errorPage, "Query failed: %v\n", err) |
| 221 | + return |
| 222 | + } |
| 223 | + |
| 224 | + if resp.Security.VPN || resp.Security.Proxy || resp.Security.Tor || resp.Security.Relay { |
| 225 | + writeResponse(w, http.StatusForbidden, vpnPage, "%t %t %t %t\n", resp.Security.VPN, resp.Security.Proxy, resp.Security.Tor, resp.Security.Relay) |
| 226 | + return |
| 227 | + } |
| 228 | + } |
| 229 | + |
| 230 | + if err := tmpl.Execute(w, d); err != nil { |
| 231 | + writeResponse(w, http.StatusInternalServerError, errorPage, "tmpl.Execute failed: %v\n", err) |
| 232 | + return |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +// Runs starts an HTTP server and blocks forever (or until a fatal error occurs). |
| 237 | +func (p Puzzle) Run() { |
| 238 | + http.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { |
| 239 | + p.handle(w, req, indexTmpl) |
| 240 | + }) |
| 241 | + http.HandleFunc("/text", func(w http.ResponseWriter, req *http.Request) { |
| 242 | + p.handle(w, req, textTmpl) |
| 243 | + }) |
| 244 | + http.HandleFunc("/style.css", func(w http.ResponseWriter, _ *http.Request) { |
| 245 | + w.Header().Set("Content-Type", "text/css; charset=utf-8") |
| 246 | + if _, err := io.WriteString(w, stylesheet); err != nil { |
| 247 | + fmt.Println("WriteString failed:", err) |
| 248 | + } |
| 249 | + }) |
| 250 | + http.HandleFunc("/lights.gif", func(w http.ResponseWriter, _ *http.Request) { |
| 251 | + if _, err := io.WriteString(w, lights); err != nil { |
| 252 | + fmt.Println("WriteString failed:", err) |
| 253 | + } |
| 254 | + }) |
| 255 | + |
| 256 | + for _, d := range p.Final { |
| 257 | + if d.Status == HIDDEN { |
| 258 | + p.hiddenCount++ |
| 259 | + } |
| 260 | + } |
| 261 | + |
| 262 | + // Google Cloud Run passes the port in the PORT environment variable. |
| 263 | + port := os.Getenv("PORT") |
| 264 | + if port == "" { |
| 265 | + port = "8080" |
| 266 | + } |
| 267 | + fmt.Println("Listening on port", port) |
| 268 | + log.Fatal(http.ListenAndServe(":8080", nil)) |
| 269 | +} |
0 commit comments