Skip to content

Commit 624307b

Browse files
authored
Merge pull request #36 from bitlux/dev
ipieces: Initial commit
2 parents a5fb5ce + 724cf3a commit 624307b

11 files changed

+479
-2
lines changed

go.mod

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
module github.com/bitlux/caches
22

3-
go 1.23
3+
go 1.23.4
44

55
require github.com/keep94/sqroot/v3 v3.7.2
66

7-
require github.com/keep94/consume2 v0.7.0 // indirect
7+
require (
8+
github.com/bitlux/vpnapi v0.0.0-20250207215125-f066bb2314a4 // indirect
9+
github.com/keep94/consume2 v0.7.0 // indirect
10+
)

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/bitlux/vpnapi v0.0.0-20250207215125-f066bb2314a4 h1:zYVWlKFsyl3tMu3Ak9es+EMW2DUe9nqPQaOQNBtu2XQ=
2+
github.com/bitlux/vpnapi v0.0.0-20250207215125-f066bb2314a4/go.mod h1:ou6ccQPRIv8uzPNeLaRLxnwd2felkT30fwGnJtbVYCg=
13
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
24
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
35
github.com/keep94/consume2 v0.7.0 h1:JbS/XxmPbHgEG+1pvGEGc192nCOEx+S/DmJBZz9fkvQ=

ipieces/README.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ipieces
2+
3+
ipieces is a Go package used to create Geocaching puzzles such as [GCB1ZXB](https://coord.info/GCB1ZXB).
4+
5+
## Documentation
6+
7+
Available at https://pkg.go.dev/github/bitlux/caches/ipieces.
8+
9+
## Deployment instructions
10+
11+
I deploy on [Google Cloud Run](https://cloud.google.com/run). To do that, you must first sign
12+
into the [Google Cloud console](https://console.cloud.google.com/),
13+
[create a project](https://cloud.google.com/resource-manager/docs/creating-managing-projects), and have
14+
[`gcloud`](https://cloud.google.com/sdk) installed.
15+
16+
To deploy, `cd` to the directory with your `main.go` file and run:
17+
```
18+
gcloud run deploy --source . <project> [--allow-unauthenticated]
19+
```
20+
21+
## Testing
22+
23+
To set the IP address of the request to `foo`, use:
24+
```
25+
curl -H "<backdoor>: foo" localhost:8080/text
26+
```
27+
where `<backdoor>` is the `Puzzle.Backdoor` string you set.
28+
29+
## Contact / Support
30+
31+
I welcome issues and pull requests on GitHub and messages and email on
32+
[geocaching.com](https://www.geocaching.com/profile/?u=bitlux).

ipieces/ipieces.go

+269
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
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">&nbsp;</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+
}

ipieces/static/error.html

+18
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<!DOCTYPE html>
2+
<html>
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>IPieces of the puzzle</title>
7+
<link rel="shortcut icon" href="https://bitlux.github.io/favicon.ico" />
8+
<link rel="stylesheet" href="style.css" />
9+
</head>
10+
11+
<body>
12+
<div>
13+
Sorry, an error occurred. Please contact
14+
<a href="https://www.geocaching.com/p/?guid=bd2601d3-4383-4c37-93bc-f1d99d9a09b6">bitlux</a> if this persists.
15+
</div>
16+
</body>
17+
18+
</html>

ipieces/static/index.tmpl

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>IPieces of the puzzle</title>
6+
<link rel="shortcut icon" href="https://bitlux.github.io/favicon.ico" />
7+
<link rel="stylesheet" href="style.css" />
8+
</head>
9+
<body>
10+
<div>
11+
Your IP address is<br>
12+
<span class="mono">{{.IP}}</span>
13+
{{ comment .IP .Hash .Index }}
14+
</div>
15+
<div>
16+
Your piece of the puzzle is<br>
17+
<span class="mono">
18+
N<span class="small"> </span>{{index .Digits 0 | format}}{{index .Digits 1 | format}}&deg;<span class="small"> </span>{{index .Digits 2 | format}}{{index .Digits 3 | format}}.{{index .Digits 4 | format}}{{index .Digits 5 | format}}{{index .Digits 6 | format}}
19+
W<span class="small"> </span>{{index .Digits 7 | format}}{{index .Digits 8 | format}}{{index .Digits 9 | format}}&deg;<span class="small"> </span>{{index .Digits 10 | format}}{{index .Digits 11 | format}}.{{index .Digits 12 | format}}{{index .Digits 13 | format}}{{index .Digits 14 | format}}
20+
</span>
21+
</div>
22+
<div>
23+
<span class="linkback">
24+
<a class="coord" href="http://coord.info/{{.GCCode}}">{{.GCCode}}</a>
25+
</span>
26+
</div>
27+
</body>
28+
</html>

ipieces/static/lights.gif

4.42 KB
Loading

ipieces/static/rate-limited.html

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<!DOCTYPE html>
2+
<html>
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<title>IPieces of the puzzle</title>
7+
<link rel="shortcut icon" href="https://bitlux.github.io/favicon.ico" />
8+
<link rel="stylesheet" href="style.css" />
9+
</head>
10+
11+
<body class="yellow">
12+
<div>
13+
You have been rate-limited. Please slow down.
14+
</div>
15+
</body>
16+
17+
</html>

0 commit comments

Comments
 (0)