Compare commits
81 Commits
Xe/hyper-g
...
wasm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a239c7a095 | ||
|
|
3d0a5c2d87 | ||
|
|
ea321b7f13 | ||
|
|
1c4f2d1851 | ||
|
|
d96074a82e | ||
|
|
95f70ddf21 | ||
|
|
5610b026cc | ||
|
|
72d6eda7de | ||
|
|
33d31e03b0 | ||
|
|
d084b7f1a1 | ||
|
|
eb53e156b9 | ||
|
|
5a3c0ee6aa | ||
|
|
d0d49a4d3c | ||
|
|
f728779c08 | ||
|
|
d4e35fe045 | ||
|
|
e4863ba484 | ||
|
|
cc1d5b71da | ||
|
|
2324395ae2 | ||
|
|
2eef15724b | ||
|
|
acce3604a4 | ||
|
|
0928c3c830 | ||
|
|
77436207e6 | ||
|
|
8adf1a06eb | ||
|
|
df27a96f1f | ||
|
|
f1f8fdf752 | ||
|
|
95416dfe82 | ||
|
|
e58abbe4de | ||
|
|
878b37178d | ||
|
|
a230a58a1d | ||
|
|
0bcc0a2429 | ||
|
|
b14aa6a0c3 | ||
|
|
21a9d77788 | ||
|
|
266d8c0cc2 | ||
|
|
573dfd099f | ||
|
|
515453c607 | ||
|
|
455a9664b4 | ||
|
|
01c2e45843 | ||
|
|
fc237a1690 | ||
|
|
6af7c5891f | ||
|
|
661d72474b | ||
|
|
2b28439137 | ||
|
|
08bb7f953c | ||
|
|
b4a2e1a6a0 | ||
|
|
28828a2e93 | ||
|
|
feca1ddeea | ||
|
|
eab62f7611 | ||
|
|
c896c63a0b | ||
|
|
f9f5430dac | ||
|
|
5a07684f99 | ||
|
|
4bc00e5a65 | ||
|
|
5237291072 | ||
|
|
0f41388bd7 | ||
|
|
052316ba25 | ||
|
|
db5143ae7a | ||
|
|
3771a3b627 | ||
|
|
3683f95933 | ||
|
|
168329fff0 | ||
|
|
52ca5390c2 | ||
|
|
6b2ae30bae | ||
|
|
937f1dd330 | ||
|
|
bb4f49cfd9 | ||
|
|
38d62eeb56 | ||
|
|
57c3e9f1b2 | ||
|
|
e9a6ebffbb | ||
|
|
a3c026977f | ||
|
|
7d4be0dcec | ||
|
|
d1d63d9c18 | ||
|
|
ecc6b47f90 | ||
|
|
e7cbd349f3 | ||
|
|
07bb5f63f9 | ||
|
|
4155719422 | ||
|
|
f29a200f09 | ||
|
|
18cd8a66a2 | ||
|
|
725e11d3a6 | ||
|
|
f462209b02 | ||
|
|
acf5586e83 | ||
|
|
9d68e73d03 | ||
|
|
6156d3d729 | ||
|
|
af6f05554f | ||
|
|
1509b06cb9 | ||
|
|
56cdb2e51b |
12
.air.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
root = "."
|
||||
tmp_dir = "var"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./var/main ./cmd/anubis"
|
||||
bin = "./var/main"
|
||||
args = ["--use-remote-address"]
|
||||
exclude_dir = ["var", "vendor", "docs", "node_modules"]
|
||||
|
||||
[logger]
|
||||
time = true
|
||||
# to change flags at runtime, prepend with -- e.g. $ air -- --target http://localhost:3000 --difficulty 20 --use-remote-address
|
||||
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
web/index_templ.go linguist-generated
|
||||
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
delete me and describe your change here, give enough context for a maintainer to understand what and why
|
||||
|
||||
See https://anubis.techaro.lol/docs/developer/code-quality for more information
|
||||
-->
|
||||
|
||||
Checklist:
|
||||
|
||||
- [ ] Added a description of the changes to the `[Unreleased]` section of docs/docs/CHANGELOG.md
|
||||
- [ ] Added test cases to [the relevant parts of the codebase](https://anubis.techaro.lol/docs/developer/code-quality)
|
||||
- [ ] Ran integration tests `npm run test:integration` (unsupported on Windows, please use WSL)
|
||||
28
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: gomod
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
gomod:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
groups:
|
||||
npm:
|
||||
patterns:
|
||||
- "*"
|
||||
70
.github/workflows/docker-pr.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
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
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Homebrew
|
||||
uses: Homebrew/actions/setup-homebrew@master
|
||||
|
||||
- name: Setup Homebrew cellar cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/home/linuxbrew/.linuxbrew/Cellar
|
||||
/home/linuxbrew/.linuxbrew/bin
|
||||
/home/linuxbrew/.linuxbrew/etc
|
||||
/home/linuxbrew/.linuxbrew/include
|
||||
/home/linuxbrew/.linuxbrew/lib
|
||||
/home/linuxbrew/.linuxbrew/opt
|
||||
/home/linuxbrew/.linuxbrew/sbin
|
||||
/home/linuxbrew/.linuxbrew/share
|
||||
/home/linuxbrew/.linuxbrew/var
|
||||
key: ${{ runner.os }}-go-homebrew-cellar-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-homebrew-cellar-
|
||||
|
||||
- name: Install Brew dependencies
|
||||
run: |
|
||||
brew bundle
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/techarohq/anubis
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
run: |
|
||||
npm ci
|
||||
npm run container
|
||||
env:
|
||||
PULL_REQUEST_ID: ${{ github.event.number }}
|
||||
DOCKER_REPO: ghcr.io/techarohq/anubis
|
||||
SLOG_LEVEL: debug
|
||||
|
||||
- run: |
|
||||
echo "Test this with:"
|
||||
echo "docker pull ${DOCKER_IMAGE}"
|
||||
env:
|
||||
DOCKER_IMAGE: ${{ steps.build.outputs.docker_image }}
|
||||
52
.github/workflows/docker.yml
vendored
@@ -5,8 +5,6 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
tags: [ "v*" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
env:
|
||||
DOCKER_METADATA_SET_OUTPUT_ENV: "true"
|
||||
@@ -27,18 +25,33 @@ jobs:
|
||||
with:
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Homebrew
|
||||
uses: Homebrew/actions/setup-homebrew@master
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
- name: Setup Homebrew cellar cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
path: |
|
||||
/home/linuxbrew/.linuxbrew/Cellar
|
||||
/home/linuxbrew/.linuxbrew/bin
|
||||
/home/linuxbrew/.linuxbrew/etc
|
||||
/home/linuxbrew/.linuxbrew/include
|
||||
/home/linuxbrew/.linuxbrew/lib
|
||||
/home/linuxbrew/.linuxbrew/opt
|
||||
/home/linuxbrew/.linuxbrew/sbin
|
||||
/home/linuxbrew/.linuxbrew/share
|
||||
/home/linuxbrew/.linuxbrew/var
|
||||
key: ${{ runner.os }}-go-homebrew-cellar-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-homebrew-cellar-
|
||||
|
||||
- uses: ko-build/setup-ko@v0.8
|
||||
- name: Install Brew dependencies
|
||||
run: |
|
||||
brew bundle
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Log into registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -56,25 +69,14 @@ jobs:
|
||||
- name: Build and push
|
||||
id: build
|
||||
run: |
|
||||
go run ./cmd/containerbuild --docker-repo ghcr.io/techarohq/anubis --slog-level debug
|
||||
npm ci
|
||||
npm run container
|
||||
env:
|
||||
PULL_REQUEST_ID: ${{ github.event.number }}
|
||||
|
||||
# - name: "Comment about where to test this"
|
||||
# uses: thollander/actions-comment-pull-request@v3
|
||||
# if: ${{github.event_name == 'pull_request'}}
|
||||
# with:
|
||||
# message: |
|
||||
# You can try this PR out by using the following docker image:
|
||||
|
||||
# ```
|
||||
# ${{ steps.build.outputs.docker_image }}
|
||||
# ```
|
||||
# comment-tag: ${{ steps.build.outputs.docker_image }}
|
||||
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 }}
|
||||
|
||||
2
.github/workflows/docs-deploy.yml
vendored
@@ -17,6 +17,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
32
.github/workflows/go.yml
vendored
@@ -11,11 +11,13 @@ permissions:
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
go_tests:
|
||||
#runs-on: alrest-techarohq
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: build essential
|
||||
run: |
|
||||
@@ -46,6 +48,8 @@ jobs:
|
||||
run: |
|
||||
brew bundle
|
||||
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Setup Golang caches
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -56,8 +60,30 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-golang-
|
||||
|
||||
- name: Cache playwright binaries
|
||||
uses: actions/cache@v4
|
||||
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 9001 &
|
||||
|
||||
- name: install node deps
|
||||
run: |
|
||||
npm ci
|
||||
npm run assets
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
run: npm run test
|
||||
|
||||
- uses: dominikh/staticcheck-action@v1
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
81
.github/workflows/package-builds-stable.yml
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
name: Package builds (stable)
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
package_builds:
|
||||
#runs-on: alrest-techarohq
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: build essential
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential
|
||||
|
||||
- name: Set up Homebrew
|
||||
uses: Homebrew/actions/setup-homebrew@master
|
||||
|
||||
- name: Setup Homebrew cellar cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/home/linuxbrew/.linuxbrew/Cellar
|
||||
/home/linuxbrew/.linuxbrew/bin
|
||||
/home/linuxbrew/.linuxbrew/etc
|
||||
/home/linuxbrew/.linuxbrew/include
|
||||
/home/linuxbrew/.linuxbrew/lib
|
||||
/home/linuxbrew/.linuxbrew/opt
|
||||
/home/linuxbrew/.linuxbrew/sbin
|
||||
/home/linuxbrew/.linuxbrew/share
|
||||
/home/linuxbrew/.linuxbrew/var
|
||||
key: ${{ runner.os }}-go-homebrew-cellar-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-homebrew-cellar-
|
||||
|
||||
- name: Install Brew dependencies
|
||||
run: |
|
||||
brew bundle
|
||||
|
||||
- name: Setup Golang caches
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-golang-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-golang-
|
||||
|
||||
- name: install node deps
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Build Packages
|
||||
run: |
|
||||
wget https://github.com/Xe/x/releases/download/v1.13.4/yeet_1.13.4_amd64.deb -O var/yeet.deb
|
||||
sudo apt -y install -f ./var/yeet.deb
|
||||
yeet
|
||||
|
||||
- name: Upload released artifacts
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.TOKEN }}
|
||||
RELEASE_VERSION: ${{github.event.release.tag_name}}
|
||||
shell: bash
|
||||
run: |
|
||||
RELEASE="${RELEASE_VERSION}"
|
||||
cd var
|
||||
for file in *; do
|
||||
gh release upload $RELEASE $file
|
||||
done
|
||||
76
.github/workflows/package-builds-unstable.yml
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
name: Package builds (unstable)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
package_builds:
|
||||
#runs-on: alrest-techarohq
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-tags: true
|
||||
fetch-depth: 0
|
||||
|
||||
- name: build essential
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential
|
||||
|
||||
- name: Set up Homebrew
|
||||
uses: Homebrew/actions/setup-homebrew@master
|
||||
|
||||
- name: Setup Homebrew cellar cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/home/linuxbrew/.linuxbrew/Cellar
|
||||
/home/linuxbrew/.linuxbrew/bin
|
||||
/home/linuxbrew/.linuxbrew/etc
|
||||
/home/linuxbrew/.linuxbrew/include
|
||||
/home/linuxbrew/.linuxbrew/lib
|
||||
/home/linuxbrew/.linuxbrew/opt
|
||||
/home/linuxbrew/.linuxbrew/sbin
|
||||
/home/linuxbrew/.linuxbrew/share
|
||||
/home/linuxbrew/.linuxbrew/var
|
||||
key: ${{ runner.os }}-go-homebrew-cellar-${{ hashFiles('go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-homebrew-cellar-
|
||||
|
||||
- name: Install Brew dependencies
|
||||
run: |
|
||||
brew bundle
|
||||
|
||||
- name: Setup Golang caches
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/go-build
|
||||
~/go/pkg/mod
|
||||
key: ${{ runner.os }}-golang-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-golang-
|
||||
|
||||
- name: install node deps
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Build Packages
|
||||
run: |
|
||||
wget https://github.com/Xe/x/releases/download/v1.13.4/yeet_1.13.4_amd64.deb -O var/yeet.deb
|
||||
sudo apt -y install -f ./var/yeet.deb
|
||||
yeet
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: packages
|
||||
path: var/*
|
||||
35
.github/workflows/zizmor.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
name: zizmor
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/*.ya?ml'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/*.ya?ml'
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
name: zizmor latest via PyPI
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
|
||||
- name: Run zizmor 🌈
|
||||
run: uvx zizmor --format sarif . > results.sarif
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
category: zizmor
|
||||
26
.gitignore
vendored
@@ -1,2 +1,26 @@
|
||||
.env
|
||||
*.rpm
|
||||
*.deb
|
||||
*.rpm
|
||||
|
||||
# Additional package locks
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
|
||||
# Go binaries and test artifacts
|
||||
main
|
||||
*.test
|
||||
|
||||
node_modules
|
||||
|
||||
# MacOS
|
||||
.DS_store
|
||||
|
||||
# Intellij
|
||||
.idea
|
||||
|
||||
# how does this get here
|
||||
doc/VERSION
|
||||
|
||||
*.wasm
|
||||
|
||||
target
|
||||
5
Brewfile
@@ -1,4 +1,7 @@
|
||||
# programming languages
|
||||
brew "go@1.24"
|
||||
brew "node"
|
||||
brew "ko"
|
||||
brew "ko"
|
||||
brew "esbuild"
|
||||
brew "zstd"
|
||||
brew "brotli"
|
||||
482
Cargo.lock
generated
Normal file
@@ -0,0 +1,482 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anubis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wee_alloc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2id"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anubis",
|
||||
"argon2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89e25b6adfb930f02d1981565a6e5d9c547ac15a96606256d3b59040e5cd4ca3"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd"
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
<<<<<<< HEAD
|
||||
name = "block-buffer"
|
||||
version = "0.11.0-rc.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a229bfd78e4827c91b9b95784f69492c1b77c1ab75a45a8a037b139215086f94"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822"
|
||||
=======
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
>>>>>>> 8793853 (feat(wasm): broken equi-x solver)
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dabb6555f92fb9ee4140454eb5dcd14c7960e1225c6d1a6cc361f032947713e"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.0-rc.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "170d71b5b14dec99db7739f6fc7d6ec2db80b78c3acb77db48392ccc3d8a9ea0"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
<<<<<<< HEAD
|
||||
name = "digest"
|
||||
version = "0.11.0-pre.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c478574b20020306f98d61c8ca3322d762e1ff08117422ac6106438605ea516"
|
||||
dependencies = [
|
||||
"block-buffer 0.11.0-rc.4",
|
||||
"const-oid",
|
||||
"crypto-common 0.2.0-rc.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
=======
|
||||
name = "dynasm"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0cecff24995c8a5a3c3169cff4c733fe7d91aedf5d8cc96238738bfe53186b8"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"byteorder",
|
||||
"lazy_static",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dynasmrt"
|
||||
version = "3.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f5eab96b8688bcbf1d2354bcfe0261005ac1dd0616747152ada34948d4e9582"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"dynasm",
|
||||
"fnv",
|
||||
"memmap2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equix"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anubis",
|
||||
"equix 0.2.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equix"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "194df1f219a987430956f20faaf702fd4d434b1b2f7300014119854184107ac7"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"hashx",
|
||||
"num-traits",
|
||||
"thiserror",
|
||||
"visibility",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixed-capacity-vec"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b31a14f5ee08ed1a40e1252b35af18bed062e3f39b69aab34decde36bc43e40"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
>>>>>>> 8793853 (feat(wasm): broken equi-x solver)
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
<<<<<<< HEAD
|
||||
name = "hybrid-array"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dab50e193aebe510fe0e40230145820e02f48dae0cf339ea4204e6e708ff7bd"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
=======
|
||||
name = "hashx"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "572a61c460658ae7db71878dd2caa163f47ffe041cb40aeee1483d1ffbf5e84b"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"blake2",
|
||||
"dynasmrt",
|
||||
"fixed-capacity-vec",
|
||||
"hex",
|
||||
"rand_core 0.9.3",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
>>>>>>> 8793853 (feat(wasm): broken equi-x solver)
|
||||
name = "libc"
|
||||
version = "0.2.171"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6"
|
||||
|
||||
[[package]]
|
||||
<<<<<<< HEAD
|
||||
name = "memory_units"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3"
|
||||
=======
|
||||
name = "memmap2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3f7eed9d3848f8b98834af67102b720745c4ec028fcd0aa0239277e7de374f"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
>>>>>>> 8793853 (feat(wasm): broken equi-x solver)
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error-attr2"
|
||||
version = "2.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-error2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
|
||||
dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.94"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0-pre.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19b4241d1a56954dce82cecda5c8e9c794eef6f53abe5e5216bac0a0ea71ffa7"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.0",
|
||||
"cpufeatures",
|
||||
"digest 0.11.0-pre.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha256"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anubis",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.100"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
<<<<<<< HEAD
|
||||
name = "wee_alloc"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e"
|
||||
dependencies = [
|
||||
"cfg-if 0.1.10",
|
||||
"libc",
|
||||
"memory_units",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
|
||||
dependencies = [
|
||||
"winapi-i686-pc-windows-gnu",
|
||||
"winapi-x86_64-pc-windows-gnu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winapi-i686-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
|
||||
|
||||
[[package]]
|
||||
name = "winapi-x86_64-pc-windows-gnu"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
=======
|
||||
name = "visibility"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
>>>>>>> 8793853 (feat(wasm): broken equi-x solver)
|
||||
10
Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["wasm/anubis", "wasm/pow/*"]
|
||||
|
||||
[profile.release]
|
||||
#strip = true
|
||||
opt-level = "s"
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
23
Dockerfile
@@ -1,23 +0,0 @@
|
||||
FROM docker.io/library/golang:1.24 AS build
|
||||
ARG BUILDKIT_SBOM_SCAN_CONTEXT=true BUILDKIT_SBOM_SCAN_STAGE=true
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum /app/
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
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 docker.io/library/debian:bookworm AS runtime
|
||||
ARG BUILDKIT_SBOM_SCAN_STAGE=true
|
||||
RUN apt-get update \
|
||||
&& apt-get -y install ca-certificates
|
||||
|
||||
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"
|
||||
33
Makefile
Normal file
@@ -0,0 +1,33 @@
|
||||
NODE_MODULES = node_modules
|
||||
VERSION := $(shell cat ./VERSION)
|
||||
|
||||
export RUSTFLAGS=-Ctarget-feature=+simd128
|
||||
|
||||
.PHONY: build assets deps lint prebaked-build test wasm
|
||||
|
||||
assets:
|
||||
npm run assets
|
||||
|
||||
deps:
|
||||
npm ci
|
||||
go mod download
|
||||
|
||||
build: deps
|
||||
npm run build
|
||||
@echo "Anubis is now built to ./var/anubis"
|
||||
|
||||
all: build
|
||||
|
||||
lint:
|
||||
go vet ./...
|
||||
go tool staticcheck ./...
|
||||
|
||||
prebaked-build:
|
||||
go build -o ./var/anubis -ldflags "-X 'github.com/TecharoHQ/anubis.Version=$(VERSION)'" ./cmd/anubis
|
||||
|
||||
test:
|
||||
npm run test
|
||||
|
||||
wasm:
|
||||
cargo build --release --target wasm32-unknown-unknown
|
||||
cp -vf ./target/wasm32-unknown-unknown/release/*.wasm ./web/static/wasm
|
||||
@@ -1,6 +0,0 @@
|
||||
<!-- 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
|
||||
16
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>
|
||||
|
||||

|
||||
@@ -22,10 +22,22 @@ If you want to try this out, connect to [anubis.techaro.lol](https://anubis.tech
|
||||
|
||||
## Support
|
||||
|
||||
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.
|
||||
If you run into any issues running Anubis, please [open an issue](https://github.com/TecharoHQ/anubis/issues/new?template=Blank+issue). 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`.
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#TecharoHQ/anubis&Date)
|
||||
|
||||
## Packaging Status
|
||||
|
||||
[](https://repology.org/project/anubis-anti-crawler/versions)
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/TecharoHQ/anubis/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=TecharoHQ/anubis" />
|
||||
</a>
|
||||
|
||||
Made with [contrib.rocks](https://contrib.rocks).
|
||||
|
||||
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 uint32 = 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,31 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecayMap(t *testing.T) {
|
||||
dm := NewDecayMap[string, string]()
|
||||
|
||||
dm.Set("test", "hi", 5*time.Minute)
|
||||
|
||||
val, ok := dm.Get("test")
|
||||
if !ok {
|
||||
t.Error("somehow the test key was not set")
|
||||
}
|
||||
|
||||
if val != "hi" {
|
||||
t.Errorf("wanted value %q, got: %q", "hi", val)
|
||||
}
|
||||
|
||||
ok = dm.expire("test")
|
||||
if !ok {
|
||||
t.Error("somehow could not force-expire the test key")
|
||||
}
|
||||
|
||||
_, ok = dm.Get("test")
|
||||
if ok {
|
||||
t.Error("got value even though it was supposed to be expired")
|
||||
}
|
||||
}
|
||||
@@ -1,217 +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;
|
||||
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>
|
||||
@templ.JSONScript("anubis_version", anubis.Version)
|
||||
</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>. Made with ❤️ in 🇨🇦.
|
||||
</p>
|
||||
</center>
|
||||
</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
|
||||
templ index() {
|
||||
<div class="centered-div">
|
||||
<img
|
||||
id="image"
|
||||
style="width:100%;max-width:256px;"
|
||||
src={ "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" +
|
||||
anubis.Version }
|
||||
/>
|
||||
<img
|
||||
style="display:none;"
|
||||
style="width:100%;max-width:256px;"
|
||||
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>
|
||||
<details>
|
||||
<summary>Why am I seeing this?</summary>
|
||||
<p>You are seeing this because the administrator of this website has set up <a href="https://github.com/TecharoHQ/anubis">Anubis</a> to protect the server against the scourge of <a href="https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p>
|
||||
<p>Anubis is a compromise. Anubis uses a <a href="https://anubis.techaro.lol/docs/design/why-proof-of-work">Proof-of-Work</a> scheme in the vein of <a href="https://en.wikipedia.org/wiki/Hashcash">Hashcash</a>, 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.</p>
|
||||
<p>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.</p>
|
||||
<p>Please note that Anubis requires the use of modern JavaScript features that plugins like <a href="https://jshelter.org/">JShelter</a> will disable. Please disable JShelter or other such plugins for this domain.</p>
|
||||
</details>
|
||||
<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"
|
||||
style="width:100%;max-width:256px;"
|
||||
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,225 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.833
|
||||
package main
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import (
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/xess"
|
||||
)
|
||||
|
||||
func base(title string, body templ.Component) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html><head><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 12, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</title><link rel=\"stylesheet\" href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(xess.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 13, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><style>\n body,\n html {\n height: 100%;\n display: flex;\n justify-content: center;\n align-items: center;\n margin-left: auto;\n margin-right: auto;\n }\n\n .centered-div {\n text-align: center;\n }\n\n .lds-roller,\n .lds-roller div,\n .lds-roller div:after {\n box-sizing: border-box;\n }\n\n .lds-roller {\n display: inline-block;\n position: relative;\n width: 80px;\n height: 80px;\n }\n\n .lds-roller div {\n animation: lds-roller 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;\n transform-origin: 40px 40px;\n }\n\n .lds-roller div:after {\n content: \" \";\n display: block;\n position: absolute;\n width: 7.2px;\n height: 7.2px;\n border-radius: 50%;\n background: currentColor;\n margin: -3.6px 0 0 -3.6px;\n }\n\n .lds-roller div:nth-child(1) {\n animation-delay: -0.036s;\n }\n\n .lds-roller div:nth-child(1):after {\n top: 62.62742px;\n left: 62.62742px;\n }\n\n .lds-roller div:nth-child(2) {\n animation-delay: -0.072s;\n }\n\n .lds-roller div:nth-child(2):after {\n top: 67.71281px;\n left: 56px;\n }\n\n .lds-roller div:nth-child(3) {\n animation-delay: -0.108s;\n }\n\n .lds-roller div:nth-child(3):after {\n top: 70.90963px;\n left: 48.28221px;\n }\n\n .lds-roller div:nth-child(4) {\n animation-delay: -0.144s;\n }\n\n .lds-roller div:nth-child(4):after {\n top: 72px;\n left: 40px;\n }\n\n .lds-roller div:nth-child(5) {\n animation-delay: -0.18s;\n }\n\n .lds-roller div:nth-child(5):after {\n top: 70.90963px;\n left: 31.71779px;\n }\n\n .lds-roller div:nth-child(6) {\n animation-delay: -0.216s;\n }\n\n .lds-roller div:nth-child(6):after {\n top: 67.71281px;\n left: 24px;\n }\n\n .lds-roller div:nth-child(7) {\n animation-delay: -0.252s;\n }\n\n .lds-roller div:nth-child(7):after {\n top: 62.62742px;\n left: 17.37258px;\n }\n\n .lds-roller div:nth-child(8) {\n animation-delay: -0.288s;\n }\n\n .lds-roller div:nth-child(8):after {\n top: 56px;\n left: 12.28719px;\n }\n\n @keyframes lds-roller {\n 0% {\n transform: rotate(0deg);\n }\n\n 100% {\n transform: rotate(360deg);\n }\n }\n </style>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templ.JSONScript("anubis_version", anubis.Version).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</head><body id=\"top\"><main><center><h1 id=\"title\" class=\".centered-div\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 146, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</h1></center>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = body.Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<footer><center><p>Protected by <a href=\"https://github.com/TecharoHQ/anubis\">Anubis</a> from <a href=\"https://techaro.lol\">Techaro</a>. Made with ❤️ in 🇨🇦.</p></center></footer></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func index() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var5 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var5 == nil {
|
||||
templ_7745c5c3_Var5 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"centered-div\"><img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" +
|
||||
anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 169, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\"> <img style=\"display:none;\" style=\"width:100%;max-width:256px;\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" +
|
||||
anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 175, Col: 18}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><p id=\"status\">Loading...</p><script async type=\"module\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 178, Col: 116}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"></script><div id=\"spinner\" class=\"lds-roller\"><div></div><div></div><div></div><div></div><div></div><div></div><div></div><div></div></div><details><summary>Why am I seeing this?</summary><p>You are seeing this because the administrator of this website has set up <a href=\"https://github.com/TecharoHQ/anubis\">Anubis</a> to protect the server against the scourge of <a href=\"https://thelibre.news/foss-infrastructure-is-under-attack-by-ai-companies/\">AI companies aggressively scraping websites</a>. This can and does cause downtime for the websites, which makes their resources inaccessible for everyone.</p><p>Anubis is a compromise. Anubis uses a <a href=\"https://anubis.techaro.lol/docs/design/why-proof-of-work\">Proof-of-Work</a> scheme in the vein of <a href=\"https://en.wikipedia.org/wiki/Hashcash\">Hashcash</a>, 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.</p><p>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.</p><p>Please note that Anubis requires the use of modern JavaScript features that plugins like <a href=\"https://jshelter.org/\">JShelter</a> will disable. Please disable JShelter or other such plugins for this domain.</p></details><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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func errorPage(message string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var9 == nil {
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"centered-div\"><img id=\"image\" style=\"width:100%;max-width:256px;\" src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("/.within.website/x/cmd/anubis/static/img/sad.webp?cacheBuster=" + anubis.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 211, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"><p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 213, Col: 14}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ".</p><button onClick=\"window.location.reload();\">Try again</button><p><a href=\"/\">Go home</a></p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,89 +0,0 @@
|
||||
import processFast from "./proof-of-work.mjs";
|
||||
import processSlow from "./proof-of-work-slow.mjs";
|
||||
import { testVideo } from "./video.mjs";
|
||||
|
||||
const algorithms = {
|
||||
"fast": processFast,
|
||||
"slow": processSlow,
|
||||
}
|
||||
|
||||
// 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, cacheBuster) =>
|
||||
u(`/.within.website/x/cmd/anubis/static/img/${mood}.webp`, { cacheBuster });
|
||||
|
||||
(async () => {
|
||||
const status = document.getElementById('status');
|
||||
const image = document.getElementById('image');
|
||||
const title = document.getElementById('title');
|
||||
const spinner = document.getElementById('spinner');
|
||||
const anubisVersion = JSON.parse(document.getElementById('anubis_version').textContent);
|
||||
|
||||
// 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, rules } = 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", anubisVersion);
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
throw err;
|
||||
});
|
||||
|
||||
const process = algorithms[rules.algorithm];
|
||||
if (!process) {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to resolve check algorithm. You may want to reload the page.`;
|
||||
image.src = imageURL("sad", anubisVersion);
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
status.innerHTML = `Calculating...<br/>Difficulty: ${rules.report_as}`;
|
||||
|
||||
const t0 = Date.now();
|
||||
const { hash, nonce } = await process(challenge, rules.difficulty);
|
||||
const t1 = Date.now();
|
||||
console.log({ hash, nonce });
|
||||
|
||||
title.innerHTML = "Success!";
|
||||
status.innerHTML = `Done! Took ${t1 - t0}ms, ${nonce} iterations`;
|
||||
image.src = imageURL("happy", anubisVersion);
|
||||
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 });
|
||||
}, 250);
|
||||
})();
|
||||
@@ -1,27 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
mrand "math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -29,73 +28,51 @@ import (
|
||||
"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"
|
||||
botPolicy "github.com/TecharoHQ/anubis/lib/policy"
|
||||
"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", "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", defaultDifficulty, "difficulty of the challenge")
|
||||
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")
|
||||
|
||||
//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", int(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")
|
||||
ed25519PrivateKeyHexFile = flag.String("ed25519-private-key-hex-file", "", "file name containing value for ed25519-private-key-hex")
|
||||
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")
|
||||
useRemoteAddress = flag.Bool("use-remote-address", false, "read the client's IP address from the network request, useful for debugging and running Anubis on bare metal")
|
||||
debugBenchmarkJS = flag.Bool("debug-benchmark-js", false, "respond to every request with a challenge for benchmarking hashrate")
|
||||
ogPassthrough = flag.Bool("og-passthrough", false, "enable Open Graph tag passthrough")
|
||||
ogTimeToLive = flag.Duration("og-expiry-time", 24*time.Hour, "Open Graph tag cache expiration time")
|
||||
extractResources = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder")
|
||||
webmasterEmail = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals")
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "within.website-x-cmd-anubis-auth"
|
||||
staticPath = "/.within.website/x/cmd/anubis/"
|
||||
defaultDifficulty = 4
|
||||
)
|
||||
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 --bundle --minify --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")
|
||||
@@ -117,7 +94,11 @@ func setupListener(network string, address string) (net.Listener, string) {
|
||||
case "unix":
|
||||
formattedAddress = "unix:" + address
|
||||
case "tcp":
|
||||
formattedAddress = "http://localhost" + address
|
||||
if strings.HasPrefix(address, ":") { // assume it's just a port e.g. :4259
|
||||
formattedAddress = "http://localhost" + address
|
||||
} else {
|
||||
formattedAddress = "http://" + address
|
||||
}
|
||||
default:
|
||||
formattedAddress = fmt.Sprintf(`(%s) %s`, network, address)
|
||||
}
|
||||
@@ -145,6 +126,48 @@ func setupListener(network string, address string) (net.Listener, string) {
|
||||
return listener, formattedAddress
|
||||
}
|
||||
|
||||
func makeReverseProxy(target string) (http.Handler, error) {
|
||||
targetUri, 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 targetUri.Scheme == "unix" {
|
||||
// clean path up so we don't use the socket path in proxied requests
|
||||
addr := targetUri.Path
|
||||
targetUri.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(targetUri)
|
||||
rp.Transport = transport
|
||||
|
||||
return rp, nil
|
||||
}
|
||||
|
||||
func startDecayMapCleanup(ctx context.Context, s *libanubis.Server) {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
s.CleanupDecayMap()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
flagenv.Parse()
|
||||
flag.Parse()
|
||||
@@ -158,13 +181,26 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := New(*target, *policyFname)
|
||||
if *extractResources != "" {
|
||||
if err := extractEmbedFS(web.Static, "static", *extractResources); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Extracted embedded static files to %s\n", *extractResources)
|
||||
return
|
||||
}
|
||||
|
||||
rp, err := makeReverseProxy(*target)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
log.Fatalf("can't make reverse proxy: %v", err)
|
||||
}
|
||||
|
||||
policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, uint32(*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
|
||||
}
|
||||
@@ -178,25 +214,57 @@ func main() {
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
// replace the bot policy rules with a single rule that always benchmarks
|
||||
if *debugBenchmarkJS {
|
||||
userAgent := regexp.MustCompile(".")
|
||||
policy.Bots = []botPolicy.Bot{{
|
||||
Name: "",
|
||||
UserAgent: userAgent,
|
||||
Action: config.RuleBenchmark,
|
||||
}}
|
||||
}
|
||||
|
||||
mux.Handle(staticPath, internal.UnchangingCache(http.StripPrefix(staticPath, http.FileServerFS(static))))
|
||||
var priv ed25519.PrivateKey
|
||||
if *ed25519PrivateKeyHex != "" && *ed25519PrivateKeyHexFile != "" {
|
||||
log.Fatal("do not specify both ED25519_PRIVATE_KEY_HEX and ED25519_PRIVATE_KEY_HEX_FILE")
|
||||
} else if *ed25519PrivateKeyHex != "" {
|
||||
priv, err = keyFromHex(*ed25519PrivateKeyHex)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse and validate ED25519_PRIVATE_KEY_HEX: %v", err)
|
||||
}
|
||||
} else if *ed25519PrivateKeyHexFile != "" {
|
||||
hex, err := os.ReadFile(*ed25519PrivateKeyHexFile)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to read ED25519_PRIVATE_KEY_HEX_FILE %s: %v", *ed25519PrivateKeyHexFile, err)
|
||||
}
|
||||
|
||||
// mux.HandleFunc("GET /.within.website/x/cmd/anubis/static/js/main.mjs", serveMainJSWithBestEncoding)
|
||||
priv, err = keyFromHex(string(bytes.TrimSpace(hex)))
|
||||
if err != nil {
|
||||
log.Fatalf("failed to parse and validate content of ED25519_PRIVATE_KEY_HEX_FILE: %v", err)
|
||||
}
|
||||
} else {
|
||||
_, priv, err = ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to generate ed25519 key: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
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")
|
||||
}
|
||||
|
||||
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")
|
||||
})
|
||||
s, err := libanubis.New(libanubis.Options{
|
||||
Next: rp,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: *robotsTxt,
|
||||
PrivateKey: priv,
|
||||
CookieDomain: *cookieDomain,
|
||||
CookiePartitioned: *cookiePartitioned,
|
||||
OGPassthrough: *ogPassthrough,
|
||||
OGTimeToLive: *ogTimeToLive,
|
||||
Target: *target,
|
||||
WebmasterEmail: *webmasterEmail,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
wg := new(sync.WaitGroup)
|
||||
@@ -209,23 +277,26 @@ func main() {
|
||||
go metricsServer(ctx, wg.Done)
|
||||
}
|
||||
|
||||
mux.HandleFunc("/", s.maybeReverseProxy)
|
||||
go startDecayMapCleanup(ctx, s)
|
||||
|
||||
var h http.Handler
|
||||
h = mux
|
||||
h = internal.DefaultXRealIP(*debugXRealIPDefault, h)
|
||||
h = s
|
||||
h = internal.RemoteXRealIP(*useRemoteAddress, *bindNetwork, h)
|
||||
h = internal.XForwardedForToXRealIP(h)
|
||||
|
||||
srv := http.Server{Handler: h}
|
||||
listener, url := setupListener(*bindNetwork, *bind)
|
||||
listener, listenerUrl := setupListener(*bindNetwork, *bind)
|
||||
slog.Info(
|
||||
"listening",
|
||||
"url", url,
|
||||
"url", listenerUrl,
|
||||
"difficulty", *challengeDifficulty,
|
||||
"serveRobotsTXT", *robotsTxt,
|
||||
"target", *target,
|
||||
"version", anubis.Version,
|
||||
"debug-x-real-ip-default", *debugXRealIPDefault,
|
||||
"use-remote-address", *useRemoteAddress,
|
||||
"debug-benchmark-js", *debugBenchmarkJS,
|
||||
"og-passthrough", *ogPassthrough,
|
||||
"og-expiry-time", *ogTimeToLive,
|
||||
)
|
||||
|
||||
go func() {
|
||||
@@ -237,7 +308,7 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.Serve(listener); err != http.ErrServerClosed {
|
||||
if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -250,8 +321,8 @@ func metricsServer(ctx context.Context, done func()) {
|
||||
mux.Handle("/metrics", promhttp.Handler())
|
||||
|
||||
srv := http.Server{Handler: mux}
|
||||
listener, url := setupListener(*metricsBindNetwork, *metricsBind)
|
||||
slog.Debug("listening for metrics", "url", url)
|
||||
listener, metricsUrl := setupListener(*metricsBindNetwork, *metricsBind)
|
||||
slog.Debug("listening for metrics", "url", metricsUrl)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -262,450 +333,33 @@ func metricsServer(ctx context.Context, done func()) {
|
||||
}
|
||||
}()
|
||||
|
||||
if err := srv.Serve(listener); err != http.ErrServerClosed {
|
||||
if err := srv.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func sha256sum(text string) string {
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(text))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
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 sha256sum(data)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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", unixRoundTripper{Transport: transport})
|
||||
}
|
||||
|
||||
rp := httputil.NewSingleHostReverseProxy(u)
|
||||
rp.Transport = transport
|
||||
|
||||
var fin io.ReadCloser
|
||||
|
||||
if policyFname != "" {
|
||||
fin, err = os.Open(policyFname)
|
||||
func extractEmbedFS(fsys embed.FS, root string, destDir string) error {
|
||||
return fs.WalkDir(fsys, root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't parse policy file %s: %w", policyFname, err)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
policyFname = "(static)/botPolicies.json"
|
||||
fin, err = static.Open("botPolicies.json")
|
||||
|
||||
relPath, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't parse builtin policy file %s: %w", policyFname, err)
|
||||
}
|
||||
}
|
||||
|
||||
defer fin.Close()
|
||||
|
||||
policy, err := parseConfig(fin, policyFname, *challengeDifficulty)
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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(base("Oh noes!", 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)
|
||||
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()
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
destPath := filepath.Join(destDir, relPath)
|
||||
|
||||
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
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(destPath, 0o700)
|
||||
}
|
||||
hash, err := rule.Hash()
|
||||
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
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
|
||||
return err
|
||||
}
|
||||
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
|
||||
}, jwt.WithExpirationRequired(), jwt.WithStrictDecoding())
|
||||
|
||||
if err != nil || !token.Valid {
|
||||
lg.Debug("invalid token", "path", r.URL.Path, "err", err)
|
||||
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.rp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
lg.Debug("invalid token claims type", "path", r.URL.Path)
|
||||
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)
|
||||
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 := sha256sum(calcString)
|
||||
|
||||
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) {
|
||||
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(base("Oh noes!", 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 == "" {
|
||||
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, rule.Challenge.Difficulty)
|
||||
|
||||
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 := sha256sum(calcString)
|
||||
|
||||
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,
|
||||
return os.WriteFile(destPath, data, 0o644)
|
||||
})
|
||||
}
|
||||
|
||||
func randomJitter() bool {
|
||||
return mrand.Intn(100) > 10
|
||||
}
|
||||
|
||||
func serveMainJSWithBestEncoding(w http.ResponseWriter, r *http.Request) {
|
||||
priorityList := []string{"zstd", "br", "gzip"}
|
||||
enc2ext := map[string]string{
|
||||
"zstd": "zst",
|
||||
"br": "br",
|
||||
"gzip": "gz",
|
||||
}
|
||||
|
||||
for _, enc := range priorityList {
|
||||
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])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
http.ServeFileFS(w, r, static, "static/js/main.mjs")
|
||||
}
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
|
||||
"github.com/TecharoHQ/anubis/cmd/anubis/internal/config"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/yl2chen/cidranger"
|
||||
)
|
||||
|
||||
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"`
|
||||
Challenge *config.ChallengeRules
|
||||
Ranger cidranger.Ranger
|
||||
}
|
||||
|
||||
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)), nil
|
||||
}
|
||||
|
||||
func parseConfig(fin io.Reader, fname string, defaultDifficulty int) (*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.RemoteAddr != nil && len(b.RemoteAddr) > 0 {
|
||||
parsedBot.Ranger = cidranger.NewPCTrieRanger()
|
||||
|
||||
for _, cidr := range b.RemoteAddr {
|
||||
_, rng, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] range %s not parsing: %w", cidr, err)
|
||||
}
|
||||
|
||||
parsedBot.Ranger.Insert(cidranger.NewBasicRangerEntry(*rng))
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if b.Challenge == nil {
|
||||
parsedBot.Challenge = &config.ChallengeRules{
|
||||
Difficulty: defaultDifficulty,
|
||||
ReportAs: defaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
}
|
||||
} else {
|
||||
parsedBot.Challenge = b.Challenge
|
||||
if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown {
|
||||
parsedBot.Challenge.Algorithm = config.AlgorithmFast
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) checkRemoteAddress(b Bot, addr net.IP) bool {
|
||||
if b.Ranger == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Check evaluates the list of rules, and returns the result
|
||||
func (s *Server) check(r *http.Request) (CheckResult, *Bot, error) {
|
||||
host := r.Header.Get("X-Real-Ip")
|
||||
if host == "" {
|
||||
return zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] X-Real-Ip header is not set")
|
||||
}
|
||||
|
||||
addr := net.ParseIP(host)
|
||||
if addr == nil {
|
||||
return zilch[CheckResult](), nil, fmt.Errorf("[misconfiguration] %q is not an IP address", host)
|
||||
}
|
||||
|
||||
for _, b := range s.policy.Bots {
|
||||
if b.UserAgent != nil {
|
||||
if uaMatch := b.UserAgent.MatchString(r.UserAgent()); uaMatch || (uaMatch && s.checkRemoteAddress(b, addr)) {
|
||||
return cr("bot/"+b.Name, b.Action), &b, nil
|
||||
}
|
||||
}
|
||||
|
||||
if b.Path != nil {
|
||||
if pathMatch := b.Path.MatchString(r.URL.Path); pathMatch || (pathMatch && 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), &Bot{
|
||||
Challenge: &config.ChallengeRules{
|
||||
Difficulty: defaultDifficulty,
|
||||
ReportAs: defaultDifficulty,
|
||||
Algorithm: config.AlgorithmFast,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
Before Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 50 KiB |
@@ -1,2 +0,0 @@
|
||||
(()=>{function p(r,n=5,t=navigator.hardwareConcurrency||1){return console.debug("fast algo"),new Promise((e,o)=>{let s=URL.createObjectURL(new Blob(["(",y(),")()"],{type:"application/javascript"})),a=[];for(let i=0;i<t;i++){let c=new Worker(s);c.onmessage=d=>{a.forEach(u=>u.terminate()),c.terminate(),e(d.data)},c.onerror=d=>{c.terminate(),o()},c.postMessage({data:r,difficulty:n,nonce:i,threads:t}),a.push(c)}URL.revokeObjectURL(s)})}function y(){return function(){let r=t=>{let e=new TextEncoder().encode(t);return crypto.subtle.digest("SHA-256",e.buffer)};function n(t){return Array.from(t).map(e=>e.toString(16).padStart(2,"0")).join("")}addEventListener("message",async t=>{let e=t.data.data,o=t.data.difficulty,s,a=t.data.nonce,i=t.data.threads;for(;;){let c=await r(e+a),d=new Uint8Array(c),u=!0;for(let m=0;m<o;m++){let l=Math.floor(m/2),g=m%2;if((d[l]>>(g===0?4:0)&15)!==0){u=!1;break}}if(u){s=n(d),console.log(s);break}a+=i}postMessage({hash:s,data:e,difficulty:o,nonce:a})})}.toString()}function f(r,n=5,t=1){return console.debug("slow algo"),new Promise((e,o)=>{let s=URL.createObjectURL(new Blob(["(",b(),")()"],{type:"application/javascript"})),a=new Worker(s);a.onmessage=i=>{a.terminate(),e(i.data)},a.onerror=i=>{a.terminate(),o()},a.postMessage({data:r,difficulty:n}),URL.revokeObjectURL(s)})}function b(){return function(){let r=n=>{let t=new TextEncoder().encode(n);return crypto.subtle.digest("SHA-256",t.buffer).then(e=>Array.from(new Uint8Array(e)).map(o=>o.toString(16).padStart(2,"0")).join(""))};addEventListener("message",async n=>{let t=n.data.data,e=n.data.difficulty,o,s=0;do o=await r(t+s++);while(o.substring(0,e)!==Array(e+1).join("0"));s-=1,postMessage({hash:o,data:t,difficulty:e,nonce:s})})}.toString()}var L={fast:p,slow:f},w=(r="",n={})=>{let t=new URL(r,window.location.href);return Object.entries(n).forEach(e=>{let[o,s]=e;t.searchParams.set(o,s)}),t.toString()},h=(r,n)=>w(`/.within.website/x/cmd/anubis/static/img/${r}.webp`,{cacheBuster:n});(async()=>{let r=document.getElementById("status"),n=document.getElementById("image"),t=document.getElementById("title"),e=document.getElementById("spinner"),o=JSON.parse(document.getElementById("anubis_version").textContent);r.innerHTML="Calculating...";let{challenge:s,rules:a}=await fetch("/.within.website/x/cmd/anubis/api/make-challenge",{method:"POST"}).then(l=>{if(!l.ok)throw new Error("Failed to fetch config");return l.json()}).catch(l=>{throw t.innerHTML="Oh no!",r.innerHTML=`Failed to fetch config: ${l.message}`,n.src=h("sad",o),e.innerHTML="",e.style.display="none",l}),i=L[a.algorithm];if(!i){t.innerHTML="Oh no!",r.innerHTML="Failed to resolve check algorithm. You may want to reload the page.",n.src=h("sad",o),e.innerHTML="",e.style.display="none";return}r.innerHTML=`Calculating...<br/>Difficulty: ${a.report_as}`;let c=Date.now(),{hash:d,nonce:u}=await i(s,a.difficulty),m=Date.now();console.log({hash:d,nonce:u}),t.innerHTML="Success!",r.innerHTML=`Done! Took ${m-c}ms, ${u} iterations`,n.src=h("happy",o),e.innerHTML="",e.style.display="none",setTimeout(()=>{let l=window.location.href;window.location.href=w("/.within.website/x/cmd/anubis/api/pass-challenge",{response:d,nonce:u,redir:l,elapsedTime:m-c})},250)})();})();
|
||||
//# sourceMappingURL=main.mjs.map
|
||||
@@ -19,38 +19,20 @@ var (
|
||||
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)")
|
||||
|
||||
knownContributors = []string{
|
||||
"Xe",
|
||||
}
|
||||
)
|
||||
|
||||
func inList(needle string, haystack []string) bool {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func main() {
|
||||
flagenv.Parse()
|
||||
flag.Parse()
|
||||
|
||||
internal.InitSlog(*slogLevel)
|
||||
|
||||
koDockerRepo := strings.TrimRight(*dockerRepo, "/"+filepath.Base(*dockerRepo))
|
||||
|
||||
if *githubEventName == "pull_request" && !inList(*githubActor, knownContributors) {
|
||||
if *pullRequestID == -1 {
|
||||
log.Fatal("Must set --pull-request-id when --github-event-name=pull_request")
|
||||
}
|
||||
koDockerRepo := strings.TrimSuffix(*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)
|
||||
@@ -130,11 +112,6 @@ type image struct {
|
||||
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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
{
|
||||
"name": "googlebot",
|
||||
"user_agent_regex": "\\+http\\:\\/\\/www\\.google\\.com/bot\\.html",
|
||||
"user_agent_regex": "\\+http\\://www\\.google\\.com/bot\\.html",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"2001:4860:4801:10::/64",
|
||||
@@ -271,7 +271,7 @@
|
||||
},
|
||||
{
|
||||
"name": "bingbot",
|
||||
"user_agent_regex": "\\+http\\:\\/\\/www\\.bing\\.com/bingbot\\.htm",
|
||||
"user_agent_regex": "\\+http\\://www\\.bing\\.com/bingbot\\.htm",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"157.55.39.0/24",
|
||||
@@ -306,7 +306,7 @@
|
||||
},
|
||||
{
|
||||
"name": "qwantbot",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/help\\.qwant\\.com/bot/",
|
||||
"user_agent_regex": "\\+https\\://help\\.qwant\\.com/bot/",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"91.242.162.0/24"
|
||||
@@ -314,7 +314,7 @@
|
||||
},
|
||||
{
|
||||
"name": "kagibot",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/kagi\\.com/bot",
|
||||
"user_agent_regex": "\\+https\\://kagi\\.com/bot",
|
||||
"action": "ALLOW",
|
||||
"remote_addresses": [
|
||||
"216.18.205.234/32",
|
||||
@@ -345,7 +345,7 @@
|
||||
},
|
||||
{
|
||||
"name": "us-artificial-intelligence-scraper",
|
||||
"user_agent_regex": "\\+https\\:\\/\\/github\\.com\\/US-Artificial-Intelligence\\/scraper",
|
||||
"user_agent_regex": "\\+https\\://github\\.com/US-Artificial-Intelligence/scraper",
|
||||
"action": "DENY"
|
||||
},
|
||||
{
|
||||
@@ -390,9 +390,9 @@
|
||||
},
|
||||
{
|
||||
"name": "generic-browser",
|
||||
"user_agent_regex": "(?i:gecko)",
|
||||
"user_agent_regex": "Mozilla",
|
||||
"action": "CHALLENGE"
|
||||
}
|
||||
],
|
||||
"dnsbl": true
|
||||
"dnsbl": false
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -85,3 +85,23 @@ func (m *DecayMap[K, V]) Set(key K, value V, ttl time.Duration) {
|
||||
expiry: time.Now().Add(ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup removes all expired entries from the DecayMap.
|
||||
func (m *Impl[K, V]) Cleanup() {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
for key, entry := range m.data {
|
||||
if now.After(entry.expiry) {
|
||||
delete(m.data, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Len returns the number of entries in the DecayMap.
|
||||
func (m *Impl[K, V]) Len() int {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
return len(m.data)
|
||||
}
|
||||
60
decaymap/decaymap_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package decaymap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestImpl(t *testing.T) {
|
||||
dm := New[string, string]()
|
||||
|
||||
dm.Set("test", "hi", 5*time.Minute)
|
||||
|
||||
val, ok := dm.Get("test")
|
||||
if !ok {
|
||||
t.Error("somehow the test key was not set")
|
||||
}
|
||||
|
||||
if val != "hi" {
|
||||
t.Errorf("wanted value %q, got: %q", "hi", val)
|
||||
}
|
||||
|
||||
ok = dm.expire("test")
|
||||
if !ok {
|
||||
t.Error("somehow could not force-expire the test key")
|
||||
}
|
||||
|
||||
_, ok = dm.Get("test")
|
||||
if ok {
|
||||
t.Error("got value even though it was supposed to be expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanup(t *testing.T) {
|
||||
dm := New[string, string]()
|
||||
|
||||
dm.Set("test1", "hi1", 1*time.Second)
|
||||
dm.Set("test2", "hi2", 2*time.Second)
|
||||
dm.Set("test3", "hi3", 3*time.Second)
|
||||
|
||||
dm.expire("test1") // Force expire test1
|
||||
dm.expire("test2") // Force expire test2
|
||||
|
||||
dm.Cleanup()
|
||||
|
||||
finalLen := dm.Len() // Get the length after cleanup
|
||||
|
||||
if finalLen != 1 { // "test3" should be the only one left
|
||||
t.Errorf("Cleanup failed to remove expired entries. Expected length 1, got %d", finalLen)
|
||||
}
|
||||
|
||||
if _, ok := dm.Get("test1"); ok { // Verify Get still behaves correctly after Cleanup
|
||||
t.Error("test1 should not be found after cleanup")
|
||||
}
|
||||
if _, ok := dm.Get("test2"); ok {
|
||||
t.Error("test2 should not be found after cleanup")
|
||||
}
|
||||
if val, ok := dm.Get("test3"); !ok || val != "hi3" {
|
||||
t.Error("test3 should still be found after cleanup")
|
||||
}
|
||||
}
|
||||
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"
|
||||
@@ -11,9 +11,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Added support for native Debian, Red Hat, and tarball packaging strategies including installation and use directions.
|
||||
- A prebaked tarball has been added, allowing distros to build Anubis like they could in v1.15.x.
|
||||
- The placeholder Anubis mascot has been replaced with a design by [CELPHASE](https://bsky.app/profile/celphase.bsky.social).
|
||||
- Added a periodic cleanup routine for the decaymap that removes expired entries, ensuring stale data is properly pruned.
|
||||
- Added a no-store Cache-Control header to the challenge page
|
||||
- Hide the directory listings for Anubis' internal static content
|
||||
- Changed `--debug-x-real-ip-default` to `--use-remote-address`, getting the IP address from the request's socket address instead.
|
||||
- DroneBL lookups have been disabled by default
|
||||
- Static asset builds are now done on demand instead of the results being committed to source control
|
||||
- The Dockerfile has been removed as it is no longer in use
|
||||
- Developer documentation has been added to the docs site
|
||||
- Show more errors when some predictable challenge page errors happen ([#150](https://github.com/TecharoHQ/anubis/issues/150))
|
||||
- Verification page now shows hash rate and a progress bar for completion probability.
|
||||
- Added the `--debug-benchmark-js` flag for testing proof-of-work performance during development.
|
||||
- Use `TrimSuffix` instead of `TrimRight` on containerbuild
|
||||
- Fix the startup logs to correctly show the address and port the server is listening on
|
||||
- Add [LibreJS](https://www.gnu.org/software/librejs/) banner to Anubis JavaScript to allow LibreJS users to run the challenge
|
||||
- Added a wait with button continue + 30 second auto continue after 30s if you click "Why am I seeing this?"
|
||||
- Fixed a typo in the challenge page title.
|
||||
- Disabled running integration tests on Windows hosts due to it's reliance on posix features (see [#133](https://github.com/TecharoHQ/anubis/pull/133#issuecomment-2764732309)).
|
||||
- Added support for passing the ed25519 signing key in a file with `-ed25519-private-key-hex-file` or `ED25519_PRIVATE_KEY_HEX_FILE`.
|
||||
- Fixed minor typos
|
||||
- Added a Makefile to enable comfortable workflows for downstream packagers.
|
||||
- Added `zizmor` for GitHub Actions static analysis
|
||||
- Fixed most `zizmor` findings
|
||||
- Enabled Dependabot
|
||||
- Added an air config for autoreload support in development ([#195](https://github.com/TecharoHQ/anubis/pull/195))
|
||||
- Added support for [OpenGraph tags](https://ogp.me/) when rendering the challenge page. This allows for social previews to be generated when sharing the challenge page on social media platforms ([#195](https://github.com/TecharoHQ/anubis/pull/195))
|
||||
- Added an `--extract-resources` flag to extract static resources to a local folder.
|
||||
- Add noindex flag to all Anubis pages ([#227](https://github.com/TecharoHQ/anubis/issues/227)).
|
||||
- Added `WEBMASTER_EMAIL` variable, if it is present then display that email address on error pages ([#235](https://github.com/TecharoHQ/anubis/pull/235), [#115](https://github.com/TecharoHQ/anubis/issues/115))
|
||||
|
||||
## 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
|
||||
- Changed default challenge logic for "Gecko" in the User-Agent string
|
||||
instead of "Mozilla" [#78](https://github.com/TecharoHQ/anubis/pull/78)
|
||||
- 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
|
||||
|
||||
@@ -22,6 +87,7 @@ 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
|
||||
|
||||
|
||||
8
docs/docs/admin/configuration/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Configuration",
|
||||
"position": 10,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Detailed information about configuring parts of Anubis."
|
||||
}
|
||||
}
|
||||
47
docs/docs/admin/configuration/open-graph.mdx
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: open-graph
|
||||
title: Open Graph Configuration
|
||||
---
|
||||
|
||||
# Open Graph Configuration
|
||||
|
||||
This page provides detailed information on how to configure [OpenGraph tag](https://ogp.me/) passthrough in Anubis. This enables social previews of resources protected by Anubis without having to exempt each scraper individually.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Name | Description | Type | Default | Example |
|
||||
|------------------|-----------------------------------------------------------|----------|---------|-------------------------|
|
||||
| `OG_PASSTHROUGH` | Enables or disables the Open Graph tag passthrough system | Boolean | `false` | `OG_PASSTHROUGH=true` |
|
||||
| `OG_EXPIRY_TIME` | Configurable cache expiration time for Open Graph tags | Duration | `24h` | `OG_EXPIRY_TIME=1h` |
|
||||
|
||||
## Usage
|
||||
|
||||
To configure Open Graph tags, you can set the following environment variables, environment file or as flags in your Anubis configuration:
|
||||
|
||||
```sh
|
||||
export OG_PASSTHROUGH=true
|
||||
export OG_EXPIRY_TIME=1h
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
When `OG_PASSTHROUGH` is enabled, Anubis will:
|
||||
|
||||
1. Check a local cache for the requested URL's Open Graph tags.
|
||||
2. If a cached entry exists and is still valid, return the cached tags.
|
||||
3. If the cached entry is stale or not found, fetch the URL, parse the Open Graph tags, update the cache, and return the new tags.
|
||||
|
||||
The cache expiration time is controlled by `OG_EXPIRY_TIME`.
|
||||
|
||||
## Example
|
||||
|
||||
Here is an example of how to configure Open Graph tags in your Anubis setup:
|
||||
|
||||
```sh
|
||||
export OG_PASSTHROUGH=true
|
||||
export OG_EXPIRY_TIME=1h
|
||||
```
|
||||
|
||||
With these settings, Anubis will cache Open Graph tags for 1 hour and pass them through to the challenge page.
|
||||
|
||||
For more information, refer to the [installation guide](../installation).
|
||||
@@ -2,8 +2,30 @@
|
||||
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>
|
||||
|
||||
## Docker image conventions
|
||||
|
||||
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 |
|
||||
@@ -11,27 +33,48 @@ Anubis is shipped in the Docker repo [`ghcr.io/techarohq/anubis`](https://github
|
||||
| `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.
|
||||
|
||||
## Environment variables
|
||||
|
||||
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. |
|
||||
| `DIFFICULTY` | `5` | The difficulty of the challenge, or the number of leading zeroes that must be in successful responses. |
|
||||
| `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`. |
|
||||
| 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` | unset | 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. |
|
||||
| `ED25519_PRIVATE_KEY_HEX_FILE` | unset | Path to a file containing the hex-encoded ed25519 private key. Only one of this or its sister option may be set. |
|
||||
| `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. |
|
||||
| `OG_EXPIRY_TIME` | `24h` | The expiration time for the Open Graph tag cache. |
|
||||
| `OG_PASSTHROUGH` | `false` | If set to `true`, Anubis will enable Open Graph tag passthrough. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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`. |
|
||||
| `USE_REMOTE_ADDRESS` | unset | If set to `true`, Anubis will take the client's IP from the network socket. For production deployments, it is expected that a reverse proxy is used in front of Anubis, which pass the IP using headers, instead. |
|
||||
| `WEBMASTER_EMAIL` | unset | If set, shows a contact email address when rendering error pages. This email address will be how users can get in contact with administrators. |
|
||||
|
||||
For more detailed information on configuring Open Graph tags, please refer to the [Open Graph Configuration](./configuration/open-graph.mdx) page.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -48,6 +91,8 @@ services:
|
||||
SERVE_ROBOTS_TXT: "true"
|
||||
TARGET: "http://nginx"
|
||||
POLICY_FNAME: "/data/cfg/botPolicy.json"
|
||||
OG_PASSTHROUGH: "true"
|
||||
OG_EXPIRY_TIME: "24h"
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
@@ -84,6 +129,10 @@ containers:
|
||||
value: "true"
|
||||
- name: "TARGET"
|
||||
value: "http://localhost:5000"
|
||||
- name: "OG_PASSTHROUGH"
|
||||
value: "true"
|
||||
- name: "OG_EXPIRY_TIME"
|
||||
value: "24h"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 500m
|
||||
|
||||
131
docs/docs/admin/native-install.mdx
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
title: Installing Anubis with a native package
|
||||
---
|
||||
|
||||
import Tabs from "@theme/Tabs";
|
||||
import TabItem from "@theme/TabItem";
|
||||
|
||||
Install the Anubis package using your package manager of choice:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="deb" label="Debian-based (apt)" default>
|
||||
|
||||
Install Anubis with `apt`:
|
||||
|
||||
```text
|
||||
sudo apt install ./anubis-$VERSION-$ARCH.deb
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="tarball" label="Tarball">
|
||||
|
||||
Extract the tarball to a folder:
|
||||
|
||||
```text
|
||||
tar zxf ./anubis-$VERSION-$OS-$ARCH.tar.gz
|
||||
cd anubis-$VERSION-$OS-$ARCH
|
||||
```
|
||||
|
||||
Install the binary to your system:
|
||||
|
||||
```text
|
||||
sudo install -D ./bin/anubis /usr/local/bin
|
||||
```
|
||||
|
||||
Edit the systemd unit to point to `/usr/local/bin/anubis` instead of `/usr/bin/anubis`:
|
||||
|
||||
```text
|
||||
perl -pi -e 's$/usr/bin/anubis$/usr/local/bin/anubis$g' ./run/anubis@.service
|
||||
```
|
||||
|
||||
Install the systemd unit to your system:
|
||||
|
||||
```text
|
||||
sudo install -D ./run/anubis@.service /etc/systemd/system
|
||||
```
|
||||
|
||||
Install the default configuration file to your system:
|
||||
|
||||
```text
|
||||
sudo install -D ./run/default.env /etc/anubis
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="rpm" label="Red Hat-based (rpm)">
|
||||
|
||||
Install Anubis with `dnf`:
|
||||
|
||||
```text
|
||||
sudo dnf -y install ./anubis-$VERSION.$ARCH.rpm
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
Install Anubis with `yum`:
|
||||
|
||||
```text
|
||||
sudo yum -y install ./anubis-$VERSION.$ARCH.rpm
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
Install Anubis with `rpm`:
|
||||
|
||||
```
|
||||
sudo rpm -ivh ./anubis-$VERSION.$ARCH.rpm
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Once it's installed, make a copy of the default configuration file `/etc/anubis/default.env` based on which service you want to protect. For example, to protect a `gitea` server:
|
||||
|
||||
```text
|
||||
sudo cp /etc/anubis/default.env /etc/anubis/gitea.env
|
||||
```
|
||||
|
||||
Copy the default bot policies file to `/etc/anubis/gitea.botPolicies.json`:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="debrpm" label="Debian or Red Hat" default>
|
||||
|
||||
```text
|
||||
sudo cp /usr/share/doc/anubis/botPolicies.json /etc/anubis/gitea.botPolicies.json
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="tarball" label="Tarball">
|
||||
|
||||
```text
|
||||
sudo cp ./doc/botPolicies.json /etc/anubis/gitea.botPolicies.json
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
Then open `gitea.env` in your favorite text editor and customize [the environment variables](./installation.mdx#environment-variables) as needed. Here's an example configuration for a Gitea server:
|
||||
|
||||
```sh
|
||||
BIND=[::1]:8239
|
||||
BIND_NETWORK=tcp
|
||||
DIFFICULTY=4
|
||||
METRICS_BIND=[::1]:8240
|
||||
METRICS_BIND_NETWORK=tcp
|
||||
POLICY_FNAME=/etc/anubis/gitea.botPolicies.json
|
||||
TARGET=http://localhost:3000
|
||||
```
|
||||
|
||||
Then start Anubis with `systemctl enable --now`:
|
||||
|
||||
```text
|
||||
sudo systemctl enable --now anubis@gitea.service
|
||||
```
|
||||
|
||||
Test to make sure it's running with `curl`:
|
||||
|
||||
```text
|
||||
curl http://localhost:8240/metrics
|
||||
```
|
||||
|
||||
Then set up your reverse proxy (Nginx, Caddy, etc.) to point to the Anubis port. Anubis will then reverse proxy all requests that meet the policies in `/etc/anubis/gitea.botPolicies.json` to the target service.
|
||||
@@ -52,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](https://github.com/TecharoHQ/anubis/blob/main/cmd/anubis/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.
|
||||
|
||||
|
||||
8
docs/docs/developer/_category_.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Developer guides",
|
||||
"position": 50,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides and suggestions to make Anubis development go smoothly for everyone."
|
||||
}
|
||||
}
|
||||
78
docs/docs/developer/building-anubis.md
Normal file
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Building Anubis without Docker
|
||||
---
|
||||
|
||||
:::note
|
||||
|
||||
These instructions may work, but for right now they are informative for downstream packagers more than they are ready-made instructions for administrators wanting to run Anubis on their servers. Pre-made binary package support is being tracked in [#156](https://github.com/TecharoHQ/anubis/issues/156).
|
||||
|
||||
:::
|
||||
|
||||
## Entirely from source
|
||||
|
||||
If you are doing a build entirely from source, here's what you need to do:
|
||||
|
||||
### Tools needed
|
||||
|
||||
In order to build a production-ready binary of Anubis, you need the following packages in your environment:
|
||||
|
||||
- [Go](https://go.dev) at least version 1.24 - the programming language that Anubis is written in
|
||||
- [esbuild](https://esbuild.github.io/) - the JavaScript bundler Anubis uses for its production JS assets
|
||||
- [Node.JS & NPM](https://nodejs.org/en) - manages some build dependencies
|
||||
- `gzip` - compresses production JS (part of coreutils)
|
||||
- `zstd` - compresses production JS
|
||||
- `brotli` - compresses production JS
|
||||
|
||||
To upgrade your version of Go without system package manager support, install `golang.org/dl/go1.24.2` (this can be done from any version of Go):
|
||||
|
||||
```text
|
||||
go install golang.org/dl/go1.24.2@latest
|
||||
go1.24.2 download
|
||||
```
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```text
|
||||
make deps
|
||||
```
|
||||
|
||||
This will download Go and NPM dependencies.
|
||||
|
||||
### Building static assets
|
||||
|
||||
```text
|
||||
make assets
|
||||
```
|
||||
|
||||
This will build all static assets (CSS, JavaScript) for distribution.
|
||||
|
||||
### Building Anubis to the `./var` folder
|
||||
|
||||
```text
|
||||
make build
|
||||
```
|
||||
|
||||
From this point it is up to you to make sure that `./var/anubis` ends up in the right place. You may want to consult the `./run` folder for useful files such as a systemd unit and `anubis.env.default` file.
|
||||
|
||||
## "Pre-baked" tarball
|
||||
|
||||
The `anubis-src-with-vendor` tarball has many pre-build steps already done, including:
|
||||
|
||||
- Go module dependencies are present in `./vendor`
|
||||
- Static assets (JS, CSS, etc.) are already built in CI
|
||||
|
||||
This means you do not have to manage Go, NPM, or other ecosystem dependencies.
|
||||
|
||||
When using this tarball, all you need to do is build `./cmd/anubis`:
|
||||
|
||||
```text
|
||||
make prebaked-build
|
||||
```
|
||||
|
||||
Anubis will be built to `./var/anubis`.
|
||||
|
||||
## Development dependencies
|
||||
|
||||
Optionally, you can install the following dependencies for development:
|
||||
|
||||
- [Staticcheck](https://staticcheck.dev/docs/getting-started/) (optional, not required due to [`go tool staticcheck`](https://www.alexedwards.net/blog/how-to-manage-tool-dependencies-in-go-1.24-plus), but required if you are using any version of Go older than 1.24)
|
||||
31
docs/docs/developer/code-quality.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: Code quality guidelines
|
||||
---
|
||||
|
||||
When submitting code to Anubis, please take the time to consider the fact that this project is security software. If things go bad, bots can pummel sites into oblivion. This is not ideal for uptime.
|
||||
|
||||
As such, code reviews will be a bit more strict than you have seen in other projects. This is not people trying to be mean, this is a side effect of taking the problem seriously.
|
||||
|
||||
When making code changes, try to do the following:
|
||||
|
||||
- If you're submitting a bugfix, add a test case for it
|
||||
- If you're changing the JavaScript, make sure the integration tests pass (`npm run test:integration`)
|
||||
|
||||
## Commit messages
|
||||
|
||||
Anubis follows the Go project's conventions for commit messages. In general, an ideal commit message should read like this:
|
||||
|
||||
```text
|
||||
path/to/folder: brief description of the change
|
||||
|
||||
If the change is subtle, has implementation consequences, or is otherwise
|
||||
not entirely self-describing: take the time to spell out why. If things
|
||||
are very subtle, please also amend the documentation accordingly
|
||||
```
|
||||
|
||||
The subject of a commit message should be the second half of the sentence "This commit changes the Anubis project to:". Here's a few examples:
|
||||
|
||||
- `disable DroneBL by default`
|
||||
- `port the challenge to WebAssembly`
|
||||
|
||||
The extended commit message is also your place to give rationale for a new feature. When maintainers are reviewing your code, they will use this to figure out if the burden from feature maintainership is worth the merge.
|
||||
86
docs/docs/developer/local-dev.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Local development
|
||||
---
|
||||
|
||||
:::note
|
||||
|
||||
TL;DR: `npm ci && npm run dev`
|
||||
|
||||
:::
|
||||
|
||||
Anubis requires the following tools to be installed to do local development:
|
||||
|
||||
- [Go](https://go.dev) - the programming language that Anubis is written in
|
||||
- [esbuild](https://esbuild.github.io/) - the JavaScript bundler Anubis uses for its production JS assets
|
||||
- [Node.JS & NPM](https://nodejs.org/en) - manages some build dependencies
|
||||
- `gzip` - compresses production JS (part of coreutils)
|
||||
- `zstd` - compresses production JS
|
||||
- `brotli` - compresses production JS
|
||||
|
||||
If you have [Homebrew](https://brew.sh) installed, you can install all the dependencies with one command:
|
||||
|
||||
```text
|
||||
brew bundle
|
||||
```
|
||||
|
||||
If you don't, you may need to figure out equivalents to the packages in Homebrew.
|
||||
|
||||
## Running Anubis locally
|
||||
|
||||
```text
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or to do it manually:
|
||||
|
||||
- Run `npm run assets` every time you change the CSS/JavaScript
|
||||
- `go run ./cmd/anubis` with any CLI flags you want
|
||||
|
||||
## Building JS/CSS assets
|
||||
|
||||
```text
|
||||
npm run assets
|
||||
```
|
||||
|
||||
If you change the build process, make sure to update `build.sh` accordingly.
|
||||
|
||||
## Production-ready builds
|
||||
|
||||
```text
|
||||
npm run container
|
||||
```
|
||||
|
||||
This builds a prod-ready container image with [ko](https://ko.build). If you want to change where the container image is pushed, you need to use environment variables:
|
||||
|
||||
```text
|
||||
DOCKER_REPO=registry.host/org/repo DOCKER_METADATA_OUTPUT_TAGS=registry.host/org/repo:latest npm run container
|
||||
```
|
||||
|
||||
## Building packages
|
||||
|
||||
For more information, see [Building native packages is complicated](https://xeiaso.net/blog/2025/anubis-packaging/) and [#156: Debian, RPM, and binary tarball packages](https://github.com/TecharoHQ/anubis/issues/156).
|
||||
|
||||
Install `yeet`:
|
||||
|
||||
:::note
|
||||
|
||||
`yeet` will soon be moved to a dedicated TecharoHQ repository. This is currently done in a hacky way in order to get this ready for user feedback.
|
||||
|
||||
:::
|
||||
|
||||
```text
|
||||
go install within.website/x/cmd/yeet@v1.13.4
|
||||
```
|
||||
|
||||
Install the dependencies for Anubis:
|
||||
|
||||
```text
|
||||
npm ci
|
||||
go mod download
|
||||
```
|
||||
|
||||
Build the packages into `./var`:
|
||||
|
||||
```text
|
||||
yeet
|
||||
```
|
||||
7
docs/docs/developer/signed-commits.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Signed commits
|
||||
---
|
||||
|
||||
Anubis requires developers to sign their commits. This is done so that we can have a better chain of custody from contribution to owner. For more information about commit signing, [read here](https://www.freecodecamp.org/news/what-is-commit-signing-in-git/).
|
||||
|
||||
We do not require GPG. SSH signed commits are fine. For an overview on how to set up commit signing with your SSH key, [read here](https://dev.to/ccoveille/git-the-complete-guide-to-sign-your-commits-with-an-ssh-key-35bg).
|
||||
@@ -23,6 +23,6 @@ Anubis is a bit of a nuclear response. This will result in your website being bl
|
||||
|
||||
## Support
|
||||
|
||||
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.
|
||||
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`.
|
||||
|
||||
@@ -45,7 +45,7 @@ const config: Config = {
|
||||
// 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/',
|
||||
'https://github.com/TecharoHQ/anubis/tree/main/docs/',
|
||||
},
|
||||
// blog: {
|
||||
// showReadingTime: true,
|
||||
@@ -76,7 +76,7 @@ const config: Config = {
|
||||
title: 'Anubis',
|
||||
logo: {
|
||||
alt: 'A happy jackal woman with brown hair and red eyes',
|
||||
src: 'img/happy.webp',
|
||||
src: 'img/favicon.webp',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ spec:
|
||||
ports:
|
||||
- containerPort: 80
|
||||
- name: anubis
|
||||
image: ghcr.io/techarohq/anubis:latest
|
||||
image: ghcr.io/techarohq/anubis:main
|
||||
imagePullPolicy: Always
|
||||
env:
|
||||
- name: "BIND"
|
||||
|
||||
6
docs/package-lock.json
generated
@@ -10184,9 +10184,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.0.tgz",
|
||||
"integrity": "sha512-4S8fwbO6w3GeCVN6OPtA9I5IGKkcDMPcKndtUlpJuCwu7JLjtj7JZpwqLuyY2nrmQT3AWsCJLSKPsc2mPBSl3w==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
--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. */
|
||||
@@ -27,10 +29,12 @@
|
||||
--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: #ccffd8;
|
||||
background-color: var(--code-block-diff-add-line-color);
|
||||
display: block;
|
||||
margin: 0 -40px;
|
||||
padding: 0 40px;
|
||||
@@ -44,7 +48,7 @@
|
||||
}
|
||||
|
||||
.code-block-diff-remove-line {
|
||||
background-color: #ffebe9;
|
||||
background-color: var(--code-block-diff-remove-line-color);
|
||||
display: block;
|
||||
margin: 0 -40px;
|
||||
padding: 0 40px;
|
||||
|
||||
BIN
docs/static/img/android-chrome-512x512.png
vendored
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 106 KiB |
BIN
docs/static/img/favicon.ico
vendored
|
Before Width: | Height: | Size: 15 KiB 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
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 30 KiB |
26
go.mod
@@ -1,31 +1,37 @@
|
||||
module github.com/TecharoHQ/anubis
|
||||
|
||||
go 1.24.1
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/a-h/templ v0.3.833
|
||||
github.com/a-h/templ v0.3.857
|
||||
github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456
|
||||
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/tetratelabs/wazero v1.9.0
|
||||
github.com/yl2chen/cidranger v1.0.2
|
||||
golang.org/x/net v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.10.1 // indirect
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
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
|
||||
@@ -35,15 +41,19 @@ require (
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 // indirect
|
||||
golang.org/x/mod v0.24.0 // indirect
|
||||
golang.org/x/net v0.37.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/tools v0.31.0 // indirect
|
||||
golang.org/x/perf v0.0.0-20250408013232-71ba5bc8ccce // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/tools v0.32.0 // indirect
|
||||
google.golang.org/protobuf v1.36.4 // indirect
|
||||
honnef.co/go/tools v0.6.1 // indirect
|
||||
)
|
||||
|
||||
tool (
|
||||
github.com/a-h/templ/cmd/templ
|
||||
golang.org/x/perf/cmd/benchstat
|
||||
golang.org/x/tools/cmd/stringer
|
||||
honnef.co/go/tools/cmd/staticcheck
|
||||
)
|
||||
|
||||
71
go.sum
@@ -1,13 +1,13 @@
|
||||
github.com/PuerkitoBio/goquery v1.10.1 h1:Y8JGYUkXWTGRB6Ars3+j3kN0xg1YqqlwvdTV8WTFQcU=
|
||||
github.com/PuerkitoBio/goquery v1.10.1/go.mod h1:IYiHrOMps66ag56LEH7QYDDupKXyo5A8qrjIx3ZtujY=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs=
|
||||
github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
|
||||
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
|
||||
github.com/a-h/templ v0.3.857 h1:6EqcJuGZW4OL+2iZ3MD+NnIcG7nGkaQeF2Zq5kf9ZGg=
|
||||
github.com/a-h/templ v0.3.857/go.mod h1:qhrhAkRFubE7khxLZHsBFHfX+gWwVNKbzKeF9GlPV4M=
|
||||
github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794 h1:xlwdaKcTNVW4PtpQb8aKA4Pjy0CdJHEqvFbAnvR5m2g=
|
||||
github.com/aclements/go-moremath v0.0.0-20210112150236-f10218a38794/go.mod h1:7e+I0LQFUI9AXWxOfsQROs9xPhoJtbsyWcjJqDd4KPY=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
@@ -19,6 +19,8 @@ github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEf
|
||||
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=
|
||||
@@ -31,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/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=
|
||||
@@ -44,10 +51,14 @@ 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=
|
||||
@@ -63,22 +74,21 @@ github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+
|
||||
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/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
|
||||
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
|
||||
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=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678 h1:1P7xPZEwZMoBoz0Yze5Nx2/4pxj6nw9ZqHWXqP0iRgQ=
|
||||
golang.org/x/exp/typeparams v0.0.0-20231108232855-2478ac86f678/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
@@ -86,21 +96,19 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
|
||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||
golang.org/x/perf v0.0.0-20250408013232-71ba5bc8ccce h1:KAIyikguO7lID+oSo3Dnut9RawUS+RWK8Ejj9KPvwU4=
|
||||
golang.org/x/perf v0.0.0-20250408013232-71ba5bc8ccce/go.mod h1:tAdCL3nMN92yGFHY2TrzbGPP0q+LaOFewlib1WPJdpA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -110,42 +118,37 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
|
||||
golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
|
||||
golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||
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=
|
||||
honnef.co/go/tools v0.6.1 h1:R094WgE8K4JirYjBaOpz/AvTyUu/3wbmAoskKN/pxTI=
|
||||
honnef.co/go/tools v0.6.1/go.mod h1:3puzxxljPCe8RGJX7BIy1plGbxEOZni5mR2aXe3/uk4=
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package internal
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/sebest/xff"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
// 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(next http.Handler) http.Handler {
|
||||
//goland:noinspection GoBoolExpressions
|
||||
if anubis.Version == "devel" {
|
||||
return next
|
||||
}
|
||||
@@ -21,16 +24,29 @@ func UnchangingCache(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// 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")
|
||||
// RemoteXRealIP sets the X-Real-Ip header to the request's real IP if
|
||||
// the setting is enabled by the user.
|
||||
func RemoteXRealIP(useRemoteAddress bool, bindNetwork string, next http.Handler) http.Handler {
|
||||
if !useRemoteAddress {
|
||||
slog.Debug("skipping middleware, useRemoteAddress is empty")
|
||||
return next
|
||||
}
|
||||
|
||||
if bindNetwork == "unix" {
|
||||
// For local sockets there is no real remote address but the localhost
|
||||
// address should be sensible.
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Set("X-Real-Ip", "127.0.0.1")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Set("X-Real-Ip", defaultIP)
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
panic(err) // this should never happen
|
||||
}
|
||||
r.Header.Set("X-Real-Ip", host)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -48,3 +64,22 @@ func XForwardedForToXRealIP(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// NoStoreCache sets the Cache-Control header to no-store for the response.
|
||||
func NoStoreCache(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Do not allow browsing directory listings in paths that end with /
|
||||
func NoBrowsing(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
51
internal/ogtags/cache.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// GetOGTags is the main function that retrieves Open Graph tags for a URL
|
||||
func (c *OGTagCache) GetOGTags(url *url.URL) (map[string]string, error) {
|
||||
if url == nil {
|
||||
return nil, errors.New("nil URL provided, cannot fetch OG tags")
|
||||
}
|
||||
urlStr := c.getTarget(url)
|
||||
// Check cache first
|
||||
if cachedTags := c.checkCache(urlStr); cachedTags != nil {
|
||||
return cachedTags, nil
|
||||
}
|
||||
|
||||
// Fetch HTML content
|
||||
doc, err := c.fetchHTMLDocument(urlStr)
|
||||
if errors.Is(err, syscall.ECONNREFUSED) {
|
||||
slog.Debug("Connection refused, returning empty tags")
|
||||
return nil, nil
|
||||
} else if errors.Is(err, ErrNotFound) {
|
||||
// not even worth a debug log...
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Extract OG tags
|
||||
ogTags := c.extractOGTags(doc)
|
||||
|
||||
// Store in cache
|
||||
c.cache.Set(urlStr, ogTags, c.ogTimeToLive)
|
||||
|
||||
return ogTags, nil
|
||||
}
|
||||
|
||||
// checkCache checks if we have the tags cached and returns them if so
|
||||
func (c *OGTagCache) checkCache(urlStr string) map[string]string {
|
||||
if cachedTags, ok := c.cache.Get(urlStr); ok {
|
||||
slog.Debug("cache hit", "tags", cachedTags)
|
||||
return cachedTags
|
||||
}
|
||||
slog.Debug("cache miss", "url", urlStr)
|
||||
return nil
|
||||
}
|
||||
122
internal/ogtags/cache_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCheckCache(t *testing.T) {
|
||||
cache := NewOGTagCache("http://example.com", true, time.Minute)
|
||||
|
||||
// Set up test data
|
||||
urlStr := "http://example.com/page"
|
||||
expectedTags := map[string]string{
|
||||
"og:title": "Test Title",
|
||||
"og:description": "Test Description",
|
||||
}
|
||||
|
||||
// Test cache miss
|
||||
tags := cache.checkCache(urlStr)
|
||||
if tags != nil {
|
||||
t.Errorf("expected nil tags on cache miss, got %v", tags)
|
||||
}
|
||||
|
||||
// Manually add to cache
|
||||
cache.cache.Set(urlStr, expectedTags, time.Minute)
|
||||
|
||||
// Test cache hit
|
||||
tags = cache.checkCache(urlStr)
|
||||
if tags == nil {
|
||||
t.Fatal("expected non-nil tags on cache hit, got nil")
|
||||
}
|
||||
|
||||
for key, expectedValue := range expectedTags {
|
||||
if value, ok := tags[key]; !ok || value != expectedValue {
|
||||
t.Errorf("expected %s: %s, got: %s", key, expectedValue, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOGTags(t *testing.T) {
|
||||
var loadCount int // Counter to track how many times the test route is loaded
|
||||
|
||||
// Create a test server to serve a sample HTML page with OG tags
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
loadCount++
|
||||
if loadCount > 1 {
|
||||
t.Fatalf("Test route loaded more than once, cache failed")
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Test Title" />
|
||||
<meta property="og:description" content="Test Description" />
|
||||
<meta property="og:image" content="http://example.com/image.jpg" />
|
||||
</head>
|
||||
<body>
|
||||
<p>Hello, world!</p>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Create an instance of OGTagCache with a short TTL for testing
|
||||
cache := NewOGTagCache(ts.URL, true, 1*time.Minute)
|
||||
|
||||
// Parse the test server URL
|
||||
parsedURL, err := url.Parse(ts.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse test server URL: %v", err)
|
||||
}
|
||||
|
||||
// Test fetching OG tags from the test server
|
||||
ogTags, err := cache.GetOGTags(parsedURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get OG tags: %v", err)
|
||||
}
|
||||
|
||||
// Verify the fetched OG tags
|
||||
expectedTags := map[string]string{
|
||||
"og:title": "Test Title",
|
||||
"og:description": "Test Description",
|
||||
"og:image": "http://example.com/image.jpg",
|
||||
}
|
||||
|
||||
for key, expectedValue := range expectedTags {
|
||||
if value, ok := ogTags[key]; !ok || value != expectedValue {
|
||||
t.Errorf("expected %s: %s, got: %s", key, expectedValue, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Test fetching OG tags from the cache
|
||||
ogTags, err = cache.GetOGTags(parsedURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get OG tags from cache: %v", err)
|
||||
}
|
||||
|
||||
// Test fetching OG tags from the cache (3rd time)
|
||||
newOgTags, err := cache.GetOGTags(parsedURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get OG tags from cache: %v", err)
|
||||
}
|
||||
|
||||
// Verify the cached OG tags
|
||||
for key, expectedValue := range expectedTags {
|
||||
if value, ok := ogTags[key]; !ok || value != expectedValue {
|
||||
t.Errorf("expected %s: %s, got: %s", key, expectedValue, value)
|
||||
}
|
||||
|
||||
initialValue := ogTags[key]
|
||||
cachedValue, ok := newOgTags[key]
|
||||
if !ok || initialValue != cachedValue {
|
||||
t.Errorf("Cache does not line up: expected %s: %s, got: %s", key, initialValue, cachedValue)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
69
internal/ogtags/fetch.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/net/html"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("page not found") /*todo: refactor into common errors lib? */
|
||||
emptyMap = map[string]string{} // used to indicate an empty result in the cache. Can't use nil as it would be a cache miss.
|
||||
)
|
||||
|
||||
func (c *OGTagCache) fetchHTMLDocument(urlStr string) (*html.Node, error) {
|
||||
resp, err := c.client.Get(urlStr)
|
||||
if err != nil {
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
slog.Debug("og: request timed out", "url", urlStr)
|
||||
c.cache.Set(urlStr, emptyMap, c.ogTimeToLive/2) // Cache empty result for half the TTL to not spam the server
|
||||
}
|
||||
return nil, fmt.Errorf("http get failed: %w", err)
|
||||
}
|
||||
// this defer will call MaxBytesReader's Close, which closes the original body.
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Debug("og: received non-OK status code", "url", urlStr, "status", resp.StatusCode)
|
||||
c.cache.Set(urlStr, emptyMap, c.ogTimeToLive) // Cache empty result for non-successful status codes
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
// Check content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
// assume non html body
|
||||
return nil, fmt.Errorf("missing Content-Type header")
|
||||
} else {
|
||||
mediaType, _, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
// Malformed Content-Type header
|
||||
return nil, fmt.Errorf("invalid Content-Type '%s': %w", ct, err)
|
||||
}
|
||||
|
||||
if mediaType != "text/html" && mediaType != "application/xhtml+xml" {
|
||||
return nil, fmt.Errorf("unsupported Content-Type: %s", mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
resp.Body = http.MaxBytesReader(nil, resp.Body, c.maxContentLength)
|
||||
|
||||
doc, err := html.Parse(resp.Body)
|
||||
if err != nil {
|
||||
// Check if the error is specifically because the limit was exceeded
|
||||
var maxBytesErr *http.MaxBytesError
|
||||
if errors.As(err, &maxBytesErr) {
|
||||
slog.Debug("og: content exceeded max length", "url", urlStr, "limit", c.maxContentLength)
|
||||
return nil, fmt.Errorf("content too large: exceeded %d bytes", c.maxContentLength)
|
||||
}
|
||||
// parsing error (e.g., malformed HTML)
|
||||
return nil, fmt.Errorf("failed to parse HTML: %w", err)
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
119
internal/ogtags/fetch_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFetchHTMLDocument(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
htmlContent string
|
||||
contentType string
|
||||
statusCode int
|
||||
contentLength int64
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Valid HTML",
|
||||
htmlContent: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Test</title></head>
|
||||
<body><p>Test content</p></body>
|
||||
</html>`,
|
||||
contentType: "text/html",
|
||||
statusCode: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty HTML",
|
||||
htmlContent: "",
|
||||
contentType: "text/html",
|
||||
statusCode: http.StatusOK,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Not found error",
|
||||
htmlContent: "",
|
||||
contentType: "text/html",
|
||||
statusCode: http.StatusNotFound,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Unsupported Content-Type",
|
||||
htmlContent: "*Insert rick roll here*",
|
||||
contentType: "video/mp4",
|
||||
statusCode: http.StatusOK,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Too large content",
|
||||
contentType: "text/html",
|
||||
statusCode: http.StatusOK,
|
||||
expectError: true,
|
||||
contentLength: 5 * 1024 * 1024, // 5MB (over 2MB limit)
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if tt.contentType != "" {
|
||||
w.Header().Set("Content-Type", tt.contentType)
|
||||
}
|
||||
if tt.contentLength > 0 {
|
||||
// Simulate content length but avoid sending too much actual data
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", tt.contentLength))
|
||||
io.CopyN(w, strings.NewReader("X"), tt.contentLength)
|
||||
} else {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
w.Write([]byte(tt.htmlContent))
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
cache := NewOGTagCache("", true, time.Minute)
|
||||
doc, err := cache.fetchHTMLDocument(ts.URL)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if doc != nil {
|
||||
t.Error("expected nil document on error, got non-nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if doc == nil {
|
||||
t.Error("expected non-nil document, got nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHTMLDocumentInvalidURL(t *testing.T) {
|
||||
if os.Getenv("DONT_USE_NETWORK") != "" {
|
||||
t.Skip("test requires theoretical network egress")
|
||||
}
|
||||
|
||||
cache := NewOGTagCache("", true, time.Minute)
|
||||
|
||||
doc, err := cache.fetchHTMLDocument("http://invalid.url.that.doesnt.exist.example")
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid URL, got nil")
|
||||
}
|
||||
|
||||
if doc != nil {
|
||||
t.Error("expected nil document for invalid URL, got non-nil")
|
||||
}
|
||||
}
|
||||
155
internal/ogtags/integration_test.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIntegrationGetOGTags(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/simple":
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Simple Page" />
|
||||
<meta property="og:type" content="website" />
|
||||
</head>
|
||||
<body><p>Simple page content</p></body>
|
||||
</html>
|
||||
`))
|
||||
case "/complete":
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Complete Page" />
|
||||
<meta property="og:description" content="A page with many OG tags" />
|
||||
<meta property="og:image" content="http://example.com/image.jpg" />
|
||||
<meta property="og:url" content="http://example.com/complete" />
|
||||
<meta property="og:type" content="article" />
|
||||
</head>
|
||||
<body><p>Complete page content</p></body>
|
||||
</html>
|
||||
`))
|
||||
case "/no-og":
|
||||
w.Write([]byte(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>No OG Tags</title>
|
||||
</head>
|
||||
<body><p>No OG tags here</p></body>
|
||||
</html>
|
||||
`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
// Test with different configurations
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
query string
|
||||
expectedTags map[string]string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "Simple page",
|
||||
path: "/simple",
|
||||
query: "",
|
||||
expectedTags: map[string]string{
|
||||
"og:title": "Simple Page",
|
||||
"og:type": "website",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Complete page",
|
||||
path: "/complete",
|
||||
query: "ref=test",
|
||||
expectedTags: map[string]string{
|
||||
"og:title": "Complete Page",
|
||||
"og:description": "A page with many OG tags",
|
||||
"og:image": "http://example.com/image.jpg",
|
||||
"og:url": "http://example.com/complete",
|
||||
"og:type": "article",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Page with no OG tags",
|
||||
path: "/no-og",
|
||||
query: "",
|
||||
expectedTags: map[string]string{},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Non-existent page",
|
||||
path: "/not-found",
|
||||
query: "",
|
||||
expectedTags: nil,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create cache instance
|
||||
cache := NewOGTagCache(ts.URL, true, 1*time.Minute)
|
||||
|
||||
// Create URL for test
|
||||
testURL, _ := url.Parse(ts.URL)
|
||||
testURL.Path = tc.path
|
||||
testURL.RawQuery = tc.query
|
||||
|
||||
// Get OG tags
|
||||
ogTags, err := cache.GetOGTags(testURL)
|
||||
|
||||
// Check error expectation
|
||||
if tc.expectError {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify all expected tags are present
|
||||
for key, expectedValue := range tc.expectedTags {
|
||||
if value, ok := ogTags[key]; !ok || value != expectedValue {
|
||||
t.Errorf("expected %s: %s, got: %s", key, expectedValue, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify no extra tags are present
|
||||
if len(ogTags) != len(tc.expectedTags) {
|
||||
t.Errorf("expected %d tags, got %d", len(tc.expectedTags), len(ogTags))
|
||||
}
|
||||
|
||||
// Test cache retrieval
|
||||
cachedOGTags, err := cache.GetOGTags(testURL)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get OG tags from cache: %v", err)
|
||||
}
|
||||
|
||||
// Verify cached tags match
|
||||
for key, expectedValue := range tc.expectedTags {
|
||||
if value, ok := cachedOGTags[key]; !ok || value != expectedValue {
|
||||
t.Errorf("cached value - expected %s: %s, got: %s", key, expectedValue, value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
51
internal/ogtags/ogtags.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis/decaymap"
|
||||
)
|
||||
|
||||
type OGTagCache struct {
|
||||
cache *decaymap.Impl[string, map[string]string]
|
||||
target string
|
||||
ogPassthrough bool
|
||||
ogTimeToLive time.Duration
|
||||
approvedTags []string
|
||||
approvedPrefixes []string
|
||||
client *http.Client
|
||||
maxContentLength int64
|
||||
}
|
||||
|
||||
func NewOGTagCache(target string, ogPassthrough bool, ogTimeToLive time.Duration) *OGTagCache {
|
||||
// Predefined approved tags and prefixes
|
||||
// In the future, these could come from configuration
|
||||
defaultApprovedTags := []string{"description", "keywords", "author"}
|
||||
defaultApprovedPrefixes := []string{"og:", "twitter:", "fediverse:"}
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second, /*make this configurable?*/
|
||||
}
|
||||
|
||||
const maxContentLength = 16 << 20 // 16 MiB in bytes
|
||||
|
||||
return &OGTagCache{
|
||||
cache: decaymap.New[string, map[string]string](),
|
||||
target: target,
|
||||
ogPassthrough: ogPassthrough,
|
||||
ogTimeToLive: ogTimeToLive,
|
||||
approvedTags: defaultApprovedTags,
|
||||
approvedPrefixes: defaultApprovedPrefixes,
|
||||
client: client,
|
||||
maxContentLength: maxContentLength,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *OGTagCache) getTarget(u *url.URL) string {
|
||||
return c.target + u.Path
|
||||
}
|
||||
|
||||
func (c *OGTagCache) Cleanup() {
|
||||
c.cache.Cleanup()
|
||||
}
|
||||
100
internal/ogtags/ogtags_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewOGTagCache(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
ogPassthrough bool
|
||||
ogTimeToLive time.Duration
|
||||
}{
|
||||
{
|
||||
name: "Basic initialization",
|
||||
target: "http://example.com",
|
||||
ogPassthrough: true,
|
||||
ogTimeToLive: 5 * time.Minute,
|
||||
},
|
||||
{
|
||||
name: "Empty target",
|
||||
target: "",
|
||||
ogPassthrough: false,
|
||||
ogTimeToLive: 10 * time.Minute,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cache := NewOGTagCache(tt.target, tt.ogPassthrough, tt.ogTimeToLive)
|
||||
|
||||
if cache == nil {
|
||||
t.Fatal("expected non-nil cache, got nil")
|
||||
}
|
||||
|
||||
if cache.target != tt.target {
|
||||
t.Errorf("expected target %s, got %s", tt.target, cache.target)
|
||||
}
|
||||
|
||||
if cache.ogPassthrough != tt.ogPassthrough {
|
||||
t.Errorf("expected ogPassthrough %v, got %v", tt.ogPassthrough, cache.ogPassthrough)
|
||||
}
|
||||
|
||||
if cache.ogTimeToLive != tt.ogTimeToLive {
|
||||
t.Errorf("expected ogTimeToLive %v, got %v", tt.ogTimeToLive, cache.ogTimeToLive)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
path string
|
||||
query string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "No path or query",
|
||||
target: "http://example.com",
|
||||
path: "",
|
||||
query: "",
|
||||
expected: "http://example.com",
|
||||
},
|
||||
{
|
||||
name: "With complex path",
|
||||
target: "http://example.com",
|
||||
path: "/pag(#*((#@)ΓΓΓΓe/Γ",
|
||||
query: "id=123",
|
||||
expected: "http://example.com/pag(#*((#@)ΓΓΓΓe/Γ",
|
||||
},
|
||||
{
|
||||
name: "With query and path",
|
||||
target: "http://example.com",
|
||||
path: "/page",
|
||||
query: "id=123",
|
||||
expected: "http://example.com/page",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cache := NewOGTagCache(tt.target, false, time.Minute)
|
||||
|
||||
u := &url.URL{
|
||||
Path: tt.path,
|
||||
RawQuery: tt.query,
|
||||
}
|
||||
|
||||
result := cache.getTarget(u)
|
||||
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %s, got %s", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
81
internal/ogtags/parse.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// extractOGTags traverses the HTML document and extracts approved Open Graph tags
|
||||
func (c *OGTagCache) extractOGTags(doc *html.Node) map[string]string {
|
||||
ogTags := make(map[string]string)
|
||||
|
||||
var traverseNodes func(*html.Node)
|
||||
traverseNodes = func(n *html.Node) {
|
||||
// isOGMetaTag only checks if it's a <meta> tag.
|
||||
// The actual filtering happens in extractMetaTagInfo now.
|
||||
if isOGMetaTag(n) {
|
||||
property, content := c.extractMetaTagInfo(n)
|
||||
if property != "" {
|
||||
ogTags[property] = content
|
||||
}
|
||||
}
|
||||
|
||||
for child := n.FirstChild; child != nil; child = child.NextSibling {
|
||||
traverseNodes(child)
|
||||
}
|
||||
}
|
||||
|
||||
traverseNodes(doc)
|
||||
return ogTags
|
||||
}
|
||||
|
||||
// isOGMetaTag checks if a node is *any* meta tag
|
||||
func isOGMetaTag(n *html.Node) bool {
|
||||
if n == nil {
|
||||
return false
|
||||
}
|
||||
return n.Type == html.ElementNode && n.Data == "meta"
|
||||
}
|
||||
|
||||
// extractMetaTagInfo extracts property and content from a meta tag
|
||||
// *and* checks if the property is approved.
|
||||
// Returns empty property string if the tag is not approved.
|
||||
func (c *OGTagCache) extractMetaTagInfo(n *html.Node) (property, content string) {
|
||||
var rawProperty string // Store the property found before approval check
|
||||
|
||||
for _, attr := range n.Attr {
|
||||
if attr.Key == "property" || attr.Key == "name" {
|
||||
rawProperty = attr.Val
|
||||
}
|
||||
if attr.Key == "content" {
|
||||
content = attr.Val
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the rawProperty is approved
|
||||
isApproved := false
|
||||
for _, prefix := range c.approvedPrefixes {
|
||||
if strings.HasPrefix(rawProperty, prefix) {
|
||||
isApproved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// Check exact approved tags if not already approved by prefix
|
||||
if !isApproved {
|
||||
for _, tag := range c.approvedTags {
|
||||
if rawProperty == tag {
|
||||
isApproved = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only return the property if it's approved
|
||||
if isApproved {
|
||||
property = rawProperty
|
||||
}
|
||||
|
||||
// Content is returned regardless, but property will be "" if not approved
|
||||
return property, content
|
||||
}
|
||||
295
internal/ogtags/parse_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package ogtags
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// TestExtractOGTags updated with correct expectations based on filtering logic
|
||||
func TestExtractOGTags(t *testing.T) {
|
||||
// Use a cache instance that reflects the default approved lists
|
||||
testCache := NewOGTagCache("", false, time.Minute)
|
||||
// Manually set approved tags/prefixes based on the user request for clarity
|
||||
testCache.approvedTags = []string{"description"}
|
||||
testCache.approvedPrefixes = []string{"og:"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
htmlStr string
|
||||
expected map[string]string
|
||||
}{
|
||||
{
|
||||
name: "Basic OG tags", // Includes standard 'description' meta tag
|
||||
htmlStr: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Test Title" />
|
||||
<meta property="og:description" content="Test Description" />
|
||||
<meta name="description" content="Regular Description" />
|
||||
<meta name="keywords" content="test, keyword" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
expected: map[string]string{
|
||||
"og:title": "Test Title",
|
||||
"og:description": "Test Description",
|
||||
"description": "Regular Description",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OG tags with name attribute",
|
||||
htmlStr: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="og:title" content="Test Title" />
|
||||
<meta property="og:description" content="Test Description" />
|
||||
<meta name="twitter:card" content="summary" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
expected: map[string]string{
|
||||
"og:title": "Test Title",
|
||||
"og:description": "Test Description",
|
||||
// twitter:card is still not approved
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No approved OG tags", // Contains only standard 'description'
|
||||
htmlStr: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="description" content="Test Description" />
|
||||
<meta name="keywords" content="Test" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
expected: map[string]string{
|
||||
"description": "Test Description",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Empty content",
|
||||
htmlStr: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="" />
|
||||
<meta property="og:description" content="Test Description" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
expected: map[string]string{
|
||||
"og:title": "",
|
||||
"og:description": "Test Description",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Explicitly approved tag",
|
||||
htmlStr: `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta property="description" content="Approved Description Tag" />
|
||||
</head>
|
||||
<body></body>
|
||||
</html>`,
|
||||
expected: map[string]string{
|
||||
// This is approved because "description" is in cache.approvedTags
|
||||
"description": "Approved Description Tag",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
doc, err := html.Parse(strings.NewReader(tt.htmlStr))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse HTML: %v", err)
|
||||
}
|
||||
|
||||
ogTags := testCache.extractOGTags(doc)
|
||||
|
||||
if !reflect.DeepEqual(ogTags, tt.expected) {
|
||||
t.Errorf("expected %v, got %v", tt.expected, ogTags)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsOGMetaTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
nodeHTML string
|
||||
targetNode string // Helper to find the right node in parsed fragment
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Meta OG tag",
|
||||
nodeHTML: `<meta property="og:title" content="Test">`,
|
||||
targetNode: "meta",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Regular meta tag",
|
||||
nodeHTML: `<meta name="description" content="Test">`,
|
||||
targetNode: "meta",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Not a meta tag",
|
||||
nodeHTML: `<div>Test</div>`,
|
||||
targetNode: "div",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Wrap the partial HTML in basic structure for parsing
|
||||
fullHTML := "<html><head>" + tt.nodeHTML + "</head><body></body></html>"
|
||||
doc, err := html.Parse(strings.NewReader(fullHTML))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse HTML: %v", err)
|
||||
}
|
||||
|
||||
// Find the target element node (meta or div based on targetNode)
|
||||
var node *html.Node
|
||||
var findNode func(*html.Node)
|
||||
findNode = func(n *html.Node) {
|
||||
// Skip finding if already found
|
||||
if node != nil {
|
||||
return
|
||||
}
|
||||
// Check if current node matches type and tag data
|
||||
if n.Type == html.ElementNode && n.Data == tt.targetNode {
|
||||
node = n
|
||||
return
|
||||
}
|
||||
// Recursively check children
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
findNode(c)
|
||||
}
|
||||
}
|
||||
findNode(doc) // Start search from root
|
||||
|
||||
if node == nil {
|
||||
t.Fatalf("Could not find target node '%s' in test HTML", tt.targetNode)
|
||||
}
|
||||
|
||||
// Call the function under test
|
||||
result := isOGMetaTag(node)
|
||||
if result != tt.expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetaTagInfo(t *testing.T) {
|
||||
// Use a cache instance that reflects the default approved lists
|
||||
testCache := NewOGTagCache("", false, time.Minute)
|
||||
testCache.approvedTags = []string{"description"}
|
||||
testCache.approvedPrefixes = []string{"og:"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
nodeHTML string
|
||||
expectedProperty string
|
||||
expectedContent string
|
||||
}{
|
||||
{
|
||||
name: "OG title with property (approved by prefix)",
|
||||
nodeHTML: `<meta property="og:title" content="Test Title">`,
|
||||
expectedProperty: "og:title",
|
||||
expectedContent: "Test Title",
|
||||
},
|
||||
{
|
||||
name: "OG description with name (approved by prefix)",
|
||||
nodeHTML: `<meta name="og:description" content="Test Description">`,
|
||||
expectedProperty: "og:description",
|
||||
expectedContent: "Test Description",
|
||||
},
|
||||
{
|
||||
name: "Regular meta tag (name=description, approved by exact match)", // Updated name for clarity
|
||||
nodeHTML: `<meta name="description" content="Test Description">`,
|
||||
expectedProperty: "description",
|
||||
expectedContent: "Test Description",
|
||||
},
|
||||
{
|
||||
name: "Regular meta tag (name=keywords, not approved)",
|
||||
nodeHTML: `<meta name="keywords" content="Test Keywords">`,
|
||||
expectedProperty: "",
|
||||
expectedContent: "Test Keywords",
|
||||
},
|
||||
{
|
||||
name: "Twitter tag (not approved by default)",
|
||||
nodeHTML: `<meta name="twitter:card" content="summary">`,
|
||||
expectedProperty: "",
|
||||
expectedContent: "summary",
|
||||
},
|
||||
{
|
||||
name: "No content (but approved property)",
|
||||
nodeHTML: `<meta property="og:title">`,
|
||||
expectedProperty: "og:title",
|
||||
expectedContent: "",
|
||||
},
|
||||
{
|
||||
name: "No property/name attribute",
|
||||
nodeHTML: `<meta content="No property">`,
|
||||
expectedProperty: "",
|
||||
expectedContent: "No property",
|
||||
},
|
||||
{
|
||||
name: "Explicitly approved tag with property attribute",
|
||||
nodeHTML: `<meta property="description" content="Approved Description Tag">`,
|
||||
expectedProperty: "description", // Approved by exact match in approvedTags
|
||||
expectedContent: "Approved Description Tag",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fullHTML := "<html><head>" + tt.nodeHTML + "</head><body></body></html>"
|
||||
doc, err := html.Parse(strings.NewReader(fullHTML))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse HTML: %v", err)
|
||||
}
|
||||
|
||||
var node *html.Node
|
||||
var findMetaNode func(*html.Node)
|
||||
findMetaNode = func(n *html.Node) {
|
||||
if node != nil { // Stop searching once found
|
||||
return
|
||||
}
|
||||
if n.Type == html.ElementNode && n.Data == "meta" {
|
||||
node = n
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
findMetaNode(c)
|
||||
}
|
||||
}
|
||||
findMetaNode(doc) // Start search from root
|
||||
|
||||
if node == nil {
|
||||
// Handle cases where the input might not actually contain a meta tag, though all test cases do.
|
||||
// If the test case is *designed* not to have a meta tag, this check should be different.
|
||||
// But for these tests, failure to find implies an issue with the test setup or parser.
|
||||
t.Fatalf("Could not find meta node in test HTML: %s", tt.nodeHTML)
|
||||
}
|
||||
|
||||
// Call extractMetaTagInfo using the test cache instance
|
||||
property, content := testCache.extractMetaTagInfo(node)
|
||||
|
||||
if property != tt.expectedProperty {
|
||||
t.Errorf("expected property '%s', got '%s'", tt.expectedProperty, property)
|
||||
}
|
||||
|
||||
if content != tt.expectedContent {
|
||||
t.Errorf("expected content '%s', got '%s'", tt.expectedContent, content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
457
internal/test/playwright_test.go
Normal file
@@ -0,0 +1,457 @@
|
||||
//go:build !windows
|
||||
|
||||
// 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"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
libanubis "github.com/TecharoHQ/anubis/lib"
|
||||
"github.com/playwright-community/playwright-go"
|
||||
)
|
||||
|
||||
var (
|
||||
playwrightPort = flag.Int("playwright-port", 9001, "Playwright port")
|
||||
playwrightServer = flag.String("playwright", "ws://localhost:9001", "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 performedAction action
|
||||
var err error
|
||||
for i := 0; i < 5; i++ {
|
||||
performedAction, err = executeTestCase(t, tc, typ, anubisURL)
|
||||
if performedAction == tc.action {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(i+1) * 250 * time.Millisecond)
|
||||
}
|
||||
if performedAction != tc.action {
|
||||
t.Errorf("unexpected test result, expected %s, got %s", tc.action, performedAction)
|
||||
}
|
||||
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("./var", "anubis-test-fail-"+strings.ReplaceAll(t.Name(), "/", "--")+"-*.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)
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", ":0")
|
||||
if err != nil {
|
||||
t.Fatalf("can't listen on random port: %v", err)
|
||||
}
|
||||
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
host := "localhost"
|
||||
port := strconv.Itoa(addr.Port)
|
||||
|
||||
s, err := libanubis.New(libanubis.Options{
|
||||
Next: h,
|
||||
Policy: policy,
|
||||
ServeRobotsTXT: true,
|
||||
Target: "http://" + host + ":" + port,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("can't construct libanubis.Server: %v", err)
|
||||
}
|
||||
|
||||
ts := &httptest.Server{
|
||||
Listener: listener,
|
||||
Config: &http.Server{Handler: s},
|
||||
}
|
||||
ts.Start()
|
||||
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
|
||||
619
lib/anubis.go
Normal file
@@ -0,0 +1,619 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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/internal/ogtags"
|
||||
"github.com/TecharoHQ/anubis/lib/policy"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/TecharoHQ/anubis/wasm"
|
||||
"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
|
||||
|
||||
OGPassthrough bool
|
||||
OGTimeToLive time.Duration
|
||||
Target string
|
||||
|
||||
WebmasterEmail string
|
||||
}
|
||||
|
||||
func LoadPoliciesOrDefault(fname string, defaultDifficulty uint32) (*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()
|
||||
|
||||
anubisPolicy, err := policy.ParseConfig(fin, fname, defaultDifficulty)
|
||||
|
||||
return anubisPolicy, 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](),
|
||||
OGTags: ogtags.NewOGTagCache(opts.Target, opts.OGPassthrough, opts.OGTimeToLive),
|
||||
validators: map[string]Verifier{
|
||||
"fast": VerifierFunc(BasicSHA256Verify),
|
||||
"slow": VerifierFunc(BasicSHA256Verify),
|
||||
},
|
||||
}
|
||||
|
||||
finfos, err := fs.ReadDir(web.Static, "static/wasm")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't read any webassembly files in the static folder: %w", err)
|
||||
}
|
||||
|
||||
for _, finfo := range finfos {
|
||||
fin, err := web.Static.Open("static/wasm/" + finfo.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] can't read static/wasm/%s: %w", finfo.Name(), err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
name := strings.TrimSuffix(finfo.Name(), filepath.Ext(finfo.Name()))
|
||||
|
||||
runner, err := wasm.NewRunner(context.Background(), finfo.Name(), fin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't load static/wasm/%s: %w", finfo.Name(), err)
|
||||
}
|
||||
|
||||
var concurrentLimit int64 = 4
|
||||
|
||||
cv := NewConcurrentVerifier(runner, concurrentLimit)
|
||||
|
||||
result.validators[name] = cv
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
xess.Mount(mux)
|
||||
|
||||
mux.Handle(anubis.StaticPath, internal.UnchangingCache(internal.NoBrowsing(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]
|
||||
OGTags *ogtags.OGTagCache
|
||||
|
||||
validators map[string]Verifier
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) challengeFor(r *http.Request, difficulty uint32) string {
|
||||
fp := sha256.Sum256(s.priv.Seed())
|
||||
|
||||
challengeData := 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(challengeData)
|
||||
}
|
||||
|
||||
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\"", s.opts.WebmasterEmail)), 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), s.opts.WebmasterEmail)), 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)", s.opts.WebmasterEmail)), 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)", s.opts.WebmasterEmail)), 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), s.opts.WebmasterEmail)), templ.WithStatus(http.StatusOK)).ServeHTTP(w, r)
|
||||
return
|
||||
case config.RuleChallenge:
|
||||
lg.Debug("challenge requested")
|
||||
case config.RuleBenchmark:
|
||||
lg.Debug("serving benchmark page")
|
||||
s.RenderBench(w, r)
|
||||
return
|
||||
default:
|
||||
s.ClearCookie(w)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Other internal server error (contact the admin)", s.opts.WebmasterEmail)), 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) {
|
||||
var ogTags map[string]string = nil
|
||||
if s.opts.OGPassthrough {
|
||||
var err error
|
||||
ogTags, err = s.OGTags.GetOGTags(r.URL)
|
||||
if err != nil {
|
||||
slog.Error("failed to get OG tags", "err", err)
|
||||
ogTags = nil
|
||||
}
|
||||
}
|
||||
handler := internal.NoStoreCache(
|
||||
templ.Handler(
|
||||
web.BaseWithOGTags("Making sure you're not a bot!", web.Index(), ogTags),
|
||||
),
|
||||
)
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) RenderBench(w http.ResponseWriter, r *http.Request) {
|
||||
templ.Handler(
|
||||
web.Base("Benchmarking Anubis!", web.Bench()),
|
||||
).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\".", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
lg = lg.With("check_result", cr, "algorithm", rule.Challenge.Algorithm)
|
||||
|
||||
nonceStr := r.FormValue("nonce")
|
||||
if nonceStr == "" {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("no nonce")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("missing nonce", s.opts.WebmasterEmail)), 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", s.opts.WebmasterEmail)), 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", s.opts.WebmasterEmail)), 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")
|
||||
|
||||
responseBytes, err := hex.DecodeString(response)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("response doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response format", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
challenge := s.challengeFor(r, rule.Challenge.Difficulty)
|
||||
challengeBytes, err := hex.DecodeString(challenge)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("challenge doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid internal challenge format", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
nonceRaw, err := strconv.ParseUint(nonceStr, 10, 32)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("nonce doesn't parse", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
nonce := uint32(nonceRaw)
|
||||
|
||||
validator, ok := s.validators[string(rule.Challenge.Algorithm)]
|
||||
if !ok {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("no validator found for algorithm", "algorithm", rule.Challenge.Algorithm)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("Internal anubis error has been detected and you cannot proceed. Tried to look up a validator for algorithm %s but wasn't able to find one. Please contact the administrator of this instance of anubis", rule.Challenge.Algorithm), s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ok, err = validator.Verify(r.Context(), challengeBytes, responseBytes, nonce, rule.Challenge.Difficulty)
|
||||
if err != nil {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("verification error", "err", err)
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Your challenge failed validation. Please go back and try your challenge again", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusBadRequest)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
s.ClearCookie(w)
|
||||
lg.Debug("response invalid")
|
||||
templ.Handler(web.Base("Oh noes!", web.ErrorPage("Your challenge failed validation. Please go back and try your challenge again", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusBadRequest)).ServeHTTP(w, r)
|
||||
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", s.opts.WebmasterEmail)), 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, s.opts.WebmasterEmail)), 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
|
||||
}
|
||||
|
||||
func (s *Server) CleanupDecayMap() {
|
||||
s.DNSBLCache.Cleanup()
|
||||
s.OGTags.Cleanup()
|
||||
}
|
||||
237
lib/anubis_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
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
|
||||
}
|
||||
|
||||
type challenge struct {
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
|
||||
func makeChallenge(t *testing.T, ts *httptest.Server) challenge {
|
||||
t.Helper()
|
||||
|
||||
resp, err := ts.Client().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 challenge
|
||||
if err := json.NewDecoder(resp.Body).Decode(&chall); err != nil {
|
||||
t.Fatalf("can't read challenge response body: %v", err)
|
||||
}
|
||||
|
||||
return chall
|
||||
}
|
||||
|
||||
// Regression test for CVE-2025-24369
|
||||
func TestCVE2025_24369(t *testing.T) {
|
||||
pol := loadPolicies(t, "")
|
||||
pol.DefaultDifficulty = 4
|
||||
|
||||
srv := spawnAnubis(t, Options{
|
||||
Next: http.NewServeMux(),
|
||||
Policy: pol,
|
||||
|
||||
CookieDomain: "local.cetacean.club",
|
||||
CookiePartitioned: true,
|
||||
CookieName: t.Name(),
|
||||
})
|
||||
|
||||
ts := httptest.NewServer(internal.RemoteXRealIP(true, "tcp", srv))
|
||||
defer ts.Close()
|
||||
|
||||
chall := makeChallenge(t, ts)
|
||||
calcString := fmt.Sprintf("%s%d", chall.Challenge, 0)
|
||||
calculated := internal.SHA256sum(calcString)
|
||||
nonce := 0
|
||||
elapsedTime := 420
|
||||
redir := "/"
|
||||
|
||||
cli := ts.Client()
|
||||
cli.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
|
||||
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.Log("Regression on CVE-2025-24369")
|
||||
t.Errorf("wanted HTTP status %d, got: %d", http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
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.RemoteXRealIP(true, "tcp", 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 := "/"
|
||||
calculated := ""
|
||||
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 == nil {
|
||||
t.Errorf("Cookie %q not found", anubis.CookieName)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) {
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintln(w, "OK")
|
||||
})
|
||||
|
||||
for i := uint32(1); i < 10; i++ {
|
||||
t.Run(fmt.Sprint(i), func(t *testing.T) {
|
||||
anubisPolicy, err := LoadPoliciesOrDefault("", i)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s, err := New(Options{
|
||||
Next: h,
|
||||
Policy: anubisPolicy,
|
||||
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)
|
||||
}
|
||||
32
lib/policy/bot.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/TecharoHQ/anubis/internal"
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
"github.com/yl2chen/cidranger"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
Name string
|
||||
UserAgent *regexp.Regexp
|
||||
Path *regexp.Regexp
|
||||
Action config.Rule `json:"action"`
|
||||
Challenge *config.ChallengeRules
|
||||
Ranger cidranger.Ranger
|
||||
}
|
||||
|
||||
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 internal.SHA256sum(fmt.Sprintf("%s::%s::%s", b.Name, pathRex, userAgentRex)), nil
|
||||
}
|
||||
@@ -7,32 +7,6 @@ import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type Rule string
|
||||
|
||||
const (
|
||||
RuleUnknown Rule = ""
|
||||
RuleAllow Rule = "ALLOW"
|
||||
RuleDeny Rule = "DENY"
|
||||
RuleChallenge Rule = "CHALLENGE"
|
||||
)
|
||||
|
||||
type Algorithm string
|
||||
|
||||
const (
|
||||
AlgorithmUnknown Algorithm = ""
|
||||
AlgorithmFast Algorithm = "fast"
|
||||
AlgorithmSlow Algorithm = "slow"
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
Name string `json:"name"`
|
||||
UserAgentRegex *string `json:"user_agent_regex"`
|
||||
PathRegex *string `json:"path_regex"`
|
||||
Action Rule `json:"action"`
|
||||
RemoteAddr []string `json:"remote_addresses"`
|
||||
Challenge *ChallengeRules `json:"challenge,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrNoBotRulesDefined = errors.New("config: must define at least one (1) bot rule")
|
||||
ErrBotMustHaveName = errors.New("config.Bot: must set name")
|
||||
@@ -44,14 +18,43 @@ var (
|
||||
ErrInvalidCIDR = errors.New("config.Bot: invalid CIDR")
|
||||
)
|
||||
|
||||
func (b Bot) Valid() error {
|
||||
type Rule string
|
||||
|
||||
const (
|
||||
RuleUnknown Rule = ""
|
||||
RuleAllow Rule = "ALLOW"
|
||||
RuleDeny Rule = "DENY"
|
||||
RuleChallenge Rule = "CHALLENGE"
|
||||
RuleBenchmark Rule = "DEBUG_BENCHMARK"
|
||||
)
|
||||
|
||||
type Algorithm string
|
||||
|
||||
const (
|
||||
AlgorithmUnknown Algorithm = ""
|
||||
AlgorithmFast Algorithm = "fast"
|
||||
AlgorithmSlow Algorithm = "slow"
|
||||
AlgorithmArgon2ID Algorithm = "argon2id"
|
||||
AlgorithmSHA256 Algorithm = "sha256"
|
||||
)
|
||||
|
||||
type BotConfig struct {
|
||||
Name string `json:"name"`
|
||||
UserAgentRegex *string `json:"user_agent_regex"`
|
||||
PathRegex *string `json:"path_regex"`
|
||||
Action Rule `json:"action"`
|
||||
RemoteAddr []string `json:"remote_addresses"`
|
||||
Challenge *ChallengeRules `json:"challenge,omitempty"`
|
||||
}
|
||||
|
||||
func (b BotConfig) Valid() error {
|
||||
var errs []error
|
||||
|
||||
if b.Name == "" {
|
||||
errs = append(errs, ErrBotMustHaveName)
|
||||
}
|
||||
|
||||
if b.UserAgentRegex == nil && b.PathRegex == nil && (b.RemoteAddr == nil || len(b.RemoteAddr) == 0) {
|
||||
if b.UserAgentRegex == nil && b.PathRegex == nil && len(b.RemoteAddr) == 0 {
|
||||
errs = append(errs, ErrBotMustHaveUserAgentOrPath)
|
||||
}
|
||||
|
||||
@@ -71,7 +74,7 @@ func (b Bot) Valid() error {
|
||||
}
|
||||
}
|
||||
|
||||
if b.RemoteAddr != nil && len(b.RemoteAddr) > 0 {
|
||||
if len(b.RemoteAddr) > 0 {
|
||||
for _, cidr := range b.RemoteAddr {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
errs = append(errs, ErrInvalidCIDR, err)
|
||||
@@ -80,7 +83,7 @@ func (b Bot) Valid() error {
|
||||
}
|
||||
|
||||
switch b.Action {
|
||||
case RuleAllow, RuleChallenge, RuleDeny:
|
||||
case RuleAllow, RuleBenchmark, RuleChallenge, RuleDeny:
|
||||
// okay
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("%w: %q", ErrUnknownAction, b.Action))
|
||||
@@ -100,8 +103,8 @@ func (b Bot) Valid() error {
|
||||
}
|
||||
|
||||
type ChallengeRules struct {
|
||||
Difficulty int `json:"difficulty"`
|
||||
ReportAs int `json:"report_as"`
|
||||
Difficulty uint32 `json:"difficulty"`
|
||||
ReportAs uint32 `json:"report_as"`
|
||||
Algorithm Algorithm `json:"algorithm"`
|
||||
}
|
||||
|
||||
@@ -123,7 +126,7 @@ func (cr ChallengeRules) Valid() error {
|
||||
}
|
||||
|
||||
switch cr.Algorithm {
|
||||
case AlgorithmFast, AlgorithmSlow, AlgorithmUnknown:
|
||||
case AlgorithmFast, AlgorithmSlow, AlgorithmArgon2ID, AlgorithmSHA256, AlgorithmUnknown:
|
||||
// do nothing, it's all good
|
||||
default:
|
||||
errs = append(errs, fmt.Errorf("%w: %q", ErrChallengeRuleHasWrongAlgorithm, cr.Algorithm))
|
||||
@@ -137,8 +140,8 @@ func (cr ChallengeRules) Valid() error {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Bots []Bot `json:"bots"`
|
||||
DNSBL bool `json:"dnsbl"`
|
||||
Bots []BotConfig `json:"bots"`
|
||||
DNSBL bool `json:"dnsbl"`
|
||||
}
|
||||
|
||||
func (c Config) Valid() error {
|
||||
@@ -13,12 +13,12 @@ func p[V any](v V) *V { return &v }
|
||||
func TestBotValid(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
bot Bot
|
||||
bot BotConfig
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "simple user agent",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -27,7 +27,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "simple path",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "well-known-path",
|
||||
Action: RuleAllow,
|
||||
PathRegex: p("^/.well-known/.*$"),
|
||||
@@ -36,7 +36,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no rule name",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Action: RuleChallenge,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
},
|
||||
@@ -44,7 +44,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "no rule matcher",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "broken-rule",
|
||||
Action: RuleAllow,
|
||||
},
|
||||
@@ -52,7 +52,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "both user-agent and path",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "path-and-user-agent",
|
||||
Action: RuleDeny,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -62,7 +62,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "Unknown action",
|
||||
Action: RuleUnknown,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -71,7 +71,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid user agent regex",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
UserAgentRegex: p("a(b"),
|
||||
@@ -80,7 +80,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid path regex",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("a(b"),
|
||||
@@ -89,7 +89,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "challenge difficulty too low",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("Mozilla"),
|
||||
@@ -103,7 +103,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "challenge difficulty too high",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("Mozilla"),
|
||||
@@ -117,7 +117,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "challenge wrong algorithm",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleChallenge,
|
||||
PathRegex: p("Mozilla"),
|
||||
@@ -131,7 +131,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "invalid cidr range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
RemoteAddr: []string{"0.0.0.0/33"},
|
||||
@@ -140,7 +140,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "only filter by IP range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
RemoteAddr: []string{"0.0.0.0/0"},
|
||||
@@ -149,7 +149,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "filter by user agent and IP range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
UserAgentRegex: p("Mozilla"),
|
||||
@@ -159,7 +159,7 @@ func TestBotValid(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "filter by path and IP range",
|
||||
bot: Bot{
|
||||
bot: BotConfig{
|
||||
Name: "mozilla-ua",
|
||||
Action: RuleAllow,
|
||||
PathRegex: p("^.*$"),
|
||||
121
lib/policy/policy.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
"github.com/yl2chen/cidranger"
|
||||
|
||||
"github.com/TecharoHQ/anubis/lib/policy/config"
|
||||
)
|
||||
|
||||
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
|
||||
DefaultDifficulty uint32
|
||||
}
|
||||
|
||||
func NewParsedConfig(orig config.Config) *ParsedConfig {
|
||||
return &ParsedConfig{
|
||||
orig: orig,
|
||||
}
|
||||
}
|
||||
|
||||
func ParseConfig(fin io.Reader, fname string, defaultDifficulty uint32) (*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 validationErrs []error
|
||||
|
||||
result := NewParsedConfig(c)
|
||||
result.DefaultDifficulty = defaultDifficulty
|
||||
|
||||
for _, b := range c.Bots {
|
||||
if berr := b.Valid(); berr != nil {
|
||||
validationErrs = append(validationErrs, berr)
|
||||
continue
|
||||
}
|
||||
|
||||
parsedBot := Bot{
|
||||
Name: b.Name,
|
||||
Action: b.Action,
|
||||
}
|
||||
|
||||
if len(b.RemoteAddr) > 0 {
|
||||
parsedBot.Ranger = cidranger.NewPCTrieRanger()
|
||||
|
||||
for _, cidr := range b.RemoteAddr {
|
||||
_, rng, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("[unexpected] range %s not parsing: %w", cidr, err)
|
||||
}
|
||||
|
||||
parsedBot.Ranger.Insert(cidranger.NewBasicRangerEntry(*rng))
|
||||
}
|
||||
}
|
||||
|
||||
if b.UserAgentRegex != nil {
|
||||
userAgent, err := regexp.Compile(*b.UserAgentRegex)
|
||||
if err != nil {
|
||||
validationErrs = append(validationErrs, 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 {
|
||||
validationErrs = append(validationErrs, fmt.Errorf("while compiling path regexp: %w", err))
|
||||
continue
|
||||
} else {
|
||||
parsedBot.Path = path
|
||||
}
|
||||
}
|
||||
|
||||
if b.Challenge == nil {
|
||||
parsedBot.Challenge = &config.ChallengeRules{
|
||||
Difficulty: defaultDifficulty,
|
||||
ReportAs: defaultDifficulty,
|
||||
Algorithm: config.AlgorithmArgon2ID,
|
||||
}
|
||||
} else {
|
||||
parsedBot.Challenge = b.Challenge
|
||||
if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown {
|
||||
parsedBot.Challenge.Algorithm = config.AlgorithmArgon2ID
|
||||
}
|
||||
}
|
||||
|
||||
result.Bots = append(result.Bots, parsedBot)
|
||||
}
|
||||
|
||||
if len(validationErrs) > 0 {
|
||||
return nil, fmt.Errorf("errors validating policy config JSON %s: %w", fname, errors.Join(validationErrs...))
|
||||
}
|
||||
|
||||
result.DNSBL = c.DNSBL
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,25 +1,28 @@
|
||||
package main
|
||||
package policy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/TecharoHQ/anubis"
|
||||
"github.com/TecharoHQ/anubis/data"
|
||||
)
|
||||
|
||||
func TestDefaultPolicyMustParse(t *testing.T) {
|
||||
fin, err := static.Open("botPolicies.json")
|
||||
fin, err := data.BotPolicies.Open("botPolicies.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := parseConfig(fin, "botPolicies.json", defaultDifficulty); err != nil {
|
||||
if _, err := ParseConfig(fin, "botPolicies.json", anubis.DefaultDifficulty); err != nil {
|
||||
t.Fatalf("can't parse config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoodConfigs(t *testing.T) {
|
||||
finfos, err := os.ReadDir("internal/config/testdata/good")
|
||||
finfos, err := os.ReadDir("config/testdata/good")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -27,13 +30,13 @@ func TestGoodConfigs(t *testing.T) {
|
||||
for _, st := range finfos {
|
||||
st := st
|
||||
t.Run(st.Name(), func(t *testing.T) {
|
||||
fin, err := os.Open(filepath.Join("internal", "config", "testdata", "good", st.Name()))
|
||||
fin, err := os.Open(filepath.Join("config", "testdata", "good", st.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := parseConfig(fin, fin.Name(), defaultDifficulty); err != nil {
|
||||
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
@@ -41,7 +44,7 @@ func TestGoodConfigs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBadConfigs(t *testing.T) {
|
||||
finfos, err := os.ReadDir("internal/config/testdata/bad")
|
||||
finfos, err := os.ReadDir("config/testdata/bad")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -49,13 +52,13 @@ func TestBadConfigs(t *testing.T) {
|
||||
for _, st := range finfos {
|
||||
st := st
|
||||
t.Run(st.Name(), func(t *testing.T) {
|
||||
fin, err := os.Open(filepath.Join("internal", "config", "testdata", "bad", st.Name()))
|
||||
fin, err := os.Open(filepath.Join("config", "testdata", "bad", st.Name()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fin.Close()
|
||||
|
||||
if _, err := parseConfig(fin, fin.Name(), defaultDifficulty); err == nil {
|
||||
if _, err := ParseConfig(fin, fin.Name(), anubis.DefaultDifficulty); err == nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
t.Log(err)
|
||||
9
lib/random.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func randomJitter() bool {
|
||||
return rand.Intn(100) > 10
|
||||
}
|
||||
92
lib/verifier.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChallengeFailed = errors.New("libanubis: challenge failed, hash does not match what the server calculated")
|
||||
ErrWrongChallengeDifficulty = errors.New("libanubis: wrong challenge difficulty")
|
||||
)
|
||||
|
||||
type Verifier interface {
|
||||
Verify(ctx context.Context, challenge, verify []byte, nonce, difficulty uint32) (bool, error)
|
||||
}
|
||||
|
||||
type VerifierFunc func(ctx context.Context, challenge, verify []byte, nonce, difficulty uint32) (bool, error)
|
||||
|
||||
func (vf VerifierFunc) Verify(ctx context.Context, challenge, verify []byte, nonce, difficulty uint32) (bool, error) {
|
||||
return vf(ctx, challenge, verify, nonce, difficulty)
|
||||
}
|
||||
|
||||
func BasicSHA256Verify(ctx context.Context, challenge, verify []byte, nonce, difficulty uint32) (bool, error) {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%x%d", challenge, nonce)
|
||||
data := h.Sum(nil)
|
||||
|
||||
if subtle.ConstantTimeCompare(data, verify) != 1 {
|
||||
return false, fmt.Errorf("%w: wanted %x, got: %x", ErrChallengeFailed, verify, data)
|
||||
}
|
||||
|
||||
if !hasLeadingZeroNibbles(data, difficulty) {
|
||||
return false, fmt.Errorf("%w: wanted %d leading zeroes in calculated data %x, but did not get it", ErrWrongChallengeDifficulty, difficulty, data)
|
||||
}
|
||||
|
||||
if !hasLeadingZeroNibbles(verify, difficulty) {
|
||||
return false, fmt.Errorf("%w: wanted %d leading zeroes in verification data %x, but did not get it", ErrWrongChallengeDifficulty, verify, difficulty)
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// hasLeadingZeroNibbles checks if the first `n` nibbles (in order) are zero.
|
||||
// Nibbles are read from high to low for each byte (e.g., 0x12 -> nibbles [0x1, 0x2]).
|
||||
func hasLeadingZeroNibbles(data []byte, n uint32) bool {
|
||||
count := uint32(0)
|
||||
for _, b := range data {
|
||||
// Check high nibble (first 4 bits)
|
||||
if (b >> 4) != 0 {
|
||||
break // Non-zero found in leading nibbles
|
||||
}
|
||||
count++
|
||||
if count >= n {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check low nibble (last 4 bits)
|
||||
if (b & 0x0F) != 0 {
|
||||
break // Non-zero found in leading nibbles
|
||||
}
|
||||
count++
|
||||
if count >= n {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return count >= n
|
||||
}
|
||||
|
||||
type ConcurrentVerifier struct {
|
||||
Verifier
|
||||
sem *semaphore.Weighted
|
||||
}
|
||||
|
||||
func NewConcurrentVerifier(v Verifier, maxConcurrent int64) *ConcurrentVerifier {
|
||||
return &ConcurrentVerifier{
|
||||
Verifier: v,
|
||||
sem: semaphore.NewWeighted(maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
func (cv *ConcurrentVerifier) Verify(ctx context.Context, challenge, verify []byte, nonce, difficulty uint32) (bool, error) {
|
||||
if err := cv.sem.Acquire(ctx, 1); err != nil {
|
||||
return false, fmt.Errorf("can't verify solution: %w", err)
|
||||
}
|
||||
|
||||
return cv.Verifier.Verify(ctx, challenge, verify, nonce, difficulty)
|
||||
}
|
||||
114
lib/verifier_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package lib
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// echo -n "hi2" | sha256sum
|
||||
const hi2SHA256 = "0251f1ec2880f67631b8d0b3a62cf71a17dfa31858a323e7fc38068fcfaeded0"
|
||||
const nonce uint32 = 5
|
||||
const expectedVerifyString = "0543cbd94db5da055e82263cb775ac16f59fbbc1900645458baa197f9036ae9d"
|
||||
|
||||
func TestBasicSHA256Verify(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
challenge, err := hex.DecodeString(hi2SHA256)
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] %s does not decode as hex", hi2SHA256)
|
||||
}
|
||||
|
||||
expectedVerify, err := hex.DecodeString(expectedVerifyString)
|
||||
if err != nil {
|
||||
t.Fatalf("[unexpected] %s does not decode as hex", expectedVerifyString)
|
||||
}
|
||||
|
||||
t.Logf("got nonce: %d", nonce)
|
||||
t.Logf("got hash: %x", expectedVerify)
|
||||
|
||||
invalidVerify := make([]byte, len(expectedVerify))
|
||||
copy(invalidVerify, expectedVerify)
|
||||
invalidVerify[len(invalidVerify)-1] ^= 0xFF // Flip the last byte
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
challenge []byte
|
||||
verify []byte
|
||||
nonce uint32
|
||||
difficulty uint32
|
||||
want bool
|
||||
expectError error
|
||||
}{
|
||||
{
|
||||
name: "valid verification",
|
||||
challenge: challenge,
|
||||
verify: expectedVerify,
|
||||
nonce: nonce,
|
||||
difficulty: 1,
|
||||
want: true,
|
||||
expectError: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid verify data",
|
||||
challenge: challenge,
|
||||
verify: invalidVerify,
|
||||
nonce: nonce,
|
||||
difficulty: 1,
|
||||
want: false,
|
||||
expectError: ErrChallengeFailed,
|
||||
},
|
||||
{
|
||||
name: "insufficient computed data difficulty",
|
||||
challenge: challenge,
|
||||
verify: expectedVerify,
|
||||
nonce: nonce,
|
||||
difficulty: 5,
|
||||
want: false,
|
||||
expectError: ErrWrongChallengeDifficulty,
|
||||
},
|
||||
{
|
||||
name: "zero difficulty",
|
||||
challenge: challenge,
|
||||
verify: expectedVerify,
|
||||
nonce: nonce,
|
||||
difficulty: 0,
|
||||
want: true,
|
||||
expectError: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := BasicSHA256Verify(ctx, tc.challenge, tc.verify, tc.nonce, tc.difficulty)
|
||||
if !errors.Is(err, tc.expectError) {
|
||||
t.Errorf("BasicSHA256Verify() error = %v, expectError %v", err, tc.expectError)
|
||||
return
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("BasicSHA256Verify() got = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasLeadingZeroNibbles(t *testing.T) {
|
||||
for _, cs := range []struct {
|
||||
data []byte
|
||||
difficulty uint32
|
||||
valid bool
|
||||
}{
|
||||
{[]byte{0x10, 0x00}, 1, false},
|
||||
{[]byte{0x00, 0x00}, 4, true},
|
||||
{[]byte{0x01, 0x00}, 4, false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%x-%d-%v", cs.data, cs.difficulty, cs.valid), func(t *testing.T) {
|
||||
result := hasLeadingZeroNibbles(cs.data, cs.difficulty)
|
||||
if result != cs.valid {
|
||||
t.Errorf("wanted %v, but got: %v", cs.valid, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
302
xess/package-lock.json → package-lock.json
generated
@@ -1,73 +1,22 @@
|
||||
{
|
||||
"name": "@xeserv/xess",
|
||||
"version": "1.0.0",
|
||||
"name": "@techaro/anubis",
|
||||
"version": "1.0.0-see-VERSION-file",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@xeserv/xess",
|
||||
"version": "1.0.0",
|
||||
"name": "@techaro/anubis",
|
||||
"version": "1.0.0-see-VERSION-file",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"cssnano": "^7.0.6",
|
||||
"cssnano-preset-advanced": "^7.0.6",
|
||||
"postcss-cli": "^11.0.0",
|
||||
"postcss-cli": "^11.0.1",
|
||||
"postcss-import": "^16.1.0",
|
||||
"postcss-import-url": "^7.2.0",
|
||||
"postcss-url": "^10.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@sindresorhus/merge-streams": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz",
|
||||
"integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@trysound/sax": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||
@@ -573,13 +522,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dependency-graph": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz",
|
||||
"integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-1.0.0.tgz",
|
||||
"integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
@@ -678,33 +627,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
"glob-parent": "^5.1.2",
|
||||
"merge2": "^1.3.0",
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
|
||||
"integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -782,19 +704,6 @@
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stdin": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-9.0.0.tgz",
|
||||
"integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
@@ -808,27 +717,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/globby": {
|
||||
"version": "14.0.2",
|
||||
"resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz",
|
||||
"integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sindresorhus/merge-streams": "^2.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"ignore": "^5.2.4",
|
||||
"path-type": "^5.0.0",
|
||||
"slash": "^5.1.0",
|
||||
"unicorn-magic": "^0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -856,16 +744,6 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
"integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1022,30 +900,6 @@
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/merge2": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "2.5.2",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz",
|
||||
@@ -1139,19 +993,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-type": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-type/-/path-type-5.0.0.tgz",
|
||||
"integrity": "sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1230,23 +1071,22 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-cli": {
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-11.0.0.tgz",
|
||||
"integrity": "sha512-xMITAI7M0u1yolVcXJ9XTZiO9aO49mcoKQy6pCDFdMh9kGqhzLVpWxeD/32M/QBmkhcGypZFFOLNLmIW4Pg4RA==",
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-cli/-/postcss-cli-11.0.1.tgz",
|
||||
"integrity": "sha512-0UnkNPSayHKRe/tc2YGW6XnSqqOA9eqpiRMgRlV1S6HdGi16vwJBx7lviARzbV1HpQHqLLRH3o8vTcB0cLc+5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.3.0",
|
||||
"dependency-graph": "^0.11.0",
|
||||
"dependency-graph": "^1.0.0",
|
||||
"fs-extra": "^11.0.0",
|
||||
"get-stdin": "^9.0.0",
|
||||
"globby": "^14.0.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"postcss-load-config": "^5.0.0",
|
||||
"postcss-reporter": "^7.0.0",
|
||||
"pretty-hrtime": "^1.0.3",
|
||||
"read-cache": "^1.0.0",
|
||||
"slash": "^5.0.0",
|
||||
"tinyglobby": "^0.2.12",
|
||||
"yargs": "^17.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1984,27 +1824,6 @@
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -2069,41 +1888,6 @@
|
||||
"url": "0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/reusify": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"iojs": ">=1.0.0",
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -2242,6 +2026,51 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz",
|
||||
"integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.4.3",
|
||||
"picomatch": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/fdir": {
|
||||
"version": "6.4.3",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz",
|
||||
"integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@@ -2255,19 +2084,6 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.1.0.tgz",
|
||||
"integrity": "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||