From 9923878c5c8b68df7f132efd28f76ce5478a1f1a Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Mon, 17 Mar 2025 19:33:07 -0400 Subject: initial import from /x/ monorepo Signed-off-by: Xe Iaso --- .github/workflows/go.yml | 62 + .gitignore | 2 + Brewfile | 3 + LICENSE | 19 + README.md | 300 +++ cmd/anubis/.gitignore | 2 + cmd/anubis/CHANGELOG.md | 5 + cmd/anubis/botPolicies.json | 70 + cmd/anubis/decaymap.go | 87 + cmd/anubis/decaymap_test.go | 31 + cmd/anubis/index.templ | 159 ++ cmd/anubis/index_templ.go | 215 ++ cmd/anubis/internal/config/config.go | 99 + cmd/anubis/internal/config/config_test.go | 168 ++ .../internal/config/testdata/bad/badregexes.json | 14 + .../internal/config/testdata/bad/invalid.json | 5 + .../internal/config/testdata/bad/nobots.json | 1 + .../config/testdata/good/challengemozilla.json | 9 + .../config/testdata/good/everything_blocked.json | 10 + cmd/anubis/internal/dnsbl/dnsbl.go | 95 + cmd/anubis/internal/dnsbl/dnsbl_test.go | 55 + .../internal/dnsbl/droneblresponse_string.go | 54 + cmd/anubis/js/main.mjs | 71 + cmd/anubis/js/proof-of-work.mjs | 62 + cmd/anubis/js/video.mjs | 16 + cmd/anubis/main.go | 574 +++++ cmd/anubis/policy.go | 146 ++ cmd/anubis/policy_test.go | 65 + cmd/anubis/static/img/happy.webp | Bin 0 -> 60572 bytes cmd/anubis/static/img/pensive.webp | Bin 0 -> 49148 bytes cmd/anubis/static/img/sad.webp | Bin 0 -> 50802 bytes cmd/anubis/static/js/main.mjs | 2 + cmd/anubis/static/js/main.mjs.br | Bin 0 -> 802 bytes cmd/anubis/static/js/main.mjs.gz | Bin 0 -> 985 bytes cmd/anubis/static/js/main.mjs.map | 7 + cmd/anubis/static/js/main.mjs.zst | Bin 0 -> 982 bytes cmd/anubis/static/robots.txt | 47 + cmd/anubis/static/testdata/black.mp4 | Bin 0 -> 1667 bytes doc.go | 8 + docs/policies.md | 77 + go.mod | 47 + go.sum | 141 ++ internal/headers.go | 20 + internal/slog.go | 24 + run/anubis.env.default | 5 + run/anubis@.service | 12 + var/.gitignore | 2 + xess/.gitignore | 1 + xess/package-lock.json | 2411 ++++++++++++++++++++ xess/package.json | 20 + xess/postcss.config.js | 8 + xess/static/geist.woff2 | Bin 0 -> 64184 bytes xess/static/iosevka-curly.woff2 | Bin 0 -> 19692 bytes xess/static/podkova.css | 7 + xess/static/podkova.woff2 | Bin 0 -> 60580 bytes xess/xess.css | 111 + xess/xess.go | 38 + xess/xess.min.css | 1 + xess/xess.templ | 41 + xess/xess_templ.go | 164 ++ yeetfile.js | 22 + 61 files changed, 5615 insertions(+) create mode 100644 .github/workflows/go.yml create mode 100644 .gitignore create mode 100644 Brewfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cmd/anubis/.gitignore create mode 100644 cmd/anubis/CHANGELOG.md create mode 100644 cmd/anubis/botPolicies.json create mode 100644 cmd/anubis/decaymap.go create mode 100644 cmd/anubis/decaymap_test.go create mode 100644 cmd/anubis/index.templ create mode 100644 cmd/anubis/index_templ.go create mode 100644 cmd/anubis/internal/config/config.go create mode 100644 cmd/anubis/internal/config/config_test.go create mode 100644 cmd/anubis/internal/config/testdata/bad/badregexes.json create mode 100644 cmd/anubis/internal/config/testdata/bad/invalid.json create mode 100644 cmd/anubis/internal/config/testdata/bad/nobots.json create mode 100644 cmd/anubis/internal/config/testdata/good/challengemozilla.json create mode 100644 cmd/anubis/internal/config/testdata/good/everything_blocked.json create mode 100644 cmd/anubis/internal/dnsbl/dnsbl.go create mode 100644 cmd/anubis/internal/dnsbl/dnsbl_test.go create mode 100644 cmd/anubis/internal/dnsbl/droneblresponse_string.go create mode 100644 cmd/anubis/js/main.mjs create mode 100644 cmd/anubis/js/proof-of-work.mjs create mode 100644 cmd/anubis/js/video.mjs create mode 100644 cmd/anubis/main.go create mode 100644 cmd/anubis/policy.go create mode 100644 cmd/anubis/policy_test.go create mode 100644 cmd/anubis/static/img/happy.webp create mode 100644 cmd/anubis/static/img/pensive.webp create mode 100644 cmd/anubis/static/img/sad.webp create mode 100644 cmd/anubis/static/js/main.mjs create mode 100644 cmd/anubis/static/js/main.mjs.br create mode 100644 cmd/anubis/static/js/main.mjs.gz create mode 100644 cmd/anubis/static/js/main.mjs.map create mode 100644 cmd/anubis/static/js/main.mjs.zst create mode 100644 cmd/anubis/static/robots.txt create mode 100644 cmd/anubis/static/testdata/black.mp4 create mode 100644 doc.go create mode 100644 docs/policies.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/headers.go create mode 100644 internal/slog.go create mode 100644 run/anubis.env.default create mode 100644 run/anubis@.service create mode 100644 var/.gitignore create mode 100644 xess/.gitignore create mode 100644 xess/package-lock.json create mode 100644 xess/package.json create mode 100644 xess/postcss.config.js create mode 100644 xess/static/geist.woff2 create mode 100644 xess/static/iosevka-curly.woff2 create mode 100644 xess/static/podkova.css create mode 100644 xess/static/podkova.woff2 create mode 100644 xess/xess.css create mode 100644 xess/xess.go create mode 100644 xess/xess.min.css create mode 100644 xess/xess.templ create mode 100644 xess/xess_templ.go create mode 100644 yeetfile.js diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..63f4ea8 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,62 @@ +name: Go + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +permissions: + contents: read + actions: write + +jobs: + build: + runs-on: alrest-techarohq + steps: + - uses: actions/checkout@v4 + + - name: build essential + run: | + sudo apt-get update + sudo apt-get install -y build-essential + + - name: Set up Homebrew + uses: Homebrew/actions/setup-homebrew@master + + - name: Setup Homebrew cellar cache + uses: actions/cache@v4 + with: + path: | + /home/linuxbrew/.linuxbrew/Cellar + /home/linuxbrew/.linuxbrew/bin + /home/linuxbrew/.linuxbrew/etc + /home/linuxbrew/.linuxbrew/include + /home/linuxbrew/.linuxbrew/lib + /home/linuxbrew/.linuxbrew/opt + /home/linuxbrew/.linuxbrew/sbin + /home/linuxbrew/.linuxbrew/share + /home/linuxbrew/.linuxbrew/var + key: ${{ runner.os }}-go-homebrew-cellar-${{ hashFiles('go.sum') }} + restore-keys: | + ${{ runner.os }}-go-homebrew-cellar- + + - name: Install Brew dependencies + run: | + brew bundle + + - name: Setup Golang caches + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-golang-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-golang- + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..105385c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +*.rpm \ No newline at end of file diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..883ba6b --- /dev/null +++ b/Brewfile @@ -0,0 +1,3 @@ +# programming languages +brew "go@1.24" +brew "node" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..488b74f --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2025 Xe Iaso + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e42ba1 --- /dev/null +++ b/README.md @@ -0,0 +1,300 @@ +# Anubis + +
+A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up +
+ +![enbyware](https://pride-badges.pony.workers.dev/static/v1?label=enbyware&labelColor=%23555&stripeWidth=8&stripeColors=FCF434%2CFFFFFF%2C9C59D1%2C2C2C2C) +![GitHub Issues or Pull Requests by label](https://img.shields.io/github/issues/TecharoHQ/anubis) +![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/TecharoHQ/anubis) +![language count](https://img.shields.io/github/languages/count/TecharoHQ/anubis) +![repo size](https://img.shields.io/github/repo-size/TecharoHQ/anubis) + +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 some 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. + +If you want to try this out, connect to [git.xeserv.us](https://git.xeserv.us). + +## 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. + +```mermaid +--- +title: Challenge generation and validation +--- + +flowchart TD + Backend("Backend") + Fail("Fail") + + style PresentChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF + style ValidateChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF + style Backend color:#FFFFFF, stroke:#00C853, fill:#00C853 + style Fail color:#FFFFFF, stroke:#FF2962, fill:#FF2962 + + subgraph Server + PresentChallenge("Present Challenge") + ValidateChallenge("Validate Challenge") + end + + subgraph Client + Main("main.mjs") + Worker("Worker") + end + + Main -- Request challenge --> PresentChallenge + PresentChallenge -- Return challenge & difficulty --> Main + Main -- Spawn worker --> Worker + Worker -- Successful challenge --> Main + Main -- Validate challenge --> ValidateChallenge + ValidateChallenge -- Return cookie --> Backend + ValidateChallenge -- If anything is wrong --> Fail +``` + +### 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. + +```mermaid +--- +title: Challenge presentation logic +--- + +flowchart LR + Request("Request") + Backend("Backend") + %%Fail("Fail") + PresentChallenge("Present +challenge") + HasMozilla{"Is browser +or scraper?"} + HasCookie{"Has cookie?"} + HasExpired{"Cookie expired?"} + HasSignature{"Has valid +signature?"} + RandomJitter{"Secondary +screening?"} + POWPass{"Proof of +work valid?"} + + style PresentChallenge color:#FFFFFF, fill:#AA00FF, stroke:#AA00FF + style Backend color:#FFFFFF, stroke:#00C853, fill:#00C853 + %%style Fail color:#FFFFFF, stroke:#FF2962, fill:#FF2962 + + Request --> HasMozilla + HasMozilla -- Yes --> HasCookie + HasMozilla -- No --> Backend + HasCookie -- Yes --> HasExpired + HasCookie -- No --> PresentChallenge + HasExpired -- Yes --> PresentChallenge + HasExpired -- No --> HasSignature + HasSignature -- Yes --> RandomJitter + HasSignature -- No --> PresentChallenge + RandomJitter -- Yes --> POWPass + RandomJitter -- No --> Backend + POWPass -- Yes --> Backend + PowPass -- No --> PresentChallenge + PresentChallenge -- Back again for another cycle --> Request +``` + +### 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. +- The current time in UTC rounded to the nearest week. +- The fingerprint (checksum) of Anubis' private ED25519 key. + +This forms a fingerprint of the requestor using metadata that any requestor already is sending. It also uses time as an input, which is known to both the server and requestor due to the nature of linear timelines. 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. + +The Docker image runs Anubis as user ID 1000 and group ID 1000. If you are mounting external volumes into Anubis' container, please be sure they are owned by or writable to this user/group. + +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. | +| `POLICY_FNAME` | `/data/cfg/botPolicy.json` | The file containing [bot policy configuration](./docs/policies.md). See the bot policy documentation for more details. | +| `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. | + +### Policies + +Anubis has support for custom bot policies, matched by User-Agent string and request path. Check the [bot policy documentation](./docs/policies.md) for more information. + +### 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 +``` + +## Known caveats + +Anubis works with most programs without any issues as long as they're configured to trust `127.0.0.0/8` and `::1/128` as "valid proxy servers". Some combinations of reverse proxy and target application can have issues. This section documents them so that you can pattern-match and fix them. + +### Caddy + Gitea/Forgejo + +Gitea/Forgejo relies on the reverse proxy setting the `X-Real-Ip` header. Caddy does not do this out of the gate. Modify your Caddyfile like this: + +```diff + ellenjoe.int.within.lgbt { + # ... +- reverse_proxy http://localhost:3000 ++ reverse_proxy http://localhost:3000 { ++ header_up X-Real-Ip {remote_host} ++ } + # ... + } +``` + +Ensure that Gitea/Forgejo have `[security].REVERSE_PROXY_TRUSTED_PROXIES` set to the IP ranges that Anubis will appear from. Typically this is sufficient: + +```ini +[security] +REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,::1/128 +``` + +However if you are running Anubis in a separate Pod/Deployment in Kubernetes, you may have to adjust this to the IP range of the Pod space in your Container Networking Interface plugin: + +```ini +[security] +REVERSE_PROXY_TRUSTED_PROXIES = 10.192.0.0/12 +``` diff --git a/cmd/anubis/.gitignore b/cmd/anubis/.gitignore new file mode 100644 index 0000000..061bf12 --- /dev/null +++ b/cmd/anubis/.gitignore @@ -0,0 +1,2 @@ +*.rpm +anubis diff --git a/cmd/anubis/CHANGELOG.md b/cmd/anubis/CHANGELOG.md new file mode 100644 index 0000000..612bec1 --- /dev/null +++ b/cmd/anubis/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2025-01-24 + +- Added support for custom bot policy documentation, allowing administrators to change how Anubis works to meet their needs. diff --git a/cmd/anubis/botPolicies.json b/cmd/anubis/botPolicies.json new file mode 100644 index 0000000..6e04a11 --- /dev/null +++ b/cmd/anubis/botPolicies.json @@ -0,0 +1,70 @@ +{ + "bots": [ + { + "name": "amazonbot", + "user_agent_regex": "Amazonbot", + "action": "DENY" + }, + { + "name": "googlebot", + "user_agent_regex": "\\+http\\:\\/\\/www\\.google\\.com/bot\\.html", + "action": "ALLOW" + }, + { + "name": "bingbot", + "user_agent_regex": "\\+http\\:\\/\\/www\\.bing\\.com/bingbot\\.htm", + "action": "ALLOW" + }, + { + "name": "qwantbot", + "user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/", + "action": "ALLOW" + }, + { + "name": "us-artificial-intelligence-scraper", + "user_agent_regex": "\\+https\\:\\/\\/github\\.com\\/US-Artificial-Intelligence\\/scraper", + "action": "DENY" + }, + { + "name": "well-known", + "path_regex": "^/.well-known/.*$", + "action": "ALLOW" + }, + { + "name": "favicon", + "path_regex": "^/favicon.ico$", + "action": "ALLOW" + }, + { + "name": "robots-txt", + "path_regex": "^/robots.txt$", + "action": "ALLOW" + }, + { + "name": "rss-readers", + "path_regex": ".*\\.(rss|xml|atom|json)$", + "action": "ALLOW" + }, + { + "name": "lightpanda", + "user_agent_regex": "^Lightpanda/.*$", + "action": "DENY" + }, + { + "name": "headless-chrome", + "user_agent_regex": "HeadlessChrome", + "action": "DENY" + }, + { + "name": "headless-chromium", + "user_agent_regex": "HeadlessChromium", + "action": "DENY" + }, + { + "name": "generic-browser", + "user_agent_regex": "Mozilla", + "action": "CHALLENGE" + } + ], + "dnsbl": true +} diff --git a/cmd/anubis/decaymap.go b/cmd/anubis/decaymap.go new file mode 100644 index 0000000..dcd2952 --- /dev/null +++ b/cmd/anubis/decaymap.go @@ -0,0 +1,87 @@ +package main + +import ( + "sync" + "time" +) + +func zilch[T any]() T { + var zero T + return zero +} + +// DecayMap is a lazy key->value map. It's a wrapper around a map and a mutex. If values exceed their time-to-live, they are pruned at Get time. +type DecayMap[K comparable, V any] struct { + data map[K]decayMapEntry[V] + lock sync.RWMutex +} + +type decayMapEntry[V any] struct { + Value V + expiry time.Time +} + +// NewDecayMap creates a new DecayMap of key type K and value type V. +// +// Key types must be comparable to work with maps. +func NewDecayMap[K comparable, V any]() *DecayMap[K, V] { + return &DecayMap[K, V]{ + data: make(map[K]decayMapEntry[V]), + } +} + +// expire forcibly expires a key by setting its time-to-live one second in the past. +func (m *DecayMap[K, V]) expire(key K) bool { + m.lock.RLock() + val, ok := m.data[key] + m.lock.RUnlock() + + if !ok { + return false + } + + m.lock.Lock() + val.expiry = time.Now().Add(-1 * time.Second) + m.data[key] = val + m.lock.Unlock() + + return true +} + +// Get gets a value from the DecayMap by key. +// +// If a value has expired, forcibly delete it if it was not updated. +func (m *DecayMap[K, V]) Get(key K) (V, bool) { + m.lock.RLock() + value, ok := m.data[key] + m.lock.RUnlock() + + if !ok { + return zilch[V](), false + } + + if time.Now().After(value.expiry) { + m.lock.Lock() + // Since previously reading m.data[key], the value may have been updated. + // Delete the entry only if the expiry time is still the same. + if m.data[key].expiry == value.expiry { + delete(m.data, key) + } + m.lock.Unlock() + + return zilch[V](), false + } + + return value.Value, true +} + +// Set sets a key value pair in the map. +func (m *DecayMap[K, V]) Set(key K, value V, ttl time.Duration) { + m.lock.Lock() + defer m.lock.Unlock() + + m.data[key] = decayMapEntry[V]{ + Value: value, + expiry: time.Now().Add(ttl), + } +} diff --git a/cmd/anubis/decaymap_test.go b/cmd/anubis/decaymap_test.go new file mode 100644 index 0000000..73e0626 --- /dev/null +++ b/cmd/anubis/decaymap_test.go @@ -0,0 +1,31 @@ +package main + +import ( + "testing" + "time" +) + +func TestDecayMap(t *testing.T) { + dm := NewDecayMap[string, string]() + + dm.Set("test", "hi", 5*time.Minute) + + val, ok := dm.Get("test") + if !ok { + t.Error("somehow the test key was not set") + } + + if val != "hi" { + t.Errorf("wanted value %q, got: %q", "hi", val) + } + + ok = dm.expire("test") + if !ok { + t.Error("somehow could not force-expire the test key") + } + + _, ok = dm.Get("test") + if ok { + t.Error("got value even though it was supposed to be expired") + } +} diff --git a/cmd/anubis/index.templ b/cmd/anubis/index.templ new file mode 100644 index 0000000..e2426d5 --- /dev/null +++ b/cmd/anubis/index.templ @@ -0,0 +1,159 @@ +package main + +import ( + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/xess" +) + +templ base(title string, body templ.Component) { + + + + { title } + + + + + +
+
+

{ title }

+
+ @body + +
+ + +} + +templ index() { +
+ Loading...

+
") + 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_Var9 := templ.GetChildren(ctx) + if templ_7745c5c3_Var9 == nil { + templ_7745c5c3_Var9 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(message) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 155, Col: 14} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, ".

Go home

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/cmd/anubis/internal/config/config.go b/cmd/anubis/internal/config/config.go new file mode 100644 index 0000000..ad338ef --- /dev/null +++ b/cmd/anubis/internal/config/config.go @@ -0,0 +1,99 @@ +package config + +import ( + "errors" + "fmt" + "regexp" +) + +type Rule string + +const ( + RuleUnknown = "" + RuleAllow = "ALLOW" + RuleDeny = "DENY" + RuleChallenge = "CHALLENGE" +) + +type Bot struct { + Name string `json:"name"` + UserAgentRegex *string `json:"user_agent_regex"` + PathRegex *string `json:"path_regex"` + Action Rule `json:"action"` +} + +var ( + ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule") + ErrBotMustHaveName = errors.New("config.Bot: must set name") + ErrBotMustHaveUserAgentOrPath = errors.New("config.Bot: must set either user_agent_regex, path_regex") + ErrBotMustHaveUserAgentOrPathNotBoth = errors.New("config.Bot: must set either user_agent_regex, path_regex, and not both") + ErrUnknownAction = errors.New("config.Bot: unknown action") + ErrInvalidUserAgentRegex = errors.New("config.Bot: invalid user agent regex") + ErrInvalidPathRegex = errors.New("config.Bot: invalid path regex") +) + +func (b Bot) Valid() error { + var errs []error + + if b.Name == "" { + errs = append(errs, ErrBotMustHaveName) + } + + if b.UserAgentRegex == nil && b.PathRegex == nil { + errs = append(errs, ErrBotMustHaveUserAgentOrPath) + } + + if b.UserAgentRegex != nil && b.PathRegex != nil { + errs = append(errs, ErrBotMustHaveUserAgentOrPathNotBoth) + } + + if b.UserAgentRegex != nil { + if _, err := regexp.Compile(*b.UserAgentRegex); err != nil { + errs = append(errs, ErrInvalidUserAgentRegex, err) + } + } + + if b.PathRegex != nil { + if _, err := regexp.Compile(*b.PathRegex); err != nil { + errs = append(errs, ErrInvalidPathRegex, err) + } + } + + switch b.Action { + case RuleAllow, RuleChallenge, RuleDeny: + // okay + default: + errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action)) + } + + if len(errs) != 0 { + return fmt.Errorf("config: bot entry for %q is not valid:\n%w", b.Name, errors.Join(errs...)) + } + + return nil +} + +type Config struct { + Bots []Bot `json:"bots"` + DNSBL bool `json:"dnsbl"` +} + +func (c Config) Valid() error { + var errs []error + + if len(c.Bots) == 0 { + errs = append(errs, ErrNoBotRulesDefined) + } + + for _, b := range c.Bots { + if err := b.Valid(); err != nil { + errs = append(errs, err) + } + } + + if len(errs) != 0 { + return fmt.Errorf("config is not valid:\n%w", errors.Join(errs...)) + } + + return nil +} diff --git a/cmd/anubis/internal/config/config_test.go b/cmd/anubis/internal/config/config_test.go new file mode 100644 index 0000000..f362a76 --- /dev/null +++ b/cmd/anubis/internal/config/config_test.go @@ -0,0 +1,168 @@ +package config + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func p[V any](v V) *V { return &v } + +func TestBotValid(t *testing.T) { + var tests = []struct { + name string + bot Bot + err error + }{ + { + name: "simple user agent", + bot: Bot{ + Name: "mozilla-ua", + Action: RuleChallenge, + UserAgentRegex: p("Mozilla"), + }, + err: nil, + }, + { + name: "simple path", + bot: Bot{ + Name: "well-known-path", + Action: RuleAllow, + PathRegex: p("^/.well-known/.*$"), + }, + err: nil, + }, + { + name: "no rule name", + bot: Bot{ + Action: RuleChallenge, + UserAgentRegex: p("Mozilla"), + }, + err: ErrBotMustHaveName, + }, + { + name: "no rule matcher", + bot: Bot{ + Name: "broken-rule", + Action: RuleAllow, + }, + err: ErrBotMustHaveUserAgentOrPath, + }, + { + name: "both user-agent and path", + bot: Bot{ + Name: "path-and-user-agent", + Action: RuleDeny, + UserAgentRegex: p("Mozilla"), + PathRegex: p("^/.secret-place/.*$"), + }, + err: ErrBotMustHaveUserAgentOrPathNotBoth, + }, + { + name: "unknown action", + bot: Bot{ + Name: "Unknown action", + Action: RuleUnknown, + UserAgentRegex: p("Mozilla"), + }, + err: ErrUnknownAction, + }, + { + name: "invalid user agent regex", + bot: Bot{ + Name: "mozilla-ua", + Action: RuleChallenge, + UserAgentRegex: p("a(b"), + }, + err: ErrInvalidUserAgentRegex, + }, + { + name: "invalid path regex", + bot: Bot{ + Name: "mozilla-ua", + Action: RuleChallenge, + PathRegex: p("a(b"), + }, + err: ErrInvalidPathRegex, + }, + } + + for _, cs := range tests { + cs := cs + t.Run(cs.name, func(t *testing.T) { + err := cs.bot.Valid() + if err == nil && cs.err == nil { + return + } + + if err == nil && cs.err != nil { + t.Errorf("didn't get an error, but wanted: %v", cs.err) + } + + if !errors.Is(err, cs.err) { + t.Logf("got wrong error from Valid()") + t.Logf("wanted: %v", cs.err) + t.Logf("got: %v", err) + t.Errorf("got invalid error from check") + } + }) + } +} + +func TestConfigValidKnownGood(t *testing.T) { + finfos, err := os.ReadDir("testdata/good") + if err != nil { + t.Fatal(err) + } + + for _, st := range finfos { + st := st + t.Run(st.Name(), func(t *testing.T) { + fin, err := os.Open(filepath.Join("testdata", "good", st.Name())) + if err != nil { + t.Fatal(err) + } + defer fin.Close() + + var c Config + if err := json.NewDecoder(fin).Decode(&c); err != nil { + t.Fatalf("can't decode file: %v", err) + } + + if err := c.Valid(); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestConfigValidBad(t *testing.T) { + finfos, err := os.ReadDir("testdata/bad") + if err != nil { + t.Fatal(err) + } + + for _, st := range finfos { + st := st + t.Run(st.Name(), func(t *testing.T) { + fin, err := os.Open(filepath.Join("testdata", "bad", st.Name())) + if err != nil { + t.Fatal(err) + } + defer fin.Close() + + var c Config + if err := json.NewDecoder(fin).Decode(&c); err != nil { + t.Fatalf("can't decode file: %v", err) + } + + if err := c.Valid(); err == nil { + t.Fatal("validation should have failed but didn't somehow") + } else { + t.Log(err) + } + }) + } +} diff --git a/cmd/anubis/internal/config/testdata/bad/badregexes.json b/cmd/anubis/internal/config/testdata/bad/badregexes.json new file mode 100644 index 0000000..e85b85b --- /dev/null +++ b/cmd/anubis/internal/config/testdata/bad/badregexes.json @@ -0,0 +1,14 @@ +{ + "bots": [ + { + "name": "path-bad", + "path_regex": "a(b", + "action": "DENY" + }, + { + "name": "user-agent-bad", + "user_agent_regex": "a(b", + "action": "DENY" + } + ] +} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/bad/invalid.json b/cmd/anubis/internal/config/testdata/bad/invalid.json new file mode 100644 index 0000000..c5d1ff6 --- /dev/null +++ b/cmd/anubis/internal/config/testdata/bad/invalid.json @@ -0,0 +1,5 @@ +{ + "bots": [ + {} + ] +} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/bad/nobots.json b/cmd/anubis/internal/config/testdata/bad/nobots.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/cmd/anubis/internal/config/testdata/bad/nobots.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/good/challengemozilla.json b/cmd/anubis/internal/config/testdata/good/challengemozilla.json new file mode 100644 index 0000000..e9d34ee --- /dev/null +++ b/cmd/anubis/internal/config/testdata/good/challengemozilla.json @@ -0,0 +1,9 @@ +{ + "bots": [ + { + "name": "generic-browser", + "user_agent_regex": "Mozilla", + "action": "CHALLENGE" + } + ] +} \ No newline at end of file diff --git a/cmd/anubis/internal/config/testdata/good/everything_blocked.json b/cmd/anubis/internal/config/testdata/good/everything_blocked.json new file mode 100644 index 0000000..e1763e4 --- /dev/null +++ b/cmd/anubis/internal/config/testdata/good/everything_blocked.json @@ -0,0 +1,10 @@ +{ + "bots": [ + { + "name": "everything", + "user_agent_regex": ".*", + "action": "DENY" + } + ], + "dnsbl": false +} \ No newline at end of file diff --git a/cmd/anubis/internal/dnsbl/dnsbl.go b/cmd/anubis/internal/dnsbl/dnsbl.go new file mode 100644 index 0000000..60edd5c --- /dev/null +++ b/cmd/anubis/internal/dnsbl/dnsbl.go @@ -0,0 +1,95 @@ +package dnsbl + +import ( + "errors" + "fmt" + "net" + "strings" +) + +//go:generate go tool golang.org/x/tools/cmd/stringer -type=DroneBLResponse + +type DroneBLResponse byte + +const ( + AllGood DroneBLResponse = 0 + IRCDrone DroneBLResponse = 3 + Bottler DroneBLResponse = 5 + UnknownSpambotOrDrone DroneBLResponse = 6 + DDOSDrone DroneBLResponse = 7 + SOCKSProxy DroneBLResponse = 8 + HTTPProxy DroneBLResponse = 9 + ProxyChain DroneBLResponse = 10 + OpenProxy DroneBLResponse = 11 + OpenDNSResolver DroneBLResponse = 12 + BruteForceAttackers DroneBLResponse = 13 + OpenWingateProxy DroneBLResponse = 14 + CompromisedRouter DroneBLResponse = 15 + AutoRootingWorms DroneBLResponse = 16 + AutoDetectedBotIP DroneBLResponse = 17 + Unknown DroneBLResponse = 255 +) + +func Reverse(ip net.IP) string { + if ip.To4() != nil { + return reverse4(ip) + } + + return reverse6(ip) +} + +func reverse4(ip net.IP) string { + splitAddress := strings.Split(ip.String(), ".") + + // swap first and last octet + splitAddress[0], splitAddress[3] = splitAddress[3], splitAddress[0] + // swap middle octets + splitAddress[1], splitAddress[2] = splitAddress[2], splitAddress[1] + + return strings.Join(splitAddress, ".") +} + +func reverse6(ip net.IP) string { + ipBytes := []byte(ip) + var sb strings.Builder + + for i := len(ipBytes) - 1; i >= 0; i-- { + // Split the byte into two nibbles + highNibble := ipBytes[i] >> 4 + lowNibble := ipBytes[i] & 0x0F + + // Append the nibbles in reversed order + sb.WriteString(fmt.Sprintf("%x.%x.", lowNibble, highNibble)) + } + + return sb.String()[:len(sb.String())-1] +} + +func Lookup(ipStr string) (DroneBLResponse, error) { + ip := net.ParseIP(ipStr) + if ip == nil { + return Unknown, errors.New("dnsbl: input is not an IP address") + } + + revIP := Reverse(ip) + ".dnsbl.dronebl.org" + + ips, err := net.LookupIP(revIP) + if err != nil { + var dnserr *net.DNSError + if errors.As(err, &dnserr) { + if dnserr.IsNotFound { + return AllGood, nil + } + } + + return Unknown, err + } + + if len(ips) != 0 { + for _, ip := range ips { + return DroneBLResponse(ip.To4()[3]), nil + } + } + + return UnknownSpambotOrDrone, nil +} diff --git a/cmd/anubis/internal/dnsbl/dnsbl_test.go b/cmd/anubis/internal/dnsbl/dnsbl_test.go new file mode 100644 index 0000000..0ead488 --- /dev/null +++ b/cmd/anubis/internal/dnsbl/dnsbl_test.go @@ -0,0 +1,55 @@ +package dnsbl + +import ( + "fmt" + "net" + "testing" +) + +func TestReverse4(t *testing.T) { + cases := []struct { + inp, out string + }{ + {"1.2.3.4", "4.3.2.1"}, + } + + for _, cs := range cases { + t.Run(fmt.Sprintf("%s->%s", cs.inp, cs.out), func(t *testing.T) { + out := reverse4(net.ParseIP(cs.inp)) + + if out != cs.out { + t.Errorf("wanted %s\ngot: %s", cs.out, out) + } + }) + } +} + +func TestReverse6(t *testing.T) { + cases := []struct { + inp, out string + }{ + { + inp: "1234:5678:9ABC:DEF0:1234:5678:9ABC:DEF0", + out: "0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1", + }, + } + + for _, cs := range cases { + t.Run(fmt.Sprintf("%s->%s", cs.inp, cs.out), func(t *testing.T) { + out := reverse6(net.ParseIP(cs.inp)) + + if out != cs.out { + t.Errorf("wanted %s, got: %s", cs.out, out) + } + }) + } +} + +func TestLookup(t *testing.T) { + resp, err := Lookup("27.65.243.194") + if err != nil { + t.Fatalf("it broked: %v", err) + } + + t.Logf("response: %d", resp) +} diff --git a/cmd/anubis/internal/dnsbl/droneblresponse_string.go b/cmd/anubis/internal/dnsbl/droneblresponse_string.go new file mode 100644 index 0000000..5104dda --- /dev/null +++ b/cmd/anubis/internal/dnsbl/droneblresponse_string.go @@ -0,0 +1,54 @@ +// Code generated by "stringer -type=DroneBLResponse"; DO NOT EDIT. + +package dnsbl + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[AllGood-0] + _ = x[IRCDrone-3] + _ = x[Bottler-5] + _ = x[UnknownSpambotOrDrone-6] + _ = x[DDOSDrone-7] + _ = x[SOCKSProxy-8] + _ = x[HTTPProxy-9] + _ = x[ProxyChain-10] + _ = x[OpenProxy-11] + _ = x[OpenDNSResolver-12] + _ = x[BruteForceAttackers-13] + _ = x[OpenWingateProxy-14] + _ = x[CompromisedRouter-15] + _ = x[AutoRootingWorms-16] + _ = x[AutoDetectedBotIP-17] + _ = x[Unknown-255] +} + +const ( + _DroneBLResponse_name_0 = "AllGood" + _DroneBLResponse_name_1 = "IRCDrone" + _DroneBLResponse_name_2 = "BottlerUnknownSpambotOrDroneDDOSDroneSOCKSProxyHTTPProxyProxyChainOpenProxyOpenDNSResolverBruteForceAttackersOpenWingateProxyCompromisedRouterAutoRootingWormsAutoDetectedBotIP" + _DroneBLResponse_name_3 = "Unknown" +) + +var ( + _DroneBLResponse_index_2 = [...]uint8{0, 7, 28, 37, 47, 56, 66, 75, 90, 109, 125, 142, 158, 175} +) + +func (i DroneBLResponse) String() string { + switch { + case i == 0: + return _DroneBLResponse_name_0 + case i == 3: + return _DroneBLResponse_name_1 + case 5 <= i && i <= 17: + i -= 5 + return _DroneBLResponse_name_2[_DroneBLResponse_index_2[i]:_DroneBLResponse_index_2[i+1]] + case i == 255: + return _DroneBLResponse_name_3 + default: + return "DroneBLResponse(" + strconv.FormatInt(int64(i), 10) + ")" + } +} diff --git a/cmd/anubis/js/main.mjs b/cmd/anubis/js/main.mjs new file mode 100644 index 0000000..3f2c652 --- /dev/null +++ b/cmd/anubis/js/main.mjs @@ -0,0 +1,71 @@ +import { process } from './proof-of-work.mjs'; +import { testVideo } from './video.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(); +}; + +const imageURL = (mood) => { + return `/.within.website/x/cmd/anubis/static/img/${mood}.webp`; +}; + +(async () => { + const status = document.getElementById('status'); + const image = document.getElementById('image'); + const title = document.getElementById('title'); + const spinner = document.getElementById('spinner'); + // const testarea = document.getElementById('testarea'); + + // const videoWorks = await testVideo(testarea); + // console.log(`videoWorks: ${videoWorks}`); + + // if (!videoWorks) { + // title.innerHTML = "Oh no!"; + // status.innerHTML = "Checks failed. Please check your browser's settings and try again."; + // image.src = imageURL("sad"); + // spinner.innerHTML = ""; + // spinner.style.display = "none"; + // return; + // } + + 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 => { + title.innerHTML = "Oh no!"; + status.innerHTML = `Failed to fetch config: ${err.message}`; + image.src = imageURL("sad"); + spinner.innerHTML = ""; + spinner.style.display = "none"; + throw err; + }); + + status.innerHTML = `Calculating...
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 = imageURL("happy"); + spinner.innerHTML = ""; + spinner.style.display = "none"; + + 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/js/proof-of-work.mjs b/cmd/anubis/js/proof-of-work.mjs new file mode 100644 index 0000000..d71d2db --- /dev/null +++ b/cmd/anubis/js/proof-of-work.mjs @@ -0,0 +1,62 @@ +// 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 () { + const sha256 = (text) => { + const encoded = new TextEncoder().encode(text); + return crypto.subtle.digest("SHA-256", encoded.buffer).then((result) => + Array.from(new Uint8Array(result)) + .map((c) => c.toString(16).padStart(2, "0")) + .join(""), + ); + }; + + 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.substring(0, difficulty) !== Array(difficulty + 1).join('0')); + + nonce -= 1; // last nonce was post-incremented + + postMessage({ + hash, + data, + difficulty, + nonce, + }); + }); + }.toString(); +} + diff --git a/cmd/anubis/js/video.mjs b/cmd/anubis/js/video.mjs new file mode 100644 index 0000000..59cde1e --- /dev/null +++ b/cmd/anubis/js/video.mjs @@ -0,0 +1,16