Compare commits
59 Commits
v1.12.1
...
release/v1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e0a8179a | ||
|
|
f844fffd1e | ||
|
|
4b8efcba9b | ||
|
|
7d4be0dcec | ||
|
|
d1d63d9c18 | ||
|
|
ecc6b47f90 | ||
|
|
e7cbd349f3 | ||
|
|
07bb5f63f9 | ||
|
|
4155719422 | ||
|
|
f29a200f09 | ||
|
|
18cd8a66a2 | ||
|
|
725e11d3a6 | ||
|
|
f462209b02 | ||
|
|
acf5586e83 | ||
|
|
9d68e73d03 | ||
|
|
6156d3d729 | ||
|
|
af6f05554f | ||
|
|
1509b06cb9 | ||
|
|
56cdb2e51b | ||
|
|
15d801be7d | ||
|
|
c66305904b | ||
|
|
5f7942faca | ||
|
|
869e46a4cc | ||
|
|
07e6695430 | ||
|
|
a9777a3126 | ||
|
|
5ad44d77d0 | ||
|
|
ad432897ca | ||
|
|
194e55088b | ||
|
|
4ec4dc3624 | ||
|
|
d6d879133e | ||
|
|
e7b9b17b92 | ||
|
|
d3e509517c | ||
|
|
90049001e9 | ||
|
|
38e1e8cb5e | ||
|
|
1c00431098 | ||
|
|
d93adbc111 | ||
|
|
f730326814 | ||
|
|
db6d424aaa | ||
|
|
95dddb5549 | ||
|
|
86b8c6c5f2 | ||
|
|
f1220ecc57 | ||
|
|
94f43c7200 | ||
|
|
f41b21b3cf | ||
|
|
d1512a1f79 | ||
|
|
c88775bb8a | ||
|
|
eeaed6a317 | ||
|
|
3e9a93f629 | ||
|
|
bf2c83c337 | ||
|
|
d84fd392c7 | ||
|
|
5258492101 | ||
|
|
d82c12de28 | ||
|
|
c49c039fae | ||
|
|
c47347ff76 | ||
|
|
240159e921 | ||
|
|
52d7a3cd2b | ||
|
|
c81e938f63 | ||
|
|
8cbe801c0c | ||
|
|
c8252d6da6 | ||
|
|
1efcb88261 |
44
.github/workflows/docker-pr.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: Docker image builds (pull requests)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
|
||||
- uses: ko-build/setup-ko@v0.8
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/techarohq/anubis
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
run: |
|
||||
go run ./cmd/containerbuild --docker-repo ghcr.io/techarohq/anubis --slog-level debug
|
||||
env:
|
||||
PULL_REQUEST_ID: ${{ github.event.number }}
|
||||
|
||||
- run: |
|
||||
echo "Test this with:"
|
||||
echo "docker pull ${{ steps.build.outputs.docker_image }}"
|
||||
30
.github/workflows/docker.yml
vendored
@@ -5,14 +5,16 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -20,12 +22,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- uses: ko-build/setup-ko@v0.8
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -42,19 +47,12 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
cache-to: type=gha
|
||||
cache-from: type=gha
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/arm64/v8,linux/amd64
|
||||
sbom: true
|
||||
push: true
|
||||
run: |
|
||||
go run ./cmd/containerbuild --docker-repo ghcr.io/techarohq/anubis --slog-level debug
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
if: ${{github.event_name == 'pull_request'}}
|
||||
with:
|
||||
subject-name: ghcr.io/techarohq/anubis
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
|
||||
61
.github/workflows/docs-deploy.yml
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
name: Docs deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: techarohq
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/techarohq/anubis/docs
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./docs
|
||||
cache-to: type=gha
|
||||
cache-from: type=gha
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
|
||||
- name: Apply k8s manifests to aeacus
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.AEACUS_KUBECONFIG }}
|
||||
with:
|
||||
args: apply -k docs/manifest
|
||||
|
||||
- name: Apply k8s manifests to aeacus
|
||||
uses: actions-hub/kubectl@master
|
||||
env:
|
||||
KUBE_CONFIG: ${{ secrets.AEACUS_KUBECONFIG }}
|
||||
with:
|
||||
args: rollout restart -n default deploy/anubis-docs
|
||||
15
.github/workflows/go.yml
vendored
@@ -56,8 +56,21 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-golang-
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: |
|
||||
~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: install playwright browsers
|
||||
run: |
|
||||
npx --yes playwright@1.50.1 install --with-deps
|
||||
npx --yes playwright@1.50.1 run-server --port 3000 &
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
run: go test -v ./...
|
||||
|
||||
6
.gitignore
vendored
@@ -1,2 +1,6 @@
|
||||
.env
|
||||
*.rpm
|
||||
*.rpm
|
||||
|
||||
# Go binaries and test artifacts
|
||||
main
|
||||
*.test
|
||||
|
||||
13
.ko.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
defaultBaseImage: cgr.dev/chainguard/static
|
||||
defaultPlatforms:
|
||||
- linux/arm64
|
||||
- linux/amd64
|
||||
- linux/arm/v7
|
||||
|
||||
builds:
|
||||
- id: anubis
|
||||
main: ./cmd/anubis
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -extldflags "-static"
|
||||
- -X github.com/TecharoHQ/anubis.Version={{.Env.VERSION}}
|
||||
3
Brewfile
@@ -1,3 +1,4 @@
|
||||
# programming languages
|
||||
brew "go@1.24"
|
||||
brew "node"
|
||||
brew "node"
|
||||
brew "ko"
|
||||
17
CHANGELOG.md
@@ -1,17 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## 1.12.1
|
||||
|
||||
- Phrasing in the `<noscript>` warning was replaced from its original placeholder text to
|
||||
something more suitable for general consumption
|
||||
([fd6903a](https://github.com/TecharoHQ/anubis/commit/fd6903aeed315b8fddee32890d7458a9271e4798)).
|
||||
- Footer links on the check page now point to Techaro's brand
|
||||
([4ebccb1](https://github.com/TecharoHQ/anubis/commit/4ebccb197ec20d024328d7f92cad39bbbe4d6359))
|
||||
- Anubis was imported from [Xe/x](https://github.com/Xe/x).
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM golang:1.24 AS build
|
||||
FROM docker.io/library/golang:1.24 AS build
|
||||
ARG BUILDKIT_SBOM_SCAN_CONTEXT=true BUILDKIT_SBOM_SCAN_STAGE=true
|
||||
|
||||
WORKDIR /app
|
||||
@@ -10,7 +10,7 @@ RUN --mount=type=cache,target=/root/.cache \
|
||||
VERSION=$(git describe --tags --always --dirty) \
|
||||
&& go build -o /app/bin/anubis -ldflags="-X github.com/TecharoHQ/anubis.Version=${VERSION}" ./cmd/anubis
|
||||
|
||||
FROM debian:bookworm AS runtime
|
||||
FROM docker.io/library/debian:bookworm AS runtime
|
||||
ARG BUILDKIT_SBOM_SCAN_STAGE=true
|
||||
RUN apt-get update \
|
||||
&& apt-get -y install ca-certificates
|
||||
@@ -20,4 +20,4 @@ COPY --from=build /app/bin/anubis /app/bin/anubis
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 CMD ["/app/bin/anubis", "--healthcheck"]
|
||||
CMD ["/app/bin/anubis"]
|
||||
|
||||
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
|
||||
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
|
||||
|
||||
6
PULL_REQUEST_TEMPLATE.md
Normal file
@@ -0,0 +1,6 @@
|
||||
<!-- delete me and describe your change here -->
|
||||
|
||||
Checklist:
|
||||
|
||||
- [ ] Added a description of the changes to the `[Unreleased]` section of docs/docs/CHANGELOG.md
|
||||
- [ ] Tested this at least manually
|
||||
279
README.md
@@ -1,7 +1,7 @@
|
||||
# Anubis
|
||||
|
||||
<center>
|
||||
<img width=256 src="./cmd/anubis/static/img/happy.webp" alt="A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up" />
|
||||
<img width=256 src="./web/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>
|
||||
|
||||

|
||||
@@ -18,283 +18,14 @@ This is a bit of a nuclear response, but AI scraper bots scraping so aggressivel
|
||||
|
||||
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).
|
||||
If you want to try this out, connect to [anubis.techaro.lol](https://anubis.techaro.lol).
|
||||
|
||||
## 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.
|
||||
If you run into any issues running Anubis, please [open an issue](https://github.com/TecharoHQ/anubis/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
|
||||
## Star History
|
||||
|
||||
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
|
||||
```
|
||||
[](https://www.star-history.com/#TecharoHQ/anubis&Date)
|
||||
|
||||
19
anubis.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// Package Anubis contains the version number of Anubis.
|
||||
package anubis
|
||||
|
||||
// Version is the current version of Anubis.
|
||||
//
|
||||
// This variable is set at build time using the -X linker flag. If not set,
|
||||
// it defaults to "devel".
|
||||
var Version = "devel"
|
||||
|
||||
// CookieName is the name of the cookie that Anubis uses in order to validate
|
||||
// access.
|
||||
const CookieName = "within.website-x-cmd-anubis-auth"
|
||||
|
||||
// StaticPath is the location where all static Anubis assets are located.
|
||||
const StaticPath = "/.within.website/x/cmd/anubis/"
|
||||
|
||||
// DefaultDifficulty is the default "difficulty" (number of leading zeroes)
|
||||
// that must be met by the client in order to pass the challenge.
|
||||
const DefaultDifficulty = 4
|
||||
@@ -1,5 +0,0 @@
|
||||
# CHANGELOG
|
||||
|
||||
## 2025-01-24
|
||||
|
||||
- Added support for custom bot policy documentation, allowing administrators to change how Anubis works to meet their needs.
|
||||
@@ -1,70 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/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;
|
||||
}
|
||||
|
||||
.lds-roller,
|
||||
.lds-roller div,
|
||||
.lds-roller div:after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.lds-roller {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.lds-roller div {
|
||||
animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
transform-origin: 40px 40px;
|
||||
}
|
||||
|
||||
.lds-roller div:after {
|
||||
content: " ";
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 7.2px;
|
||||
height: 7.2px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
margin: -3.6px 0 0 -3.6px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(1) {
|
||||
animation-delay: -0.036s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(1):after {
|
||||
top: 62.62742px;
|
||||
left: 62.62742px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(2) {
|
||||
animation-delay: -0.072s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(2):after {
|
||||
top: 67.71281px;
|
||||
left: 56px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(3) {
|
||||
animation-delay: -0.108s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(3):after {
|
||||
top: 70.90963px;
|
||||
left: 48.28221px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(4) {
|
||||
animation-delay: -0.144s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(4):after {
|
||||
top: 72px;
|
||||
left: 40px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(5) {
|
||||
animation-delay: -0.18s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(5):after {
|
||||
top: 70.90963px;
|
||||
left: 31.71779px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(6) {
|
||||
animation-delay: -0.216s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(6):after {
|
||||
top: 67.71281px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(7) {
|
||||
animation-delay: -0.252s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(7):after {
|
||||
top: 62.62742px;
|
||||
left: 17.37258px;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(8) {
|
||||
animation-delay: -0.288s;
|
||||
}
|
||||
|
||||
.lds-roller div:nth-child(8):after {
|
||||
top: 56px;
|
||||
left: 12.28719px;
|
||||
}
|
||||
|
||||
@keyframes lds-roller {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</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://github.com/TecharoHQ/anubis">Anubis</a> from <a
|
||||
href="https://techaro.lol">Techaro</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?cacheBuster=" +
|
||||
anubis.Version } />
|
||||
<img style="display:none;" width="256" src={ "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
|
||||
anubis.Version } />
|
||||
<p id="status">Loading...</p>
|
||||
<script async type="module" src={ "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version
|
||||
}></script>
|
||||
<div id="spinner" class="lds-roller">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<noscript>
|
||||
<p>Sadly, you must enable JavaScript to get past this challenge. This is required because AI companies have changed
|
||||
the social contract around how website hosting works. A no-JS solution is a work-in-progress.</p>
|
||||
</noscript>
|
||||
<div id="testarea"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ errorPage(message string) {
|
||||
<div class="centered-div">
|
||||
<img id="image" width="256" src={ "/.within.website/x/cmd/anubis/static/img/sad.webp?cacheBuster=" + anubis.Version
|
||||
} />
|
||||
<p>{ message }.</p>
|
||||
<button onClick="window.location.reload();">Try again</button>
|
||||
<p><a href="/">Go home</a></p>
|
||||
</div>
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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...<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 = 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);
|
||||
})();
|
||||
@@ -1,91 +1,65 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
mrand "math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/config"
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/dnsbl"
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/xess"
|
||||
"github.com/a-h/templ"
|
||||
libanubis "github.com/TecharoHQ/anubis/lib"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/TecharoHQ/anubis/web"
|
||||
"github.com/facebookgo/flagenv"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
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")
|
||||
policyFname = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
|
||||
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
target = flag.String("target", "http://localhost:3923", "target to reverse proxy to")
|
||||
healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis")
|
||||
|
||||
//go:embed static botPolicies.json
|
||||
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",
|
||||
})
|
||||
|
||||
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_dronebl_hits",
|
||||
Help: "The total number of hits from DroneBL",
|
||||
}, []string{"status"})
|
||||
|
||||
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.ExponentialBucketsRange(1, math.Pow(2, 18), 19),
|
||||
})
|
||||
bind = flag.String("bind", ":8923", "network address to bind HTTP to")
|
||||
bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp")
|
||||
challengeDifficulty = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge")
|
||||
cookieDomain = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for")
|
||||
cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support")
|
||||
ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned")
|
||||
metricsBind = flag.String("metrics-bind", ":9090", "network address to bind metrics to")
|
||||
metricsBindNetwork = flag.String("metrics-bind-network", "tcp", "network family for the metrics server to bind to")
|
||||
socketMode = flag.String("socket-mode", "0770", "socket mode (permissions) for unix domain sockets.")
|
||||
robotsTxt = flag.Bool("serve-robots-txt", false, "serve a robots.txt file that disallows all robots")
|
||||
policyFname = flag.String("policy-fname", "", "full path to anubis policy document (defaults to a sensible built-in policy)")
|
||||
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
target = flag.String("target", "http://localhost:3923", "target to reverse proxy to")
|
||||
healthcheck = flag.Bool("healthcheck", false, "run a health check against Anubis")
|
||||
debugXRealIPDefault = flag.String("debug-x-real-ip-default", "", "If set, replace empty X-Real-Ip headers with this value, useful only for debugging Anubis and running it locally")
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "within.website-x-cmd-anubis-auth"
|
||||
staticPath = "/.within.website/x/cmd/anubis/"
|
||||
)
|
||||
func keyFromHex(value string) (ed25519.PrivateKey, error) {
|
||||
keyBytes, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("supplied key is not hex-encoded: %w", err)
|
||||
}
|
||||
|
||||
//go:generate go tool github.com/a-h/templ/cmd/templ generate
|
||||
//go:generate esbuild js/main.mjs --sourcemap --minify --bundle --outfile=static/js/main.mjs
|
||||
//go:generate gzip -f -k static/js/main.mjs
|
||||
//go:generate zstd -f -k --ultra -22 static/js/main.mjs
|
||||
//go:generate brotli -fZk static/js/main.mjs
|
||||
if len(keyBytes) != ed25519.SeedSize {
|
||||
return nil, fmt.Errorf("supplied key is not %d bytes long, got %d bytes", ed25519.SeedSize, len(keyBytes))
|
||||
}
|
||||
|
||||
return ed25519.NewKeyFromSeed(keyBytes), nil
|
||||
}
|
||||
|
||||
func doHealthCheck() error {
|
||||
resp, err := http.Get("http://localhost" + *metricsBind + "/metrics")
|
||||
@@ -101,6 +75,68 @@ func doHealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func setupListener(network string, address string) (net.Listener, string) {
|
||||
formattedAddress := ""
|
||||
switch network {
|
||||
case "unix":
|
||||
formattedAddress = "unix:" + address
|
||||
case "tcp":
|
||||
formattedAddress = "http://localhost" + address
|
||||
default:
|
||||
formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
log.Fatal(fmt.Errorf("failed to bind to %s: %w", formattedAddress, err))
|
||||
}
|
||||
|
||||
// additional permission handling for unix sockets
|
||||
if network == "unix" {
|
||||
mode, err := strconv.ParseUint(*socketMode, 8, 0)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
log.Fatal(fmt.Errorf("could not parse socket mode %s: %w", *socketMode, err))
|
||||
}
|
||||
|
||||
err = os.Chmod(address, os.FileMode(mode))
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
log.Fatal(fmt.Errorf("could not change socket mode: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return listener, formattedAddress
|
||||
}
|
||||
|
||||
func makeReverseProxy(target string) (http.Handler, error) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL: %w", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/4e2100a2879ef06aea1411790327019c1a09217c/pkg/upstream/http.go#L124
|
||||
if u.Scheme == "unix" {
|
||||
// clean path up so we don't use the socket path in proxied requests
|
||||
addr := u.Path
|
||||
u.Path = ""
|
||||
// tell transport how to dial unix sockets
|
||||
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", addr)
|
||||
}
|
||||
// tell transport how to handle the unix url scheme
|
||||
transport.RegisterProtocol("unix", libanubis.UnixRoundTripper{Transport: transport})
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = transport
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
flagenv.Parse()
|
||||
flag.Parse()
|
||||
@@ -114,13 +150,18 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := New(*target, *policyFname)
|
||||
rp, err := makeReverseProxy(*target)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("can't make reverse proxy: %v", err)
|
||||
}
|
||||
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, *challengeDifficulty)
|
||||
if err != nil {
|
||||
log.Fatalf("can't parse policy file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("Rule error IDs:")
|
||||
for _, rule := range s.policy.Bots {
|
||||
for _, rule := range policy.Bots {
|
||||
if rule.Action != config.RuleDeny {
|
||||
continue
|
||||
}
|
||||
@@ -134,422 +175,97 @@ func main() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
var priv ed25519.PrivateKey
|
||||
if *ed25519PrivateKeyHex == "" {
|
||||
_, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to generate ed25519 key: %v", err)
|
||||
}
|
||||
|
||||
mux.Handle(staticPath, internal.UnchangingCache(http.StripPrefix(staticPath, http.FileServerFS(static))))
|
||||
|
||||
// mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding)
|
||||
|
||||
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")
|
||||
})
|
||||
slog.Warn("generating random key, Anubis will have strange behavior when multiple instances are behind the same load balancer target, for more information: see https://anubis.techaro.lol/docs/admin/installation#key-generation")
|
||||
} else {
|
||||
priv, err = keyFromHex(*ed25519PrivateKeyHex)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse and validate ED25519_PRIVATE_KEY_HEX: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s, err := libanubis.New(libanubis.Options{
|
||||
Next: rp,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: *robotsTxt,
|
||||
PrivateKey: priv,
|
||||
CookieDomain: *cookieDomain,
|
||||
CookiePartitioned: *cookiePartitioned,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
wg := new(sync.WaitGroup)
|
||||
// install signal handler
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if *metricsBind != "" {
|
||||
go metricsServer()
|
||||
wg.Add(1)
|
||||
go metricsServer(ctx, wg.Done)
|
||||
}
|
||||
|
||||
mux.HandleFunc("/", s.maybeReverseProxy)
|
||||
var h http.Handler
|
||||
h = s
|
||||
h = internal.DefaultXRealIP(*debugXRealIPDefault, h)
|
||||
h = internal.XForwardedForToXRealIP(h)
|
||||
|
||||
slog.Info("listening", "url", "http://localhost"+*bind, "difficulty", *challengeDifficulty, "serveRobotsTXT", *robotsTxt, "target", *target, "version", anubis.Version)
|
||||
log.Fatal(http.ListenAndServe(*bind, mux))
|
||||
}
|
||||
|
||||
func metricsServer() {
|
||||
http.DefaultServeMux.Handle("/metrics", promhttp.Handler())
|
||||
slog.Debug("listening for metrics", "url", "http://localhost"+*metricsBind)
|
||||
log.Fatal(http.ListenAndServe(*metricsBind, nil))
|
||||
}
|
||||
|
||||
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 (s *Server) challengeFor(r *http.Request) string {
|
||||
fp := sha256.Sum256(s.priv.Seed())
|
||||
|
||||
data := fmt.Sprintf(
|
||||
"Accept-Language=%s,X-Real-IP=%s,User-Agent=%s,WeekTime=%s,Fingerprint=%x,Difficulty=%d",
|
||||
r.Header.Get("Accept-Language"),
|
||||
r.Header.Get("X-Real-Ip"),
|
||||
r.UserAgent(),
|
||||
time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339),
|
||||
fp,
|
||||
*challengeDifficulty,
|
||||
srv := http.Server{Handler: h}
|
||||
listener, url := setupListener(*bindNetwork, *bind)
|
||||
slog.Info(
|
||||
"listening",
|
||||
"url", url,
|
||||
"difficulty", *challengeDifficulty,
|
||||
"serveRobotsTXT", *robotsTxt,
|
||||
"target", *target,
|
||||
"version", anubis.Version,
|
||||
"debug-x-real-ip-default", *debugXRealIPDefault,
|
||||
)
|
||||
result, _ := sha256sum(data)
|
||||
return result
|
||||
}
|
||||
|
||||
func New(target, policyFname 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)
|
||||
|
||||
var fin io.ReadCloser
|
||||
|
||||
if policyFname != "" {
|
||||
fin, err = os.Open(policyFname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy file %s: %w", policyFname, err)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(c); err != nil {
|
||||
log.Printf("cannot shut down: %v", err)
|
||||
}
|
||||
} else {
|
||||
policyFname = "(static)/botPolicies.json"
|
||||
fin, err = static.Open("botPolicies.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", policyFname, err)
|
||||
}()
|
||||
|
||||
if err := srv.Serve(listener); err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func metricsServer(ctx context.Context, done func()) {
|
||||
defer done()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
srv := http.Server{Handler: mux}
|
||||
listener, url := setupListener(*metricsBindNetwork, *metricsBind)
|
||||
slog.Debug("listening for metrics", "url", url)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
c, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(c); err != nil {
|
||||
log.Printf("cannot shut down: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.Serve(listener); err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
defer fin.Close()
|
||||
|
||||
policy, err := parseConfig(fin, policyFname)
|
||||
if err != nil {
|
||||
return nil, err // parseConfig sets a fancy error for us
|
||||
}
|
||||
|
||||
return &Server{
|
||||
rp: rp,
|
||||
priv: priv,
|
||||
pub: pub,
|
||||
policy: policy,
|
||||
dnsblCache: NewDecayMap[string, dnsbl.DroneBLResponse](),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
rp *httputil.ReverseProxy
|
||||
priv ed25519.PrivateKey
|
||||
pub ed25519.PublicKey
|
||||
policy *ParsedConfig
|
||||
dnsblCache *DecayMap[string, dnsbl.DroneBLResponse]
|
||||
}
|
||||
|
||||
func (s *Server) maybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
cr, rule := s.check(r)
|
||||
r.Header.Add("X-Anubis-Rule", cr.Name)
|
||||
r.Header.Add("X-Anubis-Action", string(cr.Rule))
|
||||
lg := slog.With(
|
||||
"check_result", cr,
|
||||
"user_agent", r.UserAgent(),
|
||||
"accept_language", r.Header.Get("Accept-Language"),
|
||||
"priority", r.Header.Get("Priority"),
|
||||
"x-forwarded-for",
|
||||
r.Header.Get("X-Forwarded-For"),
|
||||
"x-real-ip", r.Header.Get("X-Real-Ip"),
|
||||
)
|
||||
policyApplications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
||||
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
|
||||
if s.policy.DNSBL && ip != "" {
|
||||
resp, ok := s.dnsblCache.Get(ip)
|
||||
if !ok {
|
||||
lg.Debug("looking up ip in dnsbl")
|
||||
resp, err := dnsbl.Lookup(ip)
|
||||
if err != nil {
|
||||
lg.Error("can't look up ip in dnsbl", "err", err)
|
||||
}
|
||||
s.dnsblCache.Set(ip, resp, 24*time.Hour)
|
||||
droneBLHits.WithLabelValues(resp.String()).Inc()
|
||||
}
|
||||
|
||||
if resp != dnsbl.AllGood {
|
||||
lg.Info("DNSBL hit", "status", resp.String())
|
||||
templ.Handler(base("Oh noes!", errorPage(fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch cr.Rule {
|
||||
case config.RuleAllow:
|
||||
lg.Debug("allowing traffic to origin (explicit)")
|
||||
s.rp.ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleDeny:
|
||||
clearCookie(w)
|
||||
lg.Info("explicit deny")
|
||||
if rule == nil {
|
||||
lg.Error("rule is nil, cannot calculate checksum")
|
||||
templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
hash, err := rule.Hash()
|
||||
if err != nil {
|
||||
lg.Error("can't calculate checksum of rule", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg.Debug("rule hash", "hash", hash)
|
||||
templ.Handler(base("Oh noes!", errorPage(fmt.Sprintf("Access Denied: error code %s", hash))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleChallenge:
|
||||
lg.Debug("challenge requested")
|
||||
default:
|
||||
clearCookie(w)
|
||||
templ.Handler(base("Oh noes!", errorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ckie, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ckie.Valid(); err != nil {
|
||||
lg.Debug("cookie is invalid", "err", err)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
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 {
|
||||
lg.Debug("invalid token", "path", r.URL.Path)
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims := token.Claims.(jwt.MapClaims)
|
||||
|
||||
exp, ok := claims["exp"].(float64)
|
||||
if !ok {
|
||||
lg.Debug("exp is not int64", "ok", ok, "typeof(exp)", fmt.Sprintf("%T", exp))
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if exp := time.Unix(int64(exp), 0); time.Now().After(exp) {
|
||||
lg.Debug("token has expired", "exp", exp.Format(time.RFC3339))
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if token.Valid && randomJitter() {
|
||||
r.Header.Add("X-Anubis-Status", "PASS-BRIEF")
|
||||
lg.Debug("cookie is not enrolled into secondary screening")
|
||||
s.rp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if claims["challenge"] != s.challengeFor(r) {
|
||||
lg.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", s.challengeFor(r), nonce)
|
||||
calculated, err := sha256sum(calcString)
|
||||
if err != nil {
|
||||
lg.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 {
|
||||
lg.Debug("invalid response", "path", r.URL.Path)
|
||||
failedValidations.Inc()
|
||||
clearCookie(w)
|
||||
s.renderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("all checks passed")
|
||||
r.Header.Add("X-Anubis-Status", "PASS-FULL")
|
||||
s.rp.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) renderIndex(w http.ResponseWriter, r *http.Request) {
|
||||
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 := s.challengeFor(r)
|
||||
difficulty := *challengeDifficulty
|
||||
|
||||
lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip"))
|
||||
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Difficulty int `json:"difficulty"`
|
||||
}{
|
||||
Challenge: challenge,
|
||||
Difficulty: difficulty,
|
||||
})
|
||||
lg.Debug("made challenge", "challenge", challenge, "difficulty", difficulty)
|
||||
challengesIssued.Inc()
|
||||
}
|
||||
|
||||
func (s *Server) passChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip"))
|
||||
|
||||
nonceStr := r.FormValue("nonce")
|
||||
if nonceStr == "" {
|
||||
clearCookie(w)
|
||||
lg.Debug("no nonce")
|
||||
templ.Handler(base("Oh noes!", errorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTimeStr := r.FormValue("elapsedTime")
|
||||
if elapsedTimeStr == "" {
|
||||
clearCookie(w)
|
||||
lg.Debug("no elapsedTime")
|
||||
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)
|
||||
lg.Debug("elapsedTime doesn't parse", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
lg.Info("challenge took", "elapsedTime", elapsedTime)
|
||||
timeTaken.Observe(elapsedTime)
|
||||
|
||||
response := r.FormValue("response")
|
||||
redir := r.FormValue("redir")
|
||||
|
||||
challenge := s.challengeFor(r)
|
||||
|
||||
nonce, err := strconv.Atoi(nonceStr)
|
||||
if err != nil {
|
||||
clearCookie(w)
|
||||
lg.Debug("nonce doesn't parse", "err", err)
|
||||
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)
|
||||
lg.Debug("can't parse shasum", "err", err)
|
||||
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)
|
||||
lg.Debug("hash does not match", "got", response, "want", calculated)
|
||||
templ.Handler(base("Oh noes!", errorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
|
||||
failedValidations.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// compare the leading zeroes
|
||||
if !strings.HasPrefix(response, strings.Repeat("0", *challengeDifficulty)) {
|
||||
clearCookie(w)
|
||||
lg.Debug("difficulty check failed", "response", response, "difficulty", *challengeDifficulty)
|
||||
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 {
|
||||
lg.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.SameSiteLaxMode,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
challengesValidated.Inc()
|
||||
lg.Debug("challenge passed, redirecting to app")
|
||||
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 ohNoes(w http.ResponseWriter, r *http.Request, err error) {
|
||||
slog.Error("super fatal error", "err", err)
|
||||
templ.Handler(base("Oh noes!", errorPage("An internal server error happened")), 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),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func randomJitter() bool {
|
||||
return mrand.Intn(100) > 10
|
||||
}
|
||||
|
||||
func serveMainJSWithBestEncoding(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -564,11 +280,11 @@ func serveMainJSWithBestEncoding(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.Contains(r.Header.Get("Accept-Encoding"), enc) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
w.Header().Set("Content-Encoding", enc)
|
||||
http.ServeFileFS(w, r, static, "static/js/main.mjs."+enc2ext[enc])
|
||||
http.ServeFileFS(w, r, web.Static, "static/js/main.mjs."+enc2ext[enc])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
http.ServeFileFS(w, r, static, "static/js/main.mjs")
|
||||
http.ServeFileFS(w, r, web.Static, "static/js/main.mjs")
|
||||
}
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/config"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
)
|
||||
|
||||
var (
|
||||
policyApplications = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_policy_results",
|
||||
Help: "The results of each policy rule",
|
||||
}, []string{"rule", "action"})
|
||||
)
|
||||
|
||||
type ParsedConfig struct {
|
||||
orig config.Config
|
||||
|
||||
Bots []Bot
|
||||
DNSBL bool
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
Name string
|
||||
UserAgent *regexp.Regexp
|
||||
Path *regexp.Regexp
|
||||
Action config.Rule `json:"action"`
|
||||
}
|
||||
|
||||
func (b Bot) Hash() (string, error) {
|
||||
var pathRex string
|
||||
if b.Path != nil {
|
||||
pathRex = b.Path.String()
|
||||
}
|
||||
var userAgentRex string
|
||||
if b.UserAgent != nil {
|
||||
userAgentRex = b.UserAgent.String()
|
||||
}
|
||||
|
||||
return sha256sum(fmt.Sprintf("%s::%s::%s", b.Name, pathRex, userAgentRex))
|
||||
}
|
||||
|
||||
func parseConfig(fin io.Reader, fname string) (*ParsedConfig, error) {
|
||||
var c config.Config
|
||||
if err := json.NewDecoder(fin).Decode(&c); err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy config JSON %s: %w", fname, err)
|
||||
}
|
||||
|
||||
if err := c.Valid(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
result := &ParsedConfig{
|
||||
orig: c,
|
||||
}
|
||||
|
||||
for _, b := range c.Bots {
|
||||
if berr := b.Valid(); berr != nil {
|
||||
err = errors.Join(err, berr)
|
||||
continue
|
||||
}
|
||||
|
||||
var botParseErr error
|
||||
parsedBot := Bot{
|
||||
Name: b.Name,
|
||||
Action: b.Action,
|
||||
}
|
||||
|
||||
if b.UserAgentRegex != nil {
|
||||
userAgent, err := regexp.Compile(*b.UserAgentRegex)
|
||||
if err != nil {
|
||||
botParseErr = errors.Join(botParseErr, fmt.Errorf("while compiling user agent regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.UserAgent = userAgent
|
||||
}
|
||||
}
|
||||
|
||||
if b.PathRegex != nil {
|
||||
path, err := regexp.Compile(*b.PathRegex)
|
||||
if err != nil {
|
||||
botParseErr = errors.Join(botParseErr, fmt.Errorf("while compiling path regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.Path = path
|
||||
}
|
||||
}
|
||||
|
||||
result.Bots = append(result.Bots, parsedBot)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, err)
|
||||
}
|
||||
|
||||
result.DNSBL = c.DNSBL
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Rule config.Rule
|
||||
}
|
||||
|
||||
func (cr CheckResult) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.String("name", cr.Name),
|
||||
slog.String("rule", string(cr.Rule)))
|
||||
}
|
||||
|
||||
func cr(name string, rule config.Rule) CheckResult {
|
||||
return CheckResult{
|
||||
Name: name,
|
||||
Rule: rule,
|
||||
}
|
||||
}
|
||||
|
||||
// Check evaluates the list of rules, and returns the result
|
||||
func (s *Server) check(r *http.Request) (CheckResult, *Bot) {
|
||||
for _, b := range s.policy.Bots {
|
||||
if b.UserAgent != nil {
|
||||
if b.UserAgent.MatchString(r.UserAgent()) {
|
||||
return cr("bot/"+b.Name, b.Action), &b
|
||||
}
|
||||
}
|
||||
|
||||
if b.Path != nil {
|
||||
if b.Path.MatchString(r.URL.Path) {
|
||||
return cr("bot/"+b.Name, b.Action), &b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cr("default/allow", config.RuleAllow), nil
|
||||
}
|
||||
|
Before Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,2 +0,0 @@
|
||||
(()=>{function l(n,s=5){return new Promise((i,e)=>{let o=URL.createObjectURL(new Blob(["(",w(),")()"],{type:"application/javascript"})),t=new Worker(o);t.onmessage=r=>{t.terminate(),i(r.data)},t.onerror=r=>{t.terminate(),e()},t.postMessage({data:n,difficulty:s}),URL.revokeObjectURL(o)})}function w(){return function(){let n=s=>{let i=new TextEncoder().encode(s);return crypto.subtle.digest("SHA-256",i.buffer).then(e=>Array.from(new Uint8Array(e)).map(o=>o.toString(16).padStart(2,"0")).join(""))};addEventListener("message",async s=>{let i=s.data.data,e=s.data.difficulty,o,t=0;do o=await n(i+t++);while(o.substring(0,e)!==Array(e+1).join("0"));t-=1,postMessage({hash:o,data:i,difficulty:e,nonce:t})})}.toString()}var h=(n="",s={})=>{let i=new URL(n,window.location.href);return Object.entries(s).forEach(e=>{let[o,t]=e;i.searchParams.set(o,t)}),i.toString()},m=n=>`/.within.website/x/cmd/anubis/static/img/${n}.webp`;(async()=>{let n=document.getElementById("status"),s=document.getElementById("image"),i=document.getElementById("title"),e=document.getElementById("spinner");n.innerHTML="Calculating...";let{challenge:o,difficulty:t}=await fetch("/.within.website/x/cmd/anubis/api/make-challenge",{method:"POST"}).then(a=>{if(!a.ok)throw new Error("Failed to fetch config");return a.json()}).catch(a=>{throw i.innerHTML="Oh no!",n.innerHTML=`Failed to fetch config: ${a.message}`,s.src=m("sad"),e.innerHTML="",e.style.display="none",a});n.innerHTML=`Calculating...<br/>Difficulty: ${t}`;let r=Date.now(),{hash:u,nonce:c}=await l(o,t),d=Date.now();i.innerHTML="Success!",n.innerHTML=`Done! Took ${d-r}ms, ${c} iterations`,s.src=m("happy"),e.innerHTML="",e.style.display="none",setTimeout(()=>{let a=window.location.href;window.location.href=h("/.within.website/x/cmd/anubis/api/pass-challenge",{response:u,nonce:c,redir:a,elapsedTime:d-r})},2e3)})();})();
|
||||
//# sourceMappingURL=main.mjs.map
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../js/proof-of-work.mjs", "../../js/main.mjs"],
|
||||
"sourcesContent": ["// https://dev.to/ratmd/simple-proof-of-work-in-javascript-3kgm\n\nexport function process(data, difficulty = 5) {\n return new Promise((resolve, reject) => {\n let webWorkerURL = URL.createObjectURL(new Blob([\n '(', processTask(), ')()'\n ], { type: 'application/javascript' }));\n\n let worker = new Worker(webWorkerURL);\n\n worker.onmessage = (event) => {\n worker.terminate();\n resolve(event.data);\n };\n\n worker.onerror = (event) => {\n worker.terminate();\n reject();\n };\n\n worker.postMessage({\n data,\n difficulty\n });\n\n URL.revokeObjectURL(webWorkerURL);\n });\n}\n\nfunction processTask() {\n return function () {\n const sha256 = (text) => {\n const encoded = new TextEncoder().encode(text);\n return crypto.subtle.digest(\"SHA-256\", encoded.buffer).then((result) =>\n Array.from(new Uint8Array(result))\n .map((c) => c.toString(16).padStart(2, \"0\"))\n .join(\"\"),\n );\n };\n\n addEventListener('message', async (event) => {\n let data = event.data.data;\n let difficulty = event.data.difficulty;\n\n let hash;\n let nonce = 0;\n do {\n hash = await sha256(data + nonce++);\n } while (hash.substring(0, difficulty) !== Array(difficulty + 1).join('0'));\n\n nonce -= 1; // last nonce was post-incremented\n\n postMessage({\n hash,\n data,\n difficulty,\n nonce,\n });\n });\n }.toString();\n}\n\n", "import { process } from './proof-of-work.mjs';\nimport { testVideo } from './video.mjs';\n\n// from Xeact\nconst u = (url = \"\", params = {}) => {\n let result = new URL(url, window.location.href);\n Object.entries(params).forEach((kv) => {\n let [k, v] = kv;\n result.searchParams.set(k, v);\n });\n return result.toString();\n};\n\nconst imageURL = (mood) => {\n return `/.within.website/x/cmd/anubis/static/img/${mood}.webp`;\n};\n\n(async () => {\n const status = document.getElementById('status');\n const image = document.getElementById('image');\n const title = document.getElementById('title');\n const spinner = document.getElementById('spinner');\n // const testarea = document.getElementById('testarea');\n\n // const videoWorks = await testVideo(testarea);\n // console.log(`videoWorks: ${videoWorks}`);\n\n // if (!videoWorks) {\n // title.innerHTML = \"Oh no!\";\n // status.innerHTML = \"Checks failed. Please check your browser's settings and try again.\";\n // image.src = imageURL(\"sad\");\n // spinner.innerHTML = \"\";\n // spinner.style.display = \"none\";\n // return;\n // }\n\n status.innerHTML = 'Calculating...';\n\n const { challenge, difficulty } = await fetch(\"/.within.website/x/cmd/anubis/api/make-challenge\", { method: \"POST\" })\n .then(r => {\n if (!r.ok) {\n throw new Error(\"Failed to fetch config\");\n }\n return r.json();\n })\n .catch(err => {\n title.innerHTML = \"Oh no!\";\n status.innerHTML = `Failed to fetch config: ${err.message}`;\n image.src = imageURL(\"sad\");\n spinner.innerHTML = \"\";\n spinner.style.display = \"none\";\n throw err;\n });\n\n status.innerHTML = `Calculating...<br/>Difficulty: ${difficulty}`;\n\n const t0 = Date.now();\n const { hash, nonce } = await process(challenge, difficulty);\n const t1 = Date.now();\n\n title.innerHTML = \"Success!\";\n status.innerHTML = `Done! Took ${t1 - t0}ms, ${nonce} iterations`;\n image.src = imageURL(\"happy\");\n spinner.innerHTML = \"\";\n spinner.style.display = \"none\";\n\n setTimeout(() => {\n const redir = window.location.href;\n window.location.href = u(\"/.within.website/x/cmd/anubis/api/pass-challenge\", { response: hash, nonce, redir, elapsedTime: t1 - t0 });\n }, 2000);\n})();"],
|
||||
"mappings": "MAEO,SAASA,EAAQC,EAAMC,EAAa,EAAG,CAC5C,OAAO,IAAI,QAAQ,CAACC,EAASC,IAAW,CACtC,IAAIC,EAAe,IAAI,gBAAgB,IAAI,KAAK,CAC9C,IAAKC,EAAY,EAAG,KACtB,EAAG,CAAE,KAAM,wBAAyB,CAAC,CAAC,EAElCC,EAAS,IAAI,OAAOF,CAAY,EAEpCE,EAAO,UAAaC,GAAU,CAC5BD,EAAO,UAAU,EACjBJ,EAAQK,EAAM,IAAI,CACpB,EAEAD,EAAO,QAAWC,GAAU,CAC1BD,EAAO,UAAU,EACjBH,EAAO,CACT,EAEAG,EAAO,YAAY,CACjB,KAAAN,EACA,WAAAC,CACF,CAAC,EAED,IAAI,gBAAgBG,CAAY,CAClC,CAAC,CACH,CAEA,SAASC,GAAc,CACrB,OAAO,UAAY,CACjB,IAAMG,EAAUC,GAAS,CACvB,IAAMC,EAAU,IAAI,YAAY,EAAE,OAAOD,CAAI,EAC7C,OAAO,OAAO,OAAO,OAAO,UAAWC,EAAQ,MAAM,EAAE,KAAMC,GAC3D,MAAM,KAAK,IAAI,WAAWA,CAAM,CAAC,EAC9B,IAAKC,GAAMA,EAAE,SAAS,EAAE,EAAE,SAAS,EAAG,GAAG,CAAC,EAC1C,KAAK,EAAE,CACZ,CACF,EAEA,iBAAiB,UAAW,MAAOL,GAAU,CAC3C,IAAIP,EAAOO,EAAM,KAAK,KAClBN,EAAaM,EAAM,KAAK,WAExBM,EACAC,EAAQ,EACZ,GACED,EAAO,MAAML,EAAOR,EAAOc,GAAO,QAC3BD,EAAK,UAAU,EAAGZ,CAAU,IAAM,MAAMA,EAAa,CAAC,EAAE,KAAK,GAAG,GAEzEa,GAAS,EAET,YAAY,CACV,KAAAD,EACA,KAAAb,EACA,WAAAC,EACA,MAAAa,CACF,CAAC,CACH,CAAC,CACH,EAAE,SAAS,CACb,CCxDA,IAAMC,EAAI,CAACC,EAAM,GAAIC,EAAS,CAAC,IAAM,CACnC,IAAIC,EAAS,IAAI,IAAIF,EAAK,OAAO,SAAS,IAAI,EAC9C,cAAO,QAAQC,CAAM,EAAE,QAASE,GAAO,CACrC,GAAI,CAACC,EAAGC,CAAC,EAAIF,EACbD,EAAO,aAAa,IAAIE,EAAGC,CAAC,CAC9B,CAAC,EACMH,EAAO,SAAS,CACzB,EAEMI,EAAYC,GACT,4CAA4CA,CAAI,SAGxD,SAAY,CACX,IAAMC,EAAS,SAAS,eAAe,QAAQ,EACzCC,EAAQ,SAAS,eAAe,OAAO,EACvCC,EAAQ,SAAS,eAAe,OAAO,EACvCC,EAAU,SAAS,eAAe,SAAS,EAejDH,EAAO,UAAY,iBAEnB,GAAM,CAAE,UAAAI,EAAW,WAAAC,CAAW,EAAI,MAAM,MAAM,mDAAoD,CAAE,OAAQ,MAAO,CAAC,EACjH,KAAKC,GAAK,CACT,GAAI,CAACA,EAAE,GACL,MAAM,IAAI,MAAM,wBAAwB,EAE1C,OAAOA,EAAE,KAAK,CAChB,CAAC,EACA,MAAMC,GAAO,CACZ,MAAAL,EAAM,UAAY,SAClBF,EAAO,UAAY,2BAA2BO,EAAI,OAAO,GACzDN,EAAM,IAAMH,EAAS,KAAK,EAC1BK,EAAQ,UAAY,GACpBA,EAAQ,MAAM,QAAU,OAClBI,CACR,CAAC,EAEHP,EAAO,UAAY,kCAAkCK,CAAU,GAE/D,IAAMG,EAAK,KAAK,IAAI,EACd,CAAE,KAAAC,EAAM,MAAAC,CAAM,EAAI,MAAMC,EAAQP,EAAWC,CAAU,EACrDO,EAAK,KAAK,IAAI,EAEpBV,EAAM,UAAY,WAClBF,EAAO,UAAY,cAAcY,EAAKJ,CAAE,OAAOE,CAAK,cACpDT,EAAM,IAAMH,EAAS,OAAO,EAC5BK,EAAQ,UAAY,GACpBA,EAAQ,MAAM,QAAU,OAExB,WAAW,IAAM,CACf,IAAMU,EAAQ,OAAO,SAAS,KAC9B,OAAO,SAAS,KAAOtB,EAAE,mDAAoD,CAAE,SAAUkB,EAAM,MAAAC,EAAO,MAAAG,EAAO,YAAaD,EAAKJ,CAAG,CAAC,CACrI,EAAG,GAAI,CACT,GAAG",
|
||||
"names": ["process", "data", "difficulty", "resolve", "reject", "webWorkerURL", "processTask", "worker", "event", "sha256", "text", "encoded", "result", "c", "hash", "nonce", "u", "url", "params", "result", "kv", "k", "v", "imageURL", "mood", "status", "image", "title", "spinner", "challenge", "difficulty", "r", "err", "t0", "hash", "nonce", "process", "t1", "redir"]
|
||||
}
|
||||
1
cmd/containerbuild/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
images
|
||||
164
cmd/containerbuild/main.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/facebookgo/flagenv"
|
||||
)
|
||||
|
||||
var (
|
||||
dockerAnnotations = flag.String("docker-annotations", os.Getenv("DOCKER_METADATA_OUTPUT_ANNOTATIONS"), "Docker image annotations")
|
||||
dockerLabels = flag.String("docker-labels", os.Getenv("DOCKER_METADATA_OUTPUT_LABELS"), "Docker image labels")
|
||||
dockerRepo = flag.String("docker-repo", "registry.int.xeserv.us/techaro/anubis", "Docker image repository for Anubis")
|
||||
dockerTags = flag.String("docker-tags", os.Getenv("DOCKER_METADATA_OUTPUT_TAGS"), "newline separated docker tags including the registry name")
|
||||
githubActor = flag.String("github-actor", "", "GitHub actor")
|
||||
githubEventName = flag.String("github-event-name", "", "GitHub event name")
|
||||
pullRequestID = flag.Int("pull-request-id", -1, "GitHub pull request ID")
|
||||
slogLevel = flag.String("slog-level", "INFO", "logging level (see https://pkg.go.dev/log/slog#hdr-Levels)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flagenv.Parse()
|
||||
flag.Parse()
|
||||
|
||||
internal.InitSlog(*slogLevel)
|
||||
|
||||
koDockerRepo := strings.TrimRight(*dockerRepo, "/"+filepath.Base(*dockerRepo))
|
||||
|
||||
if *githubEventName == "pull_request" && *pullRequestID != -1 {
|
||||
*dockerRepo = fmt.Sprintf("ttl.sh/techaro/pr-%d/anubis", *pullRequestID)
|
||||
*dockerTags = fmt.Sprintf("ttl.sh/techaro/pr-%d/anubis:24h", *pullRequestID)
|
||||
koDockerRepo = fmt.Sprintf("ttl.sh/techaro/pr-%d", *pullRequestID)
|
||||
|
||||
slog.Info(
|
||||
"Building image for pull request",
|
||||
"docker-repo", *dockerRepo,
|
||||
"docker-tags", *dockerTags,
|
||||
"github-event-name", *githubEventName,
|
||||
"pull-request-id", *pullRequestID,
|
||||
)
|
||||
}
|
||||
|
||||
setOutput("docker_image", strings.SplitN(*dockerTags, "\n", 2)[0])
|
||||
|
||||
version, err := run("git describe --tags --always --dirty")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
commitTimestamp, err := run("git log -1 --format='%ct'")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
slog.Debug(
|
||||
"ko env",
|
||||
"KO_DOCKER_REPO", koDockerRepo,
|
||||
"SOURCE_DATE_EPOCH", commitTimestamp,
|
||||
"VERSION", version,
|
||||
)
|
||||
|
||||
os.Setenv("KO_DOCKER_REPO", koDockerRepo)
|
||||
os.Setenv("SOURCE_DATE_EPOCH", commitTimestamp)
|
||||
os.Setenv("VERSION", version)
|
||||
|
||||
setOutput("version", version)
|
||||
|
||||
if *dockerTags == "" {
|
||||
log.Fatal("Must set --docker-tags or DOCKER_METADATA_OUTPUT_TAGS")
|
||||
}
|
||||
|
||||
images, err := parseImageList(*dockerTags)
|
||||
if err != nil {
|
||||
log.Fatalf("can't parse images: %v", err)
|
||||
}
|
||||
|
||||
for _, img := range images {
|
||||
if img.repository != *dockerRepo {
|
||||
slog.Error(
|
||||
"Something weird is going on. Wanted docker repo differs from contents of --docker-tags. Did a flag get set incorrectly?",
|
||||
"wanted", *dockerRepo,
|
||||
"got", img.repository,
|
||||
"docker-tags", *dockerTags,
|
||||
)
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
var tags []string
|
||||
for _, img := range images {
|
||||
tags = append(tags, img.tag)
|
||||
}
|
||||
|
||||
output, err := run(fmt.Sprintf("ko build --platform=all --base-import-paths --tags=%q --image-user=1000 --image-annotation=%q --image-label=%q ./cmd/anubis | tail -n1", strings.Join(tags, ","), *dockerAnnotations, *dockerLabels))
|
||||
if err != nil {
|
||||
log.Fatalf("can't run ko build, check stderr: %v", err)
|
||||
}
|
||||
|
||||
sp := strings.SplitN(output, "@", 2)
|
||||
|
||||
setOutput("digest", sp[1])
|
||||
}
|
||||
|
||||
type image struct {
|
||||
repository string
|
||||
tag string
|
||||
}
|
||||
|
||||
func newlineSep2Comma(inp string) string {
|
||||
lines := strings.Split(inp, "\n")
|
||||
return strings.Join(lines, ",")
|
||||
}
|
||||
|
||||
func parseImageList(imageList string) ([]image, error) {
|
||||
images := strings.Split(imageList, "\n")
|
||||
var result []image
|
||||
for _, img := range images {
|
||||
if img == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// reg.xeiaso.net/techaro/anubis:latest
|
||||
// repository: reg.xeiaso.net/techaro/anubis
|
||||
// tag: latest
|
||||
parts := strings.SplitN(img, ":", 2)
|
||||
result = append(result, image{
|
||||
repository: parts[0],
|
||||
tag: parts[1],
|
||||
})
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("no images provided, bad flags??")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// run executes a command and returns the trimmed output.
|
||||
func run(command string) (string, error) {
|
||||
bin, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
slog.Debug("running command", "command", command)
|
||||
cmd := exec.Command(bin, "-c", command)
|
||||
cmd.Stderr = os.Stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func setOutput(key, val string) {
|
||||
fmt.Printf("::set-output name=%s::%s\n", key, val)
|
||||
}
|
||||
398
data/botPolicies.json
Normal file
@@ -0,0 +1,398 @@
|
||||
{
|
||||
"bots": [
|
||||
{
|
||||
"name": "amazonbot",
|
||||
"user_agent_regex": "Amazonbot",
|
||||
"action": "DENY"
|
||||
},
|
||||
{
|
||||
"name": "googlebot",
|
||||
"user_agent_regex": "\\+http\\://www\\.google\\.com/bot\\.html",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"2001:4860:4801:10::/64",
|
||||
"2001:4860:4801:11::/64",
|
||||
"2001:4860:4801:12::/64",
|
||||
"2001:4860:4801:13::/64",
|
||||
"2001:4860:4801:14::/64",
|
||||
"2001:4860:4801:15::/64",
|
||||
"2001:4860:4801:16::/64",
|
||||
"2001:4860:4801:17::/64",
|
||||
"2001:4860:4801:18::/64",
|
||||
"2001:4860:4801:19::/64",
|
||||
"2001:4860:4801:1a::/64",
|
||||
"2001:4860:4801:1b::/64",
|
||||
"2001:4860:4801:1c::/64",
|
||||
"2001:4860:4801:1d::/64",
|
||||
"2001:4860:4801:1e::/64",
|
||||
"2001:4860:4801:1f::/64",
|
||||
"2001:4860:4801:20::/64",
|
||||
"2001:4860:4801:21::/64",
|
||||
"2001:4860:4801:22::/64",
|
||||
"2001:4860:4801:23::/64",
|
||||
"2001:4860:4801:24::/64",
|
||||
"2001:4860:4801:25::/64",
|
||||
"2001:4860:4801:26::/64",
|
||||
"2001:4860:4801:27::/64",
|
||||
"2001:4860:4801:28::/64",
|
||||
"2001:4860:4801:29::/64",
|
||||
"2001:4860:4801:2::/64",
|
||||
"2001:4860:4801:2a::/64",
|
||||
"2001:4860:4801:2b::/64",
|
||||
"2001:4860:4801:2c::/64",
|
||||
"2001:4860:4801:2d::/64",
|
||||
"2001:4860:4801:2e::/64",
|
||||
"2001:4860:4801:2f::/64",
|
||||
"2001:4860:4801:31::/64",
|
||||
"2001:4860:4801:32::/64",
|
||||
"2001:4860:4801:33::/64",
|
||||
"2001:4860:4801:34::/64",
|
||||
"2001:4860:4801:35::/64",
|
||||
"2001:4860:4801:36::/64",
|
||||
"2001:4860:4801:37::/64",
|
||||
"2001:4860:4801:38::/64",
|
||||
"2001:4860:4801:39::/64",
|
||||
"2001:4860:4801:3a::/64",
|
||||
"2001:4860:4801:3b::/64",
|
||||
"2001:4860:4801:3c::/64",
|
||||
"2001:4860:4801:3d::/64",
|
||||
"2001:4860:4801:3e::/64",
|
||||
"2001:4860:4801:40::/64",
|
||||
"2001:4860:4801:41::/64",
|
||||
"2001:4860:4801:42::/64",
|
||||
"2001:4860:4801:43::/64",
|
||||
"2001:4860:4801:44::/64",
|
||||
"2001:4860:4801:45::/64",
|
||||
"2001:4860:4801:46::/64",
|
||||
"2001:4860:4801:47::/64",
|
||||
"2001:4860:4801:48::/64",
|
||||
"2001:4860:4801:49::/64",
|
||||
"2001:4860:4801:4a::/64",
|
||||
"2001:4860:4801:4b::/64",
|
||||
"2001:4860:4801:4c::/64",
|
||||
"2001:4860:4801:50::/64",
|
||||
"2001:4860:4801:51::/64",
|
||||
"2001:4860:4801:52::/64",
|
||||
"2001:4860:4801:53::/64",
|
||||
"2001:4860:4801:54::/64",
|
||||
"2001:4860:4801:55::/64",
|
||||
"2001:4860:4801:56::/64",
|
||||
"2001:4860:4801:60::/64",
|
||||
"2001:4860:4801:61::/64",
|
||||
"2001:4860:4801:62::/64",
|
||||
"2001:4860:4801:63::/64",
|
||||
"2001:4860:4801:64::/64",
|
||||
"2001:4860:4801:65::/64",
|
||||
"2001:4860:4801:66::/64",
|
||||
"2001:4860:4801:67::/64",
|
||||
"2001:4860:4801:68::/64",
|
||||
"2001:4860:4801:69::/64",
|
||||
"2001:4860:4801:6a::/64",
|
||||
"2001:4860:4801:6b::/64",
|
||||
"2001:4860:4801:6c::/64",
|
||||
"2001:4860:4801:6d::/64",
|
||||
"2001:4860:4801:6e::/64",
|
||||
"2001:4860:4801:6f::/64",
|
||||
"2001:4860:4801:70::/64",
|
||||
"2001:4860:4801:71::/64",
|
||||
"2001:4860:4801:72::/64",
|
||||
"2001:4860:4801:73::/64",
|
||||
"2001:4860:4801:74::/64",
|
||||
"2001:4860:4801:75::/64",
|
||||
"2001:4860:4801:76::/64",
|
||||
"2001:4860:4801:77::/64",
|
||||
"2001:4860:4801:78::/64",
|
||||
"2001:4860:4801:79::/64",
|
||||
"2001:4860:4801:80::/64",
|
||||
"2001:4860:4801:81::/64",
|
||||
"2001:4860:4801:82::/64",
|
||||
"2001:4860:4801:83::/64",
|
||||
"2001:4860:4801:84::/64",
|
||||
"2001:4860:4801:85::/64",
|
||||
"2001:4860:4801:86::/64",
|
||||
"2001:4860:4801:87::/64",
|
||||
"2001:4860:4801:88::/64",
|
||||
"2001:4860:4801:90::/64",
|
||||
"2001:4860:4801:91::/64",
|
||||
"2001:4860:4801:92::/64",
|
||||
"2001:4860:4801:93::/64",
|
||||
"2001:4860:4801:94::/64",
|
||||
"2001:4860:4801:95::/64",
|
||||
"2001:4860:4801:96::/64",
|
||||
"2001:4860:4801:a0::/64",
|
||||
"2001:4860:4801:a1::/64",
|
||||
"2001:4860:4801:a2::/64",
|
||||
"2001:4860:4801:a3::/64",
|
||||
"2001:4860:4801:a4::/64",
|
||||
"2001:4860:4801:a5::/64",
|
||||
"2001:4860:4801:c::/64",
|
||||
"2001:4860:4801:f::/64",
|
||||
"192.178.5.0/27",
|
||||
"192.178.6.0/27",
|
||||
"192.178.6.128/27",
|
||||
"192.178.6.160/27",
|
||||
"192.178.6.192/27",
|
||||
"192.178.6.32/27",
|
||||
"192.178.6.64/27",
|
||||
"192.178.6.96/27",
|
||||
"34.100.182.96/28",
|
||||
"34.101.50.144/28",
|
||||
"34.118.254.0/28",
|
||||
"34.118.66.0/28",
|
||||
"34.126.178.96/28",
|
||||
"34.146.150.144/28",
|
||||
"34.147.110.144/28",
|
||||
"34.151.74.144/28",
|
||||
"34.152.50.64/28",
|
||||
"34.154.114.144/28",
|
||||
"34.155.98.32/28",
|
||||
"34.165.18.176/28",
|
||||
"34.175.160.64/28",
|
||||
"34.176.130.16/28",
|
||||
"34.22.85.0/27",
|
||||
"34.64.82.64/28",
|
||||
"34.65.242.112/28",
|
||||
"34.80.50.80/28",
|
||||
"34.88.194.0/28",
|
||||
"34.89.10.80/28",
|
||||
"34.89.198.80/28",
|
||||
"34.96.162.48/28",
|
||||
"35.247.243.240/28",
|
||||
"66.249.64.0/27",
|
||||
"66.249.64.128/27",
|
||||
"66.249.64.160/27",
|
||||
"66.249.64.224/27",
|
||||
"66.249.64.32/27",
|
||||
"66.249.64.64/27",
|
||||
"66.249.64.96/27",
|
||||
"66.249.65.0/27",
|
||||
"66.249.65.128/27",
|
||||
"66.249.65.160/27",
|
||||
"66.249.65.192/27",
|
||||
"66.249.65.224/27",
|
||||
"66.249.65.32/27",
|
||||
"66.249.65.64/27",
|
||||
"66.249.65.96/27",
|
||||
"66.249.66.0/27",
|
||||
"66.249.66.128/27",
|
||||
"66.249.66.160/27",
|
||||
"66.249.66.192/27",
|
||||
"66.249.66.224/27",
|
||||
"66.249.66.32/27",
|
||||
"66.249.66.64/27",
|
||||
"66.249.66.96/27",
|
||||
"66.249.68.0/27",
|
||||
"66.249.68.128/27",
|
||||
"66.249.68.32/27",
|
||||
"66.249.68.64/27",
|
||||
"66.249.68.96/27",
|
||||
"66.249.69.0/27",
|
||||
"66.249.69.128/27",
|
||||
"66.249.69.160/27",
|
||||
"66.249.69.192/27",
|
||||
"66.249.69.224/27",
|
||||
"66.249.69.32/27",
|
||||
"66.249.69.64/27",
|
||||
"66.249.69.96/27",
|
||||
"66.249.70.0/27",
|
||||
"66.249.70.128/27",
|
||||
"66.249.70.160/27",
|
||||
"66.249.70.192/27",
|
||||
"66.249.70.224/27",
|
||||
"66.249.70.32/27",
|
||||
"66.249.70.64/27",
|
||||
"66.249.70.96/27",
|
||||
"66.249.71.0/27",
|
||||
"66.249.71.128/27",
|
||||
"66.249.71.160/27",
|
||||
"66.249.71.192/27",
|
||||
"66.249.71.224/27",
|
||||
"66.249.71.32/27",
|
||||
"66.249.71.64/27",
|
||||
"66.249.71.96/27",
|
||||
"66.249.72.0/27",
|
||||
"66.249.72.128/27",
|
||||
"66.249.72.160/27",
|
||||
"66.249.72.192/27",
|
||||
"66.249.72.224/27",
|
||||
"66.249.72.32/27",
|
||||
"66.249.72.64/27",
|
||||
"66.249.72.96/27",
|
||||
"66.249.73.0/27",
|
||||
"66.249.73.128/27",
|
||||
"66.249.73.160/27",
|
||||
"66.249.73.192/27",
|
||||
"66.249.73.224/27",
|
||||
"66.249.73.32/27",
|
||||
"66.249.73.64/27",
|
||||
"66.249.73.96/27",
|
||||
"66.249.74.0/27",
|
||||
"66.249.74.128/27",
|
||||
"66.249.74.160/27",
|
||||
"66.249.74.192/27",
|
||||
"66.249.74.32/27",
|
||||
"66.249.74.64/27",
|
||||
"66.249.74.96/27",
|
||||
"66.249.75.0/27",
|
||||
"66.249.75.128/27",
|
||||
"66.249.75.160/27",
|
||||
"66.249.75.192/27",
|
||||
"66.249.75.224/27",
|
||||
"66.249.75.32/27",
|
||||
"66.249.75.64/27",
|
||||
"66.249.75.96/27",
|
||||
"66.249.76.0/27",
|
||||
"66.249.76.128/27",
|
||||
"66.249.76.160/27",
|
||||
"66.249.76.192/27",
|
||||
"66.249.76.224/27",
|
||||
"66.249.76.32/27",
|
||||
"66.249.76.64/27",
|
||||
"66.249.76.96/27",
|
||||
"66.249.77.0/27",
|
||||
"66.249.77.128/27",
|
||||
"66.249.77.160/27",
|
||||
"66.249.77.192/27",
|
||||
"66.249.77.224/27",
|
||||
"66.249.77.32/27",
|
||||
"66.249.77.64/27",
|
||||
"66.249.77.96/27",
|
||||
"66.249.78.0/27",
|
||||
"66.249.78.32/27",
|
||||
"66.249.79.0/27",
|
||||
"66.249.79.128/27",
|
||||
"66.249.79.160/27",
|
||||
"66.249.79.192/27",
|
||||
"66.249.79.224/27",
|
||||
"66.249.79.32/27",
|
||||
"66.249.79.64/27",
|
||||
"66.249.79.96/27"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "bingbot",
|
||||
"user_agent_regex": "\\+http\\://www\\.bing\\.com/bingbot\\.htm",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"157.55.39.0/24",
|
||||
"207.46.13.0/24",
|
||||
"40.77.167.0/24",
|
||||
"13.66.139.0/24",
|
||||
"13.66.144.0/24",
|
||||
"52.167.144.0/24",
|
||||
"13.67.10.16/28",
|
||||
"13.69.66.240/28",
|
||||
"13.71.172.224/28",
|
||||
"139.217.52.0/28",
|
||||
"191.233.204.224/28",
|
||||
"20.36.108.32/28",
|
||||
"20.43.120.16/28",
|
||||
"40.79.131.208/28",
|
||||
"40.79.186.176/28",
|
||||
"52.231.148.0/28",
|
||||
"20.79.107.240/28",
|
||||
"51.105.67.0/28",
|
||||
"20.125.163.80/28",
|
||||
"40.77.188.0/22",
|
||||
"65.55.210.0/24",
|
||||
"199.30.24.0/23",
|
||||
"40.77.202.0/24",
|
||||
"40.77.139.0/25",
|
||||
"20.74.197.0/28",
|
||||
"20.15.133.160/27",
|
||||
"40.77.177.0/24",
|
||||
"40.77.178.0/23"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "qwantbot",
|
||||
"user_agent_regex": "\\+https\\://help\\.qwant\\.com/bot/",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"91.242.162.0/24"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "kagibot",
|
||||
"user_agent_regex": "\\+https\\://kagi\\.com/bot",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"216.18.205.234/32",
|
||||
"35.212.27.76/32",
|
||||
"104.254.65.50/32",
|
||||
"209.151.156.194/32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "marginalia",
|
||||
"user_agent_regex": "search\\.marginalia\\.nu",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"193.183.0.162/31",
|
||||
"193.183.0.164/30",
|
||||
"193.183.0.168/30",
|
||||
"193.183.0.172/31",
|
||||
"193.183.0.174/32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "mojeekbot",
|
||||
"user_agent_regex": "http\\://www\\.mojeek\\.com/bot\\.html",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"5.102.173.71/32"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": "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-bot-catchall",
|
||||
"user_agent_regex": "(?i:bot|crawler)",
|
||||
"action": "CHALLENGE",
|
||||
"challenge": {
|
||||
"difficulty": 16,
|
||||
"report_as": 4,
|
||||
"algorithm": "slow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "generic-browser",
|
||||
"user_agent_regex": "Mozilla",
|
||||
"action": "CHALLENGE"
|
||||
}
|
||||
],
|
||||
"dnsbl": true
|
||||
}
|
||||
8
data/embed.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package data
|
||||
|
||||
import "embed"
|
||||
|
||||
var (
|
||||
//go:embed botPolicies.json
|
||||
BotPolicies embed.FS
|
||||
)
|
||||
@@ -1,17 +1,17 @@
|
||||
package main
|
||||
package decaymap
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func zilch[T any]() T {
|
||||
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 {
|
||||
// Impl 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 Impl[K comparable, V any] struct {
|
||||
data map[K]decayMapEntry[V]
|
||||
lock sync.RWMutex
|
||||
}
|
||||
@@ -21,17 +21,17 @@ type decayMapEntry[V any] struct {
|
||||
expiry time.Time
|
||||
}
|
||||
|
||||
// NewDecayMap creates a new DecayMap of key type K and value type V.
|
||||
// New 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]{
|
||||
func New[K comparable, V any]() *Impl[K, V] {
|
||||
return &Impl[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 {
|
||||
func (m *Impl[K, V]) expire(key K) bool {
|
||||
m.lock.RLock()
|
||||
val, ok := m.data[key]
|
||||
m.lock.RUnlock()
|
||||
@@ -51,32 +51,32 @@ func (m *DecayMap[K, V]) expire(key K) bool {
|
||||
// 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) {
|
||||
func (m *Impl[K, V]) Get(key K) (V, bool) {
|
||||
m.lock.RLock()
|
||||
value, ok := m.data[key]
|
||||
m.lock.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return zilch[V](), false
|
||||
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 {
|
||||
if m.data[key].expiry.Equal(value.expiry) {
|
||||
delete(m.data, key)
|
||||
}
|
||||
m.lock.Unlock()
|
||||
|
||||
return zilch[V](), false
|
||||
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) {
|
||||
func (m *Impl[K, V]) Set(key K, value V, ttl time.Duration) {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package main
|
||||
package decaymap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecayMap(t *testing.T) {
|
||||
dm := NewDecayMap[string, string]()
|
||||
func TestImpl(t *testing.T) {
|
||||
dm := New[string, string]()
|
||||
|
||||
dm.Set("test", "hi", 5*time.Minute)
|
||||
|
||||
8
doc.go
@@ -1,8 +0,0 @@
|
||||
// Package Anubis contains the version number of Anubis.
|
||||
package anubis
|
||||
|
||||
// Version is the current version of Anubis.
|
||||
//
|
||||
// This variable is set at build time using the -X linker flag. If not set,
|
||||
// it defaults to "devel".
|
||||
var Version = "devel"
|
||||
23
docs/.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Kubernetes manifests
|
||||
/manifest
|
||||
20
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
10
docs/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM docker.io/library/node AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN npm ci && npm run build
|
||||
|
||||
FROM docker.io/library/nginx:alpine
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
LABEL org.opencontainers.image.source="https://github.com/TecharoHQ/anubis"
|
||||
41
docs/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
12
docs/blog/2019-05-28-first-blog-post.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
slug: first-blog-post
|
||||
title: First Blog Post
|
||||
authors: [slorber, yangshun]
|
||||
tags: [hola, docusaurus]
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet...
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
...consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
44
docs/blog/2019-05-29-long-blog-post.md
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
slug: long-blog-post
|
||||
title: Long Blog Post
|
||||
authors: yangshun
|
||||
tags: [hello, docusaurus]
|
||||
---
|
||||
|
||||
This is the summary of a very long blog post,
|
||||
|
||||
Use a `<!--` `truncate` `-->` comment to limit blog post size in the list view.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque elementum dignissim ultricies. Fusce rhoncus ipsum tempor eros aliquam consequat. Lorem ipsum dolor sit amet
|
||||
24
docs/blog/2021-08-01-mdx-blog-post.mdx
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
slug: mdx-blog-post
|
||||
title: MDX Blog Post
|
||||
authors: [slorber]
|
||||
tags: [docusaurus]
|
||||
---
|
||||
|
||||
Blog posts support [Docusaurus Markdown features](https://docusaurus.io/docs/markdown-features), such as [MDX](https://mdxjs.com/).
|
||||
|
||||
:::tip
|
||||
|
||||
Use the power of React to create interactive blog posts.
|
||||
|
||||
:::
|
||||
|
||||
{/* truncate */}
|
||||
|
||||
For example, use JSX to create an interactive button:
|
||||
|
||||
```js
|
||||
<button onClick={() => alert('button clicked!')}>Click me!</button>
|
||||
```
|
||||
|
||||
<button onClick={() => alert('button clicked!')}>Click me!</button>
|
||||
BIN
docs/blog/2021-08-26-welcome/docusaurus-plushie-banner.jpeg
Normal file
|
After Width: | Height: | Size: 94 KiB |
29
docs/blog/2021-08-26-welcome/index.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
slug: welcome
|
||||
title: Welcome
|
||||
authors: [slorber, yangshun]
|
||||
tags: [facebook, hello, docusaurus]
|
||||
---
|
||||
|
||||
[Docusaurus blogging features](https://docusaurus.io/docs/blog) are powered by the [blog plugin](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-blog).
|
||||
|
||||
Here are a few tips you might find useful.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
Simply add Markdown files (or folders) to the `blog` directory.
|
||||
|
||||
Regular blog authors can be added to `authors.yml`.
|
||||
|
||||
The blog post date can be extracted from filenames, such as:
|
||||
|
||||
- `2019-05-30-welcome.md`
|
||||
- `2019-05-30-welcome/index.md`
|
||||
|
||||
A blog post folder can be convenient to co-locate blog post images:
|
||||
|
||||

|
||||
|
||||
The blog supports tags as well!
|
||||
|
||||
**And if you don't want a blog**: just delete this directory, and use `blog: false` in your Docusaurus config.
|
||||
23
docs/blog/authors.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
yangshun:
|
||||
name: Yangshun Tay
|
||||
title: Front End Engineer @ Facebook
|
||||
url: https://github.com/yangshun
|
||||
image_url: https://github.com/yangshun.png
|
||||
page: true
|
||||
socials:
|
||||
x: yangshunz
|
||||
github: yangshun
|
||||
|
||||
slorber:
|
||||
name: Sébastien Lorber
|
||||
title: Docusaurus maintainer
|
||||
url: https://sebastienlorber.com
|
||||
image_url: https://github.com/slorber.png
|
||||
page:
|
||||
# customize the url of the author page at /blog/authors/<permalink>
|
||||
permalink: '/all-sebastien-lorber-articles'
|
||||
socials:
|
||||
x: sebastienlorber
|
||||
linkedin: sebastienlorber
|
||||
github: slorber
|
||||
newsletter: https://thisweekinreact.com
|
||||
19
docs/blog/tags.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
facebook:
|
||||
label: Facebook
|
||||
permalink: /facebook
|
||||
description: Facebook tag description
|
||||
|
||||
hello:
|
||||
label: Hello
|
||||
permalink: /hello
|
||||
description: Hello tag description
|
||||
|
||||
docusaurus:
|
||||
label: Docusaurus
|
||||
permalink: /docusaurus
|
||||
description: Docusaurus tag description
|
||||
|
||||
hola:
|
||||
label: Hola
|
||||
permalink: /hola
|
||||
description: Hola tag description
|
||||
144
docs/docs/CHANGELOG.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
sidebar_position: 999
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v1.15.2
|
||||
|
||||
Zenos yae Galvus: Echo 2
|
||||
|
||||
The mascot has been updated with art by [CELPHASE](https://bsky.app/profile/celphase.bsky.social).
|
||||
|
||||
## v1.15.1
|
||||
|
||||
Zenos yae Galvus: Echo 1
|
||||
|
||||
Fixes a recurrence of [CVE-2025-24369](https://github.com/Xe/x/security/advisories/GHSA-56w8-8ppj-2p4f)
|
||||
due to an incorrect logic change in a refactor. This allows an attacker to mint a valid
|
||||
access token by passing any SHA-256 hash instead of one that matches the proof-of-work
|
||||
test.
|
||||
|
||||
This case has been added as a regression test. It was not when CVE-2025-24369 was released
|
||||
due to the project not having the maturity required to enable this kind of regression testing.
|
||||
|
||||
## v1.15.0
|
||||
|
||||
Zenos yae Galvus
|
||||
|
||||
> Yes...the coming days promise to be most interesting. Most interesting.
|
||||
|
||||
Headline changes:
|
||||
|
||||
- ed25519 signing keys for Anubis can be stored in the flag `--ed25519-private-key-hex` or envvar `ED25519_PRIVATE_KEY_HEX`; if one is not provided when Anubis starts, a new one is generated and logged
|
||||
- Add the ability to set the cookie domain with the envvar `COOKIE_DOMAIN=techaro.lol` for all domains under `techaro.lol`
|
||||
- Add the ability to set the cookie partitioned flag with the envvar `COOKIE_PARTITIONED=true`
|
||||
|
||||
Many other small changes were made, including but not limited to:
|
||||
|
||||
- Fixed and clarified installation instructions
|
||||
- Introduced integration tests using Playwright
|
||||
- Refactor & Split up Anubis into cmd and lib.go
|
||||
- Fixed bot check to only apply if address range matches
|
||||
- Fix default difficulty setting that was broken in a refactor
|
||||
- Linting fixes
|
||||
- Make dark mode diff lines readable in the documentation
|
||||
- Fix CI based browser smoke test
|
||||
|
||||
Users running Anubis' test suite may run into issues with the integration tests on Windows hosts. This is a known issue and will be fixed at some point in the future. In the meantime, use the Windows Subsystem for Linux (WSL).
|
||||
|
||||
## v1.14.2
|
||||
|
||||
Livia sas Junius: Echo 2
|
||||
|
||||
- Remove default RSS reader rule as it may allow for a targeted attack against rails apps
|
||||
[#67](https://github.com/TecharoHQ/anubis/pull/67)
|
||||
- Whitelist MojeekBot in botPolicies [#47](https://github.com/TecharoHQ/anubis/issues/47)
|
||||
- botPolicies regex has been cleaned up [#66](https://github.com/TecharoHQ/anubis/pull/66)
|
||||
|
||||
## v1.14.1
|
||||
|
||||
Livia sas Junius: Echo 1
|
||||
|
||||
- Set the `X-Real-Ip` header based on the contents of `X-Forwarded-For`
|
||||
[#62](https://github.com/TecharoHQ/anubis/issues/62)
|
||||
|
||||
## v1.14.0
|
||||
|
||||
Livia sas Junius
|
||||
|
||||
> Fail to do as my lord commands...and I will spare him the trouble of blocking you.
|
||||
|
||||
- Add explanation of what Anubis is doing to the challenge page [#25](https://github.com/TecharoHQ/anubis/issues/25)
|
||||
- Administrators can now define artificially hard challenges using the "slow" algorithm:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "generic-bot-catchall",
|
||||
"user_agent_regex": "(?i:bot|crawler)",
|
||||
"action": "CHALLENGE",
|
||||
"challenge": {
|
||||
"difficulty": 16,
|
||||
"report_as": 4,
|
||||
"algorithm": "slow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This allows administrators to cause particularly malicious clients to use unreasonable amounts of CPU. The UI will also lie to the client about the difficulty.
|
||||
|
||||
- Docker images now explicitly call `docker.io/library/<thing>` to increase compatibility with Podman et. al
|
||||
[#21](https://github.com/TecharoHQ/anubis/pull/21)
|
||||
- Don't overflow the image when browser windows are small (eg. on phones)
|
||||
[#27](https://github.com/TecharoHQ/anubis/pull/27)
|
||||
- Lower the default difficulty to 4 from 5
|
||||
- Don't duplicate work across multiple threads [#36](https://github.com/TecharoHQ/anubis/pull/36)
|
||||
- Documentation has been moved to https://anubis.techaro.lol/ with sources in docs/
|
||||
- Removed several visible AI artifacts (e.g., 6 fingers) [#37](https://github.com/TecharoHQ/anubis/pull/37)
|
||||
- [KagiBot](https://kagi.com/bot) is allowed through the filter [#44](https://github.com/TecharoHQ/anubis/pull/44)
|
||||
- Fixed hang when navigator.hardwareConcurrency is undefined
|
||||
- Support Unix domain sockets [#45](https://github.com/TecharoHQ/anubis/pull/45)
|
||||
- Allow filtering by remote addresses:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "qwantbot",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": ["91.242.162.0/24"]
|
||||
}
|
||||
```
|
||||
|
||||
This also works at an IP range level:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "internal-network",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": ["100.64.0.0/10"]
|
||||
}
|
||||
```
|
||||
|
||||
## 1.13.0
|
||||
|
||||
- Proof-of-work challenges are drastically sped up [#19](https://github.com/TecharoHQ/anubis/pull/19)
|
||||
- Docker images are now built with the timestamp set to the commit timestamp
|
||||
- The README now points to TecharoHQ/anubis instead of Xe/x
|
||||
- Images are built using ko instead of `docker buildx build`
|
||||
[#13](https://github.com/TecharoHQ/anubis/pull/13)
|
||||
|
||||
## 1.12.1
|
||||
|
||||
- Phrasing in the `<noscript>` warning was replaced from its original placeholder text to
|
||||
something more suitable for general consumption
|
||||
([fd6903a](https://github.com/TecharoHQ/anubis/commit/fd6903aeed315b8fddee32890d7458a9271e4798)).
|
||||
- Footer links on the check page now point to Techaro's brand
|
||||
([4ebccb1](https://github.com/TecharoHQ/anubis/commit/4ebccb197ec20d024328d7f92cad39bbbe4d6359))
|
||||
- Anubis was imported from [Xe/x](https://github.com/Xe/x).
|
||||
8
docs/docs/admin/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Administrative guides",
|
||||
"position": 40,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Tradeoffs and considerations you may want to keep in mind when using Anubis."
|
||||
}
|
||||
}
|
||||
12
docs/docs/admin/algorithm-selection.mdx
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Proof-of-Work Algorithm Selection
|
||||
---
|
||||
|
||||
Anubis offers two proof-of-work algorithms:
|
||||
|
||||
- `"fast"`: highly optimized JavaScript that will run as fast as your computer lets it
|
||||
- `"slow"`: intentionally slow JavaScript that will waste time and memory
|
||||
|
||||
The fast algorithm is used by default to limit impacts on users' computers. Administrators may configure individual bot policy rules to use the slow algorithm in order to make known malicious clients waitloop and do nothing useful.
|
||||
|
||||
Generally, you should use the fast algorithm unless you have a good reason not to.
|
||||
34
docs/docs/admin/caveats-gitea-forgejo.mdx
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: When using Caddy with 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:
|
||||
|
||||
```python
|
||||
ellenjoe.int.within.lgbt {
|
||||
# ...
|
||||
# diff-remove
|
||||
reverse_proxy http://localhost:3000
|
||||
# diff-add
|
||||
reverse_proxy http://localhost:3000 {
|
||||
# diff-add
|
||||
header_up X-Real-Ip {remote_host}
|
||||
# diff-add
|
||||
}
|
||||
# ...
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
```
|
||||
174
docs/docs/admin/installation.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
title: Setting up Anubis
|
||||
---
|
||||
|
||||
import RandomKey from "@site/src/components/RandomKey";
|
||||
|
||||
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.
|
||||
|
||||
<center>
|
||||
|
||||
```mermaid
|
||||
---
|
||||
title: With Anubis installed
|
||||
---
|
||||
|
||||
flowchart LR
|
||||
LB(Load balancer /
|
||||
TLS terminator)
|
||||
Anubis(Anubis)
|
||||
App(App)
|
||||
|
||||
LB --> Anubis --> App
|
||||
```
|
||||
|
||||
</center>
|
||||
|
||||
Anubis is shipped in the Docker repo [`ghcr.io/techarohq/anubis`](https://github.com/TecharoHQ/anubis/pkgs/container/anubis). The following tags exist for your convenience:
|
||||
|
||||
| Tag | Meaning |
|
||||
| :------------------ | :--------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `latest` | The latest [tagged release](https://github.com/TecharoHQ/anubis/releases), if you are in doubt, start here. |
|
||||
| `v<version number>` | The Anubis image for [any given tagged release](https://github.com/TecharoHQ/anubis/tags) |
|
||||
| `main` | The current build on the `main` branch. Only use this if you need the latest and greatest features as they are merged into `main`. |
|
||||
| `pr-<number>` | The build associated with PR `#<number>`. Only use this for debugging issues fixed by a PR. |
|
||||
|
||||
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 network address that Anubis listens on. For `unix`, set this to a path: `/run/anubis/instance.sock` |
|
||||
| `BIND_NETWORK` | `tcp` | The address family that Anubis listens on. Accepts `tcp`, `unix` and anything Go's [`net.Listen`](https://pkg.go.dev/net#Listen) supports. |
|
||||
| `COOKIE_DOMAIN` | unset | The domain the Anubis challenge pass cookie should be set to. This should be set to the domain you bought from your registrar (EG: `techaro.lol` if your webapp is running on `anubis.techaro.lol`). See [here](https://stackoverflow.com/a/1063760) for more information. |
|
||||
| `COOKIE_PARTITIONED` | `false` | If set to `true`, enables the [partitioned (CHIPS) flag](https://developers.google.com/privacy-sandbox/cookies/chips), meaning that Anubis inside an iframe has a different set of cookies than the domain hosting the iframe. |
|
||||
| `DIFFICULTY` | `5` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
|
||||
| `ED25519_PRIVATE_KEY_HEX` | | The hex-encoded ed25519 private key used to sign Anubis responses. If this is not set, Anubis will generate one for you. This should be exactly 64 characters long. See below for details. |
|
||||
| `METRICS_BIND` | `:9090` | The network address that Anubis serves Prometheus metrics on. See `BIND` for more information. |
|
||||
| `METRICS_BIND_NETWORK` | `tcp` | The address family that the Anubis metrics server listens on. See `BIND_NETWORK` for more information. |
|
||||
| `SOCKET_MODE` | `0770` | _Only used when at least one of the `*_BIND_NETWORK` variables are set to `unix`._ The socket mode (permissions) for Unix domain sockets. |
|
||||
| `POLICY_FNAME` | unset | The file containing [bot policy configuration](./policies.md). See the bot policy documentation for more details. If unset, the default bot policy configuration is used. |
|
||||
| `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. Supports Unix domain sockets, set this to a URI like so: `unix:///path/to/socket.sock`. |
|
||||
|
||||
### Key generation
|
||||
|
||||
To generate an ed25519 private key, you can use this command:
|
||||
|
||||
```text
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Alternatively here is a key generated by your browser:
|
||||
|
||||
<RandomKey />
|
||||
|
||||
## Docker compose
|
||||
|
||||
Add Anubis to your compose file pointed at your service:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
anubis-nginx:
|
||||
image: ghcr.io/techarohq/anubis:latest
|
||||
environment:
|
||||
BIND: ":8080"
|
||||
DIFFICULTY: "5"
|
||||
METRICS_BIND: ":9090"
|
||||
SERVE_ROBOTS_TXT: "true"
|
||||
TARGET: "http://nginx"
|
||||
POLICY_FNAME: "/data/cfg/botPolicy.json"
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- "./botPolicy.json:/data/cfg/botPolicy.json:ro"
|
||||
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/techarohq/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:
|
||||
|
||||
```yaml
|
||||
# ...
|
||||
spec:
|
||||
ports:
|
||||
# diff-add
|
||||
- protocol: TCP
|
||||
# diff-add
|
||||
port: 8080
|
||||
# diff-add
|
||||
targetPort: 8080
|
||||
# diff-add
|
||||
name: anubis
|
||||
```
|
||||
|
||||
Then point your Ingress to the Anubis port:
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- host: git.xeserv.us
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: git
|
||||
port:
|
||||
# diff-remove
|
||||
name: http
|
||||
# diff-add
|
||||
name: anubis
|
||||
```
|
||||
@@ -1,4 +1,6 @@
|
||||
# Policies
|
||||
---
|
||||
title: Policy Definitions
|
||||
---
|
||||
|
||||
Out of the box, Anubis is pretty heavy-handed. It will aggressively challenge everything that might be a browser (usually indicated by having `Mozilla` in its user agent). However, some bots are smart enough to get past the challenge. Some things that look like bots may actually be fine (IE: RSS readers). Some resources need to be visible no matter what. Some resources and remotes are fine to begin with.
|
||||
|
||||
@@ -50,7 +52,7 @@ Here is a minimal policy file that will protect against most scraper bots:
|
||||
}
|
||||
```
|
||||
|
||||
This allows requests to [`/.well-known`](https://en.wikipedia.org/wiki/Well-known_URI), `/favicon.ico`, `/robots.txt`, and challenges any request that has the word `Mozilla` in its User-Agent string. The [default policy file](../botPolicies.json) is a bit more cohesive, but this should be more than enough for most users.
|
||||
This allows requests to [`/.well-known`](https://en.wikipedia.org/wiki/Well-known_URI), `/favicon.ico`, `/robots.txt`, and challenges any request that has the word `Mozilla` in its User-Agent string. The [default policy file](https://github.com/TecharoHQ/anubis/blob/main/data/botPolicies.json) is a bit more cohesive, but this should be more than enough for most users.
|
||||
|
||||
If no rules match the request, it is allowed through.
|
||||
|
||||
@@ -66,6 +68,58 @@ There are three actions that can be returned from a rule:
|
||||
|
||||
Name your rules in lower case using kebab-case. Rule names will be exposed in Prometheus metrics.
|
||||
|
||||
### Challenge configuration
|
||||
|
||||
Rules can also have their own challenge settings. These are customized using the `"challenge"` key. For example, here is a rule that makes challenges artificially hard for connections with the substring "bot" in their user agent:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "generic-bot-catchall",
|
||||
"user_agent_regex": "(?i:bot|crawler)",
|
||||
"action": "CHALLENGE",
|
||||
"challenge": {
|
||||
"difficulty": 16,
|
||||
"report_as": 4,
|
||||
"algorithm": "slow"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Challenges can be configured with these settings:
|
||||
|
||||
| Key | Example | Description |
|
||||
| :----------- | :------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `difficulty` | `4` | The challenge difficulty (number of leading zeros) for proof-of-work. See [Why does Anubis use Proof-of-Work?](/docs/design/why-proof-of-work) for more details. |
|
||||
| `report_as` | `4` | What difficulty the UI should report to the user. Useful for messing with industrial-scale scraping efforts. |
|
||||
| `algorithm` | `"fast"` | The algorithm used on the client to run proof-of-work calculations. This must be set to `"fast"` or `"slow"`. See [Proof-of-Work Algorithm Selection](./algorithm-selection) for more details. |
|
||||
|
||||
### Remote IP based filtering
|
||||
|
||||
The `remote_addresses` field of a Bot rule allows you to set the IP range that this ruleset applies to.
|
||||
|
||||
For example, you can allow a search engine to connect if and only if its IP address matches the ones they published:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "qwantbot",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": ["91.242.162.0/24"]
|
||||
}
|
||||
```
|
||||
|
||||
This also works at an IP range level without any other checks:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "internal-network",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": ["100.64.0.0/10"]
|
||||
}
|
||||
```
|
||||
|
||||
## Risk calculation for downstream services
|
||||
|
||||
In case your service needs it for risk calculation reasons, Anubis exposes information about the rules that any requests match using a few headers:
|
||||
|
||||
| Header | Explanation | Example |
|
||||
8
docs/docs/design/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Design",
|
||||
"position": 10,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "How Anubis is designed and the tradeoffs it makes."
|
||||
}
|
||||
}
|
||||
120
docs/docs/design/how-anubis-works.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
title: 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.
|
||||
36
docs/docs/design/why-proof-of-work.mdx
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: Why does Anubis use Proof-of-Work?
|
||||
---
|
||||
|
||||
Anubis uses a [proof of work](https://en.wikipedia.org/wiki/Proof_of_work) in order to validate that clients are genuine. The reason Anubis does this was inspired by [Hashcash](https://en.wikipedia.org/wiki/Hashcash), a suggestion from the early 2000's about extending the email protocol to avoid spam. The idea is that genuine people sending emails will have to do a small math problem that is expensive to compute, but easy to verify such as hashing a string with a given number of leading zeroes. This will have basically no impact on individuals sending a few emails a week, but the company churning out industrial quantities of advertising will be required to do prohibitively expensive computation. This is also how Bitcoin's consensus algorithm works.
|
||||
|
||||
## How Anubis' proof of work scheme works
|
||||
|
||||
A sha256 hash is a bunch of bytes like this:
|
||||
|
||||
```text
|
||||
394d1cc82924c2368d4e34fa450c6b30d5d02f8ae4bb6310e2296593008ff89f
|
||||
```
|
||||
|
||||
We usually write it out in hex form, but that's literally what the bytes in ram look like. In a proof of work validation system, you take some base value (the "challenge") and a constantly incrementing number (the "nonce"), so the thing you end up hashing is this:
|
||||
|
||||
```js
|
||||
const hash = await sha256(`${challenge}${nonce}`);
|
||||
```
|
||||
|
||||
In order to pass a challenge, the `hash` has to have the right number of leading zeros (the "difficulty"). When a client requests to pass the challenge, they include the nonce they used. The server then only has to do one sha256 operation: the one that confirms that the challenge (generated from request metadata) and the nonce (provided by the client) match the difficulty number of leading zeroes.
|
||||
|
||||
Ultimately, this is a hack whose real purpose is to give a "good enough" placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to known legitimate users.
|
||||
|
||||
## Challenge format
|
||||
|
||||
Anubis generates challenges based on browser metadata, including but not limited to the following:
|
||||
|
||||
- The contents of your [`Accept-Language` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language)
|
||||
- The IP address of your client
|
||||
- Your browser's [`User-Agent` string](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/User-Agent)
|
||||
- The date of the current week, rooted on Sundays
|
||||
- Anubis' ed25519 public signing key for [JSON web tokens](https://jwt.io/) (JWTs)
|
||||
- The challenge difficulty
|
||||
|
||||
This is intended to be a random value that is difficult for attackers to forge and guess, but also deterministic enough that it will naturally reset itself.
|
||||
10
docs/docs/funding.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
sidebar_position: 998
|
||||
title: Supporting Anubis financially
|
||||
---
|
||||
|
||||
Anubis is provided to the public for free in order to help advance the common good. In return, we ask (but not demand, these are words on the internet, not word of law) that you not remove the Anubis character from your deployment.
|
||||
|
||||
If you want to run an unbranded or white-label version of Anubis, please [contact Xe](https://xeiaso.net/contact) to arrange a contract. This is not meant to be "contact us" pricing, I am still evaluating the market for this solution and figuring out what makes sense.
|
||||
|
||||
You can donate to the project [on Patreon](https://patreon.com/cadey).
|
||||
28
docs/docs/index.mdx
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Anubis
|
||||
---
|
||||
|
||||
<img
|
||||
width={256}
|
||||
src="/img/happy.webp"
|
||||
alt="A smiling chibi dark-skinned anthro jackal with brown hair and tall ears looking victorious with a thumbs-up"
|
||||
/>
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
This program is designed to help protect the small internet from the endless storm of requests that flood in from AI companies. Anubis is as lightweight as possible to ensure that everyone can afford to protect the communities closest to them.
|
||||
|
||||
Anubis is a bit of a nuclear response. This will result in your website being blocked from smaller scrapers and may inhibit "good bots" like the Internet Archive. You can configure [bot policy definitions](./admin/policies.md) to explicitly allowlist them and we are working on a curated set of "known good" bots to allow for a compromise between discoverability and uptime.
|
||||
|
||||
## Support
|
||||
|
||||
If you run into any issues running Anubis, please [open an issue](https://github.com/TecharoHQ/anubis/issues/new?template=Blank+issue) and 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`.
|
||||
8
docs/docs/user/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "User guides",
|
||||
"position": 60,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Information for users on sites that use Anubis."
|
||||
}
|
||||
}
|
||||
19
docs/docs/user/known-broken-extensions.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: List of known browser extensions that can break Anubis
|
||||
---
|
||||
|
||||
This page contains a list of all of the browser extensions that are known to break Anubis' functionality and their associated GitHub issues, along with instructions on how to work around the issue.
|
||||
## [JShelter](https://jshelter.org/)
|
||||
|
||||
| Extension | JShelter |
|
||||
| :----------- | :-------------------------------------------- |
|
||||
| Website | [jshelter.org](https://jshelter.org/) |
|
||||
| GitHub issue | https://github.com/TecharoHQ/anubis/issues/25 |
|
||||
|
||||
Workaround steps:
|
||||
|
||||
1. Open JShelter extension settings
|
||||
2. Click on JS Shield details
|
||||
3. Enter in the domain for a website protected by Anubis
|
||||
4. Choose "Turn JavaScript Shield off"
|
||||
5. Hit "Add to list"
|
||||
9
docs/docs/user/why-see-challenge.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: Why is Anubis showing up on a website?
|
||||
---
|
||||
|
||||
You are seeing Anubis because the administrator of that website has set up [Anubis](https://github.com/TecharoHQ/anubis) to protect the server against the scourge of [AI companies aggressively scraping websites](https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/). This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.
|
||||
|
||||
Anubis is a compromise. Anubis uses a [proof-of-work](/docs/design/why-proof-of-work) scheme in the vein of [Hashcash](https://en.wikipedia.org/wiki/Hashcash), a proposed proof-of-work scheme for reducing email spam. The idea is that at individual scales the additional load is ignorable, but at mass scraper levels it adds up and makes scraping much more expensive.
|
||||
|
||||
Ultimately, this is a hack whose real purpose is to give a "good enough" placeholder solution so that more time can be spent on fingerprinting and identifying headless browsers (EG: via how they do font rendering) so that the challenge proof of work page doesn't need to be presented to users that are much more likely to be legitimate.
|
||||
158
docs/docusaurus.config.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { themes as prismThemes } from 'prism-react-renderer';
|
||||
import type { Config } from '@docusaurus/types';
|
||||
import type * as Preset from '@docusaurus/preset-classic';
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
const config: Config = {
|
||||
title: 'Anubis',
|
||||
tagline: 'Weigh the soul of incoming HTTP requests using proof-of-work to stop AI crawlers',
|
||||
favicon: 'img/favicon.ico',
|
||||
|
||||
// Set the production url of your site here
|
||||
url: 'https://anubis.techaro.lol',
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: '/',
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: 'TecharoHQ', // Usually your GitHub org/user name.
|
||||
projectName: 'anubis', // Usually your repo name.
|
||||
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
|
||||
// Even if you don't use internationalization, you can use this field to set
|
||||
// useful metadata like html lang. For example, if your site is Chinese, you
|
||||
// may want to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: 'en',
|
||||
locales: ['en'],
|
||||
},
|
||||
|
||||
markdown: {
|
||||
mermaid: true,
|
||||
},
|
||||
themes: ['@docusaurus/theme-mermaid'],
|
||||
|
||||
presets: [
|
||||
[
|
||||
'classic',
|
||||
{
|
||||
docs: {
|
||||
sidebarPath: './sidebars.ts',
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl:
|
||||
'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
// feedOptions: {
|
||||
// type: ['rss', 'atom', "json"],
|
||||
// xslt: true,
|
||||
// },
|
||||
// // Please change this to your repo.
|
||||
// // Remove this to remove the "edit this page" links.
|
||||
// editUrl:
|
||||
// 'https://github.com/facebook/docusaurus/tree/main/packages/create-docusaurus/templates/shared/',
|
||||
// // Useful options to enforce blogging best practices
|
||||
// onInlineTags: 'warn',
|
||||
// onInlineAuthors: 'warn',
|
||||
// onUntruncatedBlogPosts: 'warn',
|
||||
// },
|
||||
theme: {
|
||||
customCss: './src/css/custom.css',
|
||||
},
|
||||
} satisfies Preset.Options,
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig: {
|
||||
// Replace with your project's social card
|
||||
image: 'img/docusaurus-social-card.jpg',
|
||||
navbar: {
|
||||
title: 'Anubis',
|
||||
logo: {
|
||||
alt: 'A happy jackal woman with brown hair and red eyes',
|
||||
src: 'img/favicon.webp',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: 'docSidebar',
|
||||
sidebarId: 'tutorialSidebar',
|
||||
position: 'left',
|
||||
label: 'Tutorial',
|
||||
},
|
||||
// { to: '/blog', label: 'Blog', position: 'left' },
|
||||
{
|
||||
href: 'https://github.com/TecharoHQ/anubis',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Docs',
|
||||
items: [
|
||||
{
|
||||
label: 'Intro',
|
||||
to: '/docs/',
|
||||
},
|
||||
{
|
||||
label: "Installation",
|
||||
to: "/docs/admin/installation",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'GitHub Discussions',
|
||||
href: 'https://github.com/TecharoHQ/anubis/discussions',
|
||||
},
|
||||
{
|
||||
label: 'Bluesky',
|
||||
href: 'https://bsky.app/profile/techaro.lol',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'More',
|
||||
items: [
|
||||
{
|
||||
label: 'Blog',
|
||||
to: '/blog',
|
||||
},
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/TecharoHQ/anubis',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Techaro. Made with ❤️ in 🇨🇦.`,
|
||||
},
|
||||
prism: {
|
||||
theme: prismThemes.github,
|
||||
darkTheme: prismThemes.dracula,
|
||||
magicComments: [
|
||||
{
|
||||
className: 'code-block-diff-add-line',
|
||||
line: 'diff-add'
|
||||
},
|
||||
{
|
||||
className: 'code-block-diff-remove-line',
|
||||
line: 'diff-remove'
|
||||
}
|
||||
],
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
export default config;
|
||||
58
docs/manifest/deployment.yaml
Normal file
@@ -0,0 +1,58 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: anubis-docs
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: anubis-docs
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: anubis-docs
|
||||
spec:
|
||||
containers:
|
||||
- name: anubis-docs
|
||||
image: ghcr.io/techarohq/anubis/docs:main
|
||||
imagePullPolicy: Always
|
||||
resources:
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "500m"
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- name: anubis
|
||||
image: ghcr.io/techarohq/anubis:main
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: "BIND"
|
||||
value: ":8081"
|
||||
- name: "DIFFICULTY"
|
||||
value: "4"
|
||||
- name: "METRICS_BIND"
|
||||
value: ":9090"
|
||||
- name: "POLICY_FNAME"
|
||||
value: ""
|
||||
- name: "SERVE_ROBOTS_TXT"
|
||||
value: "false"
|
||||
- name: "TARGET"
|
||||
value: "http://localhost:80"
|
||||
# - name: "SLOG_LEVEL"
|
||||
# value: "debug"
|
||||
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
|
||||
24
docs/manifest/ingress.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: anubis-docs
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/limit-rps: "10"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
tls:
|
||||
- hosts:
|
||||
- anubis.techaro.lol
|
||||
secretName: anubis-techaro-lol-public-tls
|
||||
rules:
|
||||
- host: anubis.techaro.lol
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: "/"
|
||||
backend:
|
||||
service:
|
||||
name: anubis-docs
|
||||
port:
|
||||
name: anubis
|
||||
5
docs/manifest/kustomization.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
resources:
|
||||
- deployment.yaml
|
||||
- ingress.yaml
|
||||
- onionservice.yaml
|
||||
- service.yaml
|
||||
14
docs/manifest/onionservice.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
apiVersion: tor.k8s.torproject.org/v1alpha2
|
||||
kind: OnionService
|
||||
metadata:
|
||||
name: anubis-docs
|
||||
spec:
|
||||
version: 3
|
||||
rules:
|
||||
- port:
|
||||
number: 80
|
||||
backend:
|
||||
service:
|
||||
name: anubis-docs
|
||||
port:
|
||||
number: 80
|
||||
14
docs/manifest/service.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: anubis-docs
|
||||
spec:
|
||||
selector:
|
||||
app: anubis-docs
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 80
|
||||
name: http
|
||||
- port: 8081
|
||||
targetPort: 8081
|
||||
name: anubis
|
||||
19265
docs/package-lock.json
generated
Normal file
48
docs/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "echo 'use CI' && exit 1",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "3.7.0",
|
||||
"@docusaurus/preset-classic": "3.7.0",
|
||||
"@docusaurus/theme-mermaid": "^3.7.0",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.0.0",
|
||||
"prism-react-renderer": "^2.3.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.7.0",
|
||||
"@docusaurus/tsconfig": "3.7.0",
|
||||
"@docusaurus/types": "3.7.0",
|
||||
"typescript": "~5.6.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 3 chrome version",
|
||||
"last 3 firefox version",
|
||||
"last 5 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
33
docs/sidebars.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type {SidebarsConfig} from '@docusaurus/plugin-content-docs';
|
||||
|
||||
// This runs in Node.js - Don't use client-side code here (browser APIs, JSX...)
|
||||
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
const sidebars: SidebarsConfig = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
72
docs/src/components/HomepageFeatures/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import Heading from "@theme/Heading";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
type FeatureItem = {
|
||||
title: string;
|
||||
Svg: React.ComponentType<React.ComponentProps<"svg">>;
|
||||
description: ReactNode;
|
||||
};
|
||||
|
||||
const FeatureList: FeatureItem[] = [
|
||||
{
|
||||
title: "Easy to Use",
|
||||
Svg: require("@site/static/img/undraw_docusaurus_mountain.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Anubis is easy to set up, lightweight, and helps get rid of the lowest
|
||||
hanging fruit so you can sleep at night.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Lightweight",
|
||||
Svg: require("@site/static/img/undraw_docusaurus_tree.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Anubis is efficient and as lightweight as possible, blocking the worst
|
||||
of the bots on the internet and makes it easy to protect what you host
|
||||
online.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Multi-threaded",
|
||||
Svg: require("@site/static/img/undraw_docusaurus_react.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Anubis uses a multi-threaded proof of work check to ensure that users
|
||||
browsers are up to date and support modern standards.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function Feature({ title, Svg, description }: FeatureItem) {
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<Heading as="h3">{title}</Heading>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures(): ReactNode {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
11
docs/src/components/HomepageFeatures/styles.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
42
docs/src/components/RandomKey/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import Code from "@theme/CodeInline";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
|
||||
// https://www.xaymar.com/articles/2020/12/08/fastest-uint8array-to-hex-string-conversion-in-javascript/
|
||||
function toHex(buffer) {
|
||||
return Array.prototype.map
|
||||
.call(buffer, (x) => ("00" + x.toString(16)).slice(-2))
|
||||
.join("");
|
||||
}
|
||||
|
||||
export const genRandomKey = (): String => {
|
||||
const array = new Uint8Array(32);
|
||||
self.crypto.getRandomValues(array);
|
||||
return toHex(array);
|
||||
};
|
||||
|
||||
export default function RandomKey() {
|
||||
return (
|
||||
<BrowserOnly fallback={<div>Loading...</div>}>
|
||||
{() => {
|
||||
const [key, setKey] = useState<String>(genRandomKey());
|
||||
const genRandomKeyCb = useCallback(() => {
|
||||
setKey(genRandomKey());
|
||||
});
|
||||
return (
|
||||
<span>
|
||||
<Code>{key}</Code>
|
||||
<span style={{ marginLeft: "0.25rem", marginRight: "0.25rem" }} />
|
||||
<button
|
||||
onClick={() => {
|
||||
genRandomKeyCb();
|
||||
}}
|
||||
>
|
||||
♻️
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</BrowserOnly>
|
||||
);
|
||||
}
|
||||
73
docs/src/css/custom.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
--code-block-diff-add-line-color: #ccffd8;
|
||||
--code-block-diff-remove-line-color: #ffebe9;
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme="dark"] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
--code-block-diff-add-line-color: #216932;
|
||||
--code-block-diff-remove-line-color: #8b423b;
|
||||
}
|
||||
|
||||
.code-block-diff-add-line {
|
||||
background-color: var(--code-block-diff-add-line-color);
|
||||
display: block;
|
||||
margin: 0 -40px;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.code-block-diff-add-line::before {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-right: 8px;
|
||||
content: "+";
|
||||
}
|
||||
|
||||
.code-block-diff-remove-line {
|
||||
background-color: var(--code-block-diff-remove-line-color);
|
||||
display: block;
|
||||
margin: 0 -40px;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
.code-block-diff-remove-line::before {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
padding-right: 8px;
|
||||
content: "-";
|
||||
}
|
||||
|
||||
/**
|
||||
* use magic comments to mark diff blocks
|
||||
*/
|
||||
pre code:has(.code-block-diff-add-line) {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
|
||||
pre code:has(.code-block-diff-remove-line) {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
23
docs/src/pages/index.module.css
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||
* and scoped locally.
|
||||
*/
|
||||
|
||||
.heroBanner {
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.heroBanner {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
50
docs/src/pages/index.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import Link from "@docusaurus/Link";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
import HomepageFeatures from "@site/src/components/HomepageFeatures";
|
||||
import Heading from "@theme/Heading";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className={clsx("hero hero--primary", styles.heroBanner)}>
|
||||
<div className="container">
|
||||
<Heading as="h1" className="hero__title">
|
||||
{siteConfig.title}
|
||||
</Heading>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link className="button button--secondary button--lg" to="/docs/">
|
||||
Get started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home(): ReactNode {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`Anubis: self hostable scraper defense software`}
|
||||
description="Weigh the soul of incoming HTTP requests using proof-of-work to stop AI crawlers"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
|
||||
<center>
|
||||
<p>
|
||||
This is all placeholder text. It will be fixed. Give me time. I am
|
||||
one person and my project has unexpectedly gone viral.
|
||||
</p>
|
||||
</center>
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
0
docs/static/.nojekyll
vendored
Normal file
BIN
docs/static/img/android-chrome-512x512.png
vendored
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/static/img/docusaurus-social-card.jpg
vendored
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/static/img/docusaurus.png
vendored
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
docs/static/img/favicon.ico
vendored
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
docs/static/img/favicon.webp
vendored
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
docs/static/img/happy.webp
vendored
Normal file
|
After Width: | Height: | Size: 30 KiB |
1
docs/static/img/logo.svg
vendored
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
171
docs/static/img/undraw_docusaurus_mountain.svg
vendored
Normal file
@@ -0,0 +1,171 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1088" height="687.962" viewBox="0 0 1088 687.962">
|
||||
<title>Easy to Use</title>
|
||||
<g id="Group_12" data-name="Group 12" transform="translate(-57 -56)">
|
||||
<g id="Group_11" data-name="Group 11" transform="translate(57 56)">
|
||||
<path id="Path_83" data-name="Path 83" d="M1017.81,560.461c-5.27,45.15-16.22,81.4-31.25,110.31-20,38.52-54.21,54.04-84.77,70.28a193.275,193.275,0,0,1-27.46,11.94c-55.61,19.3-117.85,14.18-166.74,3.99a657.282,657.282,0,0,0-104.09-13.16q-14.97-.675-29.97-.67c-15.42.02-293.07,5.29-360.67-131.57-16.69-33.76-28.13-75-32.24-125.27-11.63-142.12,52.29-235.46,134.74-296.47,155.97-115.41,369.76-110.57,523.43,7.88C941.15,276.621,1036.99,396.031,1017.81,560.461Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_84" data-name="Path 84" d="M986.56,670.771c-20,38.52-47.21,64.04-77.77,80.28a193.272,193.272,0,0,1-27.46,11.94c-55.61,19.3-117.85,14.18-166.74,3.99a657.3,657.3,0,0,0-104.09-13.16q-14.97-.675-29.97-.67-23.13.03-46.25,1.72c-100.17,7.36-253.82-6.43-321.42-143.29L382,283.981,444.95,445.6l20.09,51.59,55.37-75.98L549,381.981l130.2,149.27,36.8-81.27L970.78,657.9l14.21,11.59Z" transform="translate(-56 -106.019)" fill="#f2f2f2"/>
|
||||
<path id="Path_85" data-name="Path 85" d="M302,282.962l26-57,36,83-31-60Z" opacity="0.1"/>
|
||||
<path id="Path_86" data-name="Path 86" d="M610.5,753.821q-14.97-.675-29.97-.67L465.04,497.191Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<path id="Path_87" data-name="Path 87" d="M464.411,315.191,493,292.962l130,150-132-128Z" opacity="0.1"/>
|
||||
<path id="Path_88" data-name="Path 88" d="M908.79,751.051a193.265,193.265,0,0,1-27.46,11.94L679.2,531.251Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<circle id="Ellipse_11" data-name="Ellipse 11" cx="3" cy="3" r="3" transform="translate(479 98.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_12" data-name="Ellipse 12" cx="3" cy="3" r="3" transform="translate(396 201.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_13" data-name="Ellipse 13" cx="2" cy="2" r="2" transform="translate(600 220.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_14" data-name="Ellipse 14" cx="2" cy="2" r="2" transform="translate(180 265.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_15" data-name="Ellipse 15" cx="2" cy="2" r="2" transform="translate(612 96.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_16" data-name="Ellipse 16" cx="2" cy="2" r="2" transform="translate(736 192.962)" fill="#f2f2f2"/>
|
||||
<circle id="Ellipse_17" data-name="Ellipse 17" cx="2" cy="2" r="2" transform="translate(858 344.962)" fill="#f2f2f2"/>
|
||||
<path id="Path_89" data-name="Path 89" d="M306,121.222h-2.76v-2.76h-1.48v2.76H299V122.7h2.76v2.759h1.48V122.7H306Z" fill="#f2f2f2"/>
|
||||
<path id="Path_90" data-name="Path 90" d="M848,424.222h-2.76v-2.76h-1.48v2.76H841V425.7h2.76v2.759h1.48V425.7H848Z" fill="#f2f2f2"/>
|
||||
<path id="Path_91" data-name="Path 91" d="M1144,719.981c0,16.569-243.557,74-544,74s-544-57.431-544-74,243.557,14,544,14S1144,703.413,1144,719.981Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_92" data-name="Path 92" d="M1144,719.981c0,16.569-243.557,74-544,74s-544-57.431-544-74,243.557,14,544,14S1144,703.413,1144,719.981Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<ellipse id="Ellipse_18" data-name="Ellipse 18" cx="544" cy="30" rx="544" ry="30" transform="translate(0 583.962)" fill="#3f3d56"/>
|
||||
<path id="Path_93" data-name="Path 93" d="M624,677.981c0,33.137-14.775,24-33,24s-33,9.137-33-24,33-96,33-96S624,644.844,624,677.981Z" transform="translate(-56 -106.019)" fill="#ff6584"/>
|
||||
<path id="Path_94" data-name="Path 94" d="M606,690.66c0,15.062-6.716,10.909-15,10.909s-15,4.153-15-10.909,15-43.636,15-43.636S606,675.6,606,690.66Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<rect id="Rectangle_97" data-name="Rectangle 97" width="92" height="18" rx="9" transform="translate(489 604.962)" fill="#2f2e41"/>
|
||||
<rect id="Rectangle_98" data-name="Rectangle 98" width="92" height="18" rx="9" transform="translate(489 586.962)" fill="#2f2e41"/>
|
||||
<path id="Path_95" data-name="Path 95" d="M193,596.547c0,55.343,34.719,100.126,77.626,100.126" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_96" data-name="Path 96" d="M270.626,696.673c0-55.965,38.745-101.251,86.626-101.251" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_97" data-name="Path 97" d="M221.125,601.564c0,52.57,22.14,95.109,49.5,95.109" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_98" data-name="Path 98" d="M270.626,696.673c0-71.511,44.783-129.377,100.126-129.377" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_99" data-name="Path 99" d="M254.3,697.379s11.009-.339,14.326-2.7,16.934-5.183,17.757-1.395,16.544,18.844,4.115,18.945-28.879-1.936-32.19-3.953S254.3,697.379,254.3,697.379Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_100" data-name="Path 100" d="M290.716,710.909c-12.429.1-28.879-1.936-32.19-3.953-2.522-1.536-3.527-7.048-3.863-9.591l-.368.014s.7,8.879,4.009,10.9,19.761,4.053,32.19,3.953c3.588-.029,4.827-1.305,4.759-3.2C294.755,710.174,293.386,710.887,290.716,710.909Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_101" data-name="Path 101" d="M777.429,633.081c0,38.029,23.857,68.8,53.341,68.8" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_102" data-name="Path 102" d="M830.769,701.882c0-38.456,26.623-69.575,59.525-69.575" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_103" data-name="Path 103" d="M796.755,636.528c0,36.124,15.213,65.354,34.014,65.354" transform="translate(-56 -106.019)" fill="#6c63ff"/>
|
||||
<path id="Path_104" data-name="Path 104" d="M830.769,701.882c0-49.139,30.773-88.9,68.8-88.9" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
<path id="Path_105" data-name="Path 105" d="M819.548,702.367s7.565-.233,9.844-1.856,11.636-3.562,12.2-.958,11.368,12.949,2.828,13.018-19.844-1.33-22.119-2.716S819.548,702.367,819.548,702.367Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_106" data-name="Path 106" d="M844.574,711.664c-8.54.069-19.844-1.33-22.119-2.716-1.733-1.056-2.423-4.843-2.654-6.59l-.253.01s.479,6.1,2.755,7.487,13.579,2.785,22.119,2.716c2.465-.02,3.317-.9,3.27-2.2C847.349,711.159,846.409,711.649,844.574,711.664Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_107" data-name="Path 107" d="M949.813,724.718s11.36-1.729,14.5-4.591,16.89-7.488,18.217-3.667,19.494,17.447,6.633,19.107-30.153,1.609-33.835-.065S949.813,724.718,949.813,724.718Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_108" data-name="Path 108" d="M989.228,734.173c-12.86,1.659-30.153,1.609-33.835-.065-2.8-1.275-4.535-6.858-5.2-9.45l-.379.061s1.833,9.109,5.516,10.783,20.975,1.725,33.835.065c3.712-.479,4.836-1.956,4.529-3.906C993.319,732.907,991.991,733.817,989.228,734.173Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_109" data-name="Path 109" d="M670.26,723.9s9.587-1.459,12.237-3.875,14.255-6.32,15.374-3.095,16.452,14.725,5.6,16.125-25.448,1.358-28.555-.055S670.26,723.9,670.26,723.9Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_110" data-name="Path 110" d="M703.524,731.875c-10.853,1.4-25.448,1.358-28.555-.055-2.367-1.076-3.827-5.788-4.39-7.976l-.32.051s1.547,7.687,4.655,9.1,17.7,1.456,28.555.055c3.133-.4,4.081-1.651,3.822-3.3C706.977,730.807,705.856,731.575,703.524,731.875Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_111" data-name="Path 111" d="M178.389,719.109s7.463-1.136,9.527-3.016,11.1-4.92,11.969-2.409,12.808,11.463,4.358,12.553-19.811,1.057-22.23-.043S178.389,719.109,178.389,719.109Z" transform="translate(-56 -106.019)" fill="#a8a8a8"/>
|
||||
<path id="Path_112" data-name="Path 112" d="M204.285,725.321c-8.449,1.09-19.811,1.057-22.23-.043-1.842-.838-2.979-4.506-3.417-6.209l-.249.04s1.2,5.984,3.624,7.085,13.781,1.133,22.23.043c2.439-.315,3.177-1.285,2.976-2.566C206.973,724.489,206.1,725.087,204.285,725.321Z" transform="translate(-56 -106.019)" opacity="0.2"/>
|
||||
<path id="Path_113" data-name="Path 113" d="M439.7,707.337c0,30.22-42.124,20.873-93.7,20.873s-93.074,9.347-93.074-20.873,42.118-36.793,93.694-36.793S439.7,677.117,439.7,707.337Z" transform="translate(-56 -106.019)" opacity="0.1"/>
|
||||
<path id="Path_114" data-name="Path 114" d="M439.7,699.9c0,30.22-42.124,20.873-93.7,20.873s-93.074,9.347-93.074-20.873S295.04,663.1,346.616,663.1,439.7,669.676,439.7,699.9Z" transform="translate(-56 -106.019)" fill="#3f3d56"/>
|
||||
</g>
|
||||
<g id="docusaurus_keytar" transform="translate(312.271 493.733)">
|
||||
<path id="Path_40" data-name="Path 40" d="M99,52h91.791V89.153H99Z" transform="translate(5.904 -14.001)" fill="#fff" fill-rule="evenodd"/>
|
||||
<path id="Path_41" data-name="Path 41" d="M24.855,163.927A21.828,21.828,0,0,1,5.947,153a21.829,21.829,0,0,0,18.908,32.782H46.71V163.927Z" transform="translate(-3 -4.634)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_42" data-name="Path 42" d="M121.861,61.1l76.514-4.782V45.39A21.854,21.854,0,0,0,176.52,23.535H78.173L75.441,18.8a3.154,3.154,0,0,0-5.464,0l-2.732,4.732L64.513,18.8a3.154,3.154,0,0,0-5.464,0l-2.732,4.732L53.586,18.8a3.154,3.154,0,0,0-5.464,0L45.39,23.535c-.024,0-.046,0-.071,0l-4.526-4.525a3.153,3.153,0,0,0-5.276,1.414l-1.5,5.577-5.674-1.521a3.154,3.154,0,0,0-3.863,3.864L26,34.023l-5.575,1.494a3.155,3.155,0,0,0-1.416,5.278l4.526,4.526c0,.023,0,.046,0,.07L18.8,48.122a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,59.05a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,69.977a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,80.9a3.154,3.154,0,0,0,0,5.464L23.535,89.1,18.8,91.832a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,102.76a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,113.687a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,124.615a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,135.542a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,146.469a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,157.4a3.154,3.154,0,0,0,0,5.464l4.732,2.732L18.8,168.324a3.154,3.154,0,0,0,0,5.464l4.732,2.732A21.854,21.854,0,0,0,45.39,198.375H176.52a21.854,21.854,0,0,0,21.855-21.855V89.1l-76.514-4.782a11.632,11.632,0,0,1,0-23.219" transform="translate(-1.681 -17.226)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_43" data-name="Path 43" d="M143,186.71h32.782V143H143Z" transform="translate(9.984 -5.561)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_44" data-name="Path 44" d="M196.71,159.855a5.438,5.438,0,0,0-.7.07c-.042-.164-.081-.329-.127-.493a5.457,5.457,0,1,0-5.4-9.372q-.181-.185-.366-.367a5.454,5.454,0,1,0-9.384-5.4c-.162-.046-.325-.084-.486-.126a5.467,5.467,0,1,0-10.788,0c-.162.042-.325.08-.486.126a5.457,5.457,0,1,0-9.384,5.4,21.843,21.843,0,1,0,36.421,21.02,5.452,5.452,0,1,0,.7-10.858" transform="translate(10.912 -6.025)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_45" data-name="Path 45" d="M153,124.855h32.782V103H153Z" transform="translate(10.912 -9.271)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_46" data-name="Path 46" d="M194.855,116.765a2.732,2.732,0,1,0,0-5.464,2.811,2.811,0,0,0-.349.035c-.022-.082-.04-.164-.063-.246a2.733,2.733,0,0,0-1.052-5.253,2.7,2.7,0,0,0-1.648.566q-.09-.093-.184-.184a2.7,2.7,0,0,0,.553-1.633,2.732,2.732,0,0,0-5.245-1.07,10.928,10.928,0,1,0,0,21.031,2.732,2.732,0,0,0,5.245-1.07,2.7,2.7,0,0,0-.553-1.633q.093-.09.184-.184a2.7,2.7,0,0,0,1.648.566,2.732,2.732,0,0,0,1.052-5.253c.023-.081.042-.164.063-.246a2.814,2.814,0,0,0,.349.035" transform="translate(12.767 -9.377)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_47" data-name="Path 47" d="M65.087,56.891a2.732,2.732,0,0,1-2.732-2.732,8.2,8.2,0,0,0-16.391,0,2.732,2.732,0,0,1-5.464,0,13.659,13.659,0,0,1,27.319,0,2.732,2.732,0,0,1-2.732,2.732" transform="translate(0.478 -15.068)" fill-rule="evenodd"/>
|
||||
<path id="Path_48" data-name="Path 48" d="M103,191.347h65.565a21.854,21.854,0,0,0,21.855-21.855V93H124.855A21.854,21.854,0,0,0,103,114.855Z" transform="translate(6.275 -10.199)" fill="#ffff50" fill-rule="evenodd"/>
|
||||
<path id="Path_49" data-name="Path 49" d="M173.216,129.787H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0,21.855H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186m0,21.855H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0-54.434H118.535a1.093,1.093,0,1,1,0-2.185h54.681a1.093,1.093,0,0,1,0,2.185m0,21.652H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186m0,21.855H118.535a1.093,1.093,0,1,1,0-2.186h54.681a1.093,1.093,0,0,1,0,2.186M189.585,61.611c-.013,0-.024-.007-.037-.005-3.377.115-4.974,3.492-6.384,6.472-1.471,3.114-2.608,5.139-4.473,5.078-2.064-.074-3.244-2.406-4.494-4.874-1.436-2.835-3.075-6.049-6.516-5.929-3.329.114-4.932,3.053-6.346,5.646-1.5,2.762-2.529,4.442-4.5,4.364-2.106-.076-3.225-1.972-4.52-4.167-1.444-2.443-3.112-5.191-6.487-5.1-3.272.113-4.879,2.606-6.3,4.808-1.5,2.328-2.552,3.746-4.551,3.662-2.156-.076-3.27-1.65-4.558-3.472-1.447-2.047-3.077-4.363-6.442-4.251-3.2.109-4.807,2.153-6.224,3.954-1.346,1.709-2.4,3.062-4.621,2.977a1.093,1.093,0,0,0-.079,2.186c3.3.11,4.967-1.967,6.417-3.81,1.286-1.635,2.4-3.045,4.582-3.12,2.1-.09,3.091,1.218,4.584,3.327,1.417,2,3.026,4.277,6.263,4.394,3.391.114,5.022-2.42,6.467-4.663,1.292-2,2.406-3.734,4.535-3.807,1.959-.073,3.026,1.475,4.529,4.022,1.417,2.4,3.023,5.121,6.324,5.241,3.415.118,5.064-2.863,6.5-5.5,1.245-2.282,2.419-4.437,4.5-4.509,1.959-.046,2.981,1.743,4.492,4.732,1.412,2.79,3.013,5.95,6.365,6.071l.185,0c3.348,0,4.937-3.36,6.343-6.331,1.245-2.634,2.423-5.114,4.444-5.216Z" transform="translate(7.109 -13.11)" fill-rule="evenodd"/>
|
||||
<path id="Path_50" data-name="Path 50" d="M83,186.71h43.71V143H83Z" transform="translate(4.42 -5.561)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<g id="Group_8" data-name="Group 8" transform="matrix(0.966, -0.259, 0.259, 0.966, 109.327, 91.085)">
|
||||
<rect id="Rectangle_3" data-name="Rectangle 3" width="92.361" height="36.462" rx="2" transform="translate(0 0)" fill="#d8d8d8"/>
|
||||
<g id="Group_2" data-name="Group 2" transform="translate(1.531 23.03)">
|
||||
<rect id="Rectangle_4" data-name="Rectangle 4" width="5.336" height="5.336" rx="1" transform="translate(16.797 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_5" data-name="Rectangle 5" width="5.336" height="5.336" rx="1" transform="translate(23.12 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_6" data-name="Rectangle 6" width="5.336" height="5.336" rx="1" transform="translate(29.444 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_7" data-name="Rectangle 7" width="5.336" height="5.336" rx="1" transform="translate(35.768 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_8" data-name="Rectangle 8" width="5.336" height="5.336" rx="1" transform="translate(42.091 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_9" data-name="Rectangle 9" width="5.336" height="5.336" rx="1" transform="translate(48.415 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_10" data-name="Rectangle 10" width="5.336" height="5.336" rx="1" transform="translate(54.739 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_11" data-name="Rectangle 11" width="5.336" height="5.336" rx="1" transform="translate(61.063 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_12" data-name="Rectangle 12" width="5.336" height="5.336" rx="1" transform="translate(67.386 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_51" data-name="Path 51" d="M1.093,0H14.518a1.093,1.093,0,0,1,1.093,1.093V4.243a1.093,1.093,0,0,1-1.093,1.093H1.093A1.093,1.093,0,0,1,0,4.243V1.093A1.093,1.093,0,0,1,1.093,0ZM75,0H88.426a1.093,1.093,0,0,1,1.093,1.093V4.243a1.093,1.093,0,0,1-1.093,1.093H75a1.093,1.093,0,0,1-1.093-1.093V1.093A1.093,1.093,0,0,1,75,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_3" data-name="Group 3" transform="translate(1.531 10.261)">
|
||||
<path id="Path_52" data-name="Path 52" d="M1.093,0H6.218A1.093,1.093,0,0,1,7.31,1.093V4.242A1.093,1.093,0,0,1,6.218,5.335H1.093A1.093,1.093,0,0,1,0,4.242V1.093A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_13" data-name="Rectangle 13" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_14" data-name="Rectangle 14" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_15" data-name="Rectangle 15" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_16" data-name="Rectangle 16" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_17" data-name="Rectangle 17" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_18" data-name="Rectangle 18" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_19" data-name="Rectangle 19" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_20" data-name="Rectangle 20" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_21" data-name="Rectangle 21" width="5.336" height="5.336" rx="1" transform="translate(58.888 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_22" data-name="Rectangle 22" width="5.336" height="5.336" rx="1" transform="translate(65.212 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_23" data-name="Rectangle 23" width="5.336" height="5.336" rx="1" transform="translate(71.536 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_24" data-name="Rectangle 24" width="5.336" height="5.336" rx="1" transform="translate(77.859 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_25" data-name="Rectangle 25" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_4" data-name="Group 4" transform="translate(91.05 9.546) rotate(180)">
|
||||
<path id="Path_53" data-name="Path 53" d="M1.093,0H6.219A1.093,1.093,0,0,1,7.312,1.093v3.15A1.093,1.093,0,0,1,6.219,5.336H1.093A1.093,1.093,0,0,1,0,4.243V1.093A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_26" data-name="Rectangle 26" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_27" data-name="Rectangle 27" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_28" data-name="Rectangle 28" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_29" data-name="Rectangle 29" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_30" data-name="Rectangle 30" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_31" data-name="Rectangle 31" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_32" data-name="Rectangle 32" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_33" data-name="Rectangle 33" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_34" data-name="Rectangle 34" width="5.336" height="5.336" rx="1" transform="translate(58.889 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_35" data-name="Rectangle 35" width="5.336" height="5.336" rx="1" transform="translate(65.213 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_36" data-name="Rectangle 36" width="5.336" height="5.336" rx="1" transform="translate(71.537 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_37" data-name="Rectangle 37" width="5.336" height="5.336" rx="1" transform="translate(77.86 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_38" data-name="Rectangle 38" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_39" data-name="Rectangle 39" width="5.336" height="5.336" rx="1" transform="translate(8.299 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_40" data-name="Rectangle 40" width="5.336" height="5.336" rx="1" transform="translate(14.623 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_41" data-name="Rectangle 41" width="5.336" height="5.336" rx="1" transform="translate(20.947 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_42" data-name="Rectangle 42" width="5.336" height="5.336" rx="1" transform="translate(27.271 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_43" data-name="Rectangle 43" width="5.336" height="5.336" rx="1" transform="translate(33.594 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_44" data-name="Rectangle 44" width="5.336" height="5.336" rx="1" transform="translate(39.918 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_45" data-name="Rectangle 45" width="5.336" height="5.336" rx="1" transform="translate(46.242 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_46" data-name="Rectangle 46" width="5.336" height="5.336" rx="1" transform="translate(52.565 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_47" data-name="Rectangle 47" width="5.336" height="5.336" rx="1" transform="translate(58.889 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_48" data-name="Rectangle 48" width="5.336" height="5.336" rx="1" transform="translate(65.213 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_49" data-name="Rectangle 49" width="5.336" height="5.336" rx="1" transform="translate(71.537 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_50" data-name="Rectangle 50" width="5.336" height="5.336" rx="1" transform="translate(77.86 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_51" data-name="Rectangle 51" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_6" data-name="Group 6" transform="translate(1.531 16.584)">
|
||||
<path id="Path_54" data-name="Path 54" d="M1.093,0h7.3A1.093,1.093,0,0,1,9.485,1.093v3.15A1.093,1.093,0,0,1,8.392,5.336h-7.3A1.093,1.093,0,0,1,0,4.243V1.094A1.093,1.093,0,0,1,1.093,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<g id="Group_5" data-name="Group 5" transform="translate(10.671 0)">
|
||||
<rect id="Rectangle_52" data-name="Rectangle 52" width="5.336" height="5.336" rx="1" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_53" data-name="Rectangle 53" width="5.336" height="5.336" rx="1" transform="translate(6.324 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_54" data-name="Rectangle 54" width="5.336" height="5.336" rx="1" transform="translate(12.647 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_55" data-name="Rectangle 55" width="5.336" height="5.336" rx="1" transform="translate(18.971 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_56" data-name="Rectangle 56" width="5.336" height="5.336" rx="1" transform="translate(25.295 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_57" data-name="Rectangle 57" width="5.336" height="5.336" rx="1" transform="translate(31.619 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_58" data-name="Rectangle 58" width="5.336" height="5.336" rx="1" transform="translate(37.942 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_59" data-name="Rectangle 59" width="5.336" height="5.336" rx="1" transform="translate(44.265 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_60" data-name="Rectangle 60" width="5.336" height="5.336" rx="1" transform="translate(50.589 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_61" data-name="Rectangle 61" width="5.336" height="5.336" rx="1" transform="translate(56.912 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_62" data-name="Rectangle 62" width="5.336" height="5.336" rx="1" transform="translate(63.236 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<path id="Path_55" data-name="Path 55" d="M1.094,0H8A1.093,1.093,0,0,1,9.091,1.093v3.15A1.093,1.093,0,0,1,8,5.336H1.093A1.093,1.093,0,0,1,0,4.243V1.094A1.093,1.093,0,0,1,1.093,0Z" transform="translate(80.428 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_7" data-name="Group 7" transform="translate(1.531 29.627)">
|
||||
<rect id="Rectangle_63" data-name="Rectangle 63" width="5.336" height="5.336" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_64" data-name="Rectangle 64" width="5.336" height="5.336" rx="1" transform="translate(6.324 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_65" data-name="Rectangle 65" width="5.336" height="5.336" rx="1" transform="translate(12.647 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_66" data-name="Rectangle 66" width="5.336" height="5.336" rx="1" transform="translate(18.971 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_56" data-name="Path 56" d="M1.093,0H31.515a1.093,1.093,0,0,1,1.093,1.093V4.244a1.093,1.093,0,0,1-1.093,1.093H1.093A1.093,1.093,0,0,1,0,4.244V1.093A1.093,1.093,0,0,1,1.093,0ZM34.687,0h3.942a1.093,1.093,0,0,1,1.093,1.093V4.244a1.093,1.093,0,0,1-1.093,1.093H34.687a1.093,1.093,0,0,1-1.093-1.093V1.093A1.093,1.093,0,0,1,34.687,0Z" transform="translate(25.294 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_67" data-name="Rectangle 67" width="5.336" height="5.336" rx="1" transform="translate(66.003 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_68" data-name="Rectangle 68" width="5.336" height="5.336" rx="1" transform="translate(72.327 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_69" data-name="Rectangle 69" width="5.336" height="5.336" rx="1" transform="translate(84.183 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_57" data-name="Path 57" d="M5.336,0V1.18A1.093,1.093,0,0,1,4.243,2.273H1.093A1.093,1.093,0,0,1,0,1.18V0Z" transform="translate(83.59 2.273) rotate(180)" fill="#4a4a4a"/>
|
||||
<path id="Path_58" data-name="Path 58" d="M5.336,0V1.18A1.093,1.093,0,0,1,4.243,2.273H1.093A1.093,1.093,0,0,1,0,1.18V0Z" transform="translate(78.255 3.063)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<rect id="Rectangle_70" data-name="Rectangle 70" width="88.927" height="2.371" rx="1.085" transform="translate(1.925 1.17)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_71" data-name="Rectangle 71" width="4.986" height="1.581" rx="0.723" transform="translate(4.1 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_72" data-name="Rectangle 72" width="4.986" height="1.581" rx="0.723" transform="translate(10.923 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_73" data-name="Rectangle 73" width="4.986" height="1.581" rx="0.723" transform="translate(16.173 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_74" data-name="Rectangle 74" width="4.986" height="1.581" rx="0.723" transform="translate(21.421 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_75" data-name="Rectangle 75" width="4.986" height="1.581" rx="0.723" transform="translate(26.671 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_76" data-name="Rectangle 76" width="4.986" height="1.581" rx="0.723" transform="translate(33.232 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_77" data-name="Rectangle 77" width="4.986" height="1.581" rx="0.723" transform="translate(38.48 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_78" data-name="Rectangle 78" width="4.986" height="1.581" rx="0.723" transform="translate(43.73 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_79" data-name="Rectangle 79" width="4.986" height="1.581" rx="0.723" transform="translate(48.978 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_80" data-name="Rectangle 80" width="4.986" height="1.581" rx="0.723" transform="translate(55.54 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_81" data-name="Rectangle 81" width="4.986" height="1.581" rx="0.723" transform="translate(60.788 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_82" data-name="Rectangle 82" width="4.986" height="1.581" rx="0.723" transform="translate(66.038 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_83" data-name="Rectangle 83" width="4.986" height="1.581" rx="0.723" transform="translate(72.599 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_84" data-name="Rectangle 84" width="4.986" height="1.581" rx="0.723" transform="translate(77.847 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_85" data-name="Rectangle 85" width="4.986" height="1.581" rx="0.723" transform="translate(83.097 1.566)" fill="#d8d8d8" opacity="0.136"/>
|
||||
</g>
|
||||
<path id="Path_59" data-name="Path 59" d="M146.71,159.855a5.439,5.439,0,0,0-.7.07c-.042-.164-.081-.329-.127-.493a5.457,5.457,0,1,0-5.4-9.372q-.181-.185-.366-.367a5.454,5.454,0,1,0-9.384-5.4c-.162-.046-.325-.084-.486-.126a5.467,5.467,0,1,0-10.788,0c-.162.042-.325.08-.486.126a5.457,5.457,0,1,0-9.384,5.4,21.843,21.843,0,1,0,36.421,21.02,5.452,5.452,0,1,0,.7-10.858" transform="translate(6.275 -6.025)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_60" data-name="Path 60" d="M83,124.855h43.71V103H83Z" transform="translate(4.42 -9.271)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_61" data-name="Path 61" d="M134.855,116.765a2.732,2.732,0,1,0,0-5.464,2.811,2.811,0,0,0-.349.035c-.022-.082-.04-.164-.063-.246a2.733,2.733,0,0,0-1.052-5.253,2.7,2.7,0,0,0-1.648.566q-.09-.093-.184-.184a2.7,2.7,0,0,0,.553-1.633,2.732,2.732,0,0,0-5.245-1.07,10.928,10.928,0,1,0,0,21.031,2.732,2.732,0,0,0,5.245-1.07,2.7,2.7,0,0,0-.553-1.633q.093-.09.184-.184a2.7,2.7,0,0,0,1.648.566,2.732,2.732,0,0,0,1.052-5.253c.023-.081.042-.164.063-.246a2.811,2.811,0,0,0,.349.035" transform="translate(7.202 -9.377)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_62" data-name="Path 62" d="M143.232,42.33a2.967,2.967,0,0,1-.535-.055,2.754,2.754,0,0,1-.514-.153,2.838,2.838,0,0,1-.471-.251,4.139,4.139,0,0,1-.415-.339,3.2,3.2,0,0,1-.338-.415A2.7,2.7,0,0,1,140.5,39.6a2.968,2.968,0,0,1,.055-.535,3.152,3.152,0,0,1,.152-.514,2.874,2.874,0,0,1,.252-.47,2.633,2.633,0,0,1,.753-.754,2.837,2.837,0,0,1,.471-.251,2.753,2.753,0,0,1,.514-.153,2.527,2.527,0,0,1,1.071,0,2.654,2.654,0,0,1,.983.4,4.139,4.139,0,0,1,.415.339,4.019,4.019,0,0,1,.339.415,2.786,2.786,0,0,1,.251.47,2.864,2.864,0,0,1,.208,1.049,2.77,2.77,0,0,1-.8,1.934,4.139,4.139,0,0,1-.415.339,2.722,2.722,0,0,1-1.519.459m21.855-1.366a2.789,2.789,0,0,1-1.935-.8,4.162,4.162,0,0,1-.338-.415,2.7,2.7,0,0,1-.459-1.519,2.789,2.789,0,0,1,.8-1.934,4.139,4.139,0,0,1,.415-.339,2.838,2.838,0,0,1,.471-.251,2.752,2.752,0,0,1,.514-.153,2.527,2.527,0,0,1,1.071,0,2.654,2.654,0,0,1,.983.4,4.139,4.139,0,0,1,.415.339,2.79,2.79,0,0,1,.8,1.934,3.069,3.069,0,0,1-.055.535,2.779,2.779,0,0,1-.153.514,3.885,3.885,0,0,1-.251.47,4.02,4.02,0,0,1-.339.415,4.138,4.138,0,0,1-.415.339,2.722,2.722,0,0,1-1.519.459" transform="translate(9.753 -15.532)" fill-rule="evenodd"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 31 KiB |
170
docs/static/img/undraw_docusaurus_react.svg
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1041.277" height="554.141" viewBox="0 0 1041.277 554.141">
|
||||
<title>Powered by React</title>
|
||||
<g id="Group_24" data-name="Group 24" transform="translate(-440 -263)">
|
||||
<g id="Group_23" data-name="Group 23" transform="translate(439.989 262.965)">
|
||||
<path id="Path_299" data-name="Path 299" d="M1040.82,611.12q-1.74,3.75-3.47,7.4-2.7,5.67-5.33,11.12c-.78,1.61-1.56,3.19-2.32,4.77-8.6,17.57-16.63,33.11-23.45,45.89A73.21,73.21,0,0,1,942.44,719l-151.65,1.65h-1.6l-13,.14-11.12.12-34.1.37h-1.38l-17.36.19h-.53l-107,1.16-95.51,1-11.11.12-69,.75H429l-44.75.48h-.48l-141.5,1.53-42.33.46a87.991,87.991,0,0,1-10.79-.54h0c-1.22-.14-2.44-.3-3.65-.49a87.38,87.38,0,0,1-51.29-27.54C116,678.37,102.75,655,93.85,629.64q-1.93-5.49-3.6-11.12C59.44,514.37,97,380,164.6,290.08q4.25-5.64,8.64-11l.07-.08c20.79-25.52,44.1-46.84,68.93-62,44-26.91,92.75-34.49,140.7-11.9,40.57,19.12,78.45,28.11,115.17,30.55,3.71.24,7.42.42,11.11.53,84.23,2.65,163.17-27.7,255.87-47.29,3.69-.78,7.39-1.55,11.12-2.28,66.13-13.16,139.49-20.1,226.73-5.51a189.089,189.089,0,0,1,26.76,6.4q5.77,1.86,11.12,4c41.64,16.94,64.35,48.24,74,87.46q1.37,5.46,2.37,11.11C1134.3,384.41,1084.19,518.23,1040.82,611.12Z" transform="translate(-79.34 -172.91)" fill="#f2f2f2"/>
|
||||
<path id="Path_300" data-name="Path 300" d="M576.36,618.52a95.21,95.21,0,0,1-1.87,11.12h93.7V618.52Zm-78.25,62.81,11.11-.09V653.77c-3.81-.17-7.52-.34-11.11-.52ZM265.19,618.52v11.12h198.5V618.52ZM1114.87,279h-74V191.51q-5.35-2.17-11.12-4V279H776.21V186.58c-3.73.73-7.43,1.5-11.12,2.28V279H509.22V236.15c-3.69-.11-7.4-.29-11.11-.53V279H242.24V217c-24.83,15.16-48.14,36.48-68.93,62h-.07v.08q-4.4,5.4-8.64,11h8.64V618.52h-83q1.66,5.63,3.6,11.12h79.39v93.62a87,87,0,0,0,12.2,2.79c1.21.19,2.43.35,3.65.49h0a87.991,87.991,0,0,0,10.79.54l42.33-.46v-97H498.11v94.21l11.11-.12V629.64H765.09V721l11.12-.12V629.64H1029.7v4.77c.76-1.58,1.54-3.16,2.32-4.77q2.63-5.45,5.33-11.12,1.73-3.64,3.47-7.4v-321h76.42Q1116.23,284.43,1114.87,279ZM242.24,618.52V290.08H498.11V618.52Zm267,0V290.08H765.09V618.52Zm520.48,0H776.21V290.08H1029.7Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_301" data-name="Path 301" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l46.65-28,93.6-.78,2-.01.66-.01,2-.03,44.94-.37,2.01-.01.64-.01,2-.01L315,509.3l.38-.01,35.55-.3h.29l277.4-2.34,6.79-.05h.68l5.18-.05,37.65-.31,2-.03,1.85-.02h.96l11.71-.09,2.32-.03,3.11-.02,9.75-.09,15.47-.13,2-.02,3.48-.02h.65l74.71-.64Z" fill="#65617d"/>
|
||||
<path id="Path_302" data-name="Path 302" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l46.65-28,93.6-.78,2-.01.66-.01,2-.03,44.94-.37,2.01-.01.64-.01,2-.01L315,509.3l.38-.01,35.55-.3h.29l277.4-2.34,6.79-.05h.68l5.18-.05,37.65-.31,2-.03,1.85-.02h.96l11.71-.09,2.32-.03,3.11-.02,9.75-.09,15.47-.13,2-.02,3.48-.02h.65l74.71-.64Z" opacity="0.2"/>
|
||||
<path id="Path_303" data-name="Path 303" d="M375.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
<path id="Path_304" data-name="Path 304" d="M375.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_305" data-name="Path 305" d="M377.44,656.57v24.49a6.13,6.13,0,0,1-3.5,5.54,6,6,0,0,1-2.5.6l-34.9.74a6,6,0,0,1-2.7-.57,6.12,6.12,0,0,1-3.57-5.57V656.57Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
<rect id="Rectangle_137" data-name="Rectangle 137" width="47.17" height="31.5" transform="translate(680.92 483.65)" fill="#3f3d56"/>
|
||||
<rect id="Rectangle_138" data-name="Rectangle 138" width="47.17" height="31.5" transform="translate(680.92 483.65)" opacity="0.1"/>
|
||||
<rect id="Rectangle_139" data-name="Rectangle 139" width="47.17" height="31.5" transform="translate(678.92 483.65)" fill="#3f3d56"/>
|
||||
<path id="Path_306" data-name="Path 306" d="M298.09,483.65v4.97l-47.17,1.26v-6.23Z" opacity="0.1"/>
|
||||
<path id="Path_307" data-name="Path 307" d="M460.69,485.27v168.2a4,4,0,0,1-3.85,3.95l-191.65,5.1h-.05a4,4,0,0,1-3.95-3.95V485.27a4,4,0,0,1,3.95-3.95h191.6a4,4,0,0,1,3.95,3.95Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||
<path id="Path_308" data-name="Path 308" d="M265.19,481.32v181.2h-.05a4,4,0,0,1-3.95-3.95V485.27a4,4,0,0,1,3.95-3.95Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_309" data-name="Path 309" d="M194.59,319.15h177.5V467.4l-177.5,4Z" fill="#39374d"/>
|
||||
<path id="Path_310" data-name="Path 310" d="M726.09,483.65v6.41l-47.17-1.26v-5.15Z" opacity="0.1"/>
|
||||
<path id="Path_311" data-name="Path 311" d="M867.69,485.27v173.3a4,4,0,0,1-4,3.95h0L672,657.42a4,4,0,0,1-3.85-3.95V485.27a4,4,0,0,1,3.95-3.95H863.7a4,4,0,0,1,3.99,3.95Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||
<path id="Path_312" data-name="Path 312" d="M867.69,485.27v173.3a4,4,0,0,1-4,3.95h0V481.32h0a4,4,0,0,1,4,3.95Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_313" data-name="Path 313" d="M775.59,319.15H598.09V467.4l177.5,4Z" fill="#39374d"/>
|
||||
<path id="Path_314" data-name="Path 314" d="M663.19,485.27v168.2a4,4,0,0,1-3.85,3.95l-191.65,5.1h0a4,4,0,0,1-4-3.95V485.27a4,4,0,0,1,3.95-3.95h191.6A4,4,0,0,1,663.19,485.27Z" transform="translate(-79.34 -172.91)" fill="#65617d"/>
|
||||
<path id="Path_315" data-name="Path 315" d="M397.09,319.15h177.5V467.4l-177.5,4Z" fill="#4267b2"/>
|
||||
<path id="Path_316" data-name="Path 316" d="M863.09,533.65v13l-151.92,1.4-1.62.03-57.74.53-1.38.02-17.55.15h-.52l-106.98.99L349.77,551.4h-.15l-44.65.42-.48.01-198.4,1.82v-15l202.51-1.33h.48l40.99-.28h.19l283.08-1.87h.29l.17-.01h.47l4.79-.03h1.46l74.49-.5,4.4-.02.98-.01Z" opacity="0.1"/>
|
||||
<circle id="Ellipse_111" data-name="Ellipse 111" cx="51.33" cy="51.33" r="51.33" transform="translate(435.93 246.82)" fill="#fbbebe"/>
|
||||
<path id="Path_317" data-name="Path 317" d="M617.94,550.07s-99.5,12-90,0c3.44-4.34,4.39-17.2,4.2-31.85-.06-4.45-.22-9.06-.45-13.65-1.1-22-3.75-43.5-3.75-43.5s87-41,77-8.5c-4,13.13-2.69,31.57.35,48.88.89,5.05,1.92,10,3,14.7a344.66,344.66,0,0,0,9.65,33.92Z" transform="translate(-79.34 -172.91)" fill="#fbbebe"/>
|
||||
<path id="Path_318" data-name="Path 318" d="M585.47,546c11.51-2.13,23.7-6,34.53-1.54,2.85,1.17,5.47,2.88,8.39,3.86s6.12,1.22,9.16,1.91c10.68,2.42,19.34,10.55,24.9,20s8.44,20.14,11.26,30.72l6.9,25.83c6,22.45,12,45.09,13.39,68.3a2437.506,2437.506,0,0,1-250.84,1.43c5.44-10.34,11-21.31,10.54-33s-7.19-23.22-4.76-34.74c1.55-7.34,6.57-13.39,9.64-20.22,8.75-19.52,1.94-45.79,17.32-60.65,6.92-6.68,17-9.21,26.63-8.89,12.28.41,24.85,4.24,37,6.11C555.09,547.48,569.79,548.88,585.47,546Z" transform="translate(-79.34 -172.91)" fill="#ff6584"/>
|
||||
<path id="Path_319" data-name="Path 319" d="M716.37,657.17l-.1,1.43v.1l-.17,2.3-1.33,18.51-1.61,22.3-.46,6.28-1,13.44v.17l-107,1-175.59,1.9v.84h-.14v-1.12l.45-14.36.86-28.06.74-23.79.07-2.37a10.53,10.53,0,0,1,11.42-10.17c4.72.4,10.85.89,18.18,1.41l3,.22c42.33,2.94,120.56,6.74,199.5,2,1.66-.09,3.33-.19,5-.31,12.24-.77,24.47-1.76,36.58-3a10.53,10.53,0,0,1,11.6,11.23Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_320" data-name="Path 320" d="M429.08,725.44v-.84l175.62-1.91,107-1h.3v-.17l1-13.44.43-6,1.64-22.61,1.29-17.9v-.44a10.617,10.617,0,0,0-.11-2.47.3.3,0,0,0,0-.1,10.391,10.391,0,0,0-2-4.64,10.54,10.54,0,0,0-9.42-4c-12.11,1.24-24.34,2.23-36.58,3-1.67.12-3.34.22-5,.31-78.94,4.69-157.17.89-199.5-2l-3-.22c-7.33-.52-13.46-1-18.18-1.41a10.54,10.54,0,0,0-11.24,8.53,11,11,0,0,0-.18,1.64l-.68,22.16L429.54,710l-.44,14.36v1.12Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
<path id="Path_321" data-name="Path 321" d="M716.67,664.18l-1.23,15.33-1.83,22.85-.46,5.72-1,12.81-.06.64v.17h0l-.15,1.48.11-1.48h-.29l-107,1-175.65,1.9v-.28l.49-14.36,1-28.06.64-18.65A6.36,6.36,0,0,1,434.3,658a6.25,6.25,0,0,1,3.78-.9c2.1.17,4.68.37,7.69.59,4.89.36,10.92.78,17.94,1.22,13,.82,29.31,1.7,48,2.42,52,2,122.2,2.67,188.88-3.17,3-.26,6.1-.55,9.13-.84a6.26,6.26,0,0,1,3.48.66,5.159,5.159,0,0,1,.86.54,6.14,6.14,0,0,1,2,2.46,3.564,3.564,0,0,1,.25.61A6.279,6.279,0,0,1,716.67,664.18Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_322" data-name="Path 322" d="M377.44,677.87v3.19a6.13,6.13,0,0,1-3.5,5.54l-40.1.77a6.12,6.12,0,0,1-3.57-5.57v-3Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_323" data-name="Path 323" d="M298.59,515.57l-52.25,1V507.9l52.25-1Z" fill="#3f3d56"/>
|
||||
<path id="Path_324" data-name="Path 324" d="M298.59,515.57l-52.25,1V507.9l52.25-1Z" opacity="0.1"/>
|
||||
<path id="Path_325" data-name="Path 325" d="M300.59,515.57l-52.25,1V507.9l52.25-1Z" fill="#3f3d56"/>
|
||||
<path id="Path_326" data-name="Path 326" d="M758.56,679.87v3.19a6.13,6.13,0,0,0,3.5,5.54l40.1.77a6.12,6.12,0,0,0,3.57-5.57v-3Z" transform="translate(-79.34 -172.91)" opacity="0.1"/>
|
||||
<path id="Path_327" data-name="Path 327" d="M678.72,517.57l52.25,1V509.9l-52.25-1Z" opacity="0.1"/>
|
||||
<path id="Path_328" data-name="Path 328" d="M676.72,517.57l52.25,1V509.9l-52.25-1Z" fill="#3f3d56"/>
|
||||
<path id="Path_329" data-name="Path 329" d="M534.13,486.79c.08,7-3.16,13.6-5.91,20.07a163.491,163.491,0,0,0-12.66,74.71c.73,11,2.58,22,.73,32.9s-8.43,21.77-19,24.9c17.53,10.45,41.26,9.35,57.76-2.66,8.79-6.4,15.34-15.33,21.75-24.11a97.86,97.86,0,0,1-13.31,44.75A103.43,103.43,0,0,0,637,616.53c4.31-5.81,8.06-12.19,9.72-19.23,3.09-13-1.22-26.51-4.51-39.5a266.055,266.055,0,0,1-6.17-33c-.43-3.56-.78-7.22.1-10.7,1-4.07,3.67-7.51,5.64-11.22,5.6-10.54,5.73-23.3,2.86-34.88s-8.49-22.26-14.06-32.81c-4.46-8.46-9.3-17.31-17.46-22.28-5.1-3.1-11-4.39-16.88-5.64l-25.37-5.43c-5.55-1.19-11.26-2.38-16.87-1.51-9.47,1.48-16.14,8.32-22,15.34-4.59,5.46-15.81,15.71-16.6,22.86-.72,6.59,5.1,17.63,6.09,24.58,1.3,9,2.22,6,7.3,11.52C532,478.05,534.07,482,534.13,486.79Z" transform="translate(-79.34 -172.91)" fill="#3f3d56"/>
|
||||
</g>
|
||||
<g id="docusaurus_keytar" transform="translate(670.271 615.768)">
|
||||
<path id="Path_40" data-name="Path 40" d="M99,52h43.635V69.662H99Z" transform="translate(-49.132 -33.936)" fill="#fff" fill-rule="evenodd"/>
|
||||
<path id="Path_41" data-name="Path 41" d="M13.389,158.195A10.377,10.377,0,0,1,4.4,153a10.377,10.377,0,0,0,8.988,15.584H23.779V158.195Z" transform="translate(-3 -82.47)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_42" data-name="Path 42" d="M66.967,38.083l36.373-2.273V30.615A10.389,10.389,0,0,0,92.95,20.226H46.2l-1.3-2.249a1.5,1.5,0,0,0-2.6,0L41,20.226l-1.3-2.249a1.5,1.5,0,0,0-2.6,0l-1.3,2.249-1.3-2.249a1.5,1.5,0,0,0-2.6,0l-1.3,2.249-.034,0-2.152-2.151a1.5,1.5,0,0,0-2.508.672L25.21,21.4l-2.7-.723a1.5,1.5,0,0,0-1.836,1.837l.722,2.7-2.65.71a1.5,1.5,0,0,0-.673,2.509l2.152,2.152c0,.011,0,.022,0,.033l-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6L20.226,41l-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3-2.249,1.3a1.5,1.5,0,0,0,0,2.6l2.249,1.3A10.389,10.389,0,0,0,30.615,103.34H92.95A10.389,10.389,0,0,0,103.34,92.95V51.393L66.967,49.12a5.53,5.53,0,0,1,0-11.038" transform="translate(-9.836 -17.226)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_43" data-name="Path 43" d="M143,163.779h15.584V143H143Z" transform="translate(-70.275 -77.665)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_44" data-name="Path 44" d="M173.779,148.389a2.582,2.582,0,0,0-.332.033c-.02-.078-.038-.156-.06-.234a2.594,2.594,0,1,0-2.567-4.455q-.086-.088-.174-.175a2.593,2.593,0,1,0-4.461-2.569c-.077-.022-.154-.04-.231-.06a2.6,2.6,0,1,0-5.128,0c-.077.02-.154.038-.231.06a2.594,2.594,0,1,0-4.461,2.569,10.384,10.384,0,1,0,17.314,9.992,2.592,2.592,0,1,0,.332-5.161" transform="translate(-75.08 -75.262)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_45" data-name="Path 45" d="M153,113.389h15.584V103H153Z" transform="translate(-75.08 -58.444)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_46" data-name="Path 46" d="M183.389,108.944a1.3,1.3,0,1,0,0-2.6,1.336,1.336,0,0,0-.166.017c-.01-.039-.019-.078-.03-.117a1.3,1.3,0,0,0-.5-2.5,1.285,1.285,0,0,0-.783.269q-.043-.044-.087-.087a1.285,1.285,0,0,0,.263-.776,1.3,1.3,0,0,0-2.493-.509,5.195,5.195,0,1,0,0,10,1.3,1.3,0,0,0,2.493-.509,1.285,1.285,0,0,0-.263-.776q.044-.043.087-.087a1.285,1.285,0,0,0,.783.269,1.3,1.3,0,0,0,.5-2.5c.011-.038.02-.078.03-.117a1.337,1.337,0,0,0,.166.017" transform="translate(-84.691 -57.894)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_47" data-name="Path 47" d="M52.188,48.292a1.3,1.3,0,0,1-1.3-1.3,3.9,3.9,0,0,0-7.792,0,1.3,1.3,0,1,1-2.6,0,6.493,6.493,0,0,1,12.987,0,1.3,1.3,0,0,1-1.3,1.3" transform="translate(-21.02 -28.41)" fill-rule="evenodd"/>
|
||||
<path id="Path_48" data-name="Path 48" d="M103,139.752h31.168a10.389,10.389,0,0,0,10.389-10.389V93H113.389A10.389,10.389,0,0,0,103,103.389Z" transform="translate(-51.054 -53.638)" fill="#ffff50" fill-rule="evenodd"/>
|
||||
<path id="Path_49" data-name="Path 49" d="M141.1,94.017H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0-25.877H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.293H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m0,10.389H115.106a.519.519,0,1,1,0-1.039H141.1a.519.519,0,0,1,0,1.039m7.782-47.993c-.006,0-.011,0-.018,0-1.605.055-2.365,1.66-3.035,3.077-.7,1.48-1.24,2.443-2.126,2.414-.981-.035-1.542-1.144-2.137-2.317-.683-1.347-1.462-2.876-3.1-2.819-1.582.054-2.344,1.451-3.017,2.684-.715,1.313-1.2,2.112-2.141,2.075-1-.036-1.533-.938-2.149-1.981-.686-1.162-1.479-2.467-3.084-2.423-1.555.053-2.319,1.239-2.994,2.286-.713,1.106-1.213,1.781-2.164,1.741-1.025-.036-1.554-.784-2.167-1.65-.688-.973-1.463-2.074-3.062-2.021a3.815,3.815,0,0,0-2.959,1.879c-.64.812-1.14,1.456-2.2,1.415a.52.52,0,0,0-.037,1.039,3.588,3.588,0,0,0,3.05-1.811c.611-.777,1.139-1.448,2.178-1.483,1-.043,1.47.579,2.179,1.582.674.953,1.438,2.033,2.977,2.089,1.612.054,2.387-1.151,3.074-2.217.614-.953,1.144-1.775,2.156-1.81.931-.035,1.438.7,2.153,1.912.674,1.141,1.437,2.434,3.006,2.491,1.623.056,2.407-1.361,3.09-2.616.592-1.085,1.15-2.109,2.14-2.143.931-.022,1.417.829,2.135,2.249.671,1.326,1.432,2.828,3.026,2.886l.088,0c1.592,0,2.347-1.6,3.015-3.01.592-1.252,1.152-2.431,2.113-2.479Z" transform="translate(-55.378 -38.552)" fill-rule="evenodd"/>
|
||||
<path id="Path_50" data-name="Path 50" d="M83,163.779h20.779V143H83Z" transform="translate(-41.443 -77.665)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<g id="Group_8" data-name="Group 8" transform="matrix(0.966, -0.259, 0.259, 0.966, 51.971, 43.3)">
|
||||
<rect id="Rectangle_3" data-name="Rectangle 3" width="43.906" height="17.333" rx="2" transform="translate(0 0)" fill="#d8d8d8"/>
|
||||
<g id="Group_2" data-name="Group 2" transform="translate(0.728 10.948)">
|
||||
<rect id="Rectangle_4" data-name="Rectangle 4" width="2.537" height="2.537" rx="1" transform="translate(7.985 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_5" data-name="Rectangle 5" width="2.537" height="2.537" rx="1" transform="translate(10.991 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_6" data-name="Rectangle 6" width="2.537" height="2.537" rx="1" transform="translate(13.997 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_7" data-name="Rectangle 7" width="2.537" height="2.537" rx="1" transform="translate(17.003 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_8" data-name="Rectangle 8" width="2.537" height="2.537" rx="1" transform="translate(20.009 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_9" data-name="Rectangle 9" width="2.537" height="2.537" rx="1" transform="translate(23.015 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_10" data-name="Rectangle 10" width="2.537" height="2.537" rx="1" transform="translate(26.021 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_11" data-name="Rectangle 11" width="2.537" height="2.537" rx="1" transform="translate(29.028 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_12" data-name="Rectangle 12" width="2.537" height="2.537" rx="1" transform="translate(32.034 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_51" data-name="Path 51" d="M.519,0H6.9A.519.519,0,0,1,7.421.52v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0ZM35.653,0h6.383a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H35.652a.519.519,0,0,1-.519-.519V.519A.519.519,0,0,1,35.652,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_3" data-name="Group 3" transform="translate(0.728 4.878)">
|
||||
<path id="Path_52" data-name="Path 52" d="M.519,0H2.956a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_13" data-name="Rectangle 13" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_14" data-name="Rectangle 14" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_15" data-name="Rectangle 15" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_16" data-name="Rectangle 16" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_17" data-name="Rectangle 17" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_18" data-name="Rectangle 18" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_19" data-name="Rectangle 19" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_20" data-name="Rectangle 20" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_21" data-name="Rectangle 21" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_22" data-name="Rectangle 22" width="2.537" height="2.537" rx="1" transform="translate(31 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_23" data-name="Rectangle 23" width="2.537" height="2.537" rx="1" transform="translate(34.006 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_24" data-name="Rectangle 24" width="2.537" height="2.537" rx="1" transform="translate(37.012 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_25" data-name="Rectangle 25" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_4" data-name="Group 4" transform="translate(43.283 4.538) rotate(180)">
|
||||
<path id="Path_53" data-name="Path 53" d="M.519,0H2.956a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.519A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_26" data-name="Rectangle 26" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_27" data-name="Rectangle 27" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_28" data-name="Rectangle 28" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_29" data-name="Rectangle 29" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_30" data-name="Rectangle 30" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_31" data-name="Rectangle 31" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_32" data-name="Rectangle 32" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_33" data-name="Rectangle 33" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_34" data-name="Rectangle 34" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_35" data-name="Rectangle 35" width="2.537" height="2.537" rx="1" transform="translate(31.001 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_36" data-name="Rectangle 36" width="2.537" height="2.537" rx="1" transform="translate(34.007 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_37" data-name="Rectangle 37" width="2.537" height="2.537" rx="1" transform="translate(37.013 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_38" data-name="Rectangle 38" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_39" data-name="Rectangle 39" width="2.537" height="2.537" rx="1" transform="translate(3.945 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_40" data-name="Rectangle 40" width="2.537" height="2.537" rx="1" transform="translate(6.951 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_41" data-name="Rectangle 41" width="2.537" height="2.537" rx="1" transform="translate(9.958 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_42" data-name="Rectangle 42" width="2.537" height="2.537" rx="1" transform="translate(12.964 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_43" data-name="Rectangle 43" width="2.537" height="2.537" rx="1" transform="translate(15.97 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_44" data-name="Rectangle 44" width="2.537" height="2.537" rx="1" transform="translate(18.976 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_45" data-name="Rectangle 45" width="2.537" height="2.537" rx="1" transform="translate(21.982 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_46" data-name="Rectangle 46" width="2.537" height="2.537" rx="1" transform="translate(24.988 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_47" data-name="Rectangle 47" width="2.537" height="2.537" rx="1" transform="translate(27.994 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_48" data-name="Rectangle 48" width="2.537" height="2.537" rx="1" transform="translate(31.001 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_49" data-name="Rectangle 49" width="2.537" height="2.537" rx="1" transform="translate(34.007 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_50" data-name="Rectangle 50" width="2.537" height="2.537" rx="1" transform="translate(37.013 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_51" data-name="Rectangle 51" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<g id="Group_6" data-name="Group 6" transform="translate(0.728 7.883)">
|
||||
<path id="Path_54" data-name="Path 54" d="M.519,0h3.47a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.52A.519.519,0,0,1,.519,0Z" transform="translate(0 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<g id="Group_5" data-name="Group 5" transform="translate(5.073 0)">
|
||||
<rect id="Rectangle_52" data-name="Rectangle 52" width="2.537" height="2.537" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_53" data-name="Rectangle 53" width="2.537" height="2.537" rx="1" transform="translate(3.006 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_54" data-name="Rectangle 54" width="2.537" height="2.537" rx="1" transform="translate(6.012 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_55" data-name="Rectangle 55" width="2.537" height="2.537" rx="1" transform="translate(9.018 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_56" data-name="Rectangle 56" width="2.537" height="2.537" rx="1" transform="translate(12.025 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_57" data-name="Rectangle 57" width="2.537" height="2.537" rx="1" transform="translate(15.031 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_58" data-name="Rectangle 58" width="2.537" height="2.537" rx="1" transform="translate(18.037 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_59" data-name="Rectangle 59" width="2.537" height="2.537" rx="1" transform="translate(21.042 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_60" data-name="Rectangle 60" width="2.537" height="2.537" rx="1" transform="translate(24.049 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_61" data-name="Rectangle 61" width="2.537" height="2.537" rx="1" transform="translate(27.055 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_62" data-name="Rectangle 62" width="2.537" height="2.537" rx="1" transform="translate(30.061 0)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<path id="Path_55" data-name="Path 55" d="M.52,0H3.8a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.017V.52A.519.519,0,0,1,.519,0Z" transform="translate(38.234 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="Group_7" data-name="Group 7" transform="translate(0.728 14.084)">
|
||||
<rect id="Rectangle_63" data-name="Rectangle 63" width="2.537" height="2.537" rx="1" transform="translate(0 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_64" data-name="Rectangle 64" width="2.537" height="2.537" rx="1" transform="translate(3.006 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_65" data-name="Rectangle 65" width="2.537" height="2.537" rx="1" transform="translate(6.012 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_66" data-name="Rectangle 66" width="2.537" height="2.537" rx="1" transform="translate(9.018 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_56" data-name="Path 56" d="M.519,0H14.981A.519.519,0,0,1,15.5.519v1.5a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,2.018V.519A.519.519,0,0,1,.519,0Zm15.97,0h1.874a.519.519,0,0,1,.519.519v1.5a.519.519,0,0,1-.519.519H16.489a.519.519,0,0,1-.519-.519V.519A.519.519,0,0,1,16.489,0Z" transform="translate(12.024 0)" fill="#4a4a4a" fill-rule="evenodd"/>
|
||||
<rect id="Rectangle_67" data-name="Rectangle 67" width="2.537" height="2.537" rx="1" transform="translate(31.376 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_68" data-name="Rectangle 68" width="2.537" height="2.537" rx="1" transform="translate(34.382 0)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_69" data-name="Rectangle 69" width="2.537" height="2.537" rx="1" transform="translate(40.018 0)" fill="#4a4a4a"/>
|
||||
<path id="Path_57" data-name="Path 57" d="M2.537,0V.561a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,.561V0Z" transform="translate(39.736 1.08) rotate(180)" fill="#4a4a4a"/>
|
||||
<path id="Path_58" data-name="Path 58" d="M2.537,0V.561a.519.519,0,0,1-.519.519H.519A.519.519,0,0,1,0,.561V0Z" transform="translate(37.2 1.456)" fill="#4a4a4a"/>
|
||||
</g>
|
||||
<rect id="Rectangle_70" data-name="Rectangle 70" width="42.273" height="1.127" rx="0.564" transform="translate(0.915 0.556)" fill="#4a4a4a"/>
|
||||
<rect id="Rectangle_71" data-name="Rectangle 71" width="2.37" height="0.752" rx="0.376" transform="translate(1.949 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_72" data-name="Rectangle 72" width="2.37" height="0.752" rx="0.376" transform="translate(5.193 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_73" data-name="Rectangle 73" width="2.37" height="0.752" rx="0.376" transform="translate(7.688 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_74" data-name="Rectangle 74" width="2.37" height="0.752" rx="0.376" transform="translate(10.183 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_75" data-name="Rectangle 75" width="2.37" height="0.752" rx="0.376" transform="translate(12.679 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_76" data-name="Rectangle 76" width="2.37" height="0.752" rx="0.376" transform="translate(15.797 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_77" data-name="Rectangle 77" width="2.37" height="0.752" rx="0.376" transform="translate(18.292 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_78" data-name="Rectangle 78" width="2.37" height="0.752" rx="0.376" transform="translate(20.788 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_79" data-name="Rectangle 79" width="2.37" height="0.752" rx="0.376" transform="translate(23.283 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_80" data-name="Rectangle 80" width="2.37" height="0.752" rx="0.376" transform="translate(26.402 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_81" data-name="Rectangle 81" width="2.37" height="0.752" rx="0.376" transform="translate(28.897 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_82" data-name="Rectangle 82" width="2.37" height="0.752" rx="0.376" transform="translate(31.393 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_83" data-name="Rectangle 83" width="2.37" height="0.752" rx="0.376" transform="translate(34.512 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_84" data-name="Rectangle 84" width="2.37" height="0.752" rx="0.376" transform="translate(37.007 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
<rect id="Rectangle_85" data-name="Rectangle 85" width="2.37" height="0.752" rx="0.376" transform="translate(39.502 0.744)" fill="#d8d8d8" opacity="0.136"/>
|
||||
</g>
|
||||
<path id="Path_59" data-name="Path 59" d="M123.779,148.389a2.583,2.583,0,0,0-.332.033c-.02-.078-.038-.156-.06-.234a2.594,2.594,0,1,0-2.567-4.455q-.086-.088-.174-.175a2.593,2.593,0,1,0-4.461-2.569c-.077-.022-.154-.04-.231-.06a2.6,2.6,0,1,0-5.128,0c-.077.02-.154.038-.231.06a2.594,2.594,0,1,0-4.461,2.569,10.384,10.384,0,1,0,17.314,9.992,2.592,2.592,0,1,0,.332-5.161" transform="translate(-51.054 -75.262)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_60" data-name="Path 60" d="M83,113.389h20.779V103H83Z" transform="translate(-41.443 -58.444)" fill="#3ecc5f" fill-rule="evenodd"/>
|
||||
<path id="Path_61" data-name="Path 61" d="M123.389,108.944a1.3,1.3,0,1,0,0-2.6,1.338,1.338,0,0,0-.166.017c-.01-.039-.019-.078-.03-.117a1.3,1.3,0,0,0-.5-2.5,1.285,1.285,0,0,0-.783.269q-.043-.044-.087-.087a1.285,1.285,0,0,0,.263-.776,1.3,1.3,0,0,0-2.493-.509,5.195,5.195,0,1,0,0,10,1.3,1.3,0,0,0,2.493-.509,1.285,1.285,0,0,0-.263-.776q.044-.043.087-.087a1.285,1.285,0,0,0,.783.269,1.3,1.3,0,0,0,.5-2.5c.011-.038.02-.078.03-.117a1.335,1.335,0,0,0,.166.017" transform="translate(-55.859 -57.894)" fill="#44d860" fill-rule="evenodd"/>
|
||||
<path id="Path_62" data-name="Path 62" d="M141.8,38.745a1.41,1.41,0,0,1-.255-.026,1.309,1.309,0,0,1-.244-.073,1.349,1.349,0,0,1-.224-.119,1.967,1.967,0,0,1-.2-.161,1.52,1.52,0,0,1-.161-.2,1.282,1.282,0,0,1-.218-.722,1.41,1.41,0,0,1,.026-.255,1.5,1.5,0,0,1,.072-.244,1.364,1.364,0,0,1,.12-.223,1.252,1.252,0,0,1,.358-.358,1.349,1.349,0,0,1,.224-.119,1.309,1.309,0,0,1,.244-.073,1.2,1.2,0,0,1,.509,0,1.262,1.262,0,0,1,.468.192,1.968,1.968,0,0,1,.2.161,1.908,1.908,0,0,1,.161.2,1.322,1.322,0,0,1,.12.223,1.361,1.361,0,0,1,.1.5,1.317,1.317,0,0,1-.379.919,1.968,1.968,0,0,1-.2.161,1.346,1.346,0,0,1-.223.119,1.332,1.332,0,0,1-.5.1m10.389-.649a1.326,1.326,0,0,1-.92-.379,1.979,1.979,0,0,1-.161-.2,1.282,1.282,0,0,1-.218-.722,1.326,1.326,0,0,1,.379-.919,1.967,1.967,0,0,1,.2-.161,1.351,1.351,0,0,1,.224-.119,1.308,1.308,0,0,1,.244-.073,1.2,1.2,0,0,1,.509,0,1.262,1.262,0,0,1,.468.192,1.967,1.967,0,0,1,.2.161,1.326,1.326,0,0,1,.379.919,1.461,1.461,0,0,1-.026.255,1.323,1.323,0,0,1-.073.244,1.847,1.847,0,0,1-.119.223,1.911,1.911,0,0,1-.161.2,1.967,1.967,0,0,1-.2.161,1.294,1.294,0,0,1-.722.218" transform="translate(-69.074 -26.006)" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g id="React-icon" transform="translate(906.3 541.56)">
|
||||
<path id="Path_330" data-name="Path 330" d="M263.668,117.179c0-5.827-7.3-11.35-18.487-14.775,2.582-11.4,1.434-20.477-3.622-23.382a7.861,7.861,0,0,0-4.016-1v4a4.152,4.152,0,0,1,2.044.466c2.439,1.4,3.5,6.724,2.672,13.574-.2,1.685-.52,3.461-.914,5.272a86.9,86.9,0,0,0-11.386-1.954,87.469,87.469,0,0,0-7.459-8.965c5.845-5.433,11.332-8.41,15.062-8.41V78h0c-4.931,0-11.386,3.514-17.913,9.611-6.527-6.061-12.982-9.539-17.913-9.539v4c3.712,0,9.216,2.959,15.062,8.356a84.687,84.687,0,0,0-7.405,8.947,83.732,83.732,0,0,0-11.4,1.972c-.412-1.793-.717-3.532-.932-5.2-.843-6.85.2-12.175,2.618-13.592a3.991,3.991,0,0,1,2.062-.466v-4h0a8,8,0,0,0-4.052,1c-5.039,2.9-6.168,11.96-3.568,23.328-11.153,3.443-18.415,8.947-18.415,14.757,0,5.828,7.3,11.35,18.487,14.775-2.582,11.4-1.434,20.477,3.622,23.382a7.882,7.882,0,0,0,4.034,1c4.931,0,11.386-3.514,17.913-9.611,6.527,6.061,12.982,9.539,17.913,9.539a8,8,0,0,0,4.052-1c5.039-2.9,6.168-11.96,3.568-23.328C256.406,128.511,263.668,122.988,263.668,117.179Zm-23.346-11.96c-.663,2.313-1.488,4.7-2.421,7.083-.735-1.434-1.506-2.869-2.349-4.3-.825-1.434-1.7-2.833-2.582-4.2C235.517,104.179,237.974,104.645,240.323,105.219Zm-8.212,19.1c-1.4,2.421-2.833,4.716-4.321,6.85-2.672.233-5.379.359-8.1.359-2.708,0-5.415-.126-8.069-.341q-2.232-3.2-4.339-6.814-2.044-3.523-3.73-7.136c1.112-2.4,2.367-4.805,3.712-7.154,1.4-2.421,2.833-4.716,4.321-6.85,2.672-.233,5.379-.359,8.1-.359,2.708,0,5.415.126,8.069.341q2.232,3.2,4.339,6.814,2.044,3.523,3.73,7.136C234.692,119.564,233.455,121.966,232.11,124.315Zm5.792-2.331c.968,2.4,1.793,4.805,2.474,7.136-2.349.574-4.823,1.058-7.387,1.434.879-1.381,1.757-2.8,2.582-4.25C236.4,124.871,237.167,123.419,237.9,121.984ZM219.72,141.116a73.921,73.921,0,0,1-4.985-5.738c1.614.072,3.263.126,4.931.126,1.685,0,3.353-.036,4.985-.126A69.993,69.993,0,0,1,219.72,141.116ZM206.38,130.555c-2.546-.377-5-.843-7.352-1.417.663-2.313,1.488-4.7,2.421-7.083.735,1.434,1.506,2.869,2.349,4.3S205.5,129.192,206.38,130.555ZM219.63,93.241a73.924,73.924,0,0,1,4.985,5.738c-1.614-.072-3.263-.126-4.931-.126-1.686,0-3.353.036-4.985.126A69.993,69.993,0,0,1,219.63,93.241ZM206.362,103.8c-.879,1.381-1.757,2.8-2.582,4.25-.825,1.434-1.6,2.869-2.331,4.3-.968-2.4-1.793-4.805-2.474-7.136C201.323,104.663,203.8,104.179,206.362,103.8Zm-16.227,22.449c-6.348-2.708-10.454-6.258-10.454-9.073s4.106-6.383,10.454-9.073c1.542-.663,3.228-1.255,4.967-1.811a86.122,86.122,0,0,0,4.034,10.92,84.9,84.9,0,0,0-3.981,10.866C193.38,127.525,191.694,126.915,190.134,126.252Zm9.647,25.623c-2.439-1.4-3.5-6.724-2.672-13.574.2-1.686.52-3.461.914-5.272a86.9,86.9,0,0,0,11.386,1.954,87.465,87.465,0,0,0,7.459,8.965c-5.845,5.433-11.332,8.41-15.062,8.41A4.279,4.279,0,0,1,199.781,151.875Zm42.532-13.663c.843,6.85-.2,12.175-2.618,13.592a3.99,3.99,0,0,1-2.062.466c-3.712,0-9.216-2.959-15.062-8.356a84.689,84.689,0,0,0,7.405-8.947,83.731,83.731,0,0,0,11.4-1.972A50.194,50.194,0,0,1,242.313,138.212Zm6.9-11.96c-1.542.663-3.228,1.255-4.967,1.811a86.12,86.12,0,0,0-4.034-10.92,84.9,84.9,0,0,0,3.981-10.866c1.775.556,3.461,1.165,5.039,1.829,6.348,2.708,10.454,6.258,10.454,9.073C259.67,119.994,255.564,123.562,249.216,126.252Z" fill="#61dafb"/>
|
||||
<path id="Path_331" data-name="Path 331" d="M320.8,78.4Z" transform="translate(-119.082 -0.328)" fill="#61dafb"/>
|
||||
<circle id="Ellipse_112" data-name="Ellipse 112" cx="8.194" cy="8.194" r="8.194" transform="translate(211.472 108.984)" fill="#61dafb"/>
|
||||
<path id="Path_332" data-name="Path 332" d="M520.5,78.1Z" transform="translate(-282.975 -0.082)" fill="#61dafb"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 35 KiB |
40
docs/static/img/undraw_docusaurus_tree.svg
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1129" height="663" viewBox="0 0 1129 663">
|
||||
<title>Focus on What Matters</title>
|
||||
<circle cx="321" cy="321" r="321" fill="#f2f2f2" />
|
||||
<ellipse cx="559" cy="635.49998" rx="514" ry="27.50002" fill="#3f3d56" />
|
||||
<ellipse cx="558" cy="627" rx="460" ry="22" opacity="0.2" />
|
||||
<rect x="131" y="152.5" width="840" height="50" fill="#3f3d56" />
|
||||
<path d="M166.5,727.3299A21.67009,21.67009,0,0,0,188.1701,749H984.8299A21.67009,21.67009,0,0,0,1006.5,727.3299V296h-840Z" transform="translate(-35.5 -118.5)" fill="#3f3d56" />
|
||||
<path d="M984.8299,236H188.1701A21.67009,21.67009,0,0,0,166.5,257.6701V296h840V257.6701A21.67009,21.67009,0,0,0,984.8299,236Z" transform="translate(-35.5 -118.5)" fill="#3f3d56" />
|
||||
<path d="M984.8299,236H188.1701A21.67009,21.67009,0,0,0,166.5,257.6701V296h840V257.6701A21.67009,21.67009,0,0,0,984.8299,236Z" transform="translate(-35.5 -118.5)" opacity="0.2" />
|
||||
<circle cx="181" cy="147.5" r="13" fill="#3f3d56" />
|
||||
<circle cx="217" cy="147.5" r="13" fill="#3f3d56" />
|
||||
<circle cx="253" cy="147.5" r="13" fill="#3f3d56" />
|
||||
<rect x="168" y="213.5" width="337" height="386" rx="5.33505" fill="#606060" />
|
||||
<rect x="603" y="272.5" width="284" height="22" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="352.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="396.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="440.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="537" y="484.5" width="416" height="15" rx="5.47638" fill="#2e8555" />
|
||||
<rect x="865" y="552.5" width="88" height="26" rx="7.02756" fill="#3ecc5f" />
|
||||
<path d="M1088.60287,624.61594a30.11371,30.11371,0,0,0,3.98291-15.266c0-13.79652-8.54358-24.98081-19.08256-24.98081s-19.08256,11.18429-19.08256,24.98081a30.11411,30.11411,0,0,0,3.98291,15.266,31.248,31.248,0,0,0,0,30.53213,31.248,31.248,0,0,0,0,30.53208,31.248,31.248,0,0,0,0,30.53208,30.11408,30.11408,0,0,0-3.98291,15.266c0,13.79652,8.54353,24.98081,19.08256,24.98081s19.08256-11.18429,19.08256-24.98081a30.11368,30.11368,0,0,0-3.98291-15.266,31.248,31.248,0,0,0,0-30.53208,31.248,31.248,0,0,0,0-30.53208,31.248,31.248,0,0,0,0-30.53213Z" transform="translate(-35.5 -118.5)" fill="#3f3d56" />
|
||||
<ellipse cx="1038.00321" cy="460.31783" rx="19.08256" ry="24.9808" fill="#3f3d56" />
|
||||
<ellipse cx="1038.00321" cy="429.78574" rx="19.08256" ry="24.9808" fill="#3f3d56" />
|
||||
<path d="M1144.93871,339.34489a91.61081,91.61081,0,0,0,7.10658-10.46092l-50.141-8.23491,54.22885.4033a91.566,91.566,0,0,0,1.74556-72.42605l-72.75449,37.74139,67.09658-49.32086a91.41255,91.41255,0,1,0-150.971,102.29805,91.45842,91.45842,0,0,0-10.42451,16.66946l65.0866,33.81447-69.40046-23.292a91.46011,91.46011,0,0,0,14.73837,85.83669,91.40575,91.40575,0,1,0,143.68892,0,91.41808,91.41808,0,0,0,0-113.02862Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M981.6885,395.8592a91.01343,91.01343,0,0,0,19.56129,56.51431,91.40575,91.40575,0,1,0,143.68892,0C1157.18982,436.82067,981.6885,385.60008,981.6885,395.8592Z" transform="translate(-35.5 -118.5)" opacity="0.1" />
|
||||
<path d="M365.62,461.43628H477.094v45.12043H365.62Z" transform="translate(-35.5 -118.5)" fill="#fff" fill-rule="evenodd" />
|
||||
<path d="M264.76252,608.74122a26.50931,26.50931,0,0,1-22.96231-13.27072,26.50976,26.50976,0,0,0,22.96231,39.81215H291.304V608.74122Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M384.17242,468.57061l92.92155-5.80726V449.49263a26.54091,26.54091,0,0,0-26.54143-26.54143H331.1161l-3.31768-5.74622a3.83043,3.83043,0,0,0-6.63536,0l-3.31768,5.74622-3.31767-5.74622a3.83043,3.83043,0,0,0-6.63536,0l-3.31768,5.74622L301.257,417.205a3.83043,3.83043,0,0,0-6.63536,0L291.304,422.9512c-.02919,0-.05573.004-.08625.004l-5.49674-5.49541a3.8293,3.8293,0,0,0-6.4071,1.71723l-1.81676,6.77338L270.607,424.1031a3.82993,3.82993,0,0,0-4.6912,4.69253l1.84463,6.89148-6.77072,1.81411a3.8315,3.8315,0,0,0-1.71988,6.40975l5.49673,5.49673c0,.02787-.004.05574-.004.08493l-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74621,3.31768L259.0163,466.081a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31767a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31767a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83042,3.83042,0,0,0,0,6.63535l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768L259.0163,558.976a3.83042,3.83042,0,0,0,0,6.63535l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768-5.74622,3.31768a3.83042,3.83042,0,0,0,0,6.63535l5.74622,3.31768-5.74622,3.31768a3.83043,3.83043,0,0,0,0,6.63536l5.74622,3.31768A26.54091,26.54091,0,0,0,291.304,635.28265H450.55254A26.5409,26.5409,0,0,0,477.094,608.74122V502.5755l-92.92155-5.80727a14.12639,14.12639,0,0,1,0-28.19762" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M424.01111,635.28265h39.81214V582.19979H424.01111Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M490.36468,602.10586a6.60242,6.60242,0,0,0-.848.08493c-.05042-.19906-.09821-.39945-.15393-.59852A6.62668,6.62668,0,1,0,482.80568,590.21q-.2203-.22491-.44457-.44589a6.62391,6.62391,0,1,0-11.39689-6.56369c-.1964-.05575-.39414-.10218-.59056-.15262a6.63957,6.63957,0,1,0-13.10086,0c-.1964.05042-.39414.09687-.59056.15262a6.62767,6.62767,0,1,0-11.39688,6.56369,26.52754,26.52754,0,1,0,44.23127,25.52756,6.6211,6.6211,0,1,0,.848-13.18579" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M437.28182,555.65836H477.094V529.11693H437.28182Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M490.36468,545.70532a3.31768,3.31768,0,0,0,0-6.63536,3.41133,3.41133,0,0,0-.42333.04247c-.02655-.09953-.04911-.19907-.077-.29859a3.319,3.319,0,0,0-1.278-6.37923,3.28174,3.28174,0,0,0-2.00122.68742q-.10947-.11346-.22294-.22295a3.282,3.282,0,0,0,.67149-1.98265,3.31768,3.31768,0,0,0-6.37-1.2992,13.27078,13.27078,0,1,0,0,25.54082,3.31768,3.31768,0,0,0,6.37-1.2992,3.282,3.282,0,0,0-.67149-1.98265q.11347-.10947.22294-.22294a3.28174,3.28174,0,0,0,2.00122.68742,3.31768,3.31768,0,0,0,1.278-6.37923c.02786-.0982.05042-.19907.077-.29859a3.41325,3.41325,0,0,0,.42333.04246" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M317.84538,466.081a3.31768,3.31768,0,0,1-3.31767-3.31768,9.953,9.953,0,1,0-19.90608,0,3.31768,3.31768,0,1,1-6.63535,0,16.58839,16.58839,0,1,1,33.17678,0,3.31768,3.31768,0,0,1-3.31768,3.31768" transform="translate(-35.5 -118.5)" fill-rule="evenodd" />
|
||||
<path d="M370.92825,635.28265h79.62429A26.5409,26.5409,0,0,0,477.094,608.74122v-92.895H397.46968a26.54091,26.54091,0,0,0-26.54143,26.54143Z" transform="translate(-35.5 -118.5)" fill="#ffff50" fill-rule="evenodd" />
|
||||
<path d="M457.21444,556.98543H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.54143H390.80778a1.32707,1.32707,0,1,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.54143H390.80778a1.32707,1.32707,0,1,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0-66.10674H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.29459H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414m0,26.54143H390.80778a1.32707,1.32707,0,0,1,0-2.65414h66.40666a1.32707,1.32707,0,0,1,0,2.65414M477.094,474.19076c-.01592,0-.0292-.008-.04512-.00663-4.10064.13934-6.04083,4.24132-7.75274,7.86024-1.78623,3.78215-3.16771,6.24122-5.43171,6.16691-2.50685-.09024-3.94007-2.92222-5.45825-5.91874-1.74377-3.44243-3.73438-7.34667-7.91333-7.20069-4.04227.138-5.98907,3.70784-7.70631,6.857-1.82738,3.35484-3.07084,5.39455-5.46887,5.30033-2.55727-.09289-3.91619-2.39536-5.48877-5.06013-1.75306-2.96733-3.77951-6.30359-7.8775-6.18946-3.97326.13669-5.92537,3.16507-7.64791,5.83912-1.82207,2.82666-3.09872,4.5492-5.52725,4.447-2.61832-.09289-3.9706-2.00388-5.53522-4.21611-1.757-2.4856-3.737-5.299-7.82308-5.16231-3.88567.13271-5.83779,2.61434-7.559,4.80135-1.635,2.07555-2.9116,3.71846-5.61218,3.615a1.32793,1.32793,0,1,0-.09555,2.65414c4.00377.134,6.03154-2.38873,7.79257-4.6275,1.562-1.9853,2.91027-3.69855,5.56441-3.78879,2.55594-.10882,3.75429,1.47968,5.56707,4.04093,1.7212,2.43385,3.67465,5.19416,7.60545,5.33616,4.11789.138,6.09921-2.93946,7.8536-5.66261,1.56861-2.43385,2.92221-4.53461,5.50734-4.62352,2.37944-.08892,3.67466,1.79154,5.50072,4.885,1.72121,2.91557,3.67069,6.21865,7.67977,6.36463,4.14709.14332,6.14965-3.47693,7.89475-6.68181,1.51155-2.77092,2.93814-5.38791,5.46621-5.4755,2.37944-.05573,3.62025,2.11668,5.45558,5.74622,1.71459,3.388,3.65875,7.22591,7.73019,7.37321l.22429.004c4.06614,0,5.99571-4.08074,7.70364-7.68905,1.51154-3.19825,2.94211-6.21069,5.3972-6.33411Z" transform="translate(-35.5 -118.5)" fill-rule="evenodd" />
|
||||
<path d="M344.38682,635.28265h53.08286V582.19979H344.38682Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M424.01111,602.10586a6.60242,6.60242,0,0,0-.848.08493c-.05042-.19906-.09821-.39945-.15394-.59852A6.62667,6.62667,0,1,0,416.45211,590.21q-.2203-.22491-.44458-.44589a6.62391,6.62391,0,1,0-11.39689-6.56369c-.1964-.05575-.39413-.10218-.59054-.15262a6.63957,6.63957,0,1,0-13.10084,0c-.19641.05042-.39414.09687-.59055.15262a6.62767,6.62767,0,1,0-11.39689,6.56369,26.52755,26.52755,0,1,0,44.2313,25.52756,6.6211,6.6211,0,1,0,.848-13.18579" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M344.38682,555.65836h53.08286V529.11693H344.38682Z" transform="translate(-35.5 -118.5)" fill="#3ecc5f" fill-rule="evenodd" />
|
||||
<path d="M410.74039,545.70532a3.31768,3.31768,0,1,0,0-6.63536,3.41133,3.41133,0,0,0-.42333.04247c-.02655-.09953-.04911-.19907-.077-.29859a3.319,3.319,0,0,0-1.278-6.37923,3.28174,3.28174,0,0,0-2.00122.68742q-.10947-.11346-.22294-.22295a3.282,3.282,0,0,0,.67149-1.98265,3.31768,3.31768,0,0,0-6.37-1.2992,13.27078,13.27078,0,1,0,0,25.54082,3.31768,3.31768,0,0,0,6.37-1.2992,3.282,3.282,0,0,0-.67149-1.98265q.11347-.10947.22294-.22294a3.28174,3.28174,0,0,0,2.00122.68742,3.31768,3.31768,0,0,0,1.278-6.37923c.02786-.0982.05042-.19907.077-.29859a3.41325,3.41325,0,0,0,.42333.04246" transform="translate(-35.5 -118.5)" fill="#44d860" fill-rule="evenodd" />
|
||||
<path d="M424.01111,447.8338a3.60349,3.60349,0,0,1-.65028-.06636,3.34415,3.34415,0,0,1-.62372-.18579,3.44679,3.44679,0,0,1-.572-.30522,5.02708,5.02708,0,0,1-.50429-.4114,3.88726,3.88726,0,0,1-.41007-.50428,3.27532,3.27532,0,0,1-.55737-1.84463,3.60248,3.60248,0,0,1,.06636-.65027,3.82638,3.82638,0,0,1,.18447-.62373,3.48858,3.48858,0,0,1,.30656-.57064,3.197,3.197,0,0,1,.91436-.91568,3.44685,3.44685,0,0,1,.572-.30523,3.344,3.344,0,0,1,.62372-.18578,3.06907,3.06907,0,0,1,1.30053,0,3.22332,3.22332,0,0,1,1.19436.491,5.02835,5.02835,0,0,1,.50429.41139,4.8801,4.8801,0,0,1,.41139.50429,3.38246,3.38246,0,0,1,.30522.57064,3.47806,3.47806,0,0,1,.25215,1.274A3.36394,3.36394,0,0,1,426.36,446.865a5.02708,5.02708,0,0,1-.50429.4114,3.3057,3.3057,0,0,1-1.84463.55737m26.54143-1.65884a3.38754,3.38754,0,0,1-2.35024-.96877,5.04185,5.04185,0,0,1-.41007-.50428,3.27532,3.27532,0,0,1-.55737-1.84463,3.38659,3.38659,0,0,1,.96744-2.34892,5.02559,5.02559,0,0,1,.50429-.41139,3.44685,3.44685,0,0,1,.572-.30523,3.3432,3.3432,0,0,1,.62373-.18579,3.06952,3.06952,0,0,1,1.30052,0,3.22356,3.22356,0,0,1,1.19436.491,5.02559,5.02559,0,0,1,.50429.41139,3.38792,3.38792,0,0,1,.96876,2.34892,3.72635,3.72635,0,0,1-.06636.65026,3.37387,3.37387,0,0,1-.18579.62373,4.71469,4.71469,0,0,1-.30522.57064,4.8801,4.8801,0,0,1-.41139.50429,5.02559,5.02559,0,0,1-.50429.41139,3.30547,3.30547,0,0,1-1.84463.55737" transform="translate(-35.5 -118.5)" fill-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
8
docs/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
// This file is not used in compilation. It is here just for a nice editor experience.
|
||||
"extends": "@docusaurus/tsconfig",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
},
|
||||
"exclude": [".docusaurus", "build"]
|
||||
}
|
||||
8
go.mod
@@ -5,8 +5,11 @@ go 1.24.1
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/playwright-community/playwright-go v0.5001.0
|
||||
github.com/prometheus/client_golang v1.21.1
|
||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -19,11 +22,14 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 // indirect
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 // indirect
|
||||
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052 // indirect
|
||||
github.com/facebookgo/subset v0.0.0-20150612182917-8dac2c3c4870 // indirect
|
||||
github.com/fatih/color v1.16.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-stack/stack v1.8.1 // indirect
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
|
||||
27
go.sum
@@ -16,8 +16,11 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
|
||||
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51 h1:0JZ+dUmQeA8IIVUMzysrX4/AKuQwWhV2dYQuPZdvdSQ=
|
||||
github.com/facebookgo/ensure v0.0.0-20160127193407-b4ab57deab51/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 h1:CkmB2l68uhvRlwOTPrwnuitSxi/S3Cg4L5QYOcL9MBc=
|
||||
@@ -30,8 +33,13 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw=
|
||||
github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
@@ -43,10 +51,15 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc=
|
||||
github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/playwright-community/playwright-go v0.5001.0 h1:EY3oB+rU9cUp6CLHguWE8VMZTwAg+83Yyb7dQqEmGLg=
|
||||
github.com/playwright-community/playwright-go v0.5001.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
|
||||
@@ -57,8 +70,15 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM=
|
||||
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/yl2chen/cidranger v1.0.2 h1:lbOWZVCG1tCRX4u24kuM1Tb4nHqWkDxwLdoS+SevawU=
|
||||
github.com/yl2chen/cidranger v1.0.2/go.mod h1:9U1yz7WPYDwf0vpNWFaeRh0bjwz5RVgRy/9UEQfHl0g=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
@@ -137,5 +157,8 @@ golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -3,6 +3,7 @@ package dnsbl
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -46,6 +47,11 @@ func TestReverse6(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
if os.Getenv("DONT_USE_NETWORK") != "" {
|
||||
t.Skip("test requires network egress")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := Lookup("27.65.243.194")
|
||||
if err != nil {
|
||||
t.Fatalf("it broked: %v", err)
|
||||
12
internal/hash.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
func SHA256sum(text string) string {
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(text))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
@@ -1,20 +1,50 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/sebest/xff"
|
||||
)
|
||||
|
||||
// UnchangingCache sets the Cache-Control header to cache a response for 1 year if
|
||||
// and only if the application is compiled in "release" mode by Docker.
|
||||
func UnchangingCache(h http.Handler) http.Handler {
|
||||
func UnchangingCache(next http.Handler) http.Handler {
|
||||
if anubis.Version == "devel" {
|
||||
return h
|
||||
return next
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000")
|
||||
h.ServeHTTP(w, r)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultXRealIP sets the X-Real-Ip header to the given value if and only if
|
||||
// it is not an empty string.
|
||||
func DefaultXRealIP(defaultIP string, next http.Handler) http.Handler {
|
||||
if defaultIP == "" {
|
||||
slog.Debug("skipping middleware, defaultIP is empty")
|
||||
return next
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Set("X-Real-Ip", defaultIP)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// XForwardedForToXRealIP sets the X-Real-Ip header based on the contents
|
||||
// of the X-Forwarded-For header.
|
||||
func XForwardedForToXRealIP(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if xffHeader := r.Header.Get("X-Forwarded-For"); r.Header.Get("X-Real-Ip") == "" && xffHeader != "" {
|
||||
ip := xff.Parse(xffHeader)
|
||||
slog.Debug("setting x-real-ip", "val", ip)
|
||||
r.Header.Set("X-Real-Ip", ip)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
439
internal/test/playwright_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
// Integration tests for Anubis, using Playwright.
|
||||
//
|
||||
// These tests require an already running Anubis and Playwright server.
|
||||
//
|
||||
// Anubis must be configured to redirect to the server started by the test suite.
|
||||
// The bind address and the Anubis server can be specified using the flags `-bind` and `-anubis` respectively.
|
||||
//
|
||||
// Playwright must be started in server mode using `npx playwright@1.50.1 run-server --port 3000`.
|
||||
// The version must match the minor used by the playwright-go package.
|
||||
//
|
||||
// On unsupported systems you may be able to use a container instead: https://playwright.dev/docs/docker#remote-connection
|
||||
//
|
||||
// In that case you may need to set the `-playwright` flag to the container's URL, and specify the `--host` the run-server command listens on.
|
||||
package test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
libanubis "github.com/TecharoHQ/anubis/lib"
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
var (
|
||||
serverBindAddr = flag.String("bind", "localhost:3923", "test server bind address")
|
||||
playwrightPort = flag.Int("playwright-port", 3000, "Playwright port")
|
||||
playwrightServer = flag.String("playwright", "ws://localhost:3000", "Playwright server URL")
|
||||
playwrightMaxTime = flag.Duration("playwright-max-time", 5*time.Second, "maximum time for Playwright requests")
|
||||
playwrightMaxHardTime = flag.Duration("playwright-max-hard-time", 5*time.Minute, "maximum time for hard Playwright requests")
|
||||
|
||||
testCases = []testCase{
|
||||
{
|
||||
name: "firefox",
|
||||
action: actionChallenge,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0",
|
||||
},
|
||||
{
|
||||
name: "headlessChrome",
|
||||
action: actionDeny,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/120.0.6099.28 Safari/537.36",
|
||||
},
|
||||
{
|
||||
name: "kagiBadIP",
|
||||
action: actionChallenge,
|
||||
isHard: true,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)",
|
||||
},
|
||||
{
|
||||
name: "kagiGoodIP",
|
||||
action: actionAllow,
|
||||
realIP: "216.18.205.234",
|
||||
userAgent: "Mozilla/5.0 (compatible; Kagibot/1.0; +https://kagi.com/bot)",
|
||||
},
|
||||
{
|
||||
name: "unknownAgent",
|
||||
action: actionAllow,
|
||||
realIP: placeholderIP,
|
||||
userAgent: "AnubisTest/0",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
actionAllow action = "ALLOW"
|
||||
actionDeny action = "DENY"
|
||||
actionChallenge action = "CHALLENGE"
|
||||
|
||||
placeholderIP = "fd11:5ee:bad:c0de::"
|
||||
playwrightVersion = "1.50.1"
|
||||
)
|
||||
|
||||
type action string
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
action action
|
||||
isHard bool
|
||||
realIP, userAgent string
|
||||
}
|
||||
|
||||
func doesNPXExist(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH, skipping integration smoke testing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func run(t *testing.T, command string) string {
|
||||
t.Helper()
|
||||
|
||||
shPath, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] %v", err)
|
||||
}
|
||||
|
||||
t.Logf("running command: %s", command)
|
||||
|
||||
cmd := exec.Command(shPath, "-c", command)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stderr = os.Stderr
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("can't run command: %v", err)
|
||||
}
|
||||
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func daemonize(t *testing.T, command string) {
|
||||
t.Helper()
|
||||
|
||||
shPath, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] %v", err)
|
||||
}
|
||||
|
||||
t.Logf("daemonizing command: %s", command)
|
||||
|
||||
cmd := exec.Command(shPath, "-c", command)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Stdout = os.Stdout
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("can't daemonize command: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
cmd.Process.Kill()
|
||||
})
|
||||
}
|
||||
|
||||
func startPlaywright(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
if os.Getenv("CI") == "true" {
|
||||
run(t, fmt.Sprintf("npx --yes playwright@%s install --with-deps", playwrightVersion))
|
||||
} else {
|
||||
run(t, fmt.Sprintf("npx --yes playwright@%s install", playwrightVersion))
|
||||
}
|
||||
|
||||
daemonize(t, fmt.Sprintf("npx --yes playwright@%s run-server --port %d", playwrightVersion, *playwrightPort))
|
||||
|
||||
for {
|
||||
if _, err := http.Get(fmt.Sprintf("http://localhost:%d", *playwrightPort)); err != nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
//nosleep:bypass XXX(Xe): Playwright doesn't have a good way to signal readiness. This is a HACK that will just let the tests pass.
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
|
||||
func TestPlaywrightBrowser(t *testing.T) {
|
||||
if os.Getenv("DONT_USE_NETWORK") != "" {
|
||||
t.Skip("test requires network egress")
|
||||
return
|
||||
}
|
||||
|
||||
doesNPXExist(t)
|
||||
startPlaywright(t)
|
||||
|
||||
pw := setupPlaywright(t)
|
||||
anubisURL := spawnAnubis(t)
|
||||
|
||||
browsers := []playwright.BrowserType{pw.Chromium, pw.Firefox, pw.WebKit}
|
||||
|
||||
for _, typ := range browsers {
|
||||
t.Run(typ.Name()+"/warmup", func(t *testing.T) {
|
||||
browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
|
||||
ExposeNetwork: playwright.String("<loopback>"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not connect to remote browser: %v", err)
|
||||
}
|
||||
defer browser.Close()
|
||||
|
||||
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
AcceptDownloads: playwright.Bool(false),
|
||||
ExtraHttpHeaders: map[string]string{
|
||||
"X-Real-Ip": "127.0.0.1",
|
||||
},
|
||||
UserAgent: playwright.String("Sephiroth"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not create context: %v", err)
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
page, err := ctx.NewPage()
|
||||
if err != nil {
|
||||
t.Fatalf("could not create page: %v", err)
|
||||
}
|
||||
defer page.Close()
|
||||
|
||||
timeout := 2.0
|
||||
page.Goto(anubisURL, playwright.PageGotoOptions{
|
||||
Timeout: &timeout,
|
||||
})
|
||||
})
|
||||
|
||||
for _, tc := range testCases {
|
||||
name := fmt.Sprintf("%s/%s", typ.Name(), tc.name)
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, hasDeadline := t.Deadline()
|
||||
if tc.isHard && hasDeadline {
|
||||
t.Skip("skipping hard challenge with deadline")
|
||||
}
|
||||
|
||||
var perfomedAction action
|
||||
var err error
|
||||
for i := 0; i < 5; i++ {
|
||||
perfomedAction, err = executeTestCase(t, tc, typ, anubisURL)
|
||||
if perfomedAction == tc.action {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(i+1) * 250 * time.Millisecond)
|
||||
}
|
||||
if perfomedAction != tc.action {
|
||||
t.Errorf("unexpected test result, expected %s, got %s", tc.action, perfomedAction)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("test error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildBrowserConnect(name string) string {
|
||||
u, _ := url.Parse(*playwrightServer)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("browser", name)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func executeTestCase(t *testing.T, tc testCase, typ playwright.BrowserType, anubisURL string) (action, error) {
|
||||
deadline, _ := t.Deadline()
|
||||
|
||||
browser, err := typ.Connect(buildBrowserConnect(typ.Name()), playwright.BrowserTypeConnectOptions{
|
||||
ExposeNetwork: playwright.String("<loopback>"),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not connect to remote browser: %w", err)
|
||||
}
|
||||
defer browser.Close()
|
||||
|
||||
ctx, err := browser.NewContext(playwright.BrowserNewContextOptions{
|
||||
AcceptDownloads: playwright.Bool(false),
|
||||
ExtraHttpHeaders: map[string]string{
|
||||
"X-Real-Ip": tc.realIP,
|
||||
},
|
||||
UserAgent: playwright.String(tc.userAgent),
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not create context: %w", err)
|
||||
}
|
||||
defer ctx.Close()
|
||||
|
||||
page, err := ctx.NewPage()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not create page: %w", err)
|
||||
}
|
||||
defer page.Close()
|
||||
|
||||
// Attempt challenge.
|
||||
|
||||
start := time.Now()
|
||||
_, err = page.Goto(anubisURL, playwright.PageGotoOptions{
|
||||
Timeout: pwTimeout(tc, deadline),
|
||||
})
|
||||
if err != nil {
|
||||
return "", pwFail(t, page, "could not navigate to test server: %v", err)
|
||||
}
|
||||
|
||||
hadChallenge := false
|
||||
switch tc.action {
|
||||
case actionChallenge:
|
||||
// FIXME: This could race if challenge is completed too quickly.
|
||||
checkImage(t, tc, deadline, page, "#image[src*=pensive], #image[src*=happy]")
|
||||
hadChallenge = true
|
||||
case actionDeny:
|
||||
checkImage(t, tc, deadline, page, "#image[src*=sad]")
|
||||
return actionDeny, nil
|
||||
}
|
||||
|
||||
// Ensure protected resource was provided.
|
||||
|
||||
res, err := page.Locator("#anubis-test").TextContent(playwright.LocatorTextContentOptions{
|
||||
Timeout: pwTimeout(tc, deadline),
|
||||
})
|
||||
end := time.Now()
|
||||
if err != nil {
|
||||
pwFail(t, page, "could not get text content: %v", err)
|
||||
}
|
||||
|
||||
var tm int64
|
||||
if _, err := fmt.Sscanf(res, "%d", &tm); err != nil {
|
||||
pwFail(t, page, "unexpected output: %s", res)
|
||||
}
|
||||
|
||||
if tm < start.Unix() || end.Unix() < tm {
|
||||
pwFail(t, page, "unexpected timestamp in output: %d not in range %d..%d", tm, start.Unix(), end.Unix())
|
||||
}
|
||||
|
||||
if hadChallenge {
|
||||
return actionChallenge, nil
|
||||
} else {
|
||||
return actionAllow, nil
|
||||
}
|
||||
}
|
||||
|
||||
func checkImage(t *testing.T, tc testCase, deadline time.Time, page playwright.Page, locator string) {
|
||||
image := page.Locator(locator)
|
||||
err := image.WaitFor(playwright.LocatorWaitForOptions{
|
||||
Timeout: pwTimeout(tc, deadline),
|
||||
})
|
||||
if err != nil {
|
||||
pwFail(t, page, "could not wait for result: %v", err)
|
||||
}
|
||||
|
||||
failIsVisible, err := image.IsVisible()
|
||||
if err != nil {
|
||||
pwFail(t, page, "could not check result image: %v", err)
|
||||
}
|
||||
|
||||
if !failIsVisible {
|
||||
pwFail(t, page, "expected result image not visible")
|
||||
}
|
||||
}
|
||||
|
||||
func pwFail(t *testing.T, page playwright.Page, format string, args ...any) error {
|
||||
t.Helper()
|
||||
|
||||
saveScreenshot(t, page)
|
||||
return fmt.Errorf(format, args...)
|
||||
}
|
||||
|
||||
func pwTimeout(tc testCase, deadline time.Time) *float64 {
|
||||
max := *playwrightMaxTime
|
||||
if tc.isHard {
|
||||
max = *playwrightMaxHardTime
|
||||
}
|
||||
|
||||
d := time.Until(deadline)
|
||||
if d <= 0 || d > max {
|
||||
return playwright.Float(float64(max.Milliseconds()))
|
||||
}
|
||||
return playwright.Float(float64(d.Milliseconds()))
|
||||
}
|
||||
|
||||
func saveScreenshot(t *testing.T, page playwright.Page) {
|
||||
t.Helper()
|
||||
|
||||
data, err := page.Screenshot()
|
||||
if err != nil {
|
||||
t.Logf("could not take screenshot: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp("", "anubis-test-fail-*.png")
|
||||
if err != nil {
|
||||
t.Logf("could not create temporary file: %v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(data)
|
||||
if err != nil {
|
||||
t.Logf("could not write screenshot: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
t.Logf("screenshot saved to %s", f.Name())
|
||||
}
|
||||
|
||||
func setupPlaywright(t *testing.T) *playwright.Playwright {
|
||||
err := playwright.Install(&playwright.RunOptions{
|
||||
SkipInstallBrowsers: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("could not install Playwright: %v", err)
|
||||
}
|
||||
|
||||
pw, err := playwright.Run()
|
||||
if err != nil {
|
||||
t.Fatalf("could not start Playwright: %v", err)
|
||||
}
|
||||
return pw
|
||||
}
|
||||
|
||||
func spawnAnubis(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, "<html><body><span id=anubis-test>%d</span></body></html>", time.Now().Unix())
|
||||
})
|
||||
|
||||
policy, err := libanubis.LoadPoliciesOrDefault("", anubis.DefaultDifficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := libanubis.New(libanubis.Options{
|
||||
Next: h,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(s)
|
||||
t.Log(ts.URL)
|
||||
|
||||
t.Cleanup(func() {
|
||||
ts.Close()
|
||||
})
|
||||
|
||||
return ts.URL
|
||||
}
|
||||
3
internal/test/var/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.png
|
||||
*.txt
|
||||
*.html
|
||||
527
lib/anubis.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"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"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/data"
|
||||
"github.com/TecharoHQ/anubis/decaymap"
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/internal/dnsbl"
|
||||
"github.com/TecharoHQ/anubis/lib/policy"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/TecharoHQ/anubis/web"
|
||||
"github.com/TecharoHQ/anubis/xess"
|
||||
)
|
||||
|
||||
var (
|
||||
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",
|
||||
})
|
||||
|
||||
droneBLHits = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||
Name: "anubis_dronebl_hits",
|
||||
Help: "The total number of hits from DroneBL",
|
||||
}, []string{"status"})
|
||||
|
||||
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.ExponentialBucketsRange(1, math.Pow(2, 18), 19),
|
||||
})
|
||||
)
|
||||
|
||||
type Options struct {
|
||||
Next http.Handler
|
||||
Policy *policy.ParsedConfig
|
||||
ServeRobotsTXT bool
|
||||
PrivateKey ed25519.PrivateKey
|
||||
|
||||
CookieDomain string
|
||||
CookieName string
|
||||
CookiePartitioned bool
|
||||
}
|
||||
|
||||
func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) {
|
||||
var fin io.ReadCloser
|
||||
var err error
|
||||
|
||||
if fname != "" {
|
||||
fin, err = os.Open(fname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy file %s: %w", fname, err)
|
||||
}
|
||||
} else {
|
||||
fname = "(data)/botPolicies.json"
|
||||
fin, err = data.BotPolicies.Open("botPolicies.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", fname, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer fin.Close()
|
||||
|
||||
policy, err := policy.ParseConfig(fin, fname, defaultDifficulty)
|
||||
|
||||
return policy, err
|
||||
}
|
||||
|
||||
func New(opts Options) (*Server, error) {
|
||||
if opts.PrivateKey == nil {
|
||||
slog.Debug("opts.PrivateKey not set, generating a new one")
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lib: can't generate private key: %v", err)
|
||||
}
|
||||
opts.PrivateKey = priv
|
||||
}
|
||||
|
||||
result := &Server{
|
||||
next: opts.Next,
|
||||
priv: opts.PrivateKey,
|
||||
pub: opts.PrivateKey.Public().(ed25519.PublicKey),
|
||||
policy: opts.Policy,
|
||||
opts: opts,
|
||||
DNSBLCache: decaymap.New[string, dnsbl.DroneBLResponse](),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
|
||||
mux.Handle(anubis.StaticPath, internal.UnchangingCache(http.StripPrefix(anubis.StaticPath, http.FileServerFS(web.Static))))
|
||||
|
||||
if opts.ServeRobotsTXT {
|
||||
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
})
|
||||
|
||||
mux.HandleFunc("/.well-known/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFileFS(w, r, web.Static, "static/robots.txt")
|
||||
})
|
||||
}
|
||||
|
||||
// mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding)
|
||||
|
||||
mux.HandleFunc("POST /.within.website/x/cmd/anubis/api/make-challenge", result.MakeChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/pass-challenge", result.PassChallenge)
|
||||
mux.HandleFunc("GET /.within.website/x/cmd/anubis/api/test-error", result.TestError)
|
||||
|
||||
mux.HandleFunc("/", result.MaybeReverseProxy)
|
||||
|
||||
result.mux = mux
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
next http.Handler
|
||||
priv ed25519.PrivateKey
|
||||
pub ed25519.PublicKey
|
||||
policy *policy.ParsedConfig
|
||||
opts Options
|
||||
DNSBLCache *decaymap.Impl[string, dnsbl.DroneBLResponse]
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) challengeFor(r *http.Request, difficulty int) string {
|
||||
fp := sha256.Sum256(s.priv.Seed())
|
||||
|
||||
data := fmt.Sprintf(
|
||||
"Accept-Language=%s,X-Real-IP=%s,User-Agent=%s,WeekTime=%s,Fingerprint=%x,Difficulty=%d",
|
||||
r.Header.Get("Accept-Language"),
|
||||
r.Header.Get("X-Real-Ip"),
|
||||
r.UserAgent(),
|
||||
time.Now().UTC().Round(24*7*time.Hour).Format(time.RFC3339),
|
||||
fp,
|
||||
difficulty,
|
||||
)
|
||||
return internal.SHA256sum(data)
|
||||
}
|
||||
|
||||
func (s *Server) MaybeReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
lg := slog.With(
|
||||
"user_agent", r.UserAgent(),
|
||||
"accept_language", r.Header.Get("Accept-Language"),
|
||||
"priority", r.Header.Get("Priority"),
|
||||
"x-forwarded-for",
|
||||
r.Header.Get("X-Forwarded-For"),
|
||||
"x-real-ip", r.Header.Get("X-Real-Ip"),
|
||||
)
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"maybeReverseProxy\"")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
r.Header.Add("X-Anubis-Rule", cr.Name)
|
||||
r.Header.Add("X-Anubis-Action", string(cr.Rule))
|
||||
lg = lg.With("check_result", cr)
|
||||
policy.PolicyApplications.WithLabelValues(cr.Name, string(cr.Rule)).Add(1)
|
||||
|
||||
ip := r.Header.Get("X-Real-Ip")
|
||||
|
||||
if s.policy.DNSBL && ip != "" {
|
||||
resp, ok := s.DNSBLCache.Get(ip)
|
||||
if !ok {
|
||||
lg.Debug("looking up ip in dnsbl")
|
||||
resp, err := dnsbl.Lookup(ip)
|
||||
if err != nil {
|
||||
lg.Error("can't look up ip in dnsbl", "err", err)
|
||||
}
|
||||
s.DNSBLCache.Set(ip, resp, 24*time.Hour)
|
||||
droneBLHits.WithLabelValues(resp.String()).Inc()
|
||||
}
|
||||
|
||||
if resp != dnsbl.AllGood {
|
||||
lg.Info("DNSBL hit", "status", resp.String())
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("DroneBL reported an entry: %s, see https://dronebl.org/lookup?ip=%s", resp.String(), ip))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
switch cr.Rule {
|
||||
case config.RuleAllow:
|
||||
lg.Debug("allowing traffic to origin (explicit)")
|
||||
s.next.ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleDeny:
|
||||
s.ClearCookie(w)
|
||||
lg.Info("explicit deny")
|
||||
if rule == nil {
|
||||
lg.Error("rule is nil, cannot calculate checksum")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
hash, err := rule.Hash()
|
||||
if err != nil {
|
||||
lg.Error("can't calculate checksum of rule", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg.Debug("rule hash", "hash", hash)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("Access Denied: error code %s", hash))), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleChallenge:
|
||||
lg.Debug("challenge requested")
|
||||
default:
|
||||
s.ClearCookie(w)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ckie, err := r.Cookie(anubis.CookieName)
|
||||
if err != nil {
|
||||
lg.Debug("cookie not found", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := ckie.Valid(); err != nil {
|
||||
lg.Debug("cookie is invalid", "err", err)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(ckie.Expires) && !ckie.Expires.IsZero() {
|
||||
lg.Debug("cookie expired", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(ckie.Value, jwt.MapClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return s.pub, nil
|
||||
}, jwt.WithExpirationRequired(), jwt.WithStrictDecoding())
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if randomJitter() {
|
||||
r.Header.Add("X-Anubis-Status", "PASS-BRIEF")
|
||||
lg.Debug("cookie is not enrolled into secondary screening")
|
||||
s.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
lg.Debug("invalid token claims type", "path", r.URL.Path)
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
if claims["challenge"] != challenge {
|
||||
lg.Debug("invalid challenge", "path", r.URL.Path)
|
||||
s.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", challenge, nonce)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(claims["response"].(string)), []byte(calculated)) != 1 {
|
||||
lg.Debug("invalid response", "path", r.URL.Path)
|
||||
failedValidations.Inc()
|
||||
s.ClearCookie(w)
|
||||
s.RenderIndex(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("all checks passed")
|
||||
r.Header.Add("X-Anubis-Status", "PASS-FULL")
|
||||
s.next.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) RenderIndex(w http.ResponseWriter, r *http.Request) {
|
||||
templ.Handler(
|
||||
web.Base("Making sure you're not a bot!", web.Index()),
|
||||
).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) MakeChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := slog.With("user_agent", r.UserAgent(), "accept_language", r.Header.Get("Accept-Language"), "priority", r.Header.Get("Priority"), "x-forwarded-for", r.Header.Get("X-Forwarded-For"), "x-real-ip", r.Header.Get("X-Real-Ip"))
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Error string `json:"error"`
|
||||
}{
|
||||
Error: "Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"makeChallenge\"",
|
||||
})
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr)
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
json.NewEncoder(w).Encode(struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Rules *config.ChallengeRules `json:"rules"`
|
||||
}{
|
||||
Challenge: challenge,
|
||||
Rules: rule.Challenge,
|
||||
})
|
||||
lg.Debug("made challenge", "challenge", challenge, "rules", rule.Challenge, "cr", cr)
|
||||
challengesIssued.Inc()
|
||||
}
|
||||
|
||||
func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
lg := slog.With(
|
||||
"user_agent", r.UserAgent(),
|
||||
"accept_language", r.Header.Get("Accept-Language"),
|
||||
"priority", r.Header.Get("Priority"),
|
||||
"x-forwarded-for", r.Header.Get("X-Forwarded-For"),
|
||||
"x-real-ip", r.Header.Get("X-Real-Ip"),
|
||||
)
|
||||
|
||||
cr, rule, err := s.check(r)
|
||||
if err != nil {
|
||||
lg.Error("check failed", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr)
|
||||
|
||||
nonceStr := r.FormValue("nonce")
|
||||
if nonceStr == "" {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("no nonce")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTimeStr := r.FormValue("elapsedTime")
|
||||
if elapsedTimeStr == "" {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("no elapsedTime")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("elapsedTime doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid elapsedTime")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
lg.Info("challenge took", "elapsedTime", elapsedTime)
|
||||
timeTaken.Observe(elapsedTime)
|
||||
|
||||
response := r.FormValue("response")
|
||||
redir := r.FormValue("redir")
|
||||
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
|
||||
nonce, err := strconv.Atoi(nonceStr)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("nonce doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
calcString := fmt.Sprintf("%s%d", challenge, nonce)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("hash does not match", "got", response, "want", calculated)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response")), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r)
|
||||
failedValidations.Inc()
|
||||
return
|
||||
}
|
||||
|
||||
// compare the leading zeroes
|
||||
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty)
|
||||
templ.Handler(web.Base("Oh noes!", web.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 {
|
||||
lg.Error("failed to sign JWT", "err", err)
|
||||
s.ClearCookie(w)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("failed to sign JWT")), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: anubis.CookieName,
|
||||
Value: tokenString,
|
||||
Expires: time.Now().Add(24 * 7 * time.Hour),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: s.opts.CookieDomain,
|
||||
Partitioned: s.opts.CookiePartitioned,
|
||||
Path: "/",
|
||||
})
|
||||
|
||||
challengesValidated.Inc()
|
||||
lg.Debug("challenge passed, redirecting to app")
|
||||
http.Redirect(w, r, redir, http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) TestError(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.FormValue("err")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage(err)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Check evaluates the list of rules, and returns the result
|
||||
func (s *Server) check(r *http.Request) (CheckResult, *policy.Bot, error) {
|
||||
host := r.Header.Get("X-Real-Ip")
|
||||
if host == "" {
|
||||
return decaymap.Zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
|
||||
}
|
||||
|
||||
addr := net.ParseIP(host)
|
||||
if addr == nil {
|
||||
return decaymap.Zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
|
||||
}
|
||||
|
||||
for _, b := range s.policy.Bots {
|
||||
if b.UserAgent != nil {
|
||||
if b.UserAgent.MatchString(r.UserAgent()) && s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Path != nil {
|
||||
if b.Path.MatchString(r.URL.Path) && s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Ranger != nil {
|
||||
if s.checkRemoteAddress(b, addr) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cr("default/allow", config.RuleAllow), &policy.Bot{
|
||||
Challenge: &config.ChallengeRules{
|
||||
Difficulty: s.policy.DefaultDifficulty,
|
||||
ReportAs: s.policy.DefaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) checkRemoteAddress(b policy.Bot, addr net.IP) bool {
|
||||
if b.Ranger == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
ok, err := b.Ranger.Contains(addr)
|
||||
if err != nil {
|
||||
log.Panicf("[unexpected] something very funky is going on, %q does not have a calculable network number: %v", addr.String(), err)
|
||||
}
|
||||
|
||||
return ok
|
||||
}
|
||||
163
lib/anubis_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/lib/policy"
|
||||
)
|
||||
|
||||
func loadPolicies(t *testing.T, fname string) *policy.ParsedConfig {
|
||||
t.Helper()
|
||||
|
||||
policy, err := LoadPoliciesOrDefault("", anubis.DefaultDifficulty)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return policy
|
||||
}
|
||||
|
||||
func spawnAnubis(t *testing.T, opts Options) *Server {
|
||||
t.Helper()
|
||||
|
||||
s, err := New(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func TestCookieSettings(t *testing.T) {
|
||||
pol := loadPolicies(t, "")
|
||||
pol.DefaultDifficulty = 0
|
||||
|
||||
srv := spawnAnubis(t, Options{
|
||||
Next: http.NewServeMux(),
|
||||
Policy: pol,
|
||||
|
||||
CookieDomain: "local.cetacean.club",
|
||||
CookiePartitioned: true,
|
||||
CookieName: t.Name(),
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(internal.DefaultXRealIP("127.0.0.1", srv))
|
||||
defer ts.Close()
|
||||
|
||||
cli := &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := cli.Post(ts.URL+"/.within.website/x/cmd/anubis/api/make-challenge", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't request challenge: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var chall = struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
|
||||
t.Fatalf("can't read challenge response body: %v", err)
|
||||
}
|
||||
|
||||
nonce := 0
|
||||
elapsedTime := 420
|
||||
redir := "/"
|
||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, nonce)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, ts.URL+"/.within.website/x/cmd/anubis/api/pass-challenge", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("can't make request: %v", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Set("response", calculated)
|
||||
q.Set("nonce", fmt.Sprint(nonce))
|
||||
q.Set("redir", redir)
|
||||
q.Set("elapsedTime", fmt.Sprint(elapsedTime))
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err = cli.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("can't do challenge passing")
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Errorf("wanted %d, got: %d", http.StatusFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
var ckie *http.Cookie
|
||||
for _, cookie := range resp.Cookies() {
|
||||
t.Logf("%#v", cookie)
|
||||
if cookie.Name == anubis.CookieName {
|
||||
ckie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ckie.Domain != "local.cetacean.club" {
|
||||
t.Errorf("cookie domain is wrong, wanted local.cetacean.club, got: %s", ckie.Domain)
|
||||
}
|
||||
|
||||
if ckie.Partitioned != srv.opts.CookiePartitioned {
|
||||
t.Errorf("wanted partitioned flag %v, got: %v", srv.opts.CookiePartitioned, ckie.Partitioned)
|
||||
}
|
||||
|
||||
if ckie == nil {
|
||||
t.Errorf("Cookie %q not found", anubis.CookieName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "OK")
|
||||
})
|
||||
|
||||
for i := 1; i < 10; i++ {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
policy, err := LoadPoliciesOrDefault("", i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(Options{
|
||||
Next: h,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req.Header.Add("X-Real-Ip", "127.0.0.1")
|
||||
|
||||
_, bot, err := s.check(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if bot.Challenge.Difficulty != i {
|
||||
t.Errorf("Challenge.Difficulty is wrong, wanted %d, got: %d", i, bot.Challenge.Difficulty)
|
||||
}
|
||||
|
||||
if bot.Challenge.ReportAs != i {
|
||||
t.Errorf("Challenge.ReportAs is wrong, wanted %d, got: %d", i, bot.Challenge.ReportAs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
25
lib/checkresult.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
)
|
||||
|
||||
type CheckResult struct {
|
||||
Name string
|
||||
Rule config.Rule
|
||||
}
|
||||
|
||||
func (cr CheckResult) LogValue() slog.Value {
|
||||
return slog.GroupValue(
|
||||
slog.String("name", cr.Name),
|
||||
slog.String("rule", string(cr.Rule)))
|
||||
}
|
||||
|
||||
func cr(name string, rule config.Rule) CheckResult {
|
||||
return CheckResult{
|
||||
Name: name,
|
||||
Rule: rule,
|
||||
}
|
||||
}
|
||||
35
lib/http.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
)
|
||||
|
||||
func (s *Server) ClearCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: anubis.CookieName,
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-1 * time.Hour),
|
||||
MaxAge: -1,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Domain: s.opts.CookieDomain,
|
||||
})
|
||||
}
|
||||
|
||||
// https://github.com/oauth2-proxy/oauth2-proxy/blob/master/pkg/upstream/http.go#L124
|
||||
type UnixRoundTripper struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// set bare minimum stuff
|
||||
func (t UnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
req = req.Clone(req.Context())
|
||||
if req.Host == "" {
|
||||
req.Host = "localhost"
|
||||
}
|
||||
req.URL.Host = req.Host // proxy error: no Host in request URL
|
||||
req.URL.Scheme = "http" // make http.Transport happy and avoid an infinite recursion
|
||||
return t.Transport.RoundTrip(req)
|
||||
}
|
||||