diff options
| author | Xe Iaso <me@xeiaso.net> | 2025-01-18 17:09:02 -0500 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2025-01-18 17:09:02 -0500 |
| commit | bcc7c0d28e8c225c730b5574718aa7d963c1cdea (patch) | |
| tree | 08b6abb8fa8009600a07ad2548a9b968ca0f7549 /cmd/anubis | |
| parent | b586be783c91f4d579bfa3907e8062f5c6ada739 (diff) | |
| download | x-bcc7c0d28e8c225c730b5574718aa7d963c1cdea.tar.xz x-bcc7c0d28e8c225c730b5574718aa7d963c1cdea.zip | |
cmd: add Anubis
Signed-off-by: Xe Iaso <me@xeiaso.net>
Diffstat (limited to 'cmd/anubis')
| -rw-r--r-- | cmd/anubis/README.md | 181 | ||||
| -rw-r--r-- | cmd/anubis/index.templ | 69 | ||||
| -rw-r--r-- | cmd/anubis/index_templ.go | 162 | ||||
| -rw-r--r-- | cmd/anubis/main.go | 354 | ||||
| -rw-r--r-- | cmd/anubis/static/img/happy.webp | bin | 0 -> 60572 bytes | |||
| -rw-r--r-- | cmd/anubis/static/img/pensive.webp | bin | 0 -> 49148 bytes | |||
| -rw-r--r-- | cmd/anubis/static/img/sad.webp | bin | 0 -> 50802 bytes | |||
| -rw-r--r-- | cmd/anubis/static/js/main.mjs | 47 | ||||
| -rw-r--r-- | cmd/anubis/static/js/proof-of-work.mjs | 65 | ||||
| -rw-r--r-- | cmd/anubis/static/robots.txt | 47 | ||||
| -rw-r--r-- | cmd/anubis/var/.gitignore | 2 |
11 files changed, 927 insertions, 0 deletions
diff --git a/cmd/anubis/README.md b/cmd/anubis/README.md new file mode 100644 index 0000000..b064524 --- /dev/null +++ b/cmd/anubis/README.md @@ -0,0 +1,181 @@ +# Anubis + +<center> +<img width=256 src="static/img/happy.webp" alt="A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up" /> +</center> + + + + + + + +Anubis [weighs the soul of your connection](https://en.wikipedia.org/wiki/Weighing_of_souls) using a sha256 proof-of-work challenge in order to protect upstream resources from scraper bots. + +Installing and using this will likely result in your website not being indexed by Google or other search engines. This is considered a feature of Anubis, not a bug. + +This is a bit of a nuclear response, but AI scraper bots scraping so aggressively have forced my hand. I hate that I have to do this, but this is what we get for the modern Internet because bots don't conform to standards like robots.txt, even when they claim to. + +In most cases, you should not need this and can probably get by using Cloudflare to protect a given origin. However, for circumstances where you can't or won't use Cloudflare, Anubis is there for you. + +## Support + +If you run into any issues running Anubis, please [open an issue](https://github.com/Xe/x/issues/new?template=Blank+issue) and tag it with the Anubis tag. Please include all the information I would need to diagnose your issue. + +For live chat, please join the [Patreon](https://patreon.com/cadey) and ask in the Patron discord in the channel `#anubis`. + +## How Anubis works + +Anubis uses a proof-of-work challenge to ensure that clients are using a modern browser and are able to calculate SHA-256 checksums. Anubis has a customizable difficulty for this proof-of-work challenge, but defaults to 5 leading zeroes. + +### Challenge presentation + +Anubis decides to present a challenge using this logic: + +- User-Agent contains `"Mozilla"` +- Request path is not in `/.well-known`, `/robots.txt`, or `/favicon.ico` +- Request path is not obviously an RSS feed (ends with `.rss`, `.xml`, or `.atom`) + +This should ensure that git clients, RSS readers, and other low-harm clients can get through without issue, but high-risk clients such as browsers and AI scraper bots will get blocked. + +### Proof of passing challenges + +When a client passes a challenge, Anubis sets an HTTP cookie named `"within.website-x-cmd-anubis-auth"` containing a signed [JWT](https://jwt.io/) (JSON Web Token). This JWT contains the following claims: + +- `challenge`: The challenge string derived from user request metadata +- `nonce`: The nonce / iteration number used to generate the passing response +- `response`: The hash that passed Anubis' checks +- `iat`: When the token was issued +- `nbf`: One minute prior to when the token was issued +- `exp`: The token's expiry week after the token was issued + +This ensures that the token has enough metadata to prove that the token is valid (due to the token's signature), but also so that the server can independently prove the token is valid. This cookie is allowed to be set without triggering an EU cookie banner notification; but depending on facts and circumstances, you may wish to disclose this to your users. + +### Challenge format + +Challenges are formed by taking some user request metadata and using that to generate a SHA-256 checksum. The following request headers are used: + +- `Accept-Encoding`: The content encodings that the requestor supports, such as gzip. +- `Accept-Language`: The language that the requestor would prefer the server respond in, such as English. +- `X-Real-Ip`: The IP address of the requestor, as set by a reverse proxy server. +- `User-Agent`: The user agent string of the requestor. + +This forms a fingerprint of the requestor using metadata that any requestor already is sending. Depending on facts and circumstances, you may wish to disclose this to your users. + +### JWT signing + +Anubis uses an ed25519 keypair to sign the JWTs issued when challenges are passed. Anubis will generate a new ed25519 keypair every time it starts. At this time, there is no way to share this keypair between instance of Anubis, but that will be addressed in future versions. + +## Setting up Anubis + +Anubis is meant to sit between your reverse proxy (such as Nginx or Caddy) and your target service. One instance of Anubis must be used per service you are protecting. + +Anubis is shipped in the Docker image [`ghcr.io/xe/x/anubis:latest`](https://github.com/Xe/x/pkgs/container/x%2Fanubis). Other methods to install Anubis may exist, but the Docker image is currently the only supported method. + +Anubis has very minimal system requirements. I suspect that 128Mi of ram may be sufficient for a large number of concurrent clients. Anubis may be a poor fit for apps that use WebSockets and maintain open connections, but I don't have enough real-world experience to know one way or another. + +Anubis uses these environment variables for configuration: + +| Environment Variable | Default value | Explanation | +| :------------------- | :---------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `BIND` | `:8923` | The TCP port that Anubis listens on. | +| `DIFFICULTY` | `5` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. | +| `METRICS_BIND` | `:9090` | The TCP port that Anubis serves Prometheus metrics on. | +| `SERVE_ROBOTS_TXT` | `false` | If set `true`, Anubis will serve a default `robots.txt` file that disallows all known AI scrapers by name and then additionally disallows every scraper. This is useful if facts and circumstances make it difficult to change the underlying service to serve such a `robots.txt` file. | +| `TARGET` | `http://localhost:3923` | The URL of the service that Anubis should forward valid requests to. | + +### Docker compose + +Add Anubis to your compose file pointed at your service: + +```yaml +services: + anubis-nginx: + image: ghcr.io/xe/x/anubis:latest + environment: + BIND: ":8080" + DIFFICULTY: "5" + METRICS_BIND: ":9090" + SERVE_ROBOTS_TXT: "true" + TARGET: "http://nginx" + ports: + - 8080:8080 + nginx: + image: nginx + volumes: + - "./www:/usr/share/nginx/html" +``` + +### Kubernetes + +This example makes the following assumptions: + +- Your target service is listening on TCP port `5000`. +- Anubis will be listening on port `8080`. + +Attach Anubis to your Deployment: + +```yaml +containers: + # ... + - name: anubis + image: ghcr.io/xe/x/anubis:latest + imagePullPolicy: Always + env: + - name: "BIND" + value: ":8080" + - name: "DIFFICULTY" + value: "5" + - name: "METRICS_BIND" + value: ":9090" + - name: "SERVE_ROBOTS_TXT" + value: "true" + - name: "TARGET" + value: "http://localhost:5000" + resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 250m + memory: 128Mi + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault +``` + +Then add a Service entry for Anubis: + +```diff +# ... + spec: + ports: ++ - protocol: TCP ++ port: 8080 ++ targetPort: 8080 ++ name: anubis +``` + +Then point your Ingress to the Anubis port: + +```diff + rules: + - host: git.xeserv.us + http: + paths: + - pathType: Prefix + path: "/" + backend: + service: + name: git + port: +- name: http ++ name: anubis +``` diff --git a/cmd/anubis/index.templ b/cmd/anubis/index.templ new file mode 100644 index 0000000..f969983 --- /dev/null +++ b/cmd/anubis/index.templ @@ -0,0 +1,69 @@ +package main + +import ( + "within.website/x/xess" +) + +templ base(title string, body templ.Component) { + <!DOCTYPE html> + <html> + + <head> + <title>{ title }</title> + <link rel="stylesheet" href={ xess.URL } /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <style> + body, + html { + height: 100%; + display: flex; + justify-content: center; + align-items: center; + width: 65ch; + margin-left: auto; + margin-right: auto; + } + + .centered-div { + text-align: center; + } + </style> + </head> + + <body id="top"> + <main> + <center> + <h1 id="title" class=".centered-div">{ title }</h1> + </center> + @body + <footer> + <center> + <p>Protected by <a href="https://xeiaso.net/blog/2025/anubis">Anubis</a> from <a href="https://within.website">Within</a>.</p> + </center> + </footer> + </main> + </body> + + </html> +} + +templ index() { + <div class="centered-div"> + <img id="image" width="256" src="/.within.website/x/cmd/anubis/static/img/pensive.webp" /> + <img style="display:none;" width="256" src="/.within.website/x/cmd/anubis/static/img/happy.webp" /> + <p id="status">Loading...</p> + <script async type="module" src="/.within.website/x/cmd/anubis/static/js/main.mjs"></script> + <noscript> + <p>Sadly, you must enable JavaScript to get past this challenge. I would love to not have to do this, but god is dead and AI scrapers have destroyed the common good.</p> + </noscript> + </div> +} + +templ errorPage(message string) { + <div class="centered-div"> + <img id="image" width="256" src="/.within.website/x/cmd/anubis/static/img/sad.webp" /> + <p>{ message }.</p> + <button onClick="window.location.reload();">Try again</button> + <p><a href="/">Go home</a></p> + </div> +}
\ No newline at end of file diff --git a/cmd/anubis/index_templ.go b/cmd/anubis/index_templ.go new file mode 100644 index 0000000..027934c --- /dev/null +++ b/cmd/anubis/index_templ.go @@ -0,0 +1,162 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.819 +package main + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "within.website/x/xess" +) + +func base(title string, body templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html><head><title>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 12, Col: 18} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(xess.URL) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 13, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><style>\n body,\n html {\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n width: 65ch;\n margin-left: auto;\n margin-right: auto;\n }\n\n .centered-div {\n text-align: center;\n }\n </style></head><body id=\"top\"><main><center><h1 id=\"title\" class=\".centered-div\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 36, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</h1></center>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<footer><center><p>Protected by <a href=\"https://xeiaso.net/blog/2025/anubis\">Anubis</a> from <a href=\"https://within.website\">Within</a>.</p></center></footer></main></body></html>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func index() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var5 := templ.GetChildren(ctx) + if templ_7745c5c3_Var5 == nil { + templ_7745c5c3_Var5 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"centered-div\"><img id=\"image\" width=\"256\" src=\"/.within.website/x/cmd/anubis/static/img/pensive.webp\"> <img style=\"display:none;\" width=\"256\" src=\"/.within.website/x/cmd/anubis/static/img/happy.webp\"><p id=\"status\">Loading...</p><script async type=\"module\" src=\"/.within.website/x/cmd/anubis/static/js/main.mjs\"></script><noscript><p>Sadly, you must enable JavaScript to get past this challenge. I would love to not have to do this, but god is dead and AI scrapers have destroyed the common good.</p></noscript></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func errorPage(message string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var6 := templ.GetChildren(ctx) + if templ_7745c5c3_Var6 == nil { + templ_7745c5c3_Var6 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"centered-div\"><img id=\"image\" width=\"256\" src=\"/.within.website/x/cmd/anubis/static/img/sad.webp\"><p>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 65, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, ".</p><button onClick=\"window.location.reload();\">Try again</button><p><a href=\"/\">Go home</a></p></div>") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go new file mode 100644 index 0000000..5c345e3 --- /dev/null +++ b/cmd/anubis/main.go @@ -0,0 +1,354 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "embed" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "log/slog" + mrand "math/rand" + "net/http" + "net/http/httputil" + "net/url" + "strconv" + "strings" + "time" + + "github.com/a-h/templ" + "github.com/golang-jwt/jwt/v5" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "within.website/x/internal" + "within.website/x/xess" +) + +var ( + bind = flag.String("bind", ":8923", "TCP port to bind HTTP to") + challengeDifficulty = flag.Int("difficulty", 5, "difficulty of the challenge") + metricsBind = flag.String("metrics-bind", ":9090", "TCP port to bind metrics to") + robotsTxt = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots") + target = flag.String("target", "http://localhost:3923", "target to reverse proxy to") + + //go:embed static + static embed.FS + + challengesIssued = promauto.NewCounter(prometheus.CounterOpts{ + Name: "anubis_challenges_issued", + Help: "The total number of challenges issued", + }) + + challengesValidated = promauto.NewCounter(prometheus.CounterOpts{ + Name: "anubis_challenges_validated", + Help: "The total number of challenges validated", + }) + + failedValidations = promauto.NewCounter(prometheus.CounterOpts{ + Name: "anubis_failed_validations", + Help: "The total number of failed validations", + }) + + timeTaken = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "anubis_time_taken", + Help: "The time taken for a browser to generate a response (milliseconds)", + Buckets: prometheus.DefBuckets, + }) +) + +const ( + cookieName = "within.website-x-cmd-anubis-auth" + staticPath = "/.within.website/x/cmd/anubis/" +) + +//go:generate go run github.com/a-h/templ/cmd/templ@latest generate + +func main() { + internal.HandleStartup() + + s, err := New(*target) + if err != nil { + log.Fatal(err) + } + + mux := http.NewServeMux() + + mux.Handle(staticPath, http.StripPrefix(staticPath, http.FileServerFS(static))) + xess.Mount(mux) + + mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", s.makeChallenge) + mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", s.passChallenge) + mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", s.testError) + + if *robotsTxt { + mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "static/robots.txt") + }) + + mux.HandleFunc("/.well-known/robots.txt", func(w http.ResponseWriter, r *http.Request) { + http.ServeFileFS(w, r, static, "static/robots.txt") + }) + } + + mux.HandleFunc("/", s.maybeReverseProxy) + + slog.Info("listening", "url", "http://localhost"+*bind) + log.Fatal(http.ListenAndServe(*bind, mux)) +} + +func sha256sum(text string) (string, error) { + hash := sha256.New() + _, err := hash.Write([]byte(text)) + if err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func challengeFor(r *http.Request) string { + data := fmt.Sprintf( + "Accept-Encoding=%s,Accept-Language=%s,X-Real-IP=%s,User-Agent=%s", + r.Header.Get("Accept-Encoding"), + r.Header.Get("Accept-Language"), + r.Header.Get("X-Real-Ip"), + r.UserAgent(), + ) + result, _ := sha256sum(data) + return result +} + +func New(target string) (*Server, error) { + u, err := url.Parse(target) + if err != nil { + return nil, fmt.Errorf("failed to parse target URL: %w", err) + } + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("failed to generate ed25519 key: %w", err) + } + + rp := httputil.NewSingleHostReverseProxy(u) + + return &Server{ + rp: rp, + priv: priv, + pub: pub, + }, nil +} + +type Server struct { + rp *httputil.ReverseProxy + priv ed25519.PrivateKey + pub ed25519.PublicKey +} + +func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request) { + switch { + case !strings.Contains(r.UserAgent(), "Mozilla"): + slog.Debug("non-browser user agent") + s.rp.ServeHTTP(w, r) + return + case strings.HasPrefix(r.URL.Path, "/.well-known/"): + slog.Debug("well-known path") + s.rp.ServeHTTP(w, r) + return + case strings.HasSuffix(r.URL.Path, ".rss") || strings.HasSuffix(r.URL.Path, ".xml") || strings.HasSuffix(r.URL.Path, ".atom"): + slog.Debug("rss path") + s.rp.ServeHTTP(w, r) + return + case r.URL.Path == "/favicon.ico": + slog.Debug("favicon path") + s.rp.ServeHTTP(w, r) + return + case r.URL.Path == "/robots.txt": + slog.Debug("robots.txt path") + s.rp.ServeHTTP(w, r) + return + } + + ckie, err := r.Cookie(cookieName) + if err != nil { + slog.Debug("cookie not found", "path", r.URL.Path) + s.renderIndex(w, r) + return + } + + token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) { + return s.pub, nil + }) + + if !token.Valid { + slog.Debug("invalid token", "path", r.URL.Path) + clearCookie(w) + s.renderIndex(w, r) + return + } + + if token.Valid && randomJitter() { + slog.Debug("randomly choosing to not check challenge value") + s.rp.ServeHTTP(w, r) + return + } + + claims := token.Claims.(jwt.MapClaims) + if claims["challenge"] != challengeFor(r) { + slog.Debug("invalid challenge", "path", r.URL.Path) + clearCookie(w) + s.renderIndex(w, r) + return + } + + var nonce int + + if v, ok := claims["nonce"].(float64); ok { + nonce = int(v) + } + + calcString := fmt.Sprintf("%s%d", challengeFor(r), nonce) + calculated, err := sha256sum(calcString) + if err != nil { + slog.Error("failed to calculate sha256sum", "path", r.URL.Path, "err", err) + clearCookie(w) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 { + slog.Debug("invalid response", "path", r.URL.Path) + failedValidations.Inc() + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + }) + s.renderIndex(w, r) + return + } + + s.rp.ServeHTTP(w, r) +} + +func (s *Server) renderIndex(w http.ResponseWriter, r *http.Request) { + clearCookie(w) + templ.Handler( + base("Making sure you're not a bot!", index()), + ).ServeHTTP(w, r) +} + +func (s *Server) makeChallenge(w http.ResponseWriter, r *http.Request) { + challenge := challengeFor(r) + difficulty := *challengeDifficulty + + json.NewEncoder(w).Encode(struct { + Challenge string `json:"challenge"` + Difficulty int `json:"difficulty"` + }{ + Challenge: challenge, + Difficulty: difficulty, + }) + slog.Debug("made challenge", "challenge", challenge, "difficulty", difficulty) + challengesIssued.Inc() +} + +func (s *Server) passChallenge(w http.ResponseWriter, r *http.Request) { + nonceStr := r.FormValue("nonce") + if nonceStr == "" { + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + elapsedTimeStr := r.FormValue("elapsedTime") + if elapsedTimeStr == "" { + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64) + if err != nil { + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + slog.Info("challenge took", "elapsedTime", elapsedTime) + timeTaken.Observe(elapsedTime) + + response := r.FormValue("response") + redir := r.FormValue("redir") + + challenge := challengeFor(r) + + nonce, err := strconv.Atoi(nonceStr) + if err != nil { + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + calcString := fmt.Sprintf("%s%d", challenge, nonce) + calculated, err := sha256sum(calcString) + if err != nil { + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("failed to calculate sha256sum")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) + failedValidations.Inc() + return + } + + // generate JWT cookie + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{ + "challenge": challenge, + "nonce": nonce, + "response": response, + "iat": time.Now().Unix(), + "nbf": time.Now().Add(-1 * time.Minute).Unix(), + "exp": time.Now().Add(24 * 7 * time.Hour).Unix(), + }) + tokenString, err := token.SignedString(s.priv) + if err != nil { + slog.Error("failed to sign JWT", "err", err) + clearCookie(w) + templ.Handler(base("Oh noes!", errorPage("failed to sign JWT")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: tokenString, + Expires: time.Now().Add(24 * 7 * time.Hour), + SameSite: http.SameSiteDefaultMode, + Path: "/", + }) + + challengesValidated.Inc() + http.Redirect(w, r, redir, http.StatusFound) +} + +func (s *Server) testError(w http.ResponseWriter, r *http.Request) { + err := r.FormValue("err") + templ.Handler(base("Oh noes!", errorPage(err)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) +} + +func clearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: cookieName, + Value: "", + Expires: time.Now().Add(-1 * time.Hour), + }) +} + +func randomJitter() bool { + return mrand.Intn(100) > 10 +} diff --git a/cmd/anubis/static/img/happy.webp b/cmd/anubis/static/img/happy.webp Binary files differnew file mode 100644 index 0000000..8a49631 --- /dev/null +++ b/cmd/anubis/static/img/happy.webp diff --git a/cmd/anubis/static/img/pensive.webp b/cmd/anubis/static/img/pensive.webp Binary files differnew file mode 100644 index 0000000..dc3dff1 --- /dev/null +++ b/cmd/anubis/static/img/pensive.webp diff --git a/cmd/anubis/static/img/sad.webp b/cmd/anubis/static/img/sad.webp Binary files differnew file mode 100644 index 0000000..95bebb6 --- /dev/null +++ b/cmd/anubis/static/img/sad.webp diff --git a/cmd/anubis/static/js/main.mjs b/cmd/anubis/static/js/main.mjs new file mode 100644 index 0000000..d13e1ad --- /dev/null +++ b/cmd/anubis/static/js/main.mjs @@ -0,0 +1,47 @@ +import { process } from './proof-of-work.mjs'; + +// from Xeact +const u = (url = "", params = {}) => { + let result = new URL(url, window.location.href); + Object.entries(params).forEach((kv) => { + let [k, v] = kv; + result.searchParams.set(k, v); + }); + return result.toString(); +}; + +(async () => { + const status = document.getElementById('status'); + const image = document.getElementById('image'); + const title = document.getElementById('title'); + + status.innerHTML = 'Calculating...'; + + const { challenge, difficulty } = await fetch("/.within.website/x/cmd/anubis/api/make-challenge", { method: "POST" }) + .then(r => { + if (!r.ok) { + throw new Error("Failed to fetch config"); + } + return r.json(); + }) + .catch(err => { + status.innerHTML = `Failed to fetch config: ${err.message}`; + image.src = "/.within.website/x/cmd/anubis/static/img/sad.webp"; + throw err; + }); + + status.innerHTML = `Calculating...<br/>Difficulty: ${difficulty}`; + + const t0 = Date.now(); + const { hash, nonce } = await process(challenge, difficulty); + const t1 = Date.now(); + + title.innerHTML = "Success!"; + status.innerHTML = `Done! Took ${t1 - t0}ms, ${nonce} iterations`; + image.src = "/.within.website/x/cmd/anubis/static/img/happy.webp"; + + setTimeout(() => { + const redir = window.location.href; + window.location.href = u("/.within.website/x/cmd/anubis/api/pass-challenge", { response: hash, nonce, redir, elapsedTime: t1 - t0 }); + }, 2000); +})();
\ No newline at end of file diff --git a/cmd/anubis/static/js/proof-of-work.mjs b/cmd/anubis/static/js/proof-of-work.mjs new file mode 100644 index 0000000..4019fec --- /dev/null +++ b/cmd/anubis/static/js/proof-of-work.mjs @@ -0,0 +1,65 @@ +// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm + +export function process(data, difficulty = 5) { + return new Promise((resolve, reject) => { + let webWorkerURL = URL.createObjectURL(new Blob([ + '(', processTask(), ')()' + ], { type: 'application/javascript' })); + + let worker = new Worker(webWorkerURL); + + worker.onmessage = (event) => { + worker.terminate(); + resolve(event.data); + }; + + worker.onerror = (event) => { + worker.terminate(); + reject(); + }; + + worker.postMessage({ + data, + difficulty + }); + + URL.revokeObjectURL(webWorkerURL); + }); +} + +function processTask() { + return function () { + function sha256(text) { + return new Promise((resolve, reject) => { + let buffer = (new TextEncoder).encode(text); + + crypto.subtle.digest('SHA-256', buffer.buffer).then(result => { + resolve(Array.from(new Uint8Array(result)).map( + c => c.toString(16).padStart(2, '0') + ).join('')); + }, reject); + }); + } + + addEventListener('message', async (event) => { + let data = event.data.data; + let difficulty = event.data.difficulty; + + let hash; + let nonce = 0; + do { + hash = await sha256(data + nonce++); + } while (hash.substr(0, difficulty) !== Array(difficulty + 1).join('0')); + + nonce -= 1; // last nonce was post-incremented + + postMessage({ + hash, + data, + difficulty, + nonce, + }); + }); + }.toString(); +} + diff --git a/ |
